diff --git a/PROJECTS/advanced/zig-stateless-scanner/build.zig b/PROJECTS/advanced/zig-stateless-scanner/build.zig index fdcc69a1..68e49199 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/build.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/build.zig @@ -8,7 +8,7 @@ pub fn build(b: *std.Build) void { const optimize = b.standardOptimizeOption(.{}); const opts = b.addOptions(); - opts.addOption([]const u8, "version", "0.0.0-m5"); + opts.addOption([]const u8, "version", "0.0.0-m6"); const packet_mod = b.createModule(.{ .root_source_file = b.path("src/packet.zig"), @@ -63,6 +63,21 @@ pub fn build(b: *std.Build) void { template_mod.addImport("packet", packet_mod); template_mod.addImport("cookie", cookie_mod); + const payloads_mod = b.createModule(.{ + .root_source_file = b.path("src/payloads.zig"), + .target = target, + .optimize = optimize, + }); + + const udp_mod = b.createModule(.{ + .root_source_file = b.path("src/udp.zig"), + .target = target, + .optimize = optimize, + }); + udp_mod.addImport("packet", packet_mod); + udp_mod.addImport("cookie", cookie_mod); + udp_mod.addImport("payloads", payloads_mod); + const afpacket_mod = b.createModule(.{ .root_source_file = b.path("src/afpacket.zig"), .target = target, @@ -138,6 +153,7 @@ pub fn build(b: *std.Build) void { }); scancmd_mod.addImport("targets", targets_mod); scancmd_mod.addImport("template", template_mod); + scancmd_mod.addImport("udp", udp_mod); scancmd_mod.addImport("ratelimit", ratelimit_mod); scancmd_mod.addImport("afpacket", afpacket_mod); scancmd_mod.addImport("cookie", cookie_mod); @@ -176,7 +192,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, afpacket_mod, tx_mod, txcmd_mod, classify_mod, dedup_mod, rx_mod, netutil_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, payloads_mod, udp_mod, afpacket_mod, tx_mod, txcmd_mod, classify_mod, dedup_mod, rx_mod, netutil_mod, output_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 a2d5f4c3..801adbff 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/classify.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/classify.zig @@ -18,6 +18,7 @@ const ETH_OFF_TYPE: usize = 12; const ETHERTYPE_IPV4: u16 = 0x0800; const IPPROTO_TCP: u8 = 6; +const IPPROTO_UDP: u8 = 17; const IPPROTO_ICMP: u8 = 1; const IP_MIN_IHL: usize = 20; const IP_IHL_MASK: u8 = 0x0f; @@ -27,6 +28,13 @@ const IP_OFF_SRC: usize = 12; const IP_OFF_DST: usize = 16; const INNER_TCP_MIN_LEN: usize = 8; +const INNER_UDP_MIN_LEN: usize = 8; + +const UDP_OFF_SPORT: usize = 0; +const UDP_OFF_DPORT: usize = 2; +const UDP_MIN_LEN: usize = 8; + +const ICMP_CODE_PORT_UNREACH: u8 = 3; const TCP_OFF_SPORT: usize = 0; const TCP_OFF_DPORT: usize = 2; @@ -51,6 +59,13 @@ fn icmpCodeIsFilteredForSyn(code: u8) bool { }; } +fn icmpCodeIsFilteredForUdp(code: u8) bool { + return switch (code) { + 1, 2, 9, 10, 13 => true, + else => false, + }; +} + fn ihlBytes(first_byte: u8) usize { return @as(usize, first_byte & IP_IHL_MASK) * IP_WORD_BYTES; } @@ -116,10 +131,84 @@ pub fn classify(frame: []const u8, ck: cookie.Cookie) ?Result { return null; } +pub fn classifyUdp(frame: []const u8, ck: cookie.Cookie, base: u16, span: u16) ?Result { + if (frame.len < ETH_HDR_LEN + IP_MIN_IHL) return null; + if (std.mem.readInt(u16, frame[ETH_OFF_TYPE..][0..2], .big) != ETHERTYPE_IPV4) return null; + + const ip = ETH_HDR_LEN; + const ihl = ihlBytes(frame[ip]); + if (ihl < IP_MIN_IHL or frame.len < ip + ihl) return null; + + const proto = frame[ip + IP_OFF_PROTO]; + const ip_src = std.mem.readInt(u32, frame[ip + IP_OFF_SRC ..][0..4], .big); + const ip_dst = std.mem.readInt(u32, frame[ip + IP_OFF_DST ..][0..4], .big); + + if (proto == IPPROTO_UDP) { + const udp = ip + ihl; + if (frame.len < udp + UDP_MIN_LEN) return null; + 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 null; + } + + if (proto == IPPROTO_ICMP) { + const icmp = ip + ihl; + if (frame.len < icmp + ICMP_HDR_LEN) return null; + if (frame[icmp + ICMP_OFF_TYPE] != ICMP_TYPE_DEST_UNREACH) return null; + const code = frame[icmp + ICMP_OFF_CODE]; + const state: State = if (code == ICMP_CODE_PORT_UNREACH) + .closed + else if (icmpCodeIsFilteredForUdp(code)) + .filtered + else + return null; + + const inner = icmp + ICMP_HDR_LEN; + if (frame.len < inner + IP_MIN_IHL) return null; + const inner_ihl = ihlBytes(frame[inner]); + if (inner_ihl < IP_MIN_IHL) return null; + if (frame[inner + IP_OFF_PROTO] != IPPROTO_UDP) return null; + + const inner_src = std.mem.readInt(u32, frame[inner + IP_OFF_SRC ..][0..4], .big); + const inner_dst = std.mem.readInt(u32, frame[inner + IP_OFF_DST ..][0..4], .big); + const inner_udp = inner + inner_ihl; + if (frame.len < inner_udp + INNER_UDP_MIN_LEN) return null; + const inner_sport = std.mem.readInt(u16, frame[inner_udp + UDP_OFF_SPORT ..][0..2], .big); + 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 null; + } + + return null; +} + +pub const TcpClassifier = struct { + ck: cookie.Cookie, + + pub fn match(self: TcpClassifier, frame: []const u8) ?Result { + return classify(frame, self.ck); + } +}; + +pub const UdpClassifier = struct { + ck: cookie.Cookie, + base: u16, + span: u16, + + pub fn match(self: UdpClassifier, frame: []const u8) ?Result { + return classifyUdp(frame, self.ck, self.base, self.span); + } +}; + comptime { std.debug.assert(@sizeOf(packet.EthHdr) == ETH_HDR_LEN); std.debug.assert(@sizeOf(packet.Ipv4Hdr) == IP_MIN_IHL); std.debug.assert(@sizeOf(packet.TcpHdr) == TCP_MIN_LEN); + std.debug.assert(@sizeOf(packet.UdpHdr) == UDP_MIN_LEN); } const test_key = [16]u8{ @@ -253,3 +342,133 @@ test "runt frames return null instead of reading out of bounds" { var empty = [_]u8{}; try std.testing.expect(classify(&empty, ck) == null); } + +const udp_base: u16 = 40000; +const udp_span: u16 = 8192; +const udp_port: u16 = 53; + +fn buildUdpReply(buf: *[42]u8, ip_src: u32, ip_dst: u32, sport: u16, dport: u16) void { + @memset(buf, 0); + std.mem.writeInt(u16, buf[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV4, .big); + buf[ETH_HDR_LEN] = 0x45; + buf[ETH_HDR_LEN + IP_OFF_PROTO] = IPPROTO_UDP; + std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_SRC ..][0..4], ip_src, .big); + std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_DST ..][0..4], ip_dst, .big); + const udp = ETH_HDR_LEN + IP_MIN_IHL; + std.mem.writeInt(u16, buf[udp + UDP_OFF_SPORT ..][0..2], sport, .big); + std.mem.writeInt(u16, buf[udp + UDP_OFF_DPORT ..][0..2], dport, .big); +} + +fn buildIcmpUdpUnreach(buf: *[128]u8, code: u8, inner_src: u32, inner_dst: u32, inner_sport: u16, inner_dport: u16) usize { + @memset(buf, 0); + std.mem.writeInt(u16, buf[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV4, .big); + buf[ETH_HDR_LEN] = 0x45; + buf[ETH_HDR_LEN + IP_OFF_PROTO] = IPPROTO_ICMP; + std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_SRC ..][0..4], inner_dst, .big); + std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_DST ..][0..4], inner_src, .big); + const icmp = ETH_HDR_LEN + IP_MIN_IHL; + buf[icmp + ICMP_OFF_TYPE] = ICMP_TYPE_DEST_UNREACH; + buf[icmp + ICMP_OFF_CODE] = code; + const inner = icmp + ICMP_HDR_LEN; + buf[inner] = 0x45; + buf[inner + IP_OFF_PROTO] = IPPROTO_UDP; + std.mem.writeInt(u32, buf[inner + IP_OFF_SRC ..][0..4], inner_src, .big); + std.mem.writeInt(u32, buf[inner + IP_OFF_DST ..][0..4], inner_dst, .big); + const inner_udp = inner + IP_MIN_IHL; + std.mem.writeInt(u16, buf[inner_udp + UDP_OFF_SPORT ..][0..2], inner_sport, .big); + std.mem.writeInt(u16, buf[inner_udp + UDP_OFF_DPORT ..][0..2], inner_dport, .big); + return inner_udp + INNER_UDP_MIN_LEN; +} + +test "validated UDP response classifies as open" { + const ck = cookie.Cookie.init(test_key); + const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span); + var f: [42]u8 = undefined; + 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(udp_port, r.port); +} + +test "UDP response to a non-cookie destination port is rejected (anti-spoof)" { + const ck = cookie.Cookie.init(test_key); + var f: [42]u8 = undefined; + buildUdpReply(&f, their_ip, our_ip, udp_port, 12345); + try std.testing.expect(classifyUdp(&f, ck, udp_base, udp_span) == null); +} + +test "validated ICMP port-unreachable (type 3 code 3) classifies as closed for UDP" { + const ck = cookie.Cookie.init(test_key); + const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span); + var f: [128]u8 = undefined; + 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(udp_port, r.port); +} + +test "validated ICMP type 3 code 1 classifies as filtered for UDP" { + const ck = cookie.Cookie.init(test_key); + const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span); + var f: [128]u8 = undefined; + 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(udp_port, r.port); +} + +test "ICMP UDP-unreachable with a mismatched inner source port is rejected" { + const ck = cookie.Cookie.init(test_key); + var f: [128]u8 = undefined; + const len = buildIcmpUdpUnreach(&f, 3, our_ip, their_ip, 9999, udp_port); + try std.testing.expect(classifyUdp(f[0..len], ck, udp_base, udp_span) == null); +} + +test "ICMP UDP-unreachable with a non-unreachable type is ignored" { + const ck = cookie.Cookie.init(test_key); + const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span); + var f: [128]u8 = undefined; + const len = buildIcmpUdpUnreach(&f, 3, our_ip, their_ip, our_src, udp_port); + f[ETH_HDR_LEN + IP_MIN_IHL + ICMP_OFF_TYPE] = 8; + try std.testing.expect(classifyUdp(f[0..len], ck, udp_base, udp_span) == null); +} + +test "classifyUdp ignores non-IPv4 and runt frames" { + const ck = cookie.Cookie.init(test_key); + var f: [42]u8 = undefined; + buildUdpReply(&f, their_ip, our_ip, udp_port, ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span)); + std.mem.writeInt(u16, f[ETH_OFF_TYPE..][0..2], 0x0806, .big); + try std.testing.expect(classifyUdp(&f, ck, udp_base, udp_span) == null); + var tiny = [_]u8{0} ** 20; + try std.testing.expect(classifyUdp(&tiny, ck, udp_base, udp_span) == null); +} + +test "the same ICMP code 3 is closed for UDP but filtered for a TCP SYN scan" { + const ck = cookie.Cookie.init(test_key); + const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span); + var f: [128]u8 = undefined; + const len = buildIcmpUdpUnreach(&f, 3, our_ip, their_ip, our_src, udp_port); + try std.testing.expectEqual(State.closed, classifyUdp(f[0..len], ck, udp_base, udp_span).?.state); + try std.testing.expect(icmpCodeIsFilteredForSyn(3)); + try std.testing.expect(!icmpCodeIsFilteredForUdp(3)); +} + +test "classifier adapters route each frame to the right protocol path" { + const ck = cookie.Cookie.init(test_key); + + const our_seq = ck.seq(their_ip, their_port, our_ip, our_port); + var tcp_f: [54]u8 = undefined; + buildTcpReply(&tcp_f, their_ip, our_ip, their_port, our_port, 0xCAFEBABE, our_seq +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK); + const tcp_clf = TcpClassifier{ .ck = ck }; + try std.testing.expectEqual(State.open, tcp_clf.match(&tcp_f).?.state); + + const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span); + var udp_f: [42]u8 = undefined; + buildUdpReply(&udp_f, their_ip, our_ip, udp_port, our_src); + const udp_clf = UdpClassifier{ .ck = ck, .base = udp_base, .span = udp_span }; + try std.testing.expectEqual(State.open, udp_clf.match(&udp_f).?.state); + try std.testing.expect(tcp_clf.match(&udp_f) == null); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig index 37ed0c6f..70c28542 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig @@ -48,16 +48,18 @@ pub fn printHelp(io: std.Io, env: *std.process.Environ.Map) !void { \\ \\tx / scan options: \\ --target target range, required (e.g. 10.0.0.0/24) - \\ --ports comma-separated dst ports (default 80) + \\ --ports comma-separated dst ports (default 80; --udp: 53,123,161) \\ --rate token-bucket rate, packets per second (default 10000) \\ --count stop after n packets (default: every target once) \\ --iface egress interface (default lo) \\ --src-ip source IPv4 (default: resolved from --iface) - \\ --src-port source TCP port (default 40000) + \\ --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) \\ \\scan-only options: + \\ --udp UDP scan: per-protocol payloads, ICMP type3/code3 = closed, + \\ silent ports reported honestly as open|filtered \\ --wait receive drain window after transmit (default 2000) \\ --json emit NDJSON results to stdout (visuals go to stderr) \\ --color auto | always | never (default auto) diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/cookie.zig b/PROJECTS/advanced/zig-stateless-scanner/src/cookie.zig index 90c8450a..5811054d 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/cookie.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/cookie.zig @@ -32,6 +32,12 @@ pub const Cookie = struct { pub fn validateSynAck(self: Cookie, ack: u32, ip_them: u32, port_them: u16, ip_me: u32, port_me: u16) bool { return ack == self.seq(ip_them, port_them, ip_me, port_me) +% 1; } + + pub fn udpSrcPort(self: Cookie, ip_them: u32, port_them: u16, ip_me: u32, base: u16, span: u16) u16 { + const s: u32 = if (span == 0) 1 else span; + const off: u32 = @intCast(self.generate(ip_them, port_them, ip_me, 0) % s); + return @intCast((@as(u32, base) + off) & 0xffff); + } }; const test_key = [16]u8{ @@ -72,3 +78,27 @@ test "validateSynAck wraps at the u32 boundary" { const seq_max: u32 = 0xFFFFFFFF; try std.testing.expectEqual(@as(u32, 0), seq_max +% 1); } + +test "udpSrcPort is deterministic and stays inside [base, base+span)" { + const c = Cookie.init(test_key); + const base: u16 = 40000; + const span: u16 = 8192; + const a = c.udpSrcPort(0x08080808, 53, 0x0a000001, base, span); + const b = c.udpSrcPort(0x08080808, 53, 0x0a000001, base, span); + try std.testing.expectEqual(a, b); + try std.testing.expect(a >= base and a < base + span); +} + +test "udpSrcPort excludes port_me from the tuple so RX can recompute it" { + const c = Cookie.init(test_key); + const full = c.generate(0x08080808, 53, 0x0a000001, 0); + const want: u16 = @intCast((@as(u32, 40000) + @as(u32, @intCast(full % 8192))) & 0xffff); + try std.testing.expectEqual(want, c.udpSrcPort(0x08080808, 53, 0x0a000001, 40000, 8192)); +} + +test "udpSrcPort separates distinct targets" { + const c = Cookie.init(test_key); + const p53 = c.udpSrcPort(0x08080808, 53, 0x0a000001, 40000, 8192); + const p123 = c.udpSrcPort(0x08080808, 123, 0x0a000001, 40000, 8192); + try std.testing.expect(p53 != p123); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/output.zig b/PROJECTS/advanced/zig-stateless-scanner/src/output.zig index 4a9bc18c..b2c47c47 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/output.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/output.zig @@ -340,10 +340,10 @@ pub const Dashboard = struct { } }; -pub fn emitJson(out: *std.Io.Writer, r: Result) !void { +pub fn emitJson(out: *std.Io.Writer, r: Result, proto: []const u8) !void { try out.print("{{\"ip\":\"", .{}); try writeIp(out, r.ip); - try out.print("\",\"port\":{d},\"proto\":\"tcp\",\"state\":\"{s}\"}}\n", .{ r.port, stateName(r.state) }); + try out.print("\",\"port\":{d},\"proto\":\"{s}\",\"state\":\"{s}\"}}\n", .{ r.port, proto, stateName(r.state) }); } const w_host: usize = 17; @@ -440,6 +440,7 @@ pub fn renderSummary( out: *std.Io.Writer, level: ColorLevel, sent: u64, + probe: []const u8, ifname: []const u8, elapsed_s: f64, open: u64, @@ -452,7 +453,9 @@ pub fn renderSummary( try span(out, level, chrome_gray, "sent "); try setFg(out, level, bright_white); try writeThousands(out, sent); - try span(out, level, chrome_gray, " SYN on "); + try setFg(out, level, chrome_gray); + try out.print(" {s} on ", .{probe}); + try resetFg(out, level); try setFg(out, level, bright_white); try out.writeAll(ifname); try span(out, level, chrome_gray, " in "); @@ -472,6 +475,26 @@ pub fn renderSummary( try out.writeByte('\n'); } +pub fn renderUnanswered(out: *std.Io.Writer, level: ColorLevel, count: u64) !void { + try out.writeAll(" "); + try span(out, level, violet_mid, gutter_bar); + try out.writeByte(' '); + try span(out, level, chrome_gray, "open|filtered (no response) "); + try setFg(out, level, soft_amber); + try writeThousands(out, count); + try resetFg(out, level); + try out.writeByte('\n'); +} + +test "renderUnanswered reports the silent-port count in plain mode" { + var buf: [128]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + try renderUnanswered(&w, .none, 4094); + const text = buf[0..w.end]; + try std.testing.expect(std.mem.indexOf(u8, text, "open|filtered (no response)") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "4,094") != null); +} + test "resolveLevel honors choice, tty state, and truecolor" { try std.testing.expectEqual(ColorLevel.none, resolveLevel(.never, true, true)); try std.testing.expectEqual(ColorLevel.truecolor, resolveLevel(.always, false, true)); @@ -518,14 +541,20 @@ test "Stats.record tallies per-state and total found without cross-talk" { try std.testing.expectEqual(@as(u64, 0), s.sent.v.load(.monotonic)); } -test "emitJson writes one greppable NDJSON object per result" { +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 }); + try emitJson(&w, .{ .ip = 0x0a000005, .port = 80, .state = .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 std.testing.expectEqualStrings( + "{\"ip\":\"8.8.8.8\",\"port\":53,\"proto\":\"udp\",\"state\":\"closed\"}\n", + buf[0..w.end], + ); } test "writeThousands groups digits and leaves small numbers intact" { diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig b/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig index 434a33c9..78452aea 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig @@ -35,10 +35,18 @@ pub const TcpHdr = extern struct { urgent: u16, }; +pub const UdpHdr = extern struct { + src_port: u16, + dst_port: u16, + length: u16, + checksum: u16, +}; + 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); } pub fn checksum(bytes: []const u8) u16 { @@ -118,10 +126,38 @@ pub fn tcpChecksum(src_be: u32, dst_be: u32, segment: []const u8) u16 { return ~@as(u16, @truncate(sum)); } +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)); + @memcpy(pseudo[4..8], std.mem.asBytes(&dst_be)); + pseudo[8] = 0; + pseudo[9] = 17; + std.mem.writeInt(u16, pseudo[10..12], @intCast(segment.len), .big); + + var sum: u32 = 0; + var i: usize = 0; + while (i + 1 < pseudo.len) : (i += 2) { + sum += (@as(u32, pseudo[i]) << 8) | @as(u32, pseudo[i + 1]); + } + i = 0; + while (i + 1 < segment.len) : (i += 2) { + sum += (@as(u32, segment[i]) << 8) | @as(u32, segment[i + 1]); + } + if (i < segment.len) { + sum += @as(u32, segment[i]) << 8; + } + while (sum >> 16 != 0) { + sum = (sum & 0xffff) + (sum >> 16); + } + const folded: u16 = ~@as(u16, @truncate(sum)); + return if (folded == 0) 0xffff else folded; +} + test "header sizes are wire-exact" { try std.testing.expectEqual(@as(usize, 14), @sizeOf(EthHdr)); try std.testing.expectEqual(@as(usize, 20), @sizeOf(Ipv4Hdr)); try std.testing.expectEqual(@as(usize, 20), @sizeOf(TcpHdr)); + try std.testing.expectEqual(@as(usize, 8), @sizeOf(UdpHdr)); } test "RFC 1071 checksum matches the canonical IPv4 KAT (0xb861)" { @@ -193,3 +229,27 @@ test "tcpChecksum self-verifies: a segment with its correct checksum folds to 0" tcp.checksum = std.mem.nativeToBig(u16, tcpChecksum(src, dst, std.mem.asBytes(&tcp))); try std.testing.expectEqual(@as(u16, 0), tcpChecksum(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); + var seg: [12]u8 = undefined; + var hdr = UdpHdr{ + .src_port = std.mem.nativeToBig(u16, 40000), + .dst_port = std.mem.nativeToBig(u16, 53), + .length = std.mem.nativeToBig(u16, 12), + .checksum = 0, + }; + @memcpy(seg[0..8], std.mem.asBytes(&hdr)); + @memcpy(seg[8..12], "abcd"); + const ck = udpChecksum(src, dst, &seg); + try std.testing.expect(ck != 0); + hdr.checksum = std.mem.nativeToBig(u16, ck); + @memcpy(seg[0..8], std.mem.asBytes(&hdr)); + try std.testing.expectEqual(@as(u16, 0xffff), udpChecksum(src, dst, &seg)); +} + +test "udpChecksum maps a computed 0x0000 to 0xFFFF (IPv4 UDP quirk)" { + try std.testing.expectEqual(@as(u16, 0xffff), udpChecksum(0, 0, &[_]u8{ 0xff, 0xec })); + try std.testing.expect(udpChecksum(0, 0, &[_]u8{ 0xff, 0xec }) != 0); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/payloads.zig b/PROJECTS/advanced/zig-stateless-scanner/src/payloads.zig new file mode 100644 index 00000000..a3b3ed46 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/payloads.zig @@ -0,0 +1,171 @@ +// ©AngelaMos | 2026 +// payloads.zig + +const std = @import("std"); + +const Entry = struct { port: u16, payload: []const u8 }; + +const ntp_client = [_]u8{0x1b} ++ [_]u8{0} ** 47; + +const dns_version_bind = [_]u8{ + 0x13, 0x37, + 0x01, 0x00, + 0x00, 0x01, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x07, 'v', 'e', + 'r', 's', 'i', + 'o', 'n', 0x04, + 'b', 'i', 'n', + 'd', 0x00, 0x00, + 0x10, 0x00, 0x03, +}; + +const snmp_get_public = [_]u8{ + 0x30, 0x26, + 0x02, 0x01, 0x00, + 0x04, 0x06, 'p', 'u', 'b', 'l', 'i', 'c', + 0xa0, 0x19, + 0x02, 0x01, 0x00, + 0x02, 0x01, 0x00, + 0x02, 0x01, 0x00, + 0x30, 0x0e, + 0x30, 0x0c, + 0x06, 0x08, 0x2b, 0x06, 0x01, 0x02, 0x01, 0x01, 0x01, 0x00, + 0x05, 0x00, +}; + +const netbios_node_status = [_]u8{ + 0x13, 0x37, + 0x00, 0x00, + 0x00, 0x01, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x20, 'C', 'K', +} ++ ([_]u8{'A'} ** 30) ++ [_]u8{ + 0x00, + 0x00, 0x21, + 0x00, 0x01, +}; + +const ssdp_msearch = + "M-SEARCH * HTTP/1.1\r\n" ++ + "HOST:239.255.255.250:1900\r\n" ++ + "MAN:\"ssdp:discover\"\r\n" ++ + "MX:1\r\n" ++ + "ST:ssdp:all\r\n" ++ + "\r\n"; + +const mdns_services = [_]u8{ + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x01, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x09, '_', 's', 'e', 'r', 'v', 'i', 'c', 'e', 's', + 0x07, '_', 'd', 'n', 's', '-', 's', 'd', + 0x04, '_', 'u', 'd', 'p', + 0x05, 'l', 'o', 'c', 'a', 'l', + 0x00, + 0x00, 0x0c, + 0x00, 0x01, +}; + +const table = [_]Entry{ + .{ .port = 53, .payload = &dns_version_bind }, + .{ .port = 123, .payload = &ntp_client }, + .{ .port = 137, .payload = &netbios_node_status }, + .{ .port = 161, .payload = &snmp_get_public }, + .{ .port = 1900, .payload = ssdp_msearch }, + .{ .port = 5353, .payload = &mdns_services }, +}; + +pub fn lookup(port: u16) []const u8 { + inline for (table) |e| { + if (e.port == port) return e.payload; + } + return &.{}; +} + +pub const max_len: usize = blk: { + var m: usize = 0; + for (table) |e| { + if (e.payload.len > m) m = e.payload.len; + } + break :blk m; +}; + +test "lookup returns the protocol payload for a known port and empty for the rest" { + try std.testing.expectEqual(@as(usize, 48), lookup(123).len); + try std.testing.expectEqualSlices(u8, &ntp_client, lookup(123)); + try std.testing.expectEqual(@as(usize, 0), lookup(80).len); + try std.testing.expectEqual(@as(usize, 0), lookup(0).len); +} + +test "NTP client probe is a 48-byte LI=0 VN=3 Mode=3 request" { + const p = lookup(123); + try std.testing.expectEqual(@as(usize, 48), p.len); + try std.testing.expectEqual(@as(u8, 0x1b), p[0]); +} + +test "DNS probe is a well-formed single-question version.bind CH TXT query" { + const p = lookup(53); + try std.testing.expectEqual(@as(u16, 1), std.mem.readInt(u16, p[4..6], .big)); + var name: [16]u8 = undefined; + var w: usize = 0; + var i: usize = 12; + while (p[i] != 0) { + const len = p[i]; + i += 1; + if (w != 0) { + name[w] = '.'; + w += 1; + } + @memcpy(name[w .. w + len], p[i .. i + len]); + w += len; + i += len; + } + try std.testing.expectEqualStrings("version.bind", name[0..w]); + try std.testing.expectEqual(@as(u16, 0x0010), std.mem.readInt(u16, p[i + 1 ..][0..2], .big)); + try std.testing.expectEqual(@as(u16, 0x0003), std.mem.readInt(u16, p[i + 3 ..][0..2], .big)); +} + +test "SNMP probe is a v1 GetRequest for community public with a matching outer length" { + const p = lookup(161); + try std.testing.expectEqual(@as(u8, 0x30), p[0]); + try std.testing.expectEqual(@as(usize, p[1]) + 2, p.len); + try std.testing.expect(std.mem.indexOf(u8, p, "public") != null); + try std.testing.expect(std.mem.indexOfScalar(u8, p, 0xa0) != null); +} + +test "NetBIOS probe carries the encoded wildcard name and NBSTAT type" { + const p = lookup(137); + try std.testing.expectEqual(@as(usize, 50), p.len); + try std.testing.expect(std.mem.indexOf(u8, p, "CKAAAA") != null); + try std.testing.expectEqual(@as(u16, 0x0021), std.mem.readInt(u16, p[p.len - 4 ..][0..2], .big)); +} + +test "SSDP probe is an M-SEARCH discover" { + const p = lookup(1900); + try std.testing.expect(std.mem.startsWith(u8, p, "M-SEARCH * HTTP/1.1")); + try std.testing.expect(std.mem.indexOf(u8, p, "ssdp:discover") != null); +} + +test "mDNS probe enumerates DNS-SD services over PTR" { + const p = lookup(5353); + try std.testing.expect(std.mem.indexOf(u8, p, "_services") != null); + try std.testing.expect(std.mem.indexOf(u8, p, "_dns-sd") != null); + try std.testing.expectEqual(@as(u16, 0x000c), std.mem.readInt(u16, p[p.len - 4 ..][0..2], .big)); +} + +test "max_len equals the longest table payload" { + var m: usize = 0; + for (table) |e| { + if (e.payload.len > m) m = e.payload.len; + } + try std.testing.expectEqual(m, max_len); + try std.testing.expect(max_len >= 48); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig b/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig index abd7d993..f2e8695a 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig @@ -8,6 +8,8 @@ const cookie = @import("cookie"); pub const Result = classify.Result; pub const State = classify.State; +pub const TcpClassifier = classify.TcpClassifier; +pub const UdpClassifier = classify.UdpClassifier; const RECV_BUF_LEN: usize = 2048; const PORT_BITS: u6 = 16; @@ -18,10 +20,10 @@ pub fn resultKey(r: Result) u64 { return (@as(u64, r.ip) << PORT_BITS) | r.port; } -pub fn run(source: anytype, ck: cookie.Cookie, dd: *dedup.Dedup, sink: anytype) void { +pub fn run(source: anytype, clf: anytype, dd: *dedup.Dedup, sink: anytype) void { var buf: [RECV_BUF_LEN]u8 = undefined; while (source.recv(&buf)) |n| { - if (classify.classify(buf[0..n], ck)) |r| { + if (clf.match(buf[0..n])) |r| { if (dd.insert(resultKey(r))) sink.emit(r); } } @@ -213,7 +215,7 @@ test "engine classifies, dedups, and emits each found host once" { var src = FakeSource{ .frames = &frames }; var sink = CollectSink{ .list = &list, .allocator = std.testing.allocator }; - run(&src, ck, &dd, &sink); + run(&src, classify.TcpClassifier{ .ck = ck }, &dd, &sink); try std.testing.expectEqual(@as(usize, 2), list.items.len); try std.testing.expectEqual(State.open, list.items[0].state); @@ -258,7 +260,7 @@ test "Io.Queue hands the deduped set from a producer to a consumer fiber" { var src = FakeSource{ .frames = &frames }; var sink = QueueSink{ .queue = &queue, .io = io }; - run(&src, ck, &dd, &sink); + run(&src, classify.TcpClassifier{ .ck = ck }, &dd, &sink); queue.close(io); consumer.await(io); diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig b/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig index 3d576c63..3d9bbb91 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig @@ -4,6 +4,7 @@ const std = @import("std"); const targets = @import("targets"); const template = @import("template"); +const udp = @import("udp"); const ratelimit = @import("ratelimit"); const afpacket = @import("afpacket"); const cookie = @import("cookie"); @@ -16,10 +17,12 @@ const output = @import("output"); const default_iface = "lo"; const default_rate: u64 = 10_000; const default_src_port: u16 = 40_000; +const default_udp_src_span: u16 = 8_192; const default_wait_ms: i32 = 2_000; const ns_per_ms: u64 = 1_000_000; const ns_per_sec: u64 = 1_000_000_000; -const default_ports = [_]u16{80}; +const default_tcp_ports = [_]u16{80}; +const default_udp_ports = [_]u16{ 53, 123, 161 }; const dedup_capacity: usize = 1024; const queue_capacity: usize = 2048; const drain_batch: usize = 256; @@ -53,9 +56,9 @@ const TxSink = struct { } }; -fn txWorker( +fn txWorkerImpl( engine: *targets.Engine, - tmpl: *const template.SynTemplate, + tmpl: anytype, bucket: *ratelimit.TokenBucket, sink: *TxSink, max_packets: u64, @@ -69,14 +72,21 @@ fn txWorker( return sent; } -fn rxWorker( - receiver: *rx.Receiver, - ck: cookie.Cookie, - dd: *dedup.Dedup, - sink: *rx.QueueSink, - rx_done: *std.atomic.Value(bool), -) void { - rx.run(receiver, ck, dd, sink); +fn txWorkerTcp(engine: *targets.Engine, tmpl: *const template.SynTemplate, bucket: *ratelimit.TokenBucket, sink: *TxSink, max_packets: u64, budget_ns: u64, tx_done: *std.atomic.Value(bool)) u64 { + return txWorkerImpl(engine, tmpl, bucket, sink, max_packets, budget_ns, tx_done); +} + +fn txWorkerUdp(engine: *targets.Engine, tmpl: *const udp.UdpTemplate, bucket: *ratelimit.TokenBucket, sink: *TxSink, max_packets: u64, budget_ns: u64, tx_done: *std.atomic.Value(bool)) u64 { + return txWorkerImpl(engine, tmpl, bucket, sink, max_packets, budget_ns, tx_done); +} + +fn rxWorkerTcp(receiver: *rx.Receiver, ck: cookie.Cookie, dd: *dedup.Dedup, sink: *rx.QueueSink, rx_done: *std.atomic.Value(bool)) void { + rx.run(receiver, rx.TcpClassifier{ .ck = ck }, dd, sink); + rx_done.store(true, .release); +} + +fn rxWorkerUdp(receiver: *rx.Receiver, ck: cookie.Cookie, base: u16, span: u16, dd: *dedup.Dedup, sink: *rx.QueueSink, rx_done: *std.atomic.Value(bool)) void { + rx.run(receiver, rx.UdpClassifier{ .ck = ck, .base = base, .span = span }, dd, sink); rx_done.store(true, .release); } @@ -86,11 +96,12 @@ fn absorb( allocator: std.mem.Allocator, stats: *output.Stats, json_out: ?*std.Io.Writer, + proto: []const u8, ) void { for (batch) |r| { found.append(allocator, r) catch continue; stats.record(r.state); - if (json_out) |w| output.emitJson(w, r) catch {}; + if (json_out) |w| output.emitJson(w, r, proto) catch {}; } } @@ -102,11 +113,12 @@ fn drainQueue( allocator: std.mem.Allocator, stats: *output.Stats, json_out: ?*std.Io.Writer, + proto: []const u8, ) void { while (true) { const n = queue.get(io, buf, 0) catch return; if (n == 0) return; - absorb(buf[0..n], found, allocator, stats, json_out); + absorb(buf[0..n], found, allocator, stats, json_out, proto); if (n < buf.len) return; } } @@ -138,8 +150,18 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e 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 is_udp = netutil.hasFlag(args, "--udp"); + const udp_base: u16 = src_port; + const udp_span: u16 = @intCast(@min(@as(u32, default_udp_src_span), 65536 - @as(u32, udp_base))); + const proto_json: []const u8 = if (is_udp) "udp" else "tcp"; + const probe_label: []const u8 = if (is_udp) "UDP" else "SYN"; - const ports = if (netutil.getFlag(args, "--ports")) |p| try netutil.parsePorts(allocator, p) else try allocator.dupe(u16, &default_ports); + const ports = if (netutil.getFlag(args, "--ports")) |p| + try netutil.parsePorts(allocator, p) + else if (is_udp) + try allocator.dupe(u16, &default_udp_ports) + else + try allocator.dupe(u16, &default_tcp_ports); const gw_mac = if (netutil.getFlag(args, "--gw-mac")) |m| try netutil.parseMac(m) else [_]u8{0} ** 6; const src_ip = if (netutil.getFlag(args, "--src-ip")) |s| try netutil.parseIpv4(s) else try netutil.resolveSrcIp(ifname); const src_mac = try netutil.resolveSrcMac(ifname); @@ -167,13 +189,21 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e const dash_total = @min(count, eng.total); const ck = try cookie.Cookie.random(io); - const tmpl = template.SynTemplate.init(.{ + const tcp_tmpl = template.SynTemplate.init(.{ .src_mac = src_mac, .dst_mac = gw_mac, .src_ip = src_ip, .src_port = src_port, .cookie = ck, }); + const udp_tmpl = udp.UdpTemplate.init(.{ + .src_mac = src_mac, + .dst_mac = gw_mac, + .src_ip = src_ip, + .src_port_base = udp_base, + .src_port_span = udp_span, + .cookie = ck, + }); var bucket = ratelimit.TokenBucket.init(rate, rate); var backend = afpacket.Backend.open(ifname, .{}) catch |err| switch (err) { @@ -218,7 +248,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e var stats: output.Stats = .{}; const json_out: ?*std.Io.Writer = if (json) out else null; - try derr.print("zingela target {s} iface {s} rate {d} pps ports {d}\n", .{ target_text, ifname, rate, ports.len }); + try derr.print("zingela {s} scan target {s} iface {s} rate {d} pps ports {d}\n", .{ probe_label, target_text, ifname, rate, ports.len }); try derr.flush(); var clock = netutil.RealClock{}; @@ -227,12 +257,20 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e var tx_sink = TxSink{ .backend = &backend, .sent = &stats.sent.v }; var rx_sink = rx.QueueSink{ .queue = &queue, .io = io }; - var tx_fut = io.concurrent(txWorker, .{ &eng, &tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done }) catch { + const tx_res = if (is_udp) + io.concurrent(txWorkerUdp, .{ &eng, &udp_tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done }) + else + io.concurrent(txWorkerTcp, .{ &eng, &tcp_tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done }); + var tx_fut = tx_res catch { try derr.writeAll(concurrency_hint); try derr.flush(); return; }; - var rx_fut = io.concurrent(rxWorker, .{ &receiver, ck, &dd, &rx_sink, &rx_done }) catch { + const rx_res = if (is_udp) + io.concurrent(rxWorkerUdp, .{ &receiver, ck, udp_base, udp_span, &dd, &rx_sink, &rx_done }) + else + io.concurrent(rxWorkerTcp, .{ &receiver, ck, &dd, &rx_sink, &rx_done }); + var rx_fut = rx_res catch { _ = tx_fut.await(io); try derr.writeAll(concurrency_hint); try derr.flush(); @@ -245,7 +283,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e 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); + drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, proto_json); if (json) out.flush() catch {}; const now = clock.now(); if (last_render == 0 or now -| last_render >= render_interval_ns) { @@ -258,7 +296,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e 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); + drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, proto_json); if (json) out.flush() catch {}; dash.render(derr, &stats, clock.now() -| t0) catch {}; @@ -285,6 +323,10 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e 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, ifname, elapsed_s, open_n, closed_n, filtered_n); + try output.renderSummary(derr, err_level, sent, probe_label, ifname, elapsed_s, open_n, closed_n, filtered_n); + if (is_udp) { + const answered = open_n + closed_n + filtered_n; + try output.renderUnanswered(derr, err_level, sent -| answered); + } try derr.flush(); } diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/template.zig b/PROJECTS/advanced/zig-stateless-scanner/src/template.zig index a742cfee..e86f082c 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/template.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/template.zig @@ -17,6 +17,7 @@ const default_window: u16 = 1024; pub const SynTemplate = struct { pub const frame_len: usize = 54; + pub const max_frame_len: usize = frame_len; base: [frame_len]u8, src_ip_be: u32, @@ -76,7 +77,7 @@ pub const SynTemplate = struct { }; } - pub fn stamp(self: *const SynTemplate, out: *[frame_len]u8, dst_ip: u32, dst_port: u16) void { + pub fn stamp(self: *const SynTemplate, out: *[frame_len]u8, dst_ip: u32, dst_port: u16) usize { @memcpy(out, &self.base); std.mem.writeInt(u32, out[30..34], dst_ip, .big); @@ -92,6 +93,7 @@ pub const SynTemplate = struct { const dst_be = std.mem.nativeToBig(u32, dst_ip); const tcp_ck = packet.tcpChecksum(self.src_ip_be, dst_be, out[34..54]); std.mem.writeInt(u16, out[50..52], tcp_ck, .big); + return frame_len; } }; @@ -110,7 +112,7 @@ test "stamped frame is 54 bytes with self-verifying IP and TCP checksums" { .cookie = ck, }); var frame: [SynTemplate.frame_len]u8 = undefined; - tmpl.stamp(&frame, 0x08080808, 443); + _ = tmpl.stamp(&frame, 0x08080808, 443); try std.testing.expectEqual(@as(usize, 54), frame.len); try std.testing.expectEqual(@as(u16, 0), packet.checksum(frame[14..34])); @@ -129,7 +131,7 @@ test "stamp writes the destination and the SipHash seq" { .cookie = ck, }); var frame: [SynTemplate.frame_len]u8 = undefined; - tmpl.stamp(&frame, 0x08080808, 443); + _ = tmpl.stamp(&frame, 0x08080808, 443); try std.testing.expectEqual(@as(u32, 0x08080808), std.mem.readInt(u32, frame[30..34], .big)); try std.testing.expectEqual(@as(u16, 443), std.mem.readInt(u16, frame[36..38], .big)); @@ -148,7 +150,7 @@ test "two different targets produce two different seqs" { }); var a: [SynTemplate.frame_len]u8 = undefined; var b: [SynTemplate.frame_len]u8 = undefined; - tmpl.stamp(&a, 0x08080808, 443); - tmpl.stamp(&b, 0x08080808, 80); + _ = tmpl.stamp(&a, 0x08080808, 443); + _ = tmpl.stamp(&b, 0x08080808, 80); try std.testing.expect(!std.mem.eql(u8, a[38..42], b[38..42])); } diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig b/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig index ba0b2aa3..21f62296 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig @@ -10,7 +10,7 @@ const packet = @import("packet"); pub fn run( engine: *targets.Engine, - tmpl: *const template.SynTemplate, + tmpl: anytype, bucket: *ratelimit.TokenBucket, sink: anytype, clock: anytype, @@ -19,7 +19,7 @@ pub fn run( ) u64 { _ = bucket.takeBatch(clock.now(), 0); var sent: u64 = 0; - var frame: [template.SynTemplate.frame_len]u8 = undefined; + var frame: [@TypeOf(tmpl.*).max_frame_len]u8 = undefined; var pending: ?targets.Target = engine.next(); while (pending != null and sent < max_packets) { @@ -33,11 +33,11 @@ pub fn run( var n: u64 = 0; while (n < granted) : (n += 1) { const t = pending orelse break; - tmpl.stamp(&frame, t.ip, t.port); - if (!sink.submit(&frame)) { + const len = tmpl.stamp(&frame, t.ip, t.port); + if (!sink.submit(frame[0..len])) { @branchHint(.unlikely); sink.kick(); - if (!sink.submit(&frame)) break; + if (!sink.submit(frame[0..len])) break; } sent += 1; pending = engine.next(); diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/udp.zig b/PROJECTS/advanced/zig-stateless-scanner/src/udp.zig new file mode 100644 index 00000000..7250c2a3 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/udp.zig @@ -0,0 +1,195 @@ +// ©AngelaMos | 2026 +// udp.zig + +const std = @import("std"); +const packet = @import("packet"); +const cookie = @import("cookie"); +const payloads = @import("payloads"); + +const ethertype_ipv4: u16 = 0x0800; +const ipv4_version_ihl: u8 = 0x45; +const ip_proto_udp: u8 = 17; +const ip_flag_dont_fragment: u16 = 0x4000; +const default_ttl: u8 = 64; + +const eth_len: usize = 14; +const ip_len: usize = 20; +const udp_len: usize = 8; +const l4_off: usize = eth_len + ip_len; +const payload_off: usize = l4_off + udp_len; + +const ip_total_len_off: usize = eth_len + 2; +const ip_checksum_off: usize = eth_len + 10; +const ip_dst_off: usize = eth_len + 16; +const udp_src_off: usize = l4_off; +const udp_dst_off: usize = l4_off + 2; +const udp_len_off: usize = l4_off + 4; +const udp_checksum_off: usize = l4_off + 6; + +pub const UdpTemplate = struct { + pub const max_frame_len: usize = payload_off + payloads.max_len; + + base: [payload_off]u8, + src_ip_be: u32, + src_ip: u32, + src_port_base: u16, + src_port_span: u16, + cookie: cookie.Cookie, + + pub const Config = struct { + src_mac: [6]u8, + dst_mac: [6]u8, + src_ip: u32, + src_port_base: u16, + src_port_span: u16, + cookie: cookie.Cookie, + }; + + pub fn init(cfg: Config) UdpTemplate { + var base: [payload_off]u8 = undefined; + + const eth = packet.EthHdr{ + .dst = cfg.dst_mac, + .src = cfg.src_mac, + .ethertype = std.mem.nativeToBig(u16, ethertype_ipv4), + }; + @memcpy(base[0..eth_len], std.mem.asBytes(ð)); + + const ip = packet.Ipv4Hdr{ + .version_ihl = ipv4_version_ihl, + .tos = 0, + .total_len = 0, + .id = 0, + .flags_frag = std.mem.nativeToBig(u16, ip_flag_dont_fragment), + .ttl = default_ttl, + .protocol = ip_proto_udp, + .checksum = 0, + .src = std.mem.nativeToBig(u32, cfg.src_ip), + .dst = 0, + }; + @memcpy(base[eth_len..l4_off], std.mem.asBytes(&ip)); + + const udp = packet.UdpHdr{ + .src_port = 0, + .dst_port = 0, + .length = 0, + .checksum = 0, + }; + @memcpy(base[l4_off..payload_off], std.mem.asBytes(&udp)); + + return .{ + .base = base, + .src_ip_be = std.mem.nativeToBig(u32, cfg.src_ip), + .src_ip = cfg.src_ip, + .src_port_base = cfg.src_port_base, + .src_port_span = cfg.src_port_span, + .cookie = cfg.cookie, + }; + } + + pub fn stamp(self: *const UdpTemplate, out: *[max_frame_len]u8, dst_ip: u32, dst_port: u16) usize { + const payload = payloads.lookup(dst_port); + const udp_total = udp_len + payload.len; + const ip_total = ip_len + udp_total; + const frame_len = eth_len + ip_total; + + @memcpy(out[0..payload_off], &self.base); + @memcpy(out[payload_off .. payload_off + payload.len], payload); + + std.mem.writeInt(u16, out[ip_total_len_off..][0..2], @intCast(ip_total), .big); + std.mem.writeInt(u32, out[ip_dst_off..][0..4], dst_ip, .big); + std.mem.writeInt(u16, out[ip_checksum_off..][0..2], 0, .big); + const ip_ck = packet.checksum(out[eth_len..l4_off]); + std.mem.writeInt(u16, out[ip_checksum_off..][0..2], ip_ck, .big); + + const src_port = self.cookie.udpSrcPort(dst_ip, dst_port, self.src_ip, self.src_port_base, self.src_port_span); + std.mem.writeInt(u16, out[udp_src_off..][0..2], src_port, .big); + std.mem.writeInt(u16, out[udp_dst_off..][0..2], dst_port, .big); + std.mem.writeInt(u16, out[udp_len_off..][0..2], @intCast(udp_total), .big); + std.mem.writeInt(u16, out[udp_checksum_off..][0..2], 0, .big); + const dst_be = std.mem.nativeToBig(u32, dst_ip); + const udp_ck = packet.udpChecksum(self.src_ip_be, dst_be, out[l4_off .. payload_off + payload.len]); + std.mem.writeInt(u16, out[udp_checksum_off..][0..2], udp_ck, .big); + + return frame_len; + } +}; + +const test_key = [16]u8{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, +}; + +const our_ip: u32 = 0x0a000001; +const their_ip: u32 = 0x08080808; +const base_port: u16 = 40000; +const span: u16 = 8192; + +fn testTemplate(ck: cookie.Cookie) UdpTemplate { + return UdpTemplate.init(.{ + .src_mac = .{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 }, + .dst_mac = .{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x02 }, + .src_ip = our_ip, + .src_port_base = base_port, + .src_port_span = span, + .cookie = ck, + }); +} + +test "stamped UDP frame carries self-verifying IP and UDP checksums" { + const ck = cookie.Cookie.init(test_key); + const tmpl = testTemplate(ck); + var frame: [UdpTemplate.max_frame_len]u8 = undefined; + const len = tmpl.stamp(&frame, their_ip, 53); + + try std.testing.expectEqual(@as(u16, 0), packet.checksum(frame[eth_len..l4_off])); + const src_be = std.mem.nativeToBig(u32, our_ip); + const dst_be = std.mem.nativeToBig(u32, their_ip); + try std.testing.expectEqual(@as(u16, 0xffff), packet.udpChecksum(src_be, dst_be, frame[l4_off..len])); + try std.testing.expect(std.mem.readInt(u16, frame[udp_checksum_off..][0..2], .big) != 0); +} + +test "stamp selects the per-protocol payload and computes the frame length" { + const ck = cookie.Cookie.init(test_key); + const tmpl = testTemplate(ck); + var frame: [UdpTemplate.max_frame_len]u8 = undefined; + + const known = payloads.lookup(53); + const len53 = tmpl.stamp(&frame, their_ip, 53); + try std.testing.expectEqual(payload_off + known.len, len53); + try std.testing.expectEqualSlices(u8, known, frame[payload_off..len53]); + try std.testing.expectEqual(@as(u16, @intCast(udp_len + known.len)), std.mem.readInt(u16, frame[udp_len_off..][0..2], .big)); + + const len80 = tmpl.stamp(&frame, their_ip, 80); + try std.testing.expectEqual(payload_off, len80); +} + +test "stamp writes the cookie-derived UDP source port" { + const ck = cookie.Cookie.init(test_key); + const tmpl = testTemplate(ck); + var frame: [UdpTemplate.max_frame_len]u8 = undefined; + _ = tmpl.stamp(&frame, their_ip, 53); + const want = ck.udpSrcPort(their_ip, 53, our_ip, base_port, span); + try std.testing.expectEqual(want, std.mem.readInt(u16, frame[udp_src_off..][0..2], .big)); + try std.testing.expect(want >= base_port and want < base_port + span); +} + +test "stamp writes destination, port, and the UDP protocol number" { + const ck = cookie.Cookie.init(test_key); + const tmpl = testTemplate(ck); + var frame: [UdpTemplate.max_frame_len]u8 = undefined; + _ = tmpl.stamp(&frame, their_ip, 161); + try std.testing.expectEqual(their_ip, std.mem.readInt(u32, frame[ip_dst_off..][0..4], .big)); + try std.testing.expectEqual(@as(u16, 161), std.mem.readInt(u16, frame[udp_dst_off..][0..2], .big)); + try std.testing.expectEqual(ip_proto_udp, frame[eth_len + 9]); +} + +test "two different target ports produce two different source ports" { + const ck = cookie.Cookie.init(test_key); + const tmpl = testTemplate(ck); + var a: [UdpTemplate.max_frame_len]u8 = undefined; + var b: [UdpTemplate.max_frame_len]u8 = undefined; + _ = tmpl.stamp(&a, their_ip, 53); + _ = tmpl.stamp(&b, their_ip, 123); + try std.testing.expect(!std.mem.eql(u8, a[udp_src_off..][0..2], b[udp_src_off..][0..2])); +}