From 866a809a661667d684372e24b945e2f3dfc00a92 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 4 Jul 2026 07:46:17 -0400 Subject: [PATCH] feat(zingela): M10 connect-scan fallback + cloud/VM raw-send detection + full raw IPv6 SYN connect-scan: raw non-blocking connect() + poll(POLLOUT) + SO_ERROR via std.os.linux (std.Io.net connect-with-timeout is unimplemented in 0.16); own sized std.Io.Threaded with N io.async workers off a mutex+token-bucket dispenser; --backend connect / --connect, --concurrency, --connect-timeout. cloud/VM detection: two-socket AF_PACKET self-probe (send on one, observe the tagged frame on a second - single-socket cannot see its own send); --backend auto checks CAP_NET_RAW + egress and auto-falls-back to connect with a notice; forced raw backend disables fallback and hard-fails; zero-response raw scan hints --connect. raw IPv6 SYN: packet.Addr union result + RFC5952 v6 render; Ipv6Hdr + pseudoChecksum6/tcpChecksum6; 128-bit SipHash cookie (generate6/seq6); parseIpv6, resolveSrcIp6 (/proc/net/if_inet6), defaultGateway6 (/proc/net/ipv6_route); Engine6 (bounded prefix, RFC6890 reserved floor, ::/0 reject, host cap); SynTemplate6; classifyTcp6 + ICMPv6 type1; ndp.zig NDP neighbor resolution; runV6Scan dispatch; connect-path v6 rides the connect engine. v6 scope = TCP SYN. 240 tests Debug+ReleaseSafe (-Dxdp on/off); KAT + two-namespace netns e2e proven on the wire; two read-only audits, 0 Critical/High/Medium, all findings fixed in-phase. --- .../advanced/zig-stateless-scanner/build.zig | 35 +- .../zig-stateless-scanner/src/classify.zig | 232 +++++++++- .../zig-stateless-scanner/src/cli.zig | 15 +- .../zig-stateless-scanner/src/connect.zig | 402 ++++++++++++++++++ .../zig-stateless-scanner/src/cookie.zig | 52 +++ .../zig-stateless-scanner/src/ndp.zig | 198 +++++++++ .../zig-stateless-scanner/src/netutil.zig | 173 ++++++++ .../zig-stateless-scanner/src/output.zig | 128 +++++- .../zig-stateless-scanner/src/packet.zig | 117 +++++ .../zig-stateless-scanner/src/rawprobe.zig | 172 ++++++++ .../advanced/zig-stateless-scanner/src/rx.zig | 20 +- .../zig-stateless-scanner/src/scancmd.zig | 355 +++++++++++++++- .../zig-stateless-scanner/src/targets.zig | 198 +++++++++ .../zig-stateless-scanner/src/template.zig | 161 +++++++ .../advanced/zig-stateless-scanner/src/tx.zig | 90 ++++ 15 files changed, 2303 insertions(+), 45 deletions(-) create mode 100644 PROJECTS/advanced/zig-stateless-scanner/src/connect.zig create mode 100644 PROJECTS/advanced/zig-stateless-scanner/src/ndp.zig create mode 100644 PROJECTS/advanced/zig-stateless-scanner/src/rawprobe.zig diff --git a/PROJECTS/advanced/zig-stateless-scanner/build.zig b/PROJECTS/advanced/zig-stateless-scanner/build.zig index 08735c5e..017c8415 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/build.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/build.zig @@ -10,7 +10,7 @@ pub fn build(b: *std.Build) void { const xdp_enabled = b.option(bool, "xdp", "Enable the AF_XDP TX backend (pure-syscall, no libxdp; needs CAP_NET_ADMIN at runtime)") orelse false; const opts = b.addOptions(); - opts.addOption([]const u8, "version", "0.0.0-m9"); + opts.addOption([]const u8, "version", "0.0.0-m10"); opts.addOption(bool, "xdp", xdp_enabled); const build_config_mod = opts.createModule(); @@ -166,8 +166,20 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); output_mod.addImport("classify", classify_mod); + output_mod.addImport("packet", packet_mod); cli_mod.addImport("output", output_mod); + const connect_mod = b.createModule(.{ + .root_source_file = b.path("src/connect.zig"), + .target = target, + .optimize = optimize, + }); + connect_mod.addImport("classify", classify_mod); + connect_mod.addImport("packet", packet_mod); + connect_mod.addImport("targets", targets_mod); + connect_mod.addImport("ratelimit", ratelimit_mod); + connect_mod.addImport("output", output_mod); + const dedup_mod = b.createModule(.{ .root_source_file = b.path("src/dedup.zig"), .target = target, @@ -189,6 +201,22 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + const rawprobe_mod = b.createModule(.{ + .root_source_file = b.path("src/rawprobe.zig"), + .target = target, + .optimize = optimize, + }); + + targets_mod.addImport("netutil", netutil_mod); + tx_mod.addImport("netutil", netutil_mod); + + const ndp_mod = b.createModule(.{ + .root_source_file = b.path("src/ndp.zig"), + .target = target, + .optimize = optimize, + }); + ndp_mod.addImport("packet", packet_mod); + const stealth_mod = b.createModule(.{ .root_source_file = b.path("src/stealth.zig"), .target = target, @@ -229,6 +257,9 @@ pub fn build(b: *std.Build) void { scancmd_mod.addImport("output", output_mod); scancmd_mod.addImport("stealth", stealth_mod); scancmd_mod.addImport("service", service_mod); + scancmd_mod.addImport("connect", connect_mod); + scancmd_mod.addImport("rawprobe", rawprobe_mod); + scancmd_mod.addImport("ndp", ndp_mod); const exe = b.addExecutable(.{ .name = "zingela", @@ -259,7 +290,7 @@ pub fn build(b: *std.Build) void { smoke_step.dependOn(&smoke_cmd.step); const test_step = b.step("test", "Run unit tests"); - const test_mods = [_]*std.Build.Module{ packet_mod, cli_mod, smoke_mod, cookie_mod, numtheory_mod, targets_mod, ratelimit_mod, template_mod, segment_mod, regex_mod, probe_mod, service_mod, payloads_mod, udp_mod, afpacket_mod, xdp_mod, afxdp_mod, packet_io_mod, tx_mod, txcmd_mod, classify_mod, dedup_mod, rx_mod, netutil_mod, stealth_mod, output_mod, scancmd_mod }; + const test_mods = [_]*std.Build.Module{ packet_mod, cli_mod, smoke_mod, cookie_mod, numtheory_mod, targets_mod, ratelimit_mod, template_mod, segment_mod, regex_mod, probe_mod, service_mod, payloads_mod, udp_mod, afpacket_mod, xdp_mod, afxdp_mod, packet_io_mod, tx_mod, txcmd_mod, classify_mod, dedup_mod, rx_mod, netutil_mod, rawprobe_mod, ndp_mod, stealth_mod, output_mod, connect_mod, scancmd_mod }; for (test_mods) |mod| { const t = b.addTest(.{ .root_module = mod }); const rt = b.addRunArtifact(t); diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/classify.zig b/PROJECTS/advanced/zig-stateless-scanner/src/classify.zig index 3db24101..3ccd3ec0 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/classify.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/classify.zig @@ -8,9 +8,17 @@ const cookie = @import("cookie"); pub const State = enum { open, closed, filtered, unfiltered }; pub const Result = struct { - ip: u32, + addr: packet.Addr, port: u16, state: State, + + pub fn v4(ip: u32, port: u16, state: State) Result { + return .{ .addr = .{ .v4 = ip }, .port = port, .state = state }; + } + + pub fn v6(a: [16]u8, port: u16, state: State) Result { + return .{ .addr = .{ .v6 = a }, .port = port, .state = state }; + } }; const ETH_HDR_LEN: usize = 14; @@ -53,6 +61,15 @@ const ICMP_TYPE_DEST_UNREACH: u8 = 3; const ICMP_OFF_TYPE: usize = 0; const ICMP_OFF_CODE: usize = 1; +const ETHERTYPE_IPV6: u16 = 0x86dd; +const IP6_HDR_LEN: usize = 40; +const IP6_OFF_NEXT: usize = 6; +const IP6_OFF_SRC: usize = 8; +const IP6_OFF_DST: usize = 24; +const IPPROTO_ICMPV6: u8 = 58; +const ICMP6_TYPE_DEST_UNREACH: u8 = 1; +const ICMP6_ERR_HDR_LEN: usize = 8; + fn icmpCodeIsFilteredForSyn(code: u8) bool { return switch (code) { 1, 2, 3, 9, 10, 13 => true, @@ -104,29 +121,29 @@ pub fn classifyTcp(frame: []const u8, ck: cookie.Cookie, scan: packet.ScanType) .syn => { const is_synack = (flags & (TCP_FLAG_SYN | TCP_FLAG_ACK)) == (TCP_FLAG_SYN | TCP_FLAG_ACK); if (is_synack and ackno == cookie_val +% 1) - return .{ .ip = ip_src, .port = sport, .state = .open }; + return Result.v4(ip_src, sport, .open); if (has_rst and (flags & TCP_FLAG_ACK) != 0 and ackno == cookie_val +% 1) - return .{ .ip = ip_src, .port = sport, .state = .closed }; + return Result.v4(ip_src, sport, .closed); return null; }, .fin, .null_scan, .xmas => { if (has_rst and ackno == cookie_val +% scan.seqConsumed()) - return .{ .ip = ip_src, .port = sport, .state = .closed }; + return Result.v4(ip_src, sport, .closed); return null; }, .maimon => { if (has_rst and seqno == cookie_val) - return .{ .ip = ip_src, .port = sport, .state = .closed }; + return Result.v4(ip_src, sport, .closed); return null; }, .ack => { if (has_rst and seqno == cookie_val) - return .{ .ip = ip_src, .port = sport, .state = .unfiltered }; + return Result.v4(ip_src, sport, .unfiltered); return null; }, .window => { if (has_rst and seqno == cookie_val) - return .{ .ip = ip_src, .port = sport, .state = if (window != 0) .open else .closed }; + return Result.v4(ip_src, sport, if (window != 0) .open else .closed); return null; }, } @@ -153,7 +170,7 @@ pub fn classifyTcp(frame: []const u8, ck: cookie.Cookie, scan: packet.ScanType) const inner_seq = std.mem.readInt(u32, frame[inner_tcp + TCP_OFF_SEQ ..][0..4], .big); if (inner_seq == ck.seq(inner_dst, inner_dport, inner_src, inner_sport)) - return .{ .ip = inner_dst, .port = inner_dport, .state = .filtered }; + return Result.v4(inner_dst, inner_dport, .filtered); return null; } @@ -178,7 +195,7 @@ pub fn classifyUdp(frame: []const u8, ck: cookie.Cookie, base: u16, span: u16) ? const sport = std.mem.readInt(u16, frame[udp + UDP_OFF_SPORT ..][0..2], .big); const dport = std.mem.readInt(u16, frame[udp + UDP_OFF_DPORT ..][0..2], .big); if (dport == ck.udpSrcPort(ip_src, sport, ip_dst, base, span)) - return .{ .ip = ip_src, .port = sport, .state = .open }; + return Result.v4(ip_src, sport, .open); return null; } @@ -208,7 +225,86 @@ pub fn classifyUdp(frame: []const u8, ck: cookie.Cookie, base: u16, span: u16) ? const inner_dport = std.mem.readInt(u16, frame[inner_udp + UDP_OFF_DPORT ..][0..2], .big); if (inner_sport == ck.udpSrcPort(inner_dst, inner_dport, inner_src, base, span)) - return .{ .ip = inner_dst, .port = inner_dport, .state = state }; + return Result.v4(inner_dst, inner_dport, state); + return null; + } + + return null; +} + +pub fn classifyTcp6(frame: []const u8, ck: cookie.Cookie, scan: packet.ScanType) ?Result { + if (frame.len < ETH_HDR_LEN + IP6_HDR_LEN) return null; + if (std.mem.readInt(u16, frame[ETH_OFF_TYPE..][0..2], .big) != ETHERTYPE_IPV6) return null; + + const ip = ETH_HDR_LEN; + const next = frame[ip + IP6_OFF_NEXT]; + const src: [16]u8 = frame[ip + IP6_OFF_SRC ..][0..16].*; + const dst: [16]u8 = frame[ip + IP6_OFF_DST ..][0..16].*; + + if (next == IPPROTO_TCP) { + const tcp = ip + IP6_HDR_LEN; + if (frame.len < tcp + TCP_MIN_LEN) return null; + const sport = std.mem.readInt(u16, frame[tcp + TCP_OFF_SPORT ..][0..2], .big); + const dport = std.mem.readInt(u16, frame[tcp + TCP_OFF_DPORT ..][0..2], .big); + const seqno = std.mem.readInt(u32, frame[tcp + TCP_OFF_SEQ ..][0..4], .big); + const ackno = std.mem.readInt(u32, frame[tcp + TCP_OFF_ACK ..][0..4], .big); + const flags = frame[tcp + TCP_OFF_FLAGS]; + const window = std.mem.readInt(u16, frame[tcp + TCP_OFF_WINDOW ..][0..2], .big); + + const cookie_val = ck.seq6(src, sport, dst, dport); + const has_rst = (flags & TCP_FLAG_RST) != 0; + + switch (scan) { + .syn => { + const is_synack = (flags & (TCP_FLAG_SYN | TCP_FLAG_ACK)) == (TCP_FLAG_SYN | TCP_FLAG_ACK); + if (is_synack and ackno == cookie_val +% 1) + return Result.v6(src, sport, .open); + if (has_rst and (flags & TCP_FLAG_ACK) != 0 and ackno == cookie_val +% 1) + return Result.v6(src, sport, .closed); + return null; + }, + .fin, .null_scan, .xmas => { + if (has_rst and ackno == cookie_val +% scan.seqConsumed()) + return Result.v6(src, sport, .closed); + return null; + }, + .maimon => { + if (has_rst and seqno == cookie_val) + return Result.v6(src, sport, .closed); + return null; + }, + .ack => { + if (has_rst and seqno == cookie_val) + return Result.v6(src, sport, .unfiltered); + return null; + }, + .window => { + if (has_rst and seqno == cookie_val) + return Result.v6(src, sport, if (window != 0) .open else .closed); + return null; + }, + } + } + + if (next == IPPROTO_ICMPV6) { + const icmp = ip + IP6_HDR_LEN; + if (frame.len < icmp + ICMP6_ERR_HDR_LEN) return null; + if (frame[icmp + ICMP_OFF_TYPE] != ICMP6_TYPE_DEST_UNREACH) return null; + + const inner = icmp + ICMP6_ERR_HDR_LEN; + if (frame.len < inner + IP6_HDR_LEN) return null; + if (frame[inner + IP6_OFF_NEXT] != IPPROTO_TCP) return null; + + const inner_src: [16]u8 = frame[inner + IP6_OFF_SRC ..][0..16].*; + const inner_dst: [16]u8 = frame[inner + IP6_OFF_DST ..][0..16].*; + const inner_tcp = inner + IP6_HDR_LEN; + if (frame.len < inner_tcp + INNER_TCP_MIN_LEN) return null; + const inner_sport = std.mem.readInt(u16, frame[inner_tcp + TCP_OFF_SPORT ..][0..2], .big); + const inner_dport = std.mem.readInt(u16, frame[inner_tcp + TCP_OFF_DPORT ..][0..2], .big); + const inner_seq = std.mem.readInt(u32, frame[inner_tcp + TCP_OFF_SEQ ..][0..4], .big); + + if (inner_seq == ck.seq6(inner_dst, inner_dport, inner_src, inner_sport)) + return Result.v6(inner_dst, inner_dport, .filtered); return null; } @@ -224,6 +320,15 @@ pub const TcpClassifier = struct { } }; +pub const TcpClassifier6 = struct { + ck: cookie.Cookie, + scan: packet.ScanType = .syn, + + pub fn match(self: TcpClassifier6, frame: []const u8) ?Result { + return classifyTcp6(frame, self.ck, self.scan); + } +}; + pub const UdpClassifier = struct { ck: cookie.Cookie, base: u16, @@ -295,7 +400,7 @@ test "validated SYN-ACK classifies as open" { buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0xCAFEBABE, our_seq +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK); const r = classify(&f, ck).?; try std.testing.expectEqual(State.open, r.state); - try std.testing.expectEqual(their_ip, r.ip); + try std.testing.expectEqual(their_ip, r.addr.v4); try std.testing.expectEqual(their_port, r.port); } @@ -313,7 +418,7 @@ test "validated RST/ACK classifies as closed" { buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0, our_seq +% 1, TCP_FLAG_RST | TCP_FLAG_ACK); const r = classify(&f, ck).?; try std.testing.expectEqual(State.closed, r.state); - try std.testing.expectEqual(their_ip, r.ip); + try std.testing.expectEqual(their_ip, r.addr.v4); try std.testing.expectEqual(their_port, r.port); } @@ -338,7 +443,7 @@ test "validated ICMP dest-unreachable classifies as filtered" { const len = buildIcmpUnreach(&f, 3, our_ip, their_ip, our_port, their_port, our_seq); const r = classify(f[0..len], ck).?; try std.testing.expectEqual(State.filtered, r.state); - try std.testing.expectEqual(their_ip, r.ip); + try std.testing.expectEqual(their_ip, r.addr.v4); try std.testing.expectEqual(their_port, r.port); } @@ -417,7 +522,7 @@ test "validated UDP response classifies as open" { buildUdpReply(&f, their_ip, our_ip, udp_port, our_src); const r = classifyUdp(&f, ck, udp_base, udp_span).?; try std.testing.expectEqual(State.open, r.state); - try std.testing.expectEqual(their_ip, r.ip); + try std.testing.expectEqual(their_ip, r.addr.v4); try std.testing.expectEqual(udp_port, r.port); } @@ -435,7 +540,7 @@ test "validated ICMP port-unreachable (type 3 code 3) classifies as closed for U const len = buildIcmpUdpUnreach(&f, 3, our_ip, their_ip, our_src, udp_port); const r = classifyUdp(f[0..len], ck, udp_base, udp_span).?; try std.testing.expectEqual(State.closed, r.state); - try std.testing.expectEqual(their_ip, r.ip); + try std.testing.expectEqual(their_ip, r.addr.v4); try std.testing.expectEqual(udp_port, r.port); } @@ -446,7 +551,7 @@ test "validated ICMP type 3 code 1 classifies as filtered for UDP" { const len = buildIcmpUdpUnreach(&f, 1, our_ip, their_ip, our_src, udp_port); const r = classifyUdp(f[0..len], ck, udp_base, udp_span).?; try std.testing.expectEqual(State.filtered, r.state); - try std.testing.expectEqual(their_ip, r.ip); + try std.testing.expectEqual(their_ip, r.addr.v4); try std.testing.expectEqual(udp_port, r.port); } @@ -583,3 +688,98 @@ test "the scan-type classifier adapter threads the mode into classifyTcp" { const syn_clf = TcpClassifier{ .ck = ck }; try std.testing.expect(syn_clf.match(&f) == null); } + +const our_ip6 = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01 }; +const their_ip6 = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x99 }; +const our_port6: u16 = 40000; +const their_port6: u16 = 80; + +fn buildTcp6Reply(buf: *[74]u8, src: [16]u8, dst: [16]u8, sport: u16, dport: u16, seq: u32, ack: u32, flags: u8) void { + @memset(buf, 0); + std.mem.writeInt(u16, buf[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV6, .big); + buf[ETH_HDR_LEN] = 0x60; + buf[ETH_HDR_LEN + IP6_OFF_NEXT] = IPPROTO_TCP; + @memcpy(buf[ETH_HDR_LEN + IP6_OFF_SRC ..][0..16], &src); + @memcpy(buf[ETH_HDR_LEN + IP6_OFF_DST ..][0..16], &dst); + const tcp = ETH_HDR_LEN + IP6_HDR_LEN; + std.mem.writeInt(u16, buf[tcp + TCP_OFF_SPORT ..][0..2], sport, .big); + std.mem.writeInt(u16, buf[tcp + TCP_OFF_DPORT ..][0..2], dport, .big); + std.mem.writeInt(u32, buf[tcp + TCP_OFF_SEQ ..][0..4], seq, .big); + std.mem.writeInt(u32, buf[tcp + TCP_OFF_ACK ..][0..4], ack, .big); + buf[tcp + TCP_OFF_FLAGS] = flags; +} + +fn buildIcmp6Unreach(buf: *[122]u8, code: u8, inner_src: [16]u8, inner_dst: [16]u8, inner_sport: u16, inner_dport: u16, inner_seq: u32) usize { + @memset(buf, 0); + std.mem.writeInt(u16, buf[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV6, .big); + buf[ETH_HDR_LEN] = 0x60; + buf[ETH_HDR_LEN + IP6_OFF_NEXT] = IPPROTO_ICMPV6; + const icmp = ETH_HDR_LEN + IP6_HDR_LEN; + buf[icmp + ICMP_OFF_TYPE] = ICMP6_TYPE_DEST_UNREACH; + buf[icmp + ICMP_OFF_CODE] = code; + const inner = icmp + ICMP6_ERR_HDR_LEN; + buf[inner] = 0x60; + buf[inner + IP6_OFF_NEXT] = IPPROTO_TCP; + @memcpy(buf[inner + IP6_OFF_SRC ..][0..16], &inner_src); + @memcpy(buf[inner + IP6_OFF_DST ..][0..16], &inner_dst); + const inner_tcp = inner + IP6_HDR_LEN; + std.mem.writeInt(u16, buf[inner_tcp + TCP_OFF_SPORT ..][0..2], inner_sport, .big); + std.mem.writeInt(u16, buf[inner_tcp + TCP_OFF_DPORT ..][0..2], inner_dport, .big); + std.mem.writeInt(u32, buf[inner_tcp + TCP_OFF_SEQ ..][0..4], inner_seq, .big); + return inner_tcp + INNER_TCP_MIN_LEN; +} + +test "validated IPv6 SYN-ACK classifies as open with the responder address" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq6(their_ip6, their_port6, our_ip6, our_port6); + var f: [74]u8 = undefined; + buildTcp6Reply(&f, their_ip6, our_ip6, their_port6, our_port6, 0xCAFEBABE, cv +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK); + const r = classifyTcp6(&f, ck, .syn).?; + try std.testing.expectEqual(State.open, r.state); + try std.testing.expectEqualSlices(u8, &their_ip6, &r.addr.v6); + try std.testing.expectEqual(their_port6, r.port); +} + +test "IPv6 SYN-ACK with a wrong ack is rejected, RST/ACK is closed" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq6(their_ip6, their_port6, our_ip6, our_port6); + var f: [74]u8 = undefined; + buildTcp6Reply(&f, their_ip6, our_ip6, their_port6, our_port6, 0, 0xDEADBEEF, TCP_FLAG_SYN | TCP_FLAG_ACK); + try std.testing.expect(classifyTcp6(&f, ck, .syn) == null); + + buildTcp6Reply(&f, their_ip6, our_ip6, their_port6, our_port6, 0, cv +% 1, TCP_FLAG_RST | TCP_FLAG_ACK); + try std.testing.expectEqual(State.closed, classifyTcp6(&f, ck, .syn).?.state); +} + +test "validated ICMPv6 destination-unreachable classifies as filtered for the probed target" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq6(their_ip6, their_port6, our_ip6, our_port6); + var f: [122]u8 = undefined; + const len = buildIcmp6Unreach(&f, 4, our_ip6, their_ip6, our_port6, their_port6, cv); + const r = classifyTcp6(f[0..len], ck, .syn).?; + try std.testing.expectEqual(State.filtered, r.state); + try std.testing.expectEqualSlices(u8, &their_ip6, &r.addr.v6); + try std.testing.expectEqual(their_port6, r.port); + + const bad = buildIcmp6Unreach(&f, 4, our_ip6, their_ip6, our_port6, their_port6, 0x99999999); + try std.testing.expect(classifyTcp6(f[0..bad], ck, .syn) == null); +} + +test "classifyTcp6 ignores IPv4 frames and runt frames" { + const ck = cookie.Cookie.init(test_key); + var f: [74]u8 = undefined; + buildTcp6Reply(&f, their_ip6, our_ip6, their_port6, our_port6, 0, ck.seq6(their_ip6, their_port6, our_ip6, our_port6) +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK); + std.mem.writeInt(u16, f[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV4, .big); + try std.testing.expect(classifyTcp6(&f, ck, .syn) == null); + var tiny = [_]u8{0} ** 30; + try std.testing.expect(classifyTcp6(&tiny, ck, .syn) == null); +} + +test "the IPv6 classifier adapter threads the scan mode" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq6(their_ip6, their_port6, our_ip6, our_port6); + var f: [74]u8 = undefined; + buildTcp6Reply(&f, their_ip6, our_ip6, their_port6, our_port6, 0xCAFEBABE, cv +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK); + const clf = TcpClassifier6{ .ck = ck }; + try std.testing.expectEqual(State.open, clf.match(&f).?.state); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig index 6bf50e68..0c553277 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig @@ -56,7 +56,8 @@ pub fn printHelp(io: std.Io, env: *std.process.Environ.Map) !void { \\ --src-port source port; UDP uses it as the cookie-range base (default 40000) \\ --gw-mac gateway/dst MAC aa:bb:cc:dd:ee:ff (default 00:..:00) \\ --seed permutation seed (default: per-scan CSPRNG) - \\ --backend TX path: auto | xdp | afpacket (default auto; xdp needs a -Dxdp build) + \\ --backend TX path: auto | xdp | afpacket | connect (default auto; + \\ xdp needs a -Dxdp build; connect = unprivileged TCP connect scan) \\ \\scan-only options: \\ --udp UDP scan: per-protocol payloads, ICMP type3/code3 = closed, @@ -68,6 +69,18 @@ pub fn printHelp(io: std.Io, env: *std.process.Environ.Map) !void { \\ --json emit NDJSON results to stdout (visuals go to stderr) \\ --color auto | always | never (default auto) \\ + \\connect-scan options (--backend connect / --connect; no CAP_NET_RAW needed): + \\ --connect TCP connect() scan via the OS stack (open/closed/filtered) + \\ --concurrency concurrent connect workers (default 128) + \\ --connect-timeout per-connect timeout, filtered on no reply (default 3000) + \\ + \\IPv6 scan (a --target with ':' is IPv6; raw stateless SYN, or add --connect): + \\ --target bounded IPv6 prefix, e.g. 2001:db8::/112 (::/0 is refused) + \\ --src-ip6 source IPv6 (default: resolved from --iface via /proc/net/if_inet6) + \\ --gw-ip6 next-hop IPv6 to resolve via NDP (default: the iface v6 default route) + \\ --gw-mac skip NDP and stamp this next-hop MAC directly + \\ --max-hosts host-address cap per IPv6 prefix (default 1048576) + \\ \\stealth / evasion (tx + scan; every flag requires --authorized-scan): \\ --authorized-scan confirm you are authorized to scan the target \\ --os-template SYN fingerprint none|masscan|linux|windows|macos diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/connect.zig b/PROJECTS/advanced/zig-stateless-scanner/src/connect.zig new file mode 100644 index 00000000..f1d7913e --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/connect.zig @@ -0,0 +1,402 @@ +// ©AngelaMos | 2026 +// connect.zig + +const std = @import("std"); +const linux = std.os.linux; +const mem = std.mem; +const net = std.Io.net; +const classify = @import("classify"); +const packet = @import("packet"); +const targets = @import("targets"); +const ratelimit = @import("ratelimit"); +const output = @import("output"); + +pub const Result = classify.Result; +pub const State = classify.State; + +pub const default_concurrency: usize = 128; +pub const default_timeout_ms: u64 = 3000; +pub const min_concurrency: usize = 1; +pub const max_concurrency: usize = 1024; + +const NS_PER_MS: u64 = 1_000_000; +const NS_PER_SEC: u64 = 1_000_000_000; +const fd_retry_limit: u32 = 4; +const fd_retry_sleep_ns: u64 = 5 * NS_PER_MS; +const render_tick_interactive_ns: u64 = 125 * NS_PER_MS; +const render_tick_plain_ns: u64 = 1000 * NS_PER_MS; +const drain_tick_ns: u64 = 40 * NS_PER_MS; + +fn monoNow() u64 { + var ts: linux.timespec = undefined; + _ = linux.clock_gettime(.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) * NS_PER_SEC + @as(u64, @intCast(ts.nsec)); +} + +fn sleepNs(ns: u64) void { + const ts = linux.timespec{ .sec = @intCast(ns / NS_PER_SEC), .nsec = @intCast(ns % NS_PER_SEC) }; + _ = linux.nanosleep(&ts, null); +} + +pub const Target = struct { + addr: packet.Addr, + port: u16, +}; + +pub const Source = union(enum) { + v4: *targets.Engine, + v6: *targets.Engine6, + + fn next(self: Source) ?Target { + switch (self) { + .v4 => |eng| { + const t = eng.next() orelse return null; + return .{ .addr = .{ .v4 = t.ip }, .port = t.port }; + }, + .v6 => |eng| { + const t = eng.next() orelse return null; + return .{ .addr = .{ .v6 = t.addr }, .port = t.port }; + }, + } + } +}; + +const Probe = union(enum) { + done: State, + retry, +}; + +fn classifySoError(so_err: u32) State { + return switch (so_err) { + 0 => .open, + @intFromEnum(linux.E.CONNREFUSED) => .closed, + @intFromEnum(linux.E.HOSTUNREACH), + @intFromEnum(linux.E.NETUNREACH), + @intFromEnum(linux.E.TIMEDOUT), + @intFromEnum(linux.E.ACCES), + @intFromEnum(linux.E.PERM), + @intFromEnum(linux.E.CONNRESET), + => .filtered, + else => .filtered, + }; +} + +fn connectOnce(addr: packet.Addr, port: u16, timeout_ns: u64) Probe { + const domain: u32 = switch (addr) { + .v4 => linux.AF.INET, + .v6 => linux.AF.INET6, + }; + const rc_sock = linux.socket(domain, linux.SOCK.STREAM | linux.SOCK.NONBLOCK | linux.SOCK.CLOEXEC, 0); + switch (linux.errno(rc_sock)) { + .SUCCESS => {}, + .MFILE, .NFILE, .NOBUFS, .NOMEM => return .retry, + else => return .{ .done = .filtered }, + } + const fd: i32 = @intCast(rc_sock); + defer _ = linux.close(fd); + + var v4sa: linux.sockaddr.in = undefined; + var v6sa: linux.sockaddr.in6 = undefined; + const sa_ptr: *const anyopaque = switch (addr) { + .v4 => |ip| blk: { + v4sa = .{ .port = mem.nativeToBig(u16, port), .addr = mem.nativeToBig(u32, ip) }; + break :blk @ptrCast(&v4sa); + }, + .v6 => |b| blk: { + v6sa = .{ .port = mem.nativeToBig(u16, port), .flowinfo = 0, .addr = b, .scope_id = 0 }; + break :blk @ptrCast(&v6sa); + }, + }; + const sa_len: linux.socklen_t = switch (addr) { + .v4 => @sizeOf(linux.sockaddr.in), + .v6 => @sizeOf(linux.sockaddr.in6), + }; + + switch (linux.errno(linux.connect(fd, sa_ptr, sa_len))) { + .SUCCESS, .ISCONN => return .{ .done = .open }, + .INPROGRESS, .INTR, .AGAIN => {}, + .CONNREFUSED => return .{ .done = .closed }, + .MFILE, .NFILE, .NOBUFS, .NOMEM => return .retry, + else => return .{ .done = .filtered }, + } + + const timeout_ms: i32 = @intCast(@min(timeout_ns / NS_PER_MS, @as(u64, std.math.maxInt(i32)))); + var pfd = [_]linux.pollfd{.{ .fd = fd, .events = linux.POLL.OUT, .revents = 0 }}; + while (true) { + const pr = linux.poll(&pfd, 1, timeout_ms); + switch (linux.errno(pr)) { + .SUCCESS => {}, + .INTR => continue, + else => return .{ .done = .filtered }, + } + if (pr == 0) return .{ .done = .filtered }; + break; + } + + var so_err: u32 = 0; + var so_len: linux.socklen_t = @sizeOf(u32); + _ = linux.getsockopt(fd, linux.SOL.SOCKET, linux.SO.ERROR, @ptrCast(&so_err), &so_len); + return .{ .done = classifySoError(so_err) }; +} + +fn connectProbe(addr: packet.Addr, port: u16, timeout_ns: u64) State { + var attempt: u32 = 0; + while (true) { + switch (connectOnce(addr, port, timeout_ns)) { + .done => |st| return st, + .retry => { + attempt += 1; + if (attempt >= fd_retry_limit) return .filtered; + sleepNs(fd_retry_sleep_ns); + }, + } + } +} + +const Dispenser = struct { + mutex: std.Io.Mutex = .init, + source: Source, + bucket: ratelimit.TokenBucket, + remaining: u64, + exhausted: bool = false, + + fn next(self: *Dispenser, io: std.Io) ?Target { + while (true) { + self.mutex.lockUncancelable(io); + if (self.exhausted or self.remaining == 0) { + self.mutex.unlock(io); + return null; + } + const granted = self.bucket.takeBatch(monoNow(), 1); + if (granted == 0) { + const wait = self.bucket.step_ns; + self.mutex.unlock(io); + std.Io.sleep(io, .{ .nanoseconds = @intCast(wait) }, .awake) catch {}; + continue; + } + const t = self.source.next() orelse { + self.exhausted = true; + self.mutex.unlock(io); + return null; + }; + self.remaining -= 1; + self.mutex.unlock(io); + return t; + } + } +}; + +const Collected = struct { + mutex: std.Io.Mutex = .init, + list: std.ArrayList(Result) = .empty, + allocator: std.mem.Allocator, + + fn push(self: *Collected, io: std.Io, r: Result) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + self.list.append(self.allocator, r) catch {}; + } +}; + +fn worker( + io: std.Io, + disp: *Dispenser, + timeout_ns: u64, + collected: *Collected, + stats: *output.Stats, + remaining: *std.atomic.Value(usize), +) void { + while (disp.next(io)) |t| { + const st = connectProbe(t.addr, t.port, timeout_ns); + _ = stats.sent.v.fetchAdd(1, .monotonic); + stats.record(st); + collected.push(io, .{ .addr = t.addr, .port = t.port, .state = st }); + } + _ = remaining.fetchSub(1, .release); +} + +pub const Params = struct { + source: Source, + count: u64, + total: u64, + rate: u64, + concurrency: usize, + timeout_ns: u64, + out_level: output.ColorLevel, + err_level: output.ColorLevel, + interactive: bool, + json: bool, + target_text: []const u8, + iface: []const u8, +}; + +fn drainNew(io: std.Io, collected: *Collected, cursor: *usize, json_out: ?*std.Io.Writer) void { + collected.mutex.lockUncancelable(io); + const n = collected.list.items.len; + if (json_out) |w| { + while (cursor.* < n) : (cursor.* += 1) { + output.emitJson(w, collected.list.items[cursor.*], "tcp") catch {}; + } + } else { + cursor.* = n; + } + collected.mutex.unlock(io); + if (json_out) |w| w.flush() catch {}; +} + +pub fn run(gpa: std.mem.Allocator, p: Params) !void { + const concurrency = std.math.clamp(p.concurrency, min_concurrency, max_concurrency); + + var threaded = std.Io.Threaded.init(gpa, .{ + .async_limit = .limited(concurrency + 1), + .concurrent_limit = .limited(concurrency + 1), + }); + defer threaded.deinit(); + const io = threaded.io(); + + var obuf: [4096]u8 = undefined; + var ow = std.Io.File.stdout().writer(io, &obuf); + const out = &ow.interface; + var ebuf: [4096]u8 = undefined; + var ew = std.Io.File.stderr().writer(io, &ebuf); + const derr = &ew.interface; + + var bucket = ratelimit.TokenBucket.init(p.rate, p.rate); + bucket.prime(monoNow()); + + var disp = Dispenser{ .source = p.source, .bucket = bucket, .remaining = p.count }; + var collected = Collected{ .allocator = gpa }; + defer collected.list.deinit(gpa); + var stats: output.Stats = .{}; + var remaining = std.atomic.Value(usize).init(concurrency); + const json_out: ?*std.Io.Writer = if (p.json) out else null; + + try derr.print("zingela connect scan target {s} iface {s} rate {d} pps concurrency {d} timeout {d}ms\n", .{ + p.target_text, p.iface, p.rate, concurrency, p.timeout_ns / NS_PER_MS, + }); + try derr.flush(); + + const t0 = monoNow(); + + var group: std.Io.Group = .init; + defer group.cancel(io); + var spawned: usize = 0; + while (spawned < concurrency) : (spawned += 1) { + group.async(io, worker, .{ io, &disp, p.timeout_ns, &collected, &stats, &remaining }); + } + + var dash = output.Dashboard.init(p.err_level, p.interactive, p.total); + const render_interval_ns: u64 = if (p.interactive) render_tick_interactive_ns else render_tick_plain_ns; + var cursor: usize = 0; + var last_render: u64 = 0; + + while (remaining.load(.acquire) > 0) { + drainNew(io, &collected, &cursor, json_out); + const now = monoNow(); + if (last_render == 0 or now -| last_render >= render_interval_ns) { + dash.render(derr, &stats, now -| t0) catch {}; + last_render = now; + } + std.Io.sleep(io, .{ .nanoseconds = @intCast(drain_tick_ns) }, .awake) catch {}; + } + + group.await(io) catch {}; + drainNew(io, &collected, &cursor, json_out); + dash.render(derr, &stats, monoNow() -| t0) catch {}; + + var open_n: u64 = 0; + var closed_n: u64 = 0; + var filtered_n: u64 = 0; + for (collected.list.items) |r| switch (r.state) { + .open => open_n += 1, + .closed => closed_n += 1, + .filtered => filtered_n += 1, + .unfiltered => {}, + }; + + if (!p.json) { + if (collected.list.items.len > 0) { + std.mem.sort(Result, collected.list.items, {}, output.ipPortLess); + try out.writeByte('\n'); + try output.renderTable(out, p.out_level, collected.list.items); + try out.flush(); + } else { + try derr.writeAll(" no hosts responded\n"); + } + } + + const elapsed_s = @as(f64, @floatFromInt(monoNow() - t0)) / @as(f64, @floatFromInt(NS_PER_SEC)); + try derr.writeByte('\n'); + try output.renderSummary(derr, p.err_level, stats.sent.v.load(.monotonic), "CONNECT", p.iface, elapsed_s, open_n, closed_n, filtered_n, 0, 0); + try derr.flush(); +} + +// ---- tests ---- + +test "classifySoError maps SO_ERROR values to scan states" { + try std.testing.expectEqual(State.open, classifySoError(0)); + try std.testing.expectEqual(State.closed, classifySoError(@intFromEnum(linux.E.CONNREFUSED))); + try std.testing.expectEqual(State.filtered, classifySoError(@intFromEnum(linux.E.HOSTUNREACH))); + try std.testing.expectEqual(State.filtered, classifySoError(@intFromEnum(linux.E.TIMEDOUT))); + try std.testing.expectEqual(State.filtered, classifySoError(9999)); +} + +test "connectOnce surfaces a retry for fd exhaustion but a real state otherwise" { + try std.testing.expect(connectOnce(.{ .v4 = 0x7f000001 }, 1, 200 * NS_PER_MS) == .done); +} + +const FoundListener = struct { server: net.Server, port: u16 }; + +fn bindFreeLoopback(io: std.Io, start: u16) ?FoundListener { + var port: u16 = start; + while (port < start +% 200) : (port += 1) { + var la: net.IpAddress = .{ .ip4 = net.Ip4Address.loopback(port) }; + if (net.IpAddress.listen(&la, io, .{ .reuse_address = true })) |s| { + return .{ .server = s, .port = port }; + } else |_| {} + } + return null; +} + +test "connectProbe classifies a live loopback listener as open and a released port as closed" { + var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const live = bindFreeLoopback(io, 39_211) orelse return error.SkipZigTest; + var live_server = live.server; + defer live_server.deinit(io); + + var dead = bindFreeLoopback(io, live.port + 1) orelse return error.SkipZigTest; + const dead_port = dead.port; + dead.server.deinit(io); + + try std.testing.expectEqual(State.open, connectProbe(.{ .v4 = 0x7f000001 }, live.port, 2 * NS_PER_SEC)); + try std.testing.expectEqual(State.closed, connectProbe(.{ .v4 = 0x7f000001 }, dead_port, 2 * NS_PER_SEC)); +} + +test "Dispenser stops at the count cap and hands out distinct targets under the token bucket" { + var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const cidr = try targets.parseCidr("8.8.8.0/28"); + const ports = [_]u16{ 80, 443 }; + var eng = try targets.Engine.init(std.testing.allocator, &.{cidr}, &ports, 0xABCDEF); + defer eng.deinit(); + + var bucket = ratelimit.TokenBucket.init(1_000_000, 1_000_000); + bucket.prime(monoNow()); + var disp = Dispenser{ .source = .{ .v4 = &eng }, .bucket = bucket, .remaining = 5 }; + + var seen = std.AutoHashMap(u64, void).init(std.testing.allocator); + defer seen.deinit(); + var n: usize = 0; + while (disp.next(io)) |t| { + const key = (@as(u64, t.addr.v4) << 16) | t.port; + try std.testing.expect(!seen.contains(key)); + try seen.put(key, {}); + n += 1; + } + try std.testing.expectEqual(@as(usize, 5), n); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/cookie.zig b/PROJECTS/advanced/zig-stateless-scanner/src/cookie.zig index 5811054d..d204fd4a 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/cookie.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/cookie.zig @@ -38,6 +38,23 @@ pub const Cookie = struct { const off: u32 = @intCast(self.generate(ip_them, port_them, ip_me, 0) % s); return @intCast((@as(u32, base) + off) & 0xffff); } + + pub fn generate6(self: Cookie, ip_them: [16]u8, port_them: u16, ip_me: [16]u8, port_me: u16) u64 { + var data: [36]u8 = undefined; + @memcpy(data[0..16], &ip_them); + std.mem.writeInt(u16, data[16..18], port_them, .big); + @memcpy(data[18..34], &ip_me); + std.mem.writeInt(u16, data[34..36], port_me, .big); + return std.hash.SipHash64(2, 4).toInt(&data, &self.key); + } + + pub fn seq6(self: Cookie, ip_them: [16]u8, port_them: u16, ip_me: [16]u8, port_me: u16) u32 { + return @truncate(self.generate6(ip_them, port_them, ip_me, port_me)); + } + + pub fn validateSynAck6(self: Cookie, ack: u32, ip_them: [16]u8, port_them: u16, ip_me: [16]u8, port_me: u16) bool { + return ack == self.seq6(ip_them, port_them, ip_me, port_me) +% 1; + } }; const test_key = [16]u8{ @@ -102,3 +119,38 @@ test "udpSrcPort separates distinct targets" { const p123 = c.udpSrcPort(0x08080808, 123, 0x0a000001, 40000, 8192); try std.testing.expect(p53 != p123); } + +const v6_them = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x11 }; +const v6_me = [16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x22 }; + +test "generate6 is deterministic for a fixed key + IPv6 4-tuple" { + const c = Cookie.init(test_key); + const a = c.generate6(v6_them, 443, v6_me, 51000); + const b = c.generate6(v6_them, 443, v6_me, 51000); + try std.testing.expectEqual(a, b); +} + +test "seq6 is the low 32 bits and validateSynAck6 accepts seq+1 with u32 wrap" { + const c = Cookie.init(test_key); + const full = c.generate6(v6_them, 443, v6_me, 51000); + const s = c.seq6(v6_them, 443, v6_me, 51000); + try std.testing.expectEqual(@as(u32, @truncate(full)), s); + try std.testing.expect(c.validateSynAck6(s +% 1, v6_them, 443, v6_me, 51000)); + try std.testing.expect(!c.validateSynAck6(s, v6_them, 443, v6_me, 51000)); + try std.testing.expect(!c.validateSynAck6(s +% 2, v6_them, 443, v6_me, 51000)); +} + +test "generate6 separates distinct addresses and ports" { + const c = Cookie.init(test_key); + var other = v6_them; + other[15] = 0x12; + try std.testing.expect(c.generate6(v6_them, 443, v6_me, 51000) != c.generate6(other, 443, v6_me, 51000)); + try std.testing.expect(c.generate6(v6_them, 443, v6_me, 51000) != c.generate6(v6_them, 80, v6_me, 51000)); +} + +test "v4 and v6 cookies over analogous tuples do not collide" { + const c = Cookie.init(test_key); + const v4 = c.generate(0x0a000001, 443, 0xc0a80002, 51000); + const v6 = c.generate6(v6_them, 443, v6_me, 51000); + try std.testing.expect(v4 != v6); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/ndp.zig b/PROJECTS/advanced/zig-stateless-scanner/src/ndp.zig new file mode 100644 index 00000000..742a5cf1 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/ndp.zig @@ -0,0 +1,198 @@ +// ©AngelaMos | 2026 +// ndp.zig + +const std = @import("std"); +const linux = std.os.linux; +const packet = @import("packet"); + +const ETH_P_ALL: u16 = 0x0003; +const ethertype_ipv6: u16 = 0x86dd; +const ip6_version_tc_flow: u32 = 0x60000000; +const ip6_next_icmpv6: u8 = 58; +const ndp_hop_limit: u8 = 255; + +const icmp6_ns: u8 = 135; +const icmp6_na: u8 = 136; +const opt_src_lladdr: u8 = 1; + +const eth_len: usize = 14; +const ip6_len: usize = 40; +const ns_len: usize = 24; +const opt_len: usize = 8; +pub const solicit_frame_len: usize = eth_len + ip6_len + ns_len + opt_len; + +const icmp6_off: usize = eth_len + ip6_len; +const na_target_off: usize = icmp6_off + 8; + +const NS_PER_MS: u64 = 1_000_000; +const NS_PER_SEC: u64 = 1_000_000_000; + +fn solicitedNodeMulticast(target: [16]u8) [16]u8 { + return [16]u8{ 0xff, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0xff, target[13], target[14], target[15] }; +} + +fn multicastMac(target: [16]u8) [6]u8 { + return [6]u8{ 0x33, 0x33, 0xff, target[13], target[14], target[15] }; +} + +pub fn buildSolicit(our_ip6: [16]u8, our_mac: [6]u8, target: [16]u8) [solicit_frame_len]u8 { + var frame = [_]u8{0} ** solicit_frame_len; + const mcast = solicitedNodeMulticast(target); + + const eth = packet.EthHdr{ + .dst = multicastMac(target), + .src = our_mac, + .ethertype = std.mem.nativeToBig(u16, ethertype_ipv6), + }; + @memcpy(frame[0..eth_len], std.mem.asBytes(ð)); + + const ip6 = packet.Ipv6Hdr{ + .version_tc_flow = std.mem.nativeToBig(u32, ip6_version_tc_flow), + .payload_len = std.mem.nativeToBig(u16, ns_len + opt_len), + .next_header = ip6_next_icmpv6, + .hop_limit = ndp_hop_limit, + .src = our_ip6, + .dst = mcast, + }; + @memcpy(frame[eth_len .. eth_len + ip6_len], std.mem.asBytes(&ip6)); + + frame[icmp6_off] = icmp6_ns; + frame[icmp6_off + 1] = 0; + @memcpy(frame[icmp6_off + 8 .. icmp6_off + 24], &target); + frame[icmp6_off + 24] = opt_src_lladdr; + frame[icmp6_off + 25] = 1; + @memcpy(frame[icmp6_off + 26 .. icmp6_off + 32], &our_mac); + + const ck = packet.pseudoChecksum6(our_ip6, mcast, ip6_next_icmpv6, frame[icmp6_off..]); + std.mem.writeInt(u16, frame[icmp6_off + 2 ..][0..2], ck, .big); + return frame; +} + +pub fn matchNa(frame: []const u8, target: [16]u8) ?[6]u8 { + if (frame.len < eth_len + ip6_len + 24) return null; + if (std.mem.readInt(u16, frame[12..14], .big) != ethertype_ipv6) return null; + if (frame[eth_len + 6] != ip6_next_icmpv6) return null; + if (frame[eth_len + 7] != ndp_hop_limit) return null; + if (frame[icmp6_off] != icmp6_na) return null; + if (frame[icmp6_off + 1] != 0) return null; + if (target[0] == 0xff) return null; + if (!std.mem.eql(u8, frame[na_target_off .. na_target_off + 16], &target)) return null; + return frame[6..12].*; +} + +const OpenError = error{ NeedCapNetRaw, Failed }; + +fn openBound(ifname: []const u8) OpenError!struct { fd: i32, sll: linux.sockaddr.ll } { + const rc_sock = linux.socket(linux.AF.PACKET, linux.SOCK.RAW, std.mem.nativeToBig(u16, ETH_P_ALL)); + switch (linux.errno(rc_sock)) { + .SUCCESS => {}, + .PERM, .ACCES => return error.NeedCapNetRaw, + else => return error.Failed, + } + const fd: i32 = @intCast(rc_sock); + errdefer _ = linux.close(fd); + + var ifr = std.mem.zeroes(linux.ifreq); + if (ifname.len >= ifr.ifrn.name.len) return error.Failed; + @memcpy(ifr.ifrn.name[0..ifname.len], ifname); + if (linux.errno(linux.ioctl(fd, linux.SIOCGIFINDEX, @intFromPtr(&ifr))) != .SUCCESS) return error.Failed; + + var sll = std.mem.zeroes(linux.sockaddr.ll); + sll.family = linux.AF.PACKET; + sll.protocol = std.mem.nativeToBig(u16, ETH_P_ALL); + sll.ifindex = ifr.ifru.ivalue; + if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS) return error.Failed; + return .{ .fd = fd, .sll = sll }; +} + +fn monoMs() i64 { + var ts: linux.timespec = undefined; + _ = linux.clock_gettime(.MONOTONIC, &ts); + return @as(i64, @intCast(ts.sec)) * 1000 + @divTrunc(@as(i64, @intCast(ts.nsec)), NS_PER_MS); +} + +pub const ResolveError = error{ NeedCapNetRaw, SocketFailed, NoNeighbor }; + +pub fn resolve(ifname: []const u8, our_ip6: [16]u8, our_mac: [6]u8, target: [16]u8, timeout_ms: i64) ResolveError![6]u8 { + const s = openBound(ifname) catch |e| return switch (e) { + error.NeedCapNetRaw => error.NeedCapNetRaw, + error.Failed => error.SocketFailed, + }; + defer _ = linux.close(s.fd); + var sll = s.sll; + + const frame = buildSolicit(our_ip6, our_mac, target); + var send_round: usize = 0; + while (send_round < 3) : (send_round += 1) { + _ = linux.sendto(s.fd, &frame, frame.len, 0, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll)); + } + + var buf: [2048]u8 = undefined; + const deadline = monoMs() + timeout_ms; + while (monoMs() < deadline) { + var pfd = [_]linux.pollfd{.{ .fd = s.fd, .events = linux.POLL.IN, .revents = 0 }}; + const pr = linux.poll(&pfd, 1, 50); + switch (linux.errno(pr)) { + .SUCCESS => {}, + .INTR => continue, + else => return error.NoNeighbor, + } + if (pr == 0) continue; + const rc = linux.recvfrom(s.fd, &buf, buf.len, 0, null, null); + switch (linux.errno(rc)) { + .SUCCESS => {}, + .INTR, .AGAIN => continue, + else => return error.NoNeighbor, + } + const n: usize = @intCast(rc); + if (matchNa(buf[0..n], target)) |mac| return mac; + } + return error.NoNeighbor; +} + +// ---- tests ---- + +const k_our_ip6 = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01 }; +const k_our_mac = [6]u8{ 0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0x01 }; +const k_target = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x11, 0x22, 0x33 }; + +test "solicited-node multicast + MAC derive from the target's low 24 bits" { + try std.testing.expectEqual([16]u8{ 0xff, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0xff, 0x11, 0x22, 0x33 }, solicitedNodeMulticast(k_target)); + try std.testing.expectEqual([6]u8{ 0x33, 0x33, 0xff, 0x11, 0x22, 0x33 }, multicastMac(k_target)); +} + +test "buildSolicit produces a hop-limit-255 NS with a self-verifying ICMPv6 checksum" { + const f = buildSolicit(k_our_ip6, k_our_mac, k_target); + try std.testing.expectEqual(@as(usize, 86), f.len); + try std.testing.expectEqual(@as(u16, ethertype_ipv6), std.mem.readInt(u16, f[12..14], .big)); + try std.testing.expectEqualSlices(u8, &multicastMac(k_target), f[0..6]); + try std.testing.expectEqual(ndp_hop_limit, f[eth_len + 7]); + try std.testing.expectEqual(icmp6_ns, f[icmp6_off]); + try std.testing.expectEqualSlices(u8, &k_target, f[icmp6_off + 8 .. icmp6_off + 24]); + try std.testing.expectEqualSlices(u8, &k_our_mac, f[icmp6_off + 26 .. icmp6_off + 32]); + const mcast = solicitedNodeMulticast(k_target); + try std.testing.expectEqual(@as(u16, 0), packet.pseudoChecksum6(k_our_ip6, mcast, ip6_next_icmpv6, f[icmp6_off..])); +} + +test "matchNa returns the neighbor MAC for a matching advertisement and rejects mismatches" { + const neighbor_mac = [6]u8{ 0x02, 0x11, 0x22, 0x33, 0x44, 0x55 }; + var na = [_]u8{0} ** 78; + @memcpy(na[6..12], &neighbor_mac); + std.mem.writeInt(u16, na[12..14], ethertype_ipv6, .big); + na[eth_len + 6] = ip6_next_icmpv6; + na[eth_len + 7] = ndp_hop_limit; + na[icmp6_off] = icmp6_na; + @memcpy(na[na_target_off .. na_target_off + 16], &k_target); + try std.testing.expectEqual(neighbor_mac, matchNa(&na, k_target).?); + + var low_hop = na; + low_hop[eth_len + 7] = 64; + try std.testing.expect(matchNa(&low_hop, k_target) == null); + + var wrong = k_target; + wrong[15] = 0x99; + try std.testing.expect(matchNa(&na, wrong) == null); + + na[icmp6_off] = icmp6_ns; + try std.testing.expect(matchNa(&na, k_target) == null); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/netutil.zig b/PROJECTS/advanced/zig-stateless-scanner/src/netutil.zig index adb5a6d5..f78528a1 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/netutil.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/netutil.zig @@ -33,6 +33,43 @@ pub fn parseIpv4(text: []const u8) !u32 { return addr; } +fn parseV6Groups(text: []const u8, out: *[8]u16) !usize { + if (text.len == 0) return 0; + var n: usize = 0; + var it = std.mem.splitScalar(u8, text, ':'); + while (it.next()) |part| { + if (n >= 8) return error.InvalidIpv6; + if (part.len == 0 or part.len > 4) return error.InvalidIpv6; + for (part) |c| if (!std.ascii.isHex(c)) return error.InvalidIpv6; + out[n] = std.fmt.parseInt(u16, part, 16) catch return error.InvalidIpv6; + n += 1; + } + return n; +} + +pub fn parseIpv6(text: []const u8) ![16]u8 { + if (text.len == 0 or text.len > 45) return error.InvalidIpv6; + var out = [_]u8{0} ** 16; + + if (std.mem.indexOf(u8, text, "::")) |pos| { + if (std.mem.indexOf(u8, text[pos + 2 ..], "::") != null) return error.InvalidIpv6; + var left: [8]u16 = undefined; + var right: [8]u16 = undefined; + const ln = try parseV6Groups(text[0..pos], &left); + const rn = try parseV6Groups(text[pos + 2 ..], &right); + if (ln + rn > 7) return error.InvalidIpv6; + for (0..ln) |i| std.mem.writeInt(u16, out[i * 2 ..][0..2], left[i], .big); + for (0..rn) |i| std.mem.writeInt(u16, out[(8 - rn + i) * 2 ..][0..2], right[i], .big); + return out; + } + + var groups: [8]u16 = undefined; + const n = try parseV6Groups(text, &groups); + if (n != 8) return error.InvalidIpv6; + for (0..8) |i| std.mem.writeInt(u16, out[i * 2 ..][0..2], groups[i], .big); + return out; +} + pub fn parseMac(text: []const u8) ![6]u8 { var mac: [6]u8 = undefined; var octets: usize = 0; @@ -82,6 +119,93 @@ pub fn resolveSrcMac(ifname: []const u8) ![6]u8 { return sa.data[0..6].*; } +fn parseIfInet6(contents: []const u8, ifname: []const u8) ?[16]u8 { + var fallback: ?[16]u8 = null; + var lines = std.mem.splitScalar(u8, contents, '\n'); + while (lines.next()) |line| { + var it = std.mem.tokenizeAny(u8, line, " \t"); + const addr_hex = it.next() orelse continue; + _ = it.next() orelse continue; + _ = it.next() orelse continue; + const scope = it.next() orelse continue; + _ = it.next() orelse continue; + const dev = it.next() orelse continue; + if (!std.mem.eql(u8, dev, ifname)) continue; + if (addr_hex.len != 32) continue; + var addr: [16]u8 = undefined; + _ = std.fmt.hexToBytes(&addr, addr_hex) catch continue; + if (std.mem.eql(u8, scope, "00")) return addr; + if (fallback == null) fallback = addr; + } + return fallback; +} + +pub fn resolveSrcIp6(ifname: []const u8) ![16]u8 { + const rc = linux.openat(linux.AT.FDCWD, "/proc/net/if_inet6", .{}, 0); + if (linux.errno(rc) != .SUCCESS) return error.NoV6Address; + const fd: i32 = @intCast(rc); + defer _ = linux.close(fd); + + var buf: [16384]u8 = undefined; + var total: usize = 0; + while (total < buf.len) { + const n_rc = linux.read(fd, buf[total..].ptr, buf.len - total); + switch (linux.errno(n_rc)) { + .SUCCESS => {}, + .INTR => continue, + else => return error.NoV6Address, + } + const n: usize = @intCast(n_rc); + if (n == 0) break; + total += n; + } + return parseIfInet6(buf[0..total], ifname) orelse error.NoV6Address; +} + +fn parseIpv6Route(contents: []const u8, ifname: []const u8) ?[16]u8 { + var lines = std.mem.splitScalar(u8, contents, '\n'); + while (lines.next()) |line| { + var it = std.mem.tokenizeAny(u8, line, " \t"); + const dest = it.next() orelse continue; + const dest_plen = it.next() orelse continue; + _ = it.next() orelse continue; + _ = it.next() orelse continue; + const nexthop = it.next() orelse continue; + var i: usize = 0; + while (i < 4) : (i += 1) _ = it.next() orelse break; + const dev = it.next() orelse continue; + if (!std.mem.eql(u8, dev, ifname)) continue; + if (!std.mem.eql(u8, dest_plen, "00")) continue; + if (dest.len != 32 or nexthop.len != 32) continue; + var addr: [16]u8 = undefined; + _ = std.fmt.hexToBytes(&addr, nexthop) catch continue; + if (std.mem.allEqual(u8, &addr, 0)) continue; + return addr; + } + return null; +} + +pub fn defaultGateway6(ifname: []const u8) ?[16]u8 { + const rc = linux.openat(linux.AT.FDCWD, "/proc/net/ipv6_route", .{}, 0); + if (linux.errno(rc) != .SUCCESS) return null; + const fd: i32 = @intCast(rc); + defer _ = linux.close(fd); + var buf: [65536]u8 = undefined; + var total: usize = 0; + while (total < buf.len) { + const n_rc = linux.read(fd, buf[total..].ptr, buf.len - total); + switch (linux.errno(n_rc)) { + .SUCCESS => {}, + .INTR => continue, + else => return null, + } + const n: usize = @intCast(n_rc); + if (n == 0) break; + total += n; + } + return parseIpv6Route(buf[0..total], ifname); +} + pub const RealClock = struct { pub fn now(_: *RealClock) u64 { var ts: linux.timespec = undefined; @@ -104,6 +228,55 @@ test "parseIpv4 round-trips dotted quads" { try std.testing.expectError(error.InvalidIpv4, parseIpv4("256.0.0.1")); } +test "parseIpv6 handles compression, full form, and edge positions" { + try std.testing.expectEqual([16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, try parseIpv6("::1")); + try std.testing.expectEqual([16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, try parseIpv6("::")); + try std.testing.expectEqual([16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, try parseIpv6("fe80::")); + const a = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }; + try std.testing.expectEqual(a, try parseIpv6("2001:db8::1")); + try std.testing.expectEqual(a, try parseIpv6("2001:db8:0:0:0:0:0:1")); + try std.testing.expectEqual(a, try parseIpv6("2001:0db8:0000:0000:0000:0000:0000:0001")); + try std.testing.expectEqual([16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, try parseIpv6("2001:db8:1::1")); + try std.testing.expectEqual([16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xc0, 0xa8, 0, 1 }, try parseIpv6("::ffff:c0a8:1")); +} + +test "parseIpv6 rejects malformed literals" { + try std.testing.expectError(error.InvalidIpv6, parseIpv6("")); + try std.testing.expectError(error.InvalidIpv6, parseIpv6("1:2:3")); + try std.testing.expectError(error.InvalidIpv6, parseIpv6(":1")); + try std.testing.expectError(error.InvalidIpv6, parseIpv6("1::2::3")); + try std.testing.expectError(error.InvalidIpv6, parseIpv6("gggg::")); + try std.testing.expectError(error.InvalidIpv6, parseIpv6("1:2:3:4:5:6:7:8:9")); + try std.testing.expectError(error.InvalidIpv6, parseIpv6("12345::")); + try std.testing.expectError(error.InvalidIpv6, parseIpv6("2001:+db8::1")); + try std.testing.expectError(error.InvalidIpv6, parseIpv6("2001:d_b8::1")); +} + +test "parseIfInet6 prefers a global-scope address and falls back to link-local" { + const contents = + "fe80000000000000e81e99fffeae865d 1b245 40 20 80 veth0\n" ++ + "20010db8000000000000000000000001 1b245 40 00 80 veth0\n" ++ + "00000000000000000000000000000001 00001 80 10 80 lo\n"; + const g = parseIfInet6(contents, "veth0").?; + try std.testing.expectEqual([16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, g); + + const only_ll = "fe80000000000000e81e99fffeae865d 1b245 40 20 80 eth9\n"; + const ll = parseIfInet6(only_ll, "eth9").?; + try std.testing.expectEqual(@as(u8, 0xfe), ll[0]); + try std.testing.expectEqual(@as(u8, 0x80), ll[1]); + + try std.testing.expect(parseIfInet6(contents, "eth0") == null); +} + +test "parseIpv6Route returns the default-route nexthop for the matching iface" { + const contents = + "20010db8000000000000000000000000 40 00000000000000000000000000000000 00 00000000000000000000000000000000 00000100 00000000 00000000 00000001 veth0\n" ++ + "00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000000 00000000 00000003 veth0\n"; + const gw = parseIpv6Route(contents, "veth0").?; + try std.testing.expectEqual([16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, gw); + try std.testing.expect(parseIpv6Route(contents, "eth9") == null); +} + test "parseMac parses colon-separated hex" { try std.testing.expectEqual([6]u8{ 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff }, try parseMac("aa:bb:cc:dd:ee:ff")); try std.testing.expectEqual([_]u8{0} ** 6, try parseMac("00:00:00:00:00:00")); diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/output.zig b/PROJECTS/advanced/zig-stateless-scanner/src/output.zig index 5ec5309d..1d912d76 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/output.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/output.zig @@ -3,6 +3,7 @@ const std = @import("std"); const classify = @import("classify"); +const packet = @import("packet"); pub const State = classify.State; pub const Result = classify.Result; @@ -209,6 +210,60 @@ fn writeIp(out: *std.Io.Writer, ip: u32) !void { try out.print("{d}.{d}.{d}.{d}", .{ (ip >> 24) & 0xff, (ip >> 16) & 0xff, (ip >> 8) & 0xff, ip & 0xff }); } +pub const max_addr_str: usize = 45; + +fn writeIp6(out: *std.Io.Writer, b: [16]u8) !void { + var groups: [8]u16 = undefined; + for (0..8) |i| groups[i] = (@as(u16, b[i * 2]) << 8) | b[i * 2 + 1]; + + var best_start: usize = 8; + var best_len: usize = 1; + var i: usize = 0; + while (i < 8) { + if (groups[i] != 0) { + i += 1; + continue; + } + var j = i + 1; + while (j < 8 and groups[j] == 0) j += 1; + if (j - i > best_len) { + best_len = j - i; + best_start = i; + } + i = j; + } + const compress = best_start < 8; + + var k: usize = 0; + var first = true; + while (k < 8) { + if (compress and k == best_start) { + try out.writeAll("::"); + k += best_len; + first = true; + continue; + } + if (!first) try out.writeByte(':'); + try out.print("{x}", .{groups[k]}); + first = false; + k += 1; + } +} + +fn writeAddr(out: *std.Io.Writer, addr: packet.Addr) !void { + switch (addr) { + .v4 => |ip| try writeIp(out, ip), + .v6 => |b| try writeIp6(out, b), + } +} + +fn addrWidth(addr: packet.Addr) usize { + var buf: [max_addr_str]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + writeAddr(&w, addr) catch return max_addr_str; + return w.end; +} + fn stateName(st: State) []const u8 { return switch (st) { .open => "open", @@ -351,7 +406,7 @@ pub const Dashboard = struct { pub fn emitJson(out: *std.Io.Writer, r: Result, proto: []const u8) !void { try out.print("{{\"ip\":\"", .{}); - try writeIp(out, r.ip); + try writeAddr(out, r.addr); try out.print("\",\"port\":{d},\"proto\":\"{s}\",\"state\":\"{s}\"}}\n", .{ r.port, proto, stateName(r.state) }); } @@ -364,10 +419,10 @@ fn repeat(out: *std.Io.Writer, cell: []const u8, n: usize) !void { while (i < n) : (i += 1) try out.writeAll(cell); } -fn rule(out: *std.Io.Writer, level: ColorLevel, left: []const u8, mid: []const u8, right: []const u8) !void { +fn rule(out: *std.Io.Writer, level: ColorLevel, hw: usize, left: []const u8, mid: []const u8, right: []const u8) !void { try setFg(out, level, chrome_gray); try out.writeAll(left); - try repeat(out, box_h, w_host + 2); + try repeat(out, box_h, hw + 2); try out.writeAll(mid); try repeat(out, box_h, w_port + 2); try out.writeAll(mid); @@ -383,14 +438,17 @@ fn pad(out: *std.Io.Writer, n: usize) !void { } pub fn renderTable(out: *std.Io.Writer, level: ColorLevel, results: []const Result) !void { + var hw: usize = "HOST".len; + for (results) |r| hw = @max(hw, addrWidth(r.addr)); + try out.writeAll(" "); - try rule(out, level, "\u{250c}", "\u{252c}", "\u{2510}"); + try rule(out, level, hw, "\u{250c}", "\u{252c}", "\u{2510}"); try out.writeAll(" "); try span(out, level, chrome_gray, "\u{2502} "); try setFg(out, level, bright_white); try out.writeAll("HOST"); - try pad(out, w_host - "HOST".len); + try pad(out, hw - "HOST".len); try span(out, level, chrome_gray, " \u{2502} "); try setFg(out, level, bright_white); try pad(out, w_port - "PORT".len); @@ -403,12 +461,12 @@ pub fn renderTable(out: *std.Io.Writer, level: ColorLevel, results: []const Resu try out.writeByte('\n'); try out.writeAll(" "); - try rule(out, level, "\u{251c}", "\u{253c}", "\u{2524}"); + try rule(out, level, hw, "\u{251c}", "\u{253c}", "\u{2524}"); for (results) |r| { - var ipbuf: [15]u8 = undefined; + var ipbuf: [max_addr_str]u8 = undefined; var ipw = std.Io.Writer.fixed(&ipbuf); - try writeIp(&ipw, r.ip); + try writeAddr(&ipw, r.addr); const ip_str = ipbuf[0..ipw.end]; try out.writeAll(" "); @@ -416,7 +474,7 @@ pub fn renderTable(out: *std.Io.Writer, level: ColorLevel, results: []const Resu try setFg(out, level, bright_white); try out.writeAll(ip_str); try resetFg(out, level); - try pad(out, w_host - ip_str.len); + try pad(out, hw - ip_str.len); try span(out, level, chrome_gray, " \u{2502} "); var portbuf: [5]u8 = undefined; @@ -437,12 +495,15 @@ pub fn renderTable(out: *std.Io.Writer, level: ColorLevel, results: []const Resu } try out.writeAll(" "); - try rule(out, level, "\u{2514}", "\u{2534}", "\u{2518}"); + try rule(out, level, hw, "\u{2514}", "\u{2534}", "\u{2518}"); } pub fn ipPortLess(_: void, a: Result, b: Result) bool { - if (a.ip != b.ip) return a.ip < b.ip; - return a.port < b.port; + return switch (packet.Addr.order(a.addr, b.addr)) { + .lt => true, + .gt => false, + .eq => a.port < b.port, + }; } pub const ServiceRow = struct { @@ -668,13 +729,13 @@ test "Stats.record tallies per-state and total found without cross-talk" { test "emitJson writes one greppable NDJSON object per result with its proto" { var buf: [128]u8 = undefined; var w = std.Io.Writer.fixed(&buf); - try emitJson(&w, .{ .ip = 0x0a000005, .port = 80, .state = .open }, "tcp"); + try emitJson(&w, Result.v4(0x0a000005, 80, .open), "tcp"); try std.testing.expectEqualStrings( "{\"ip\":\"10.0.0.5\",\"port\":80,\"proto\":\"tcp\",\"state\":\"open\"}\n", buf[0..w.end], ); w = std.Io.Writer.fixed(&buf); - try emitJson(&w, .{ .ip = 0x08080808, .port = 53, .state = .closed }, "udp"); + try emitJson(&w, Result.v4(0x08080808, 53, .closed), "udp"); try std.testing.expectEqualStrings( "{\"ip\":\"8.8.8.8\",\"port\":53,\"proto\":\"udp\",\"state\":\"closed\"}\n", buf[0..w.end], @@ -695,8 +756,8 @@ test "renderTable with no color is plain and contains every host row" { var buf: [1024]u8 = undefined; var w = std.Io.Writer.fixed(&buf); const rows = [_]Result{ - .{ .ip = 0x0a000005, .port = 80, .state = .open }, - .{ .ip = 0x0a000006, .port = 443, .state = .closed }, + Result.v4(0x0a000005, 80, .open), + Result.v4(0x0a000006, 443, .closed), }; try renderTable(&w, .none, &rows); const text = buf[0..w.end]; @@ -721,10 +782,39 @@ test "dashboard non-interactive frame is a single plain line" { try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, text, "\n")); } +test "writeIp6 renders RFC 5952 compressed IPv6 (longest-first run, no single-group elision)" { + const cases = .{ + .{ [16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "::" }, + .{ [16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, "::1" }, + .{ [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, "2001:db8::1" }, + .{ [16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "fe80::" }, + .{ [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, "2001:db8:1::1" }, + .{ [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6 }, "2001:db8:1:2:3:4:5:6" }, + .{ [16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xc0, 0xa8, 0, 1 }, "::ffff:c0a8:1" }, + }; + inline for (cases) |c| { + var buf: [max_addr_str]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + try writeIp6(&w, c[0]); + try std.testing.expectEqualStrings(c[1], buf[0..w.end]); + } +} + +test "emitJson renders an IPv6 result address" { + var buf: [128]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + const a = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }; + try emitJson(&w, Result.v6(a, 443, .open), "tcp"); + try std.testing.expectEqualStrings( + "{\"ip\":\"2001:db8::1\",\"port\":443,\"proto\":\"tcp\",\"state\":\"open\"}\n", + buf[0..w.end], + ); +} + test "ipPortLess orders by ip then port" { - const a = Result{ .ip = 0x0a000001, .port = 443, .state = .open }; - const b = Result{ .ip = 0x0a000001, .port = 80, .state = .open }; - const c = Result{ .ip = 0x0a000002, .port = 1, .state = .open }; + const a = Result.v4(0x0a000001, 443, .open); + const b = Result.v4(0x0a000001, 80, .open); + const c = Result.v4(0x0a000002, 1, .open); try std.testing.expect(ipPortLess({}, b, a)); try std.testing.expect(ipPortLess({}, a, c)); try std.testing.expect(!ipPortLess({}, a, b)); diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig b/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig index 7b3d40ec..de64b3ef 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig @@ -42,13 +42,46 @@ pub const UdpHdr = extern struct { checksum: u16, }; +pub const Ipv6Hdr = extern struct { + version_tc_flow: u32, + payload_len: u16, + next_header: u8, + hop_limit: u8, + src: [16]u8, + dst: [16]u8, +}; + comptime { std.debug.assert(@sizeOf(EthHdr) == 14); std.debug.assert(@sizeOf(Ipv4Hdr) == 20); std.debug.assert(@sizeOf(TcpHdr) == 20); std.debug.assert(@sizeOf(UdpHdr) == 8); + std.debug.assert(@sizeOf(Ipv6Hdr) == 40); } +pub const Addr = union(enum) { + v4: u32, + v6: [16]u8, + + pub fn eql(a: Addr, b: Addr) bool { + if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; + return switch (a) { + .v4 => |x| x == b.v4, + .v6 => |x| std.mem.eql(u8, &x, &b.v6), + }; + } + + pub fn order(a: Addr, b: Addr) std.math.Order { + const fam_a: u2 = if (a == .v4) 0 else 1; + const fam_b: u2 = if (b == .v4) 0 else 1; + if (fam_a != fam_b) return std.math.order(fam_a, fam_b); + return switch (a) { + .v4 => |x| std.math.order(x, b.v4), + .v6 => |x| std.mem.order(u8, &x, &b.v6), + }; + } +}; + pub fn checksum(bytes: []const u8) u16 { var sum: u32 = 0; var i: usize = 0; @@ -126,6 +159,27 @@ pub fn tcpChecksum(src_be: u32, dst_be: u32, segment: []const u8) u16 { return ~@as(u16, @truncate(sum)); } +pub fn pseudoChecksum6(src: [16]u8, dst: [16]u8, next_header: u8, payload: []const u8) u16 { + var sum: u32 = 0; + var i: usize = 0; + while (i + 1 < src.len) : (i += 2) sum += (@as(u32, src[i]) << 8) | src[i + 1]; + i = 0; + while (i + 1 < dst.len) : (i += 2) sum += (@as(u32, dst[i]) << 8) | dst[i + 1]; + const len: u32 = @intCast(payload.len); + sum += (len >> 16) & 0xffff; + sum += len & 0xffff; + sum += next_header; + i = 0; + while (i + 1 < payload.len) : (i += 2) sum += (@as(u32, payload[i]) << 8) | payload[i + 1]; + if (i < payload.len) sum += @as(u32, payload[i]) << 8; + while (sum >> 16 != 0) sum = (sum & 0xffff) + (sum >> 16); + return ~@as(u16, @truncate(sum)); +} + +pub fn tcpChecksum6(src: [16]u8, dst: [16]u8, segment: []const u8) u16 { + return pseudoChecksum6(src, dst, 6, segment); +} + pub fn udpChecksum(src_be: u32, dst_be: u32, segment: []const u8) u16 { var pseudo: [12]u8 = undefined; @memcpy(pseudo[0..4], std.mem.asBytes(&src_be)); @@ -429,6 +483,44 @@ test "tcpChecksum self-verifies: a segment with its correct checksum folds to 0" try std.testing.expectEqual(@as(u16, 0), tcpChecksum(src, dst, std.mem.asBytes(&tcp))); } +test "Ipv6 header is wire-exact 40 bytes" { + try std.testing.expectEqual(@as(usize, 40), @sizeOf(Ipv6Hdr)); +} + +test "tcpChecksum6 equals a full RFC 1071 sum over the assembled IPv6 pseudo-header (independent path)" { + const src = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }; + const dst = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2 }; + const seg = [_]u8{ 0xde, 0xad, 0x00, 0x50, 0x11, 0x22, 0x33, 0x44, 0, 0, 0, 0, 0x50, 0x02, 0x04, 0x00, 0, 0, 0, 0 }; + var buf: [40 + seg.len]u8 = undefined; + @memcpy(buf[0..16], &src); + @memcpy(buf[16..32], &dst); + std.mem.writeInt(u32, buf[32..36], @intCast(seg.len), .big); + buf[36] = 0; + buf[37] = 0; + buf[38] = 0; + buf[39] = 6; + @memcpy(buf[40..], &seg); + try std.testing.expectEqual(checksum(&buf), tcpChecksum6(src, dst, &seg)); +} + +test "tcpChecksum6 self-verifies: a correctly-summed segment folds back to 0" { + const src = [16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }; + const dst = [16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2 }; + var tcp = TcpHdr{ + .src_port = std.mem.nativeToBig(u16, 54321), + .dst_port = std.mem.nativeToBig(u16, 443), + .seq = std.mem.nativeToBig(u32, 0x1234_5678), + .ack = 0, + .data_off_ns = 0x50, + .flags = 0x02, + .window = std.mem.nativeToBig(u16, 65535), + .checksum = 0, + .urgent = 0, + }; + tcp.checksum = std.mem.nativeToBig(u16, tcpChecksum6(src, dst, std.mem.asBytes(&tcp))); + try std.testing.expectEqual(@as(u16, 0), tcpChecksum6(src, dst, std.mem.asBytes(&tcp))); +} + test "udpChecksum self-verifies: a correct datagram re-sums to the 0xFFFF all-ones marker" { const src = std.mem.nativeToBig(u32, 0x7f000001); const dst = std.mem.nativeToBig(u32, 0x08080808); @@ -529,3 +621,28 @@ test "scan-type and OS-profile parsers round-trip the CLI spellings" { try std.testing.expectEqual(OsProfile.macos, OsProfile.parse("macos").?); try std.testing.expect(OsProfile.parse("bogus") == null); } + +test "Addr.eql distinguishes families and values" { + const a = Addr{ .v4 = 0x0a000001 }; + const b = Addr{ .v4 = 0x0a000001 }; + const c = Addr{ .v4 = 0x0a000002 }; + const v6a = Addr{ .v6 = [_]u8{0} ** 15 ++ [_]u8{1} }; + const v6b = Addr{ .v6 = [_]u8{0} ** 15 ++ [_]u8{1} }; + const v6c = Addr{ .v6 = [_]u8{0} ** 15 ++ [_]u8{2} }; + try std.testing.expect(a.eql(b)); + try std.testing.expect(!a.eql(c)); + try std.testing.expect(v6a.eql(v6b)); + try std.testing.expect(!v6a.eql(v6c)); + try std.testing.expect(!a.eql(v6a)); +} + +test "Addr.order sorts v4 before v6, then by value" { + const v4lo = Addr{ .v4 = 1 }; + const v4hi = Addr{ .v4 = 2 }; + const v6lo = Addr{ .v6 = [_]u8{0} ** 16 }; + const v6hi = Addr{ .v6 = [_]u8{0} ** 15 ++ [_]u8{1} }; + try std.testing.expectEqual(std.math.Order.lt, v4lo.order(v4hi)); + try std.testing.expectEqual(std.math.Order.lt, v4hi.order(v6lo)); + try std.testing.expectEqual(std.math.Order.lt, v6lo.order(v6hi)); + try std.testing.expectEqual(std.math.Order.eq, v6hi.order(v6hi)); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/rawprobe.zig b/PROJECTS/advanced/zig-stateless-scanner/src/rawprobe.zig new file mode 100644 index 00000000..905820f2 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/rawprobe.zig @@ -0,0 +1,172 @@ +// ©AngelaMos | 2026 +// rawprobe.zig + +const std = @import("std"); +const linux = std.os.linux; + +pub const Status = enum { + ok, + no_cap, + no_egress, + + pub fn reason(self: Status) []const u8 { + return switch (self) { + .ok => "raw sends work", + .no_cap => "no CAP_NET_RAW", + .no_egress => "raw sends are silently dropped", + }; + } +}; + +const ETH_P_ALL: u16 = 0x0003; +const probe_ethertype: u16 = 0x88b5; +const eth_hdr_len: usize = 14; +const nonce_off: usize = eth_hdr_len; +const nonce_len: usize = 8; +const frame_len: usize = 60; +const poll_budget_ms: i64 = 250; +const poll_tick_ms: i32 = 25; +const probe_mac = [6]u8{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x00 }; + +fn monoMs() i64 { + var ts: linux.timespec = undefined; + _ = linux.clock_gettime(.MONOTONIC, &ts); + return @as(i64, @intCast(ts.sec)) * 1000 + @divTrunc(@as(i64, @intCast(ts.nsec)), 1_000_000); +} + +fn freshNonce() [nonce_len]u8 { + var ts: linux.timespec = undefined; + _ = linux.clock_gettime(.MONOTONIC, &ts); + const mixed: u64 = (@as(u64, @intCast(ts.nsec)) ^ (@as(u64, @intCast(ts.sec)) << 20)) *% 0x9e3779b97f4a7c15; + var out: [nonce_len]u8 = undefined; + std.mem.writeInt(u64, &out, mixed, .little); + return out; +} + +fn buildFrame(nonce: [nonce_len]u8) [frame_len]u8 { + var frame = [_]u8{0} ** frame_len; + @memcpy(frame[0..6], &probe_mac); + @memcpy(frame[6..12], &probe_mac); + std.mem.writeInt(u16, frame[12..14], probe_ethertype, .big); + @memcpy(frame[nonce_off .. nonce_off + nonce_len], &nonce); + return frame; +} + +fn carriesNonce(frame: []const u8, nonce: [nonce_len]u8) bool { + if (frame.len < nonce_off + nonce_len) return false; + if (std.mem.readInt(u16, frame[12..14], .big) != probe_ethertype) return false; + return std.mem.eql(u8, frame[nonce_off .. nonce_off + nonce_len], &nonce); +} + +const send_bursts: usize = 3; + +const BoundSock = struct { fd: i32, sll: linux.sockaddr.ll }; + +const OpenError = error{ NoCap, Failed }; + +fn openBound(ifname: []const u8) OpenError!BoundSock { + const rc_sock = linux.socket(linux.AF.PACKET, linux.SOCK.RAW, std.mem.nativeToBig(u16, ETH_P_ALL)); + switch (linux.errno(rc_sock)) { + .SUCCESS => {}, + .PERM, .ACCES => return error.NoCap, + else => return error.Failed, + } + const fd: i32 = @intCast(rc_sock); + errdefer _ = linux.close(fd); + + var ifr = std.mem.zeroes(linux.ifreq); + if (ifname.len >= ifr.ifrn.name.len) return error.Failed; + @memcpy(ifr.ifrn.name[0..ifname.len], ifname); + if (linux.errno(linux.ioctl(fd, linux.SIOCGIFINDEX, @intFromPtr(&ifr))) != .SUCCESS) return error.Failed; + + var sll = std.mem.zeroes(linux.sockaddr.ll); + sll.family = linux.AF.PACKET; + sll.protocol = std.mem.nativeToBig(u16, ETH_P_ALL); + sll.ifindex = ifr.ifru.ivalue; + if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS) return error.Failed; + return .{ .fd = fd, .sll = sll }; +} + +fn openStatus(e: OpenError) Status { + return switch (e) { + error.NoCap => .no_cap, + error.Failed => .no_egress, + }; +} + +pub fn probe(ifname: []const u8) Status { + const observer = openBound(ifname) catch |e| return openStatus(e); + defer _ = linux.close(observer.fd); + var sender = openBound(ifname) catch |e| return openStatus(e); + defer _ = linux.close(sender.fd); + + const nonce = freshNonce(); + const frame = buildFrame(nonce); + var burst: usize = 0; + while (burst < send_bursts) : (burst += 1) { + _ = linux.sendto(sender.fd, &frame, frame.len, 0, @ptrCast(&sender.sll), @sizeOf(linux.sockaddr.ll)); + } + + var buf: [2048]u8 = undefined; + const deadline = monoMs() + poll_budget_ms; + while (monoMs() < deadline) { + var pfd = [_]linux.pollfd{.{ .fd = observer.fd, .events = linux.POLL.IN, .revents = 0 }}; + const pr = linux.poll(&pfd, 1, poll_tick_ms); + switch (linux.errno(pr)) { + .SUCCESS => {}, + .INTR => continue, + else => return .no_egress, + } + if (pr == 0) continue; + const rc = linux.recvfrom(observer.fd, &buf, buf.len, 0, null, null); + switch (linux.errno(rc)) { + .SUCCESS => {}, + .INTR, .AGAIN => continue, + else => return .no_egress, + } + const n: usize = @intCast(rc); + if (carriesNonce(buf[0..n], nonce)) return .ok; + } + return .no_egress; +} + +test "buildFrame stamps the experimental ethertype, self-addressed MACs, and the nonce" { + const nonce = [8]u8{ 1, 2, 3, 4, 5, 6, 7, 8 }; + const f = buildFrame(nonce); + try std.testing.expectEqual(@as(usize, 60), f.len); + try std.testing.expectEqualSlices(u8, &probe_mac, f[0..6]); + try std.testing.expectEqualSlices(u8, &probe_mac, f[6..12]); + try std.testing.expectEqual(@as(u16, probe_ethertype), std.mem.readInt(u16, f[12..14], .big)); + try std.testing.expectEqualSlices(u8, &nonce, f[14..22]); +} + +test "carriesNonce matches only our tagged frame, rejects other traffic and wrong nonce" { + const nonce = freshNonce(); + const other = freshNonce(); + const f = buildFrame(nonce); + try std.testing.expect(carriesNonce(&f, nonce)); + try std.testing.expect(!carriesNonce(&f, other)); + + var ip_frame = [_]u8{0} ** 60; + std.mem.writeInt(u16, ip_frame[12..14], 0x0800, .big); + @memcpy(ip_frame[14..22], &nonce); + try std.testing.expect(!carriesNonce(&ip_frame, nonce)); + + try std.testing.expect(!carriesNonce(&[_]u8{ 0, 1, 2 }, nonce)); +} + +test "freshNonce is not trivially constant across calls" { + const a = freshNonce(); + var differs = false; + var i: usize = 0; + while (i < 8) : (i += 1) { + const b = freshNonce(); + if (!std.mem.eql(u8, &a, &b)) differs = true; + } + try std.testing.expect(differs); +} + +test "probe returns no_cap without CAP_NET_RAW, otherwise a defined status" { + const st = probe("lo"); + try std.testing.expect(st == .ok or st == .no_cap or st == .no_egress); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig b/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig index f2e8695a..c215078c 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig @@ -9,6 +9,7 @@ const cookie = @import("cookie"); pub const Result = classify.Result; pub const State = classify.State; pub const TcpClassifier = classify.TcpClassifier; +pub const TcpClassifier6 = classify.TcpClassifier6; pub const UdpClassifier = classify.UdpClassifier; const RECV_BUF_LEN: usize = 2048; @@ -17,7 +18,15 @@ const NS_PER_MS: u64 = 1_000_000; const NS_PER_SEC: u64 = 1_000_000_000; pub fn resultKey(r: Result) u64 { - return (@as(u64, r.ip) << PORT_BITS) | r.port; + return switch (r.addr) { + .v4 => |ip| (@as(u64, ip) << PORT_BITS) | r.port, + .v6 => |b| blk: { + var h = std.hash.Wyhash.init(r.port); + h.update(&b); + const k = h.final(); + break :blk if (k == std.math.maxInt(u64)) k -% 1 else k; + }, + }; } pub fn run(source: anytype, clf: anytype, dd: *dedup.Dedup, sink: anytype) void { @@ -40,6 +49,9 @@ pub const QueueSink = struct { const linux = std.os.linux; +pub const ETH_P_IP: u16 = 0x0800; +pub const ETH_P_IPV6: u16 = 0x86dd; + pub const OpenError = error{ NeedCapNetRaw, SocketFailed, @@ -85,11 +97,11 @@ pub const Receiver = struct { return @as(u64, @intCast(ts.sec)) * NS_PER_SEC + @as(u64, @intCast(ts.nsec)); } - pub fn open(ifname: []const u8, tx_done: *std.atomic.Value(bool), drain_window_ns: u64, hard_cap_ns: u64) OpenError!Receiver { + pub fn open(ifname: []const u8, proto: u16, tx_done: *std.atomic.Value(bool), drain_window_ns: u64, hard_cap_ns: u64) OpenError!Receiver { const rc_sock = linux.socket( linux.AF.PACKET, linux.SOCK.RAW, - std.mem.nativeToBig(u16, @as(u16, linux.ETH.P.IP)), + std.mem.nativeToBig(u16, proto), ); switch (linux.errno(rc_sock)) { .SUCCESS => {}, @@ -111,7 +123,7 @@ pub const Receiver = struct { var sll = std.mem.zeroes(linux.sockaddr.ll); sll.family = linux.AF.PACKET; - sll.protocol = std.mem.nativeToBig(u16, @as(u16, linux.ETH.P.IP)); + sll.protocol = std.mem.nativeToBig(u16, proto); sll.ifindex = ifindex; if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS) return error.BindFailed; diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig b/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig index f507b26a..85c58bfd 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig @@ -15,6 +15,9 @@ const netutil = @import("netutil"); const output = @import("output"); const stealth = @import("stealth"); const service = @import("service"); +const connect = @import("connect"); +const rawprobe = @import("rawprobe"); +const ndp = @import("ndp"); const default_iface = "lo"; const default_rate: u64 = 10_000; @@ -216,6 +219,329 @@ fn terminalCols(fd: i32) ?u16 { return ws.col; } +fn txWorkerV6(engine: *targets.Engine6, tmpl: *const template.SynTemplate6, bucket: *ratelimit.TokenBucket, sink: *TxSink, max_packets: u64, budget_ns: u64, tx_done: *std.atomic.Value(bool)) u64 { + var clock = netutil.RealClock{}; + const deadline_ns = clock.now() +| budget_ns; + const sent = tx.runV6(engine, tmpl, bucket, sink, &clock, max_packets, deadline_ns); + tx_done.store(true, .release); + return sent; +} + +fn rxWorkerV6(receiver: *rx.Receiver, clf: rx.TcpClassifier6, dd: *dedup.Dedup, sink: *rx.QueueSink, rx_done: *std.atomic.Value(bool)) void { + rx.run(receiver, clf, dd, sink); + rx_done.store(true, .release); +} + +fn resolveV6Gateway(io: std.Io, args: []const []const u8, ifname: []const u8, src_ip6: [16]u8, src_mac: [6]u8, derr: *std.Io.Writer) !?[6]u8 { + _ = io; + if (netutil.getFlag(args, "--gw-mac")) |m| return try netutil.parseMac(m); + const next_hop = if (netutil.getFlag(args, "--gw-ip6")) |g| try netutil.parseIpv6(g) else netutil.defaultGateway6(ifname) orelse { + try derr.writeAll("scan: IPv6 raw scan needs a next hop: pass --gw-mac , or --gw-ip6 to resolve it via NDP\n"); + try derr.flush(); + return null; + }; + return ndp.resolve(ifname, src_ip6, src_mac, next_hop, 1500) catch |e| { + try derr.print("scan: NDP neighbor resolution failed ({s}); pass --gw-mac explicitly\n", .{@errorName(e)}); + try derr.flush(); + return null; + }; +} + +fn runV6Scan(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, env: *std.process.Environ.Map, target_text: []const u8, ifname: []const u8, rate: u64, backend_choice: packet_io.Choice, derr: *std.Io.Writer) !void { + var obuf: [4096]u8 = undefined; + var ow = std.Io.File.stdout().writer(io, &obuf); + const out = &ow.interface; + + if (netutil.hasFlag(args, "--udp")) { + try derr.writeAll("scan: IPv6 UDP scanning is not supported yet; run a TCP SYN scan or --connect\n"); + try derr.flush(); + return; + } + if (netutil.getFlag(args, "--scan-type") != null or netutil.getFlag(args, "--os-template") != null or + netutil.getFlag(args, "--decoys") != null or netutil.getFlag(args, "--jitter") != null or + netutil.hasFlag(args, "--source-port-rotation")) + { + try derr.writeAll(" note: stealth/scan-type options are not yet wired for IPv6; running a plain SYN scan\n"); + } + + const ports = if (netutil.getFlag(args, "--ports")) |p| try netutil.parsePorts(allocator, p) else try allocator.dupe(u16, &default_tcp_ports); + const src_port = if (netutil.getFlag(args, "--src-port")) |p| try std.fmt.parseInt(u16, p, 10) else default_src_port; + const wait_ms = if (netutil.getFlag(args, "--wait")) |w| try std.fmt.parseInt(i32, w, 10) else default_wait_ms; + const json = netutil.hasFlag(args, "--json"); + const max_hosts = if (netutil.getFlag(args, "--max-hosts")) |m| try std.fmt.parseInt(u64, m, 10) else targets.default_max_hosts6; + + var seed: u64 = undefined; + if (netutil.getFlag(args, "--seed")) |s| { + seed = try std.fmt.parseInt(u64, s, 10); + } else { + var seed_bytes: [8]u8 = undefined; + try io.randomSecure(&seed_bytes); + seed = std.mem.readInt(u64, &seed_bytes, .little); + } + + const src_ip6 = if (netutil.getFlag(args, "--src-ip6")) |s| try netutil.parseIpv6(s) else netutil.resolveSrcIp6(ifname) catch { + try derr.print("scan: no IPv6 source address on '{s}' (assign one, or pass --src-ip6 )\n", .{ifname}); + try derr.flush(); + return; + }; + const src_mac = try netutil.resolveSrcMac(ifname); + const gw_mac = (try resolveV6Gateway(io, args, ifname, src_ip6, src_mac, derr)) orelse return; + + const cidr = targets.parseCidr6(target_text) catch |e| { + try derr.print("scan: invalid IPv6 target '{s}' ({s})\n", .{ target_text, @errorName(e) }); + try derr.flush(); + return; + }; + var eng = targets.Engine6.init(allocator, cidr, ports, seed, max_hosts) catch |e| switch (e) { + error.PrefixTooLarge => { + try derr.print("scan: IPv6 prefix '{s}' is too large to enumerate (over --max-hosts {d}); narrow the prefix or raise --max-hosts\n", .{ target_text, max_hosts }); + try derr.flush(); + return; + }, + else => return e, + }; + defer eng.deinit(); + const count = if (netutil.getFlag(args, "--count")) |c| try std.fmt.parseInt(u64, c, 10) else eng.total; + + const ck = try cookie.Cookie.random(io); + const tmpl = template.SynTemplate6.init(.{ + .src_mac = src_mac, + .dst_mac = gw_mac, + .src_ip = src_ip6, + .src_port = src_port, + .cookie = ck, + }); + var bucket = ratelimit.TokenBucket.init(rate, rate); + + const choice = output.parseColorChoice(netutil.getFlag(args, "--color")); + const out_level = output.envLevel(io, std.Io.File.stdout(), env, choice); + const err_level = output.envLevel(io, std.Io.File.stderr(), env, choice); + const stderr_tty = std.Io.File.stderr().isTty(io) catch false; + const wide_enough = if (terminalCols(2)) |c| c >= min_dashboard_cols else true; + const interactive = stderr_tty and wide_enough; + + const dash_total = @min(count, eng.total); + const drain_window_ns: u64 = @as(u64, @intCast(@max(wait_ms, 0))) * ns_per_ms; + const est_tx_ns: u64 = if (rate > 0) (dash_total / rate) *| ns_per_sec else rx_hard_cap_floor_ns; + const tx_budget_ns: u64 = (est_tx_ns *| 4) +| rx_hard_cap_floor_ns; + const hard_cap_ns: u64 = tx_budget_ns +| drain_window_ns; + + var backend = packet_io.select(allocator, ifname, backend_choice, .{}, .{}, derr) catch |err| switch (err) { + error.NeedCapNetRaw => { + try derr.writeAll(need_cap_hint); + try derr.flush(); + return; + }, + error.XdpNotCompiledIn => { + try derr.writeAll("scan: --backend xdp needs a build with -Dxdp\n"); + try derr.flush(); + return; + }, + else => return err, + }; + defer backend.close(); + try derr.print(" using {s}\n", .{packet_io.kindLabel(backend.kind())}); + + var tx_done = std.atomic.Value(bool).init(false); + var rx_done = std.atomic.Value(bool).init(false); + + var receiver = rx.Receiver.open(ifname, rx.ETH_P_IPV6, &tx_done, drain_window_ns, hard_cap_ns) catch |err| switch (err) { + error.NeedCapNetRaw => { + try derr.writeAll(need_cap_hint); + try derr.flush(); + return; + }, + else => return err, + }; + defer receiver.close(); + + var dd = try dedup.Dedup.init(allocator, dedup_capacity); + defer dd.deinit(); + var qbuf: [queue_capacity]rx.Result = undefined; + var queue = std.Io.Queue(rx.Result).init(&qbuf); + + var found_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer found_arena.deinit(); + const found_alloc = found_arena.allocator(); + var found: std.ArrayList(rx.Result) = .empty; + + var stats: output.Stats = .{}; + const json_out: ?*std.Io.Writer = if (json) out else null; + + try derr.print("zingela SYN scan (IPv6) target {s} iface {s} rate {d} pps ports {d}\n", .{ target_text, ifname, rate, ports.len }); + try derr.flush(); + + var clock = netutil.RealClock{}; + const t0 = clock.now(); + + var tx_sink = TxSink{ .backend = &backend, .sent = &stats.sent.v }; + var rx_sink = rx.QueueSink{ .queue = &queue, .io = io }; + + var tx_fut = (io.concurrent(txWorkerV6, .{ &eng, &tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done })) catch { + try derr.writeAll(concurrency_hint); + try derr.flush(); + return; + }; + var rx_fut = (io.concurrent(rxWorkerV6, .{ &receiver, rx.TcpClassifier6{ .ck = ck }, &dd, &rx_sink, &rx_done })) catch { + _ = tx_fut.await(io); + try derr.writeAll(concurrency_hint); + try derr.flush(); + return; + }; + + var dash = output.Dashboard.init(err_level, interactive, dash_total); + const render_interval_ns: u64 = if (interactive) render_tick_interactive_ns else render_tick_plain_ns; + var drain_buf: [drain_batch]rx.Result = undefined; + var last_render: u64 = 0; + + while (!(tx_done.load(.acquire) and rx_done.load(.acquire))) { + drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, "tcp"); + if (json) out.flush() catch {}; + const now = clock.now(); + if (last_render == 0 or now -| last_render >= render_interval_ns) { + dash.render(derr, &stats, now -| t0) catch {}; + last_render = now; + } + clock.sleepNs(drain_tick_ns); + } + + const sent = tx_fut.await(io); + rx_fut.await(io); + queue.close(io); + drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, "tcp"); + if (json) out.flush() catch {}; + dash.render(derr, &stats, clock.now() -| t0) catch {}; + + var open_n: u64 = 0; + var closed_n: u64 = 0; + var filtered_n: u64 = 0; + var unfiltered_n: u64 = 0; + for (found.items) |r| switch (r.state) { + .open => open_n += 1, + .closed => closed_n += 1, + .filtered => filtered_n += 1, + .unfiltered => unfiltered_n += 1, + }; + + if (!json) { + if (found.items.len > 0) { + std.mem.sort(rx.Result, found.items, {}, output.ipPortLess); + try out.writeByte('\n'); + try output.renderTable(out, out_level, found.items); + try out.flush(); + } else { + try derr.writeAll(" no open, closed, or filtered responses observed\n"); + if (sent > 0) try derr.writeAll(" (if a cloud/VM hypervisor is filtering raw sends upstream, retry with --connect)\n"); + } + } + + const elapsed_s = @as(f64, @floatFromInt(clock.now() - t0)) / @as(f64, @floatFromInt(ns_per_sec)); + try derr.writeByte('\n'); + try output.renderSummary(derr, err_level, sent, "SYN", ifname, elapsed_s, open_n, closed_n, filtered_n, unfiltered_n, 0); + try derr.flush(); +} + +fn runConnect( + io: std.Io, + allocator: std.mem.Allocator, + args: []const []const u8, + env: *std.process.Environ.Map, + target_text: []const u8, + ifname: []const u8, + rate: u64, + is_udp: bool, + json: bool, + derr: *std.Io.Writer, +) !void { + if (is_udp) { + try derr.writeAll("scan: connect mode is TCP-only; --udp is not supported with --backend connect\n"); + try derr.flush(); + return; + } + if (netutil.hasFlag(args, "--banners")) { + try derr.writeAll(" note: --banners is unavailable in connect mode; reporting open/closed/filtered only\n"); + } + + const ports = if (netutil.getFlag(args, "--ports")) |p| + try netutil.parsePorts(allocator, p) + else + try allocator.dupe(u16, &default_tcp_ports); + + var seed: u64 = undefined; + if (netutil.getFlag(args, "--seed")) |s| { + seed = try std.fmt.parseInt(u64, s, 10); + } else { + var seed_bytes: [8]u8 = undefined; + try io.randomSecure(&seed_bytes); + seed = std.mem.readInt(u64, &seed_bytes, .little); + } + + const concurrency = if (netutil.getFlag(args, "--concurrency")) |c| try std.fmt.parseInt(usize, c, 10) else connect.default_concurrency; + const timeout_ms = if (netutil.getFlag(args, "--connect-timeout")) |t| try std.fmt.parseInt(u64, t, 10) else connect.default_timeout_ms; + const max_hosts = if (netutil.getFlag(args, "--max-hosts")) |m| try std.fmt.parseInt(u64, m, 10) else targets.default_max_hosts6; + + const choice = output.parseColorChoice(netutil.getFlag(args, "--color")); + const out_level = output.envLevel(io, std.Io.File.stdout(), env, choice); + const err_level = output.envLevel(io, std.Io.File.stderr(), env, choice); + const stderr_tty = std.Io.File.stderr().isTty(io) catch false; + const wide_enough = if (terminalCols(2)) |c| c >= min_dashboard_cols else true; + const interactive = stderr_tty and wide_enough; + try derr.flush(); + + if (std.mem.indexOfScalar(u8, target_text, ':') != null) { + const cidr = targets.parseCidr6(target_text) catch |e| { + try derr.print("scan: invalid IPv6 target '{s}' ({s})\n", .{ target_text, @errorName(e) }); + try derr.flush(); + return; + }; + var eng = targets.Engine6.init(allocator, cidr, ports, seed, max_hosts) catch |e| switch (e) { + error.PrefixTooLarge => { + try derr.print("scan: IPv6 prefix '{s}' is too large to enumerate (over --max-hosts {d}); narrow the prefix or raise --max-hosts\n", .{ target_text, max_hosts }); + try derr.flush(); + return; + }, + else => return e, + }; + defer eng.deinit(); + const count = if (netutil.getFlag(args, "--count")) |c| try std.fmt.parseInt(u64, c, 10) else eng.total; + try connect.run(allocator, .{ + .source = .{ .v6 = &eng }, + .count = count, + .total = @min(count, eng.total), + .rate = rate, + .concurrency = concurrency, + .timeout_ns = timeout_ms *| ns_per_ms, + .out_level = out_level, + .err_level = err_level, + .interactive = interactive, + .json = json, + .target_text = target_text, + .iface = ifname, + }); + return; + } + + const cidr = try targets.parseCidr(target_text); + var eng = try targets.Engine.init(allocator, &.{cidr}, ports, seed); + defer eng.deinit(); + const count = if (netutil.getFlag(args, "--count")) |c| try std.fmt.parseInt(u64, c, 10) else eng.total; + + try connect.run(allocator, .{ + .source = .{ .v4 = &eng }, + .count = count, + .total = @min(count, eng.total), + .rate = rate, + .concurrency = concurrency, + .timeout_ns = timeout_ms *| ns_per_ms, + .out_level = out_level, + .err_level = err_level, + .interactive = interactive, + .json = json, + .target_text = target_text, + .iface = ifname, + }); +} + pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, env: *std.process.Environ.Map) !void { var obuf: [4096]u8 = undefined; var ow = std.Io.File.stdout().writer(io, &obuf); @@ -237,12 +563,34 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e const json = netutil.hasFlag(args, "--json"); const is_udp = netutil.hasFlag(args, "--udp"); const banners_flag = netutil.hasFlag(args, "--banners"); - const backend_choice = packet_io.parseChoice(netutil.getFlag(args, "--backend")) orelse { - try derr.writeAll("scan: --backend must be one of auto, xdp, afpacket\n"); + + const backend_arg = netutil.getFlag(args, "--backend"); + const connect_mode = netutil.hasFlag(args, "--connect") or + (backend_arg != null and std.mem.eql(u8, backend_arg.?, "connect")); + if (connect_mode) { + return runConnect(io, allocator, args, env, target_text, ifname, rate, is_udp, json, derr); + } + + const backend_choice = packet_io.parseChoice(backend_arg) orelse { + try derr.writeAll("scan: --backend must be one of auto, xdp, afpacket, connect\n"); try derr.flush(); return; }; + if (backend_choice == .auto) { + const raw_status = rawprobe.probe(ifname); + if (raw_status != .ok) { + try derr.print(" raw sends unavailable on '{s}' ({s}); falling back to unprivileged connect scan\n", .{ ifname, raw_status.reason() }); + try derr.writeAll(" (force the raw engine with --backend afpacket, or select it directly with --connect)\n"); + try derr.flush(); + return runConnect(io, allocator, args, env, target_text, ifname, rate, is_udp, json, derr); + } + } + + if (std.mem.indexOfScalar(u8, target_text, ':') != null) { + return runV6Scan(io, allocator, args, env, target_text, ifname, rate, backend_choice, derr); + } + var scfg = stealth.parse(allocator, io, args) catch |e| switch (e) { error.AuthorizationRequired => { try derr.writeAll(authorized_warning); @@ -441,7 +789,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e var fdedup = try dedup.Dedup.init(allocator, dedup_capacity); defer fdedup.deinit(); - var receiver = rx.Receiver.open(ifname, &tx_done, drain_window_ns, hard_cap_ns) catch |err| switch (err) { + var receiver = rx.Receiver.open(ifname, rx.ETH_P_IP, &tx_done, drain_window_ns, hard_cap_ns) catch |err| switch (err) { error.NeedCapNetRaw => { try derr.writeAll(need_cap_hint); try derr.flush(); @@ -566,6 +914,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e try out.flush(); } else { try derr.writeAll(" no open, closed, or filtered responses observed\n"); + if (sent > 0) try derr.writeAll(" (if a cloud/VM hypervisor is filtering raw sends upstream, retry with --connect)\n"); } if (findings.items.len > 0) { std.mem.sort(service.Finding, findings.items, {}, findingLess); diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/targets.zig b/PROJECTS/advanced/zig-stateless-scanner/src/targets.zig index ab32a14a..35183c99 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/targets.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/targets.zig @@ -3,6 +3,7 @@ const std = @import("std"); const numtheory = @import("numtheory"); +const netutil = @import("netutil"); pub const Range = struct { start: u32, @@ -220,6 +221,142 @@ pub const Engine = struct { } }; +pub const default_max_hosts6: u64 = 1 << 20; + +pub const Cidr6 = struct { + base: [16]u8, + prefix: u8, +}; + +const Reserved6 = struct { prefix: [16]u8, bits: u8 }; + +const reserved6 = [_]Reserved6{ + .{ .prefix = [_]u8{0} ** 16, .bits = 128 }, + .{ .prefix = [_]u8{0} ** 15 ++ [_]u8{1}, .bits = 128 }, + .{ .prefix = [_]u8{0} ** 10 ++ [_]u8{ 0xff, 0xff } ++ [_]u8{0} ** 4, .bits = 96 }, + .{ .prefix = [_]u8{ 0x01, 0x00 } ++ [_]u8{0} ** 14, .bits = 8 }, + .{ .prefix = [_]u8{ 0x20, 0x01, 0x0d, 0xb8 } ++ [_]u8{0} ** 12, .bits = 32 }, + .{ .prefix = [_]u8{ 0xfc, 0x00 } ++ [_]u8{0} ** 14, .bits = 7 }, + .{ .prefix = [_]u8{ 0xfe, 0x80 } ++ [_]u8{0} ** 14, .bits = 10 }, + .{ .prefix = [_]u8{ 0xff, 0x00 } ++ [_]u8{0} ** 14, .bits = 8 }, +}; + +fn inPrefix6(addr: [16]u8, prefix: [16]u8, bits: u8) bool { + const full = bits / 8; + for (0..full) |i| if (addr[i] != prefix[i]) return false; + const rem: u3 = @intCast(bits % 8); + if (rem != 0) { + const mask: u8 = @as(u8, 0xff) << @intCast(8 - @as(u4, rem)); + if ((addr[full] & mask) != (prefix[full] & mask)) return false; + } + return true; +} + +pub fn isReserved6(addr: [16]u8) bool { + for (reserved6) |res| { + if (inPrefix6(addr, res.prefix, res.bits)) return true; + } + return false; +} + +fn maskAddr6(addr: [16]u8, prefix: u8) [16]u8 { + var out = addr; + var bit: usize = prefix; + while (bit < 128) : (bit += 1) { + const byte = bit / 8; + const off: u3 = @intCast(7 - (bit % 8)); + out[byte] &= ~(@as(u8, 1) << off); + } + return out; +} + +pub fn parseCidr6(text: []const u8) !Cidr6 { + const slash = std.mem.indexOfScalar(u8, text, '/') orelse return error.InvalidCidr; + const addr = try netutil.parseIpv6(text[0..slash]); + const prefix = std.fmt.parseInt(u8, text[slash + 1 ..], 10) catch return error.InvalidCidr; + if (prefix > 128) return error.InvalidCidr; + if (prefix == 0) return error.PrefixTooLarge; + return .{ .base = maskAddr6(addr, prefix), .prefix = prefix }; +} + +pub const Target6 = struct { + addr: [16]u8, + port: u16, +}; + +pub const Engine6 = struct { + base: [16]u8, + ports: []u16, + num_ports: u64, + host_count: u64, + total: u64, + prime: u64, + generator: u64, + current: u64, + steps_left: u64, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator, cidr: Cidr6, ports: []const u16, seed: u64, max_hosts: u64) !Engine6 { + const host_bits: u8 = 128 - cidr.prefix; + if (host_bits >= 64) return error.PrefixTooLarge; + const host_count: u64 = if (host_bits == 0) 1 else (@as(u64, 1) << @intCast(host_bits)); + if (host_count > max_hosts) return error.PrefixTooLarge; + + const ports_copy = try allocator.dupe(u16, ports); + errdefer allocator.free(ports_copy); + const num_ports: u64 = @intCast(ports.len); + if (num_ports == 0 or host_count > std.math.maxInt(u64) / num_ports) return error.PrefixTooLarge; + const total = host_count * num_ports; + const prime = numtheory.smallestPrimeAbove(total); + + var prng = std.Random.DefaultPrng.init(seed); + const rand = prng.random(); + const generator = numtheory.findPrimitiveRoot(prime, rand); + const start = rand.intRangeAtMost(u64, 1, prime - 1); + + return .{ + .base = cidr.base, + .ports = ports_copy, + .num_ports = num_ports, + .host_count = host_count, + .total = total, + .prime = prime, + .generator = generator, + .current = start, + .steps_left = prime - 1, + .allocator = allocator, + }; + } + + pub fn deinit(self: *Engine6) void { + self.allocator.free(self.ports); + } + + fn addrAt(self: *const Engine6, host_index: u64) [16]u8 { + var addr = self.base; + const lo = std.mem.readInt(u64, addr[8..16], .big); + std.mem.writeInt(u64, addr[8..16], lo | host_index, .big); + return addr; + } + + pub fn next(self: *Engine6) ?Target6 { + while (self.steps_left > 0) { + self.current = numtheory.mulMod(self.current, self.generator, self.prime); + self.steps_left -= 1; + const idx = self.current; + if (idx >= 1 and idx <= self.total) { + const idx0 = idx - 1; + const host_pos = idx0 / self.num_ports; + const port_pos = idx0 % self.num_ports; + const addr = self.addrAt(host_pos); + if (isReserved6(addr)) continue; + return .{ .addr = addr, .port = self.ports[@intCast(port_pos)] }; + } + } + return null; + } +}; + test "parseCidr yields the right range and count" { const a = try parseCidr("10.0.0.0/24"); try std.testing.expectEqual(@as(u32, 0x0a000000), a.start); @@ -326,3 +463,64 @@ test "initShard rejects nonsensical shard counts" { try std.testing.expectError(error.InvalidShardCount, Engine.initShard(std.testing.allocator, &cidrs, &ports, 1, 100, 0)); try std.testing.expectError(error.InvalidShardCount, Engine.initShard(std.testing.allocator, &cidrs, &ports, 1, 2, 5)); } + +test "parseCidr6 masks host bits, rejects ::/0 and bad prefixes" { + const c = try parseCidr6("2001:db8:1:2:3:4:5:6/64"); + try std.testing.expectEqual([16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0 }, c.base); + try std.testing.expectEqual(@as(u8, 64), c.prefix); + + const c120 = try parseCidr6("2001:470:1:2::ab/120"); + try std.testing.expectEqual(@as(u8, 0), c120.base[15]); + + try std.testing.expectError(error.PrefixTooLarge, parseCidr6("::/0")); + try std.testing.expectError(error.InvalidCidr, parseCidr6("2001:db8::/129")); + try std.testing.expectError(error.InvalidCidr, parseCidr6("2001:db8::")); +} + +test "isReserved6 flags special-use IPv6 blocks and passes global space" { + try std.testing.expect(isReserved6(try netutil.parseIpv6("::1"))); + try std.testing.expect(isReserved6(try netutil.parseIpv6("::"))); + try std.testing.expect(isReserved6(try netutil.parseIpv6("fe80::1"))); + try std.testing.expect(isReserved6(try netutil.parseIpv6("fc00::1"))); + try std.testing.expect(isReserved6(try netutil.parseIpv6("ff02::1"))); + try std.testing.expect(isReserved6(try netutil.parseIpv6("2001:db8::1"))); + try std.testing.expect(isReserved6(try netutil.parseIpv6("::ffff:c0a8:1"))); + try std.testing.expect(!isReserved6(try netutil.parseIpv6("2001:470:1:2::5"))); + try std.testing.expect(!isReserved6(try netutil.parseIpv6("2606:4700:4700::1111"))); +} + +test "Engine6 is a bijection over a bounded prefix, every addr:port once, none reserved" { + const cidr = try parseCidr6("2001:470:1:2::/120"); + const ports = [_]u16{ 80, 443 }; + var eng = try Engine6.init(std.testing.allocator, cidr, &ports, 0xC0FFEE, default_max_hosts6); + defer eng.deinit(); + + try std.testing.expectEqual(@as(u64, 512), eng.total); + var seen = std.AutoHashMap(u160Key, void).init(std.testing.allocator); + defer seen.deinit(); + var n: u64 = 0; + while (eng.next()) |t| { + try std.testing.expect(!isReserved6(t.addr)); + const key = u160Key{ .addr = t.addr, .port = t.port }; + try std.testing.expect(!seen.contains(key)); + try seen.put(key, {}); + n += 1; + } + try std.testing.expectEqual(@as(u64, 512), n); +} + +const u160Key = struct { addr: [16]u8, port: u16 }; + +test "Engine6 rejects prefixes whose host space is too large" { + const ports = [_]u16{80}; + try std.testing.expectError(error.PrefixTooLarge, Engine6.init(std.testing.allocator, try parseCidr6("2001:470::/64"), &ports, 1, default_max_hosts6)); + try std.testing.expectError(error.PrefixTooLarge, Engine6.init(std.testing.allocator, try parseCidr6("2001:470::/100"), &ports, 1, default_max_hosts6)); + var eng = try Engine6.init(std.testing.allocator, try parseCidr6("2001:470::/112"), &ports, 1, default_max_hosts6); + eng.deinit(); +} + +test "Engine6 rejects a host-times-port product that would overflow u64" { + const ports = [_]u16{ 1, 2, 3, 4 }; + const cidr = try parseCidr6("2001:470::/65"); + try std.testing.expectError(error.PrefixTooLarge, Engine6.init(std.testing.allocator, cidr, &ports, 1, std.math.maxInt(u64))); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/template.zig b/PROJECTS/advanced/zig-stateless-scanner/src/template.zig index c83898cf..6a5dd2d8 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/template.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/template.zig @@ -183,11 +183,172 @@ pub const SynTemplate = struct { } }; +const ethertype_ipv6: u16 = 0x86dd; +const ip6_version_tc_flow: u32 = 0x60000000; +const ip6_next_tcp: u8 = 6; +const default_hop_limit: u8 = 64; + +const ip6_len: usize = 40; +const l4_off6: usize = eth_len + ip6_len; +const opts_off6: usize = l4_off6 + tcp_len; + +const ip6_payload_len_off: usize = eth_len + 4; +const ip6_dst_off: usize = eth_len + 24; +const tcp6_src_off: usize = l4_off6; +const tcp6_dst_off: usize = l4_off6 + 2; +const tcp6_seq_off: usize = l4_off6 + 4; +const tcp6_ack_off: usize = l4_off6 + 8; +const tcp6_checksum_off: usize = l4_off6 + 16; + +pub const SynTemplate6 = struct { + pub const max_frame_len: usize = opts_off6 + packet.max_syn_options_len; + + base: [max_frame_len]u8, + frame_len: usize, + src_ip: [16]u8, + src_port: u16, + cookie: cookie.Cookie, + scan: packet.ScanType, + + pub const Config = struct { + src_mac: [6]u8, + dst_mac: [6]u8, + src_ip: [16]u8, + src_port: u16, + cookie: cookie.Cookie, + profile: packet.OsProfile = .none, + scan: packet.ScanType = .syn, + }; + + pub fn init(cfg: Config) SynTemplate6 { + const opts = cfg.profile.options(); + const tcp_total = tcp_len + opts.len; + const data_off_words: u8 = @intCast(tcp_total / 4); + + var base: [max_frame_len]u8 = [_]u8{0} ** max_frame_len; + + const eth = packet.EthHdr{ + .dst = cfg.dst_mac, + .src = cfg.src_mac, + .ethertype = std.mem.nativeToBig(u16, ethertype_ipv6), + }; + @memcpy(base[0..eth_len], std.mem.asBytes(ð)); + + const ip6 = packet.Ipv6Hdr{ + .version_tc_flow = std.mem.nativeToBig(u32, ip6_version_tc_flow), + .payload_len = std.mem.nativeToBig(u16, @intCast(tcp_total)), + .next_header = ip6_next_tcp, + .hop_limit = default_hop_limit, + .src = cfg.src_ip, + .dst = [_]u8{0} ** 16, + }; + @memcpy(base[eth_len..l4_off6], std.mem.asBytes(&ip6)); + + const tcp = packet.TcpHdr{ + .src_port = std.mem.nativeToBig(u16, cfg.src_port), + .dst_port = 0, + .seq = 0, + .ack = 0, + .data_off_ns = data_off_words << 4, + .flags = cfg.scan.probeFlags(), + .window = std.mem.nativeToBig(u16, cfg.profile.window()), + .checksum = 0, + .urgent = 0, + }; + @memcpy(base[l4_off6..opts_off6], std.mem.asBytes(&tcp)); + if (opts.len > 0) @memcpy(base[opts_off6 .. opts_off6 + opts.len], opts); + + return .{ + .base = base, + .frame_len = opts_off6 + opts.len, + .src_ip = cfg.src_ip, + .src_port = cfg.src_port, + .cookie = cfg.cookie, + .scan = cfg.scan, + }; + } + + pub fn stamp(self: *const SynTemplate6, out: *[max_frame_len]u8, dst_ip: [16]u8, dst_port: u16) usize { + const n = self.frame_len; + @memcpy(out[0..n], self.base[0..n]); + + @memcpy(out[ip6_dst_off .. ip6_dst_off + 16], &dst_ip); + std.mem.writeInt(u16, out[tcp6_dst_off..][0..2], dst_port, .big); + + const ck = self.cookie.seq6(dst_ip, dst_port, self.src_ip, self.src_port); + if (self.scan.cookieInAck()) { + std.mem.writeInt(u32, out[tcp6_seq_off..][0..4], 0, .big); + std.mem.writeInt(u32, out[tcp6_ack_off..][0..4], ck, .big); + } else { + std.mem.writeInt(u32, out[tcp6_seq_off..][0..4], ck, .big); + std.mem.writeInt(u32, out[tcp6_ack_off..][0..4], 0, .big); + } + + std.mem.writeInt(u16, out[tcp6_checksum_off..][0..2], 0, .big); + const tcp_ck = packet.tcpChecksum6(self.src_ip, dst_ip, out[l4_off6..n]); + std.mem.writeInt(u16, out[tcp6_checksum_off..][0..2], tcp_ck, .big); + return n; + } +}; + const test_key = [16]u8{ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, }; +const v6_src = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }; +const v6_dst = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x99 }; + +fn testTemplate6(profile: packet.OsProfile) SynTemplate6 { + return SynTemplate6.init(.{ + .src_mac = .{ 0x02, 0, 0, 0, 0, 1 }, + .dst_mac = .{ 0x02, 0, 0, 0, 0, 2 }, + .src_ip = v6_src, + .src_port = 40000, + .cookie = cookie.Cookie.init(test_key), + .profile = profile, + }); +} + +test "the bare IPv6 SYN template stamps an 74-byte frame with a self-verifying TCP checksum" { + const tmpl = testTemplate6(.none); + var frame: [SynTemplate6.max_frame_len]u8 = undefined; + const len = tmpl.stamp(&frame, v6_dst, 443); + + try std.testing.expectEqual(@as(usize, 14 + 40 + 20), len); + try std.testing.expectEqual(@as(u16, ethertype_ipv6), std.mem.readInt(u16, frame[12..14], .big)); + try std.testing.expectEqual(@as(u8, 0x60), frame[14] & 0xf0); + try std.testing.expectEqual(ip6_next_tcp, frame[14 + 6]); + try std.testing.expectEqual(@as(u16, 20), std.mem.readInt(u16, frame[ip6_payload_len_off..][0..2], .big)); + try std.testing.expectEqualSlices(u8, &v6_dst, frame[ip6_dst_off .. ip6_dst_off + 16]); + try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum6(v6_src, v6_dst, frame[l4_off6..len])); +} + +test "the IPv6 SYN carries the SipHash6 seq and the Linux option chain self-verifies" { + const tmpl = testTemplate6(.linux); + var frame: [SynTemplate6.max_frame_len]u8 = undefined; + const len = tmpl.stamp(&frame, v6_dst, 22); + + try std.testing.expectEqual(@as(usize, 14 + 40 + 20 + 20), len); + const ck = cookie.Cookie.init(test_key); + const want_seq = ck.seq6(v6_dst, 22, v6_src, 40000); + try std.testing.expectEqual(want_seq, std.mem.readInt(u32, frame[tcp6_seq_off..][0..4], .big)); + try std.testing.expectEqual(@as(u16, 40), std.mem.readInt(u16, frame[ip6_payload_len_off..][0..2], .big)); + try std.testing.expectEqualSlices(u8, packet.OsProfile.linux.options(), frame[opts_off6..len]); + try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum6(v6_src, v6_dst, frame[l4_off6..len])); +} + +test "two IPv6 targets produce two different seqs" { + const tmpl = testTemplate6(.none); + var a: [SynTemplate6.max_frame_len]u8 = undefined; + var b: [SynTemplate6.max_frame_len]u8 = undefined; + var dst2 = v6_dst; + dst2[15] = 0x98; + _ = tmpl.stamp(&a, v6_dst, 443); + _ = tmpl.stamp(&b, dst2, 443); + try std.testing.expect(!std.mem.eql(u8, a[tcp6_seq_off..][0..4], b[tcp6_seq_off..][0..4])); +} + fn testTemplate(profile: packet.OsProfile, scan: packet.ScanType) SynTemplate { return SynTemplate.init(.{ .src_mac = .{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 }, diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig b/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig index 1f1c87df..c42066dd 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig @@ -103,6 +103,44 @@ fn runJittered( return sent; } +pub fn runV6( + engine: *targets.Engine6, + tmpl: anytype, + bucket: *ratelimit.TokenBucket, + sink: anytype, + clock: anytype, + max_packets: u64, + deadline_ns: u64, +) u64 { + bucket.prime(clock.now()); + var sent: u64 = 0; + var frame: [@TypeOf(tmpl.*).max_frame_len]u8 = undefined; + + var pending: ?targets.Target6 = engine.next(); + while (pending != null and sent < max_packets) { + const now_ns = clock.now(); + if (now_ns >= deadline_ns) break; + const granted = bucket.takeBatch(now_ns, max_packets - sent); + if (granted == 0) { + clock.sleepNs(bucket.step_ns); + continue; + } + var used: u64 = 0; + while (used < granted and sent < max_packets) { + const t = pending orelse break; + const len = tmpl.stamp(&frame, t.addr, t.port); + if (!submitFrame(sink, frame[0..len])) break; + used += 1; + sent += 1; + pending = engine.next(); + } + if (used < granted) bucket.refund(granted - used); + sink.kick(); + } + sink.kick(); + return sent; +} + const FakeClock = struct { t: u64 = 0, fn now(self: *FakeClock) u64 { @@ -346,6 +384,58 @@ const BoundedSink = struct { } }; +const V6Sink = struct { + frames: std.ArrayList([74]u8) = .empty, + allocator: std.mem.Allocator, + fn submit(self: *V6Sink, frame: []const u8) bool { + self.frames.append(self.allocator, frame[0..74].*) catch return false; + return true; + } + fn kick(_: *V6Sink) void {} +}; + +test "the IPv6 TX engine drives Engine6 through stamp6 with self-verifying checksums and a bijection" { + const test_key = [_]u8{0} ** 16; + const cidr = try targets.parseCidr6("2001:470:1:2::/122"); + const ports = [_]u16{ 80, 443 }; + var eng = try targets.Engine6.init(std.testing.allocator, cidr, &ports, 0xBEEF, targets.default_max_hosts6); + defer eng.deinit(); + const total = eng.total; + + const src_ip = try @import("netutil").parseIpv6("2001:470:1:2::1"); + const tmpl = template.SynTemplate6.init(.{ + .src_mac = .{0} ** 6, + .dst_mac = .{0} ** 6, + .src_ip = src_ip, + .src_port = 40000, + .cookie = cookie.Cookie.init(test_key), + }); + var tb = ratelimit.TokenBucket.init(1000, 1000); + var clock = FakeClock{}; + var sink = V6Sink{ .allocator = std.testing.allocator }; + defer sink.frames.deinit(std.testing.allocator); + + const sent = runV6(&eng, &tmpl, &tb, &sink, &clock, total, std.math.maxInt(u64)); + try std.testing.expectEqual(total, sent); + try std.testing.expectEqual(@as(usize, @intCast(total)), sink.frames.items.len); + + var seen = std.AutoHashMap([18]u8, void).init(std.testing.allocator); + defer seen.deinit(); + for (sink.frames.items) |*f| { + try std.testing.expectEqual(@as(u16, 0x86dd), std.mem.readInt(u16, f[12..14], .big)); + var dst: [16]u8 = undefined; + @memcpy(&dst, f[38..54]); + try std.testing.expect(!targets.isReserved6(dst)); + try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum6(src_ip, dst, f[54..74])); + var key: [18]u8 = undefined; + @memcpy(key[0..16], &dst); + @memcpy(key[16..18], f[56..58]); + try std.testing.expect(!seen.contains(key)); + try seen.put(key, {}); + } + try std.testing.expectEqual(@as(usize, @intCast(total)), seen.count()); +} + test "decoy groups resume across backpressure without re-sending the real probe" { const test_key = [_]u8{0} ** 16; const cidrs = [_]targets.Range{try targets.parseCidr("8.8.8.0/29")};