From 6e3bfccba7ac55cabfa90ee4f3dabc85a4abadd6 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 3 Jul 2026 05:54:17 -0400 Subject: [PATCH] feat(zingela): M8 stealth/evasion suite behind --authorized-scan OS-realistic SYN templates (Linux/Windows/macOS/masscan JA4T option chains plus varying IP-id), Poisson jitter, source-port rotation (RX recomputes off the reply, zero classify changes), scoped RST-suppression (iptables plus ambient CAP_NET_ADMIN, self-healing delete-before-insert), decoys (bogon-free RND, real probe always sent), and FIN/NULL/Xmas/Maimon/ACK/Window flag scans with per-mode cookie matching plus State.unfiltered. Dead theater (idle scan, fragmentation, TTL, MAC/source-route spoof, badsum) omitted and documented as obsolete with citations. Cursor-based TX emission with token refund plus cold-start pacing; non-stealth path byte-identical to M7. --- .../advanced/zig-stateless-scanner/build.zig | 14 +- .../zig-stateless-scanner/src/classify.zig | 135 ++++++- .../zig-stateless-scanner/src/cli.zig | 11 + .../zig-stateless-scanner/src/output.zig | 12 + .../zig-stateless-scanner/src/packet.zig | 276 ++++++++++++++ .../zig-stateless-scanner/src/ratelimit.zig | 82 +++++ .../zig-stateless-scanner/src/scancmd.zig | 99 ++++- .../zig-stateless-scanner/src/stealth.zig | 341 +++++++++++++++++ .../zig-stateless-scanner/src/template.zig | 342 +++++++++++++++--- .../advanced/zig-stateless-scanner/src/tx.zig | 234 +++++++++++- .../zig-stateless-scanner/src/txcmd.zig | 50 +++ .../zig-stateless-scanner/src/udp.zig | 9 + 12 files changed, 1519 insertions(+), 86 deletions(-) create mode 100644 PROJECTS/advanced/zig-stateless-scanner/src/stealth.zig diff --git a/PROJECTS/advanced/zig-stateless-scanner/build.zig b/PROJECTS/advanced/zig-stateless-scanner/build.zig index 09bd2b57..810d2e39 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-m7"); + opts.addOption([]const u8, "version", "0.0.0-m8"); opts.addOption(bool, "xdp", xdp_enabled); const build_config_mod = opts.createModule(); @@ -159,6 +159,14 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + const stealth_mod = b.createModule(.{ + .root_source_file = b.path("src/stealth.zig"), + .target = target, + .optimize = optimize, + }); + stealth_mod.addImport("packet", packet_mod); + stealth_mod.addImport("netutil", netutil_mod); + const txcmd_mod = b.createModule(.{ .root_source_file = b.path("src/txcmd.zig"), .target = target, @@ -171,6 +179,7 @@ pub fn build(b: *std.Build) void { txcmd_mod.addImport("cookie", cookie_mod); txcmd_mod.addImport("tx", tx_mod); txcmd_mod.addImport("netutil", netutil_mod); + txcmd_mod.addImport("stealth", stealth_mod); const scancmd_mod = b.createModule(.{ .root_source_file = b.path("src/scancmd.zig"), @@ -188,6 +197,7 @@ pub fn build(b: *std.Build) void { scancmd_mod.addImport("dedup", dedup_mod); scancmd_mod.addImport("netutil", netutil_mod); scancmd_mod.addImport("output", output_mod); + scancmd_mod.addImport("stealth", stealth_mod); const exe = b.addExecutable(.{ .name = "zingela", @@ -218,7 +228,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, 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, 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, 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 }; 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 801adbff..3db24101 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/classify.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/classify.zig @@ -5,7 +5,7 @@ const std = @import("std"); const packet = @import("packet"); const cookie = @import("cookie"); -pub const State = enum { open, closed, filtered }; +pub const State = enum { open, closed, filtered, unfiltered }; pub const Result = struct { ip: u32, @@ -41,6 +41,7 @@ const TCP_OFF_DPORT: usize = 2; const TCP_OFF_SEQ: usize = 4; const TCP_OFF_ACK: usize = 8; const TCP_OFF_FLAGS: usize = 13; +const TCP_OFF_WINDOW: usize = 14; const TCP_MIN_LEN: usize = 20; const TCP_FLAG_SYN: u8 = 0x02; @@ -71,6 +72,10 @@ fn ihlBytes(first_byte: u8) usize { } pub fn classify(frame: []const u8, ck: cookie.Cookie) ?Result { + return classifyTcp(frame, ck, .syn); +} + +pub fn classifyTcp(frame: []const u8, ck: cookie.Cookie, scan: packet.ScanType) ?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; @@ -87,20 +92,44 @@ pub fn classify(frame: []const u8, ck: cookie.Cookie) ?Result { 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 is_synack = (flags & (TCP_FLAG_SYN | TCP_FLAG_ACK)) == (TCP_FLAG_SYN | TCP_FLAG_ACK); - if (is_synack) { - if (ck.validateSynAck(ackno, ip_src, sport, ip_dst, dport)) - return .{ .ip = ip_src, .port = sport, .state = .open }; - return null; + const cookie_val = ck.seq(ip_src, sport, ip_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 .{ .ip = ip_src, .port = sport, .state = .open }; + if (has_rst and (flags & TCP_FLAG_ACK) != 0 and ackno == cookie_val +% 1) + return .{ .ip = ip_src, .port = sport, .state = .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 null; + }, + .maimon => { + if (has_rst and seqno == cookie_val) + return .{ .ip = ip_src, .port = sport, .state = .closed }; + return null; + }, + .ack => { + if (has_rst and seqno == cookie_val) + return .{ .ip = ip_src, .port = sport, .state = .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 null; + }, } - if ((flags & TCP_FLAG_RST) != 0 and (flags & TCP_FLAG_ACK) != 0) { - if (ck.validateSynAck(ackno, ip_src, sport, ip_dst, dport)) - return .{ .ip = ip_src, .port = sport, .state = .closed }; - } - return null; } if (proto == IPPROTO_ICMP) { @@ -188,9 +217,10 @@ pub fn classifyUdp(frame: []const u8, ck: cookie.Cookie, base: u16, span: u16) ? pub const TcpClassifier = struct { ck: cookie.Cookie, + scan: packet.ScanType = .syn, pub fn match(self: TcpClassifier, frame: []const u8) ?Result { - return classify(frame, self.ck); + return classifyTcp(frame, self.ck, self.scan); } }; @@ -472,3 +502,84 @@ test "classifier adapters route each frame to the right protocol path" { try std.testing.expectEqual(State.open, udp_clf.match(&udp_f).?.state); try std.testing.expect(tcp_clf.match(&udp_f) == null); } + +fn flagScanReply(buf: *[54]u8, seq: u32, ack: u32, flags: u8) void { + buildTcpReply(buf, their_ip, our_ip, their_port, our_port, seq, ack, flags); +} + +test "FIN and Xmas scans classify a cookie-1 RST as closed and reject a wrong ack" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq(their_ip, their_port, our_ip, our_port); + var f: [54]u8 = undefined; + + for ([_]packet.ScanType{ .fin, .xmas }) |st| { + flagScanReply(&f, 0, cv +% 1, TCP_FLAG_RST | TCP_FLAG_ACK); + try std.testing.expectEqual(State.closed, classifyTcp(&f, ck, st).?.state); + flagScanReply(&f, 0, cv +% 5, TCP_FLAG_RST | TCP_FLAG_ACK); + try std.testing.expect(classifyTcp(&f, ck, st) == null); + } +} + +test "NULL scan expects the RST ack to equal the cookie exactly (no sequence consumed)" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq(their_ip, their_port, our_ip, our_port); + var f: [54]u8 = undefined; + flagScanReply(&f, 0, cv, TCP_FLAG_RST | TCP_FLAG_ACK); + try std.testing.expectEqual(State.closed, classifyTcp(&f, ck, .null_scan).?.state); + flagScanReply(&f, 0, cv +% 1, TCP_FLAG_RST | TCP_FLAG_ACK); + try std.testing.expect(classifyTcp(&f, ck, .null_scan) == null); +} + +test "a FIN scan does not classify a SYN-ACK (open ports stay silent)" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq(their_ip, their_port, our_ip, our_port); + var f: [54]u8 = undefined; + flagScanReply(&f, 0xCAFEBABE, cv +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK); + try std.testing.expect(classifyTcp(&f, ck, .fin) == null); +} + +test "Maimon scan matches the RST sequence to the ack-field cookie" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq(their_ip, their_port, our_ip, our_port); + var f: [54]u8 = undefined; + flagScanReply(&f, cv, 0, TCP_FLAG_RST); + try std.testing.expectEqual(State.closed, classifyTcp(&f, ck, .maimon).?.state); + flagScanReply(&f, cv +% 3, 0, TCP_FLAG_RST); + try std.testing.expect(classifyTcp(&f, ck, .maimon) == null); +} + +test "ACK scan reports a validated RST as unfiltered, not open or closed" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq(their_ip, their_port, our_ip, our_port); + var f: [54]u8 = undefined; + flagScanReply(&f, cv, 0, TCP_FLAG_RST); + try std.testing.expectEqual(State.unfiltered, classifyTcp(&f, ck, .ack).?.state); + flagScanReply(&f, cv +% 7, 0, TCP_FLAG_RST); + try std.testing.expect(classifyTcp(&f, ck, .ack) == null); +} + +test "Window scan reads the RST window: nonzero is open, zero is closed" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq(their_ip, their_port, our_ip, our_port); + const win_off = ETH_HDR_LEN + IP_MIN_IHL + TCP_OFF_WINDOW; + var f: [54]u8 = undefined; + + flagScanReply(&f, cv, 0, TCP_FLAG_RST); + std.mem.writeInt(u16, f[win_off..][0..2], 8192, .big); + try std.testing.expectEqual(State.open, classifyTcp(&f, ck, .window).?.state); + + flagScanReply(&f, cv, 0, TCP_FLAG_RST); + std.mem.writeInt(u16, f[win_off..][0..2], 0, .big); + try std.testing.expectEqual(State.closed, classifyTcp(&f, ck, .window).?.state); +} + +test "the scan-type classifier adapter threads the mode into classifyTcp" { + const ck = cookie.Cookie.init(test_key); + const cv = ck.seq(their_ip, their_port, our_ip, our_port); + var f: [54]u8 = undefined; + flagScanReply(&f, cv, 0, TCP_FLAG_RST); + const ack_clf = TcpClassifier{ .ck = ck, .scan = .ack }; + try std.testing.expectEqual(State.unfiltered, ack_clf.match(&f).?.state); + const syn_clf = TcpClassifier{ .ck = ck }; + try std.testing.expect(syn_clf.match(&f) == null); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig index 09f62450..5fe2d19b 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig @@ -65,6 +65,17 @@ 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) \\ + \\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 + \\ --scan-type syn|fin|null|xmas|maimon|ack|window (default syn) + \\ --jitter poisson | none: exponential inter-packet timing + \\ --source-port-rotation vary the source port per probe (cookie still matches) + \\ --decoys spoofed decoys ip1,ip2,RND:N (real probe always sent) + \\ --suppress-rst drop our own kernel RSTs on the scan port range + \\ (idle-scan, fragmentation, TTL, and MAC/source-route spoofing are deliberately + \\ omitted as obsolete in 2026; run with --authorized-scan for the rationale) + \\ \\authorized use only. responsible default rate; needs CAP_NET_RAW \\(grant once: sudo setcap cap_net_raw,cap_net_admin=eip ./zig-out/bin/zingela) \\ diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/output.zig b/PROJECTS/advanced/zig-stateless-scanner/src/output.zig index b2c47c47..a875324b 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/output.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/output.zig @@ -30,6 +30,7 @@ pub const Stats = struct { open: Padded = .{}, closed: Padded = .{}, filtered: Padded = .{}, + unfiltered: Padded = .{}, pub fn record(self: *Stats, st: State) void { _ = self.found.v.fetchAdd(1, .monotonic); @@ -37,6 +38,7 @@ pub const Stats = struct { .open => _ = self.open.v.fetchAdd(1, .monotonic), .closed => _ = self.closed.v.fetchAdd(1, .monotonic), .filtered => _ = self.filtered.v.fetchAdd(1, .monotonic), + .unfiltered => _ = self.unfiltered.v.fetchAdd(1, .monotonic), } } }; @@ -211,6 +213,7 @@ fn stateName(st: State) []const u8 { .open => "open", .closed => "closed", .filtered => "filtered", + .unfiltered => "unfiltered", }; } @@ -219,6 +222,7 @@ fn stateLabel(st: State) []const u8 { .open => "OPEN", .closed => "CLOSED", .filtered => "FILTERED", + .unfiltered => "UNFILTERED", }; } @@ -227,6 +231,7 @@ fn stateColor(st: State) Rgb { .open => neon_green, .closed => chrome_gray, .filtered => soft_amber, + .unfiltered => bright_white, }; } @@ -446,6 +451,7 @@ pub fn renderSummary( open: u64, closed: u64, filtered: u64, + unfiltered: u64, ) !void { try out.writeAll(" "); try span(out, level, violet_mid, gutter_bar); @@ -471,6 +477,12 @@ pub fn renderSummary( try setFg(out, level, soft_amber); try writeThousands(out, filtered); try span(out, level, chrome_gray, " filtered"); + if (unfiltered > 0) { + try span(out, level, chrome_gray, " "); + try setFg(out, level, bright_white); + try writeThousands(out, unfiltered); + try span(out, level, chrome_gray, " unfiltered"); + } try resetFg(out, level); try out.writeByte('\n'); } diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig b/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig index 78452aea..7b3d40ec 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/packet.zig @@ -153,6 +153,205 @@ pub fn udpChecksum(src_be: u32, dst_be: u32, segment: []const u8) u16 { return if (folded == 0) 0xffff else folded; } +pub const TcpFlag = struct { + pub const fin: u8 = 0x01; + pub const syn: u8 = 0x02; + pub const rst: u8 = 0x04; + pub const psh: u8 = 0x08; + pub const ack: u8 = 0x10; + pub const urg: u8 = 0x20; +}; + +pub const ScanType = enum { + syn, + fin, + null_scan, + xmas, + maimon, + ack, + window, + + pub fn probeFlags(self: ScanType) u8 { + return switch (self) { + .syn => TcpFlag.syn, + .fin => TcpFlag.fin, + .null_scan => 0, + .xmas => TcpFlag.fin | TcpFlag.psh | TcpFlag.urg, + .maimon => TcpFlag.fin | TcpFlag.ack, + .ack, .window => TcpFlag.ack, + }; + } + + pub fn cookieInAck(self: ScanType) bool { + return switch (self) { + .maimon, .ack, .window => true, + else => false, + }; + } + + pub fn seqConsumed(self: ScanType) u32 { + return switch (self) { + .syn, .fin, .xmas => 1, + else => 0, + }; + } + + pub fn parse(text: []const u8) ?ScanType { + const map = .{ + .{ "syn", ScanType.syn }, + .{ "fin", ScanType.fin }, + .{ "null", ScanType.null_scan }, + .{ "xmas", ScanType.xmas }, + .{ "maimon", ScanType.maimon }, + .{ "ack", ScanType.ack }, + .{ "window", ScanType.window }, + }; + inline for (map) |entry| { + if (std.mem.eql(u8, text, entry[0])) return entry[1]; + } + return null; + } +}; + +const opt_eol: u8 = 0; +const opt_nop: u8 = 1; +const opt_mss: u8 = 2; +const opt_mss_len: u8 = 4; +const opt_wscale: u8 = 3; +const opt_wscale_len: u8 = 3; +const opt_sack_perm: u8 = 4; +const opt_sack_perm_len: u8 = 2; +const opt_ts: u8 = 8; +const opt_ts_len: u8 = 10; + +const mss_ethernet_hi: u8 = 0x05; +const mss_ethernet_lo: u8 = 0xb4; + +const wscale_linux: u8 = 7; +const wscale_windows: u8 = 8; +const wscale_macos: u8 = 6; + +const window_minimal: u16 = 1024; +const window_linux: u16 = 64240; +const window_windows: u16 = 64240; +const window_macos: u16 = 65535; + +const syn_opts_masscan = [_]u8{ opt_mss, opt_mss_len, mss_ethernet_hi, mss_ethernet_lo }; + +const syn_opts_linux = [_]u8{ + opt_mss, opt_mss_len, mss_ethernet_hi, mss_ethernet_lo, + opt_sack_perm, opt_sack_perm_len, + opt_ts, opt_ts_len, 0, 0, + 0, 0, 0, 0, + 0, 0, opt_nop, opt_wscale, + opt_wscale_len, wscale_linux, +}; + +const syn_opts_windows = [_]u8{ + opt_mss, opt_mss_len, mss_ethernet_hi, mss_ethernet_lo, + opt_nop, opt_wscale, opt_wscale_len, wscale_windows, + opt_nop, opt_nop, opt_sack_perm, opt_sack_perm_len, +}; + +const syn_opts_macos = [_]u8{ + opt_mss, opt_mss_len, mss_ethernet_hi, mss_ethernet_lo, + opt_nop, opt_wscale, opt_wscale_len, wscale_macos, + opt_nop, opt_nop, opt_ts, opt_ts_len, + 0, 0, 0, 0, + 0, 0, 0, 0, + opt_sack_perm, opt_sack_perm_len, opt_eol, opt_eol, +}; + +pub const max_syn_options_len: usize = syn_opts_macos.len; + +pub const OsProfile = enum { + none, + masscan, + linux, + windows, + macos, + + pub fn options(self: OsProfile) []const u8 { + return switch (self) { + .none => &.{}, + .masscan => &syn_opts_masscan, + .linux => &syn_opts_linux, + .windows => &syn_opts_windows, + .macos => &syn_opts_macos, + }; + } + + pub fn window(self: OsProfile) u16 { + return switch (self) { + .none, .masscan => window_minimal, + .linux => window_linux, + .windows => window_windows, + .macos => window_macos, + }; + } + + pub fn tsValOffset(self: OsProfile) ?usize { + return switch (self) { + .linux => 8, + .macos => 12, + else => null, + }; + } + + pub fn variesIpId(self: OsProfile) bool { + return switch (self) { + .windows, .macos => true, + else => false, + }; + } + + pub fn parse(text: []const u8) ?OsProfile { + const map = .{ + .{ "none", OsProfile.none }, + .{ "masscan", OsProfile.masscan }, + .{ "linux", OsProfile.linux }, + .{ "windows", OsProfile.windows }, + .{ "macos", OsProfile.macos }, + }; + inline for (map) |entry| { + if (std.mem.eql(u8, text, entry[0])) return entry[1]; + } + return null; + } +}; + +pub fn optionKinds(opts: []const u8, out: []u8) usize { + var i: usize = 0; + var n: usize = 0; + while (i < opts.len) { + const kind = opts[i]; + if (kind == opt_eol) break; + if (n < out.len) { + out[n] = kind; + n += 1; + } + if (kind == opt_nop) { + i += 1; + continue; + } + if (i + 1 >= opts.len) break; + const len = opts[i + 1]; + if (len < 2) break; + i += len; + } + return n; +} + +comptime { + std.debug.assert((@sizeOf(TcpHdr) + syn_opts_masscan.len) % 4 == 0); + std.debug.assert((@sizeOf(TcpHdr) + syn_opts_linux.len) % 4 == 0); + std.debug.assert((@sizeOf(TcpHdr) + syn_opts_windows.len) % 4 == 0); + std.debug.assert((@sizeOf(TcpHdr) + syn_opts_macos.len) % 4 == 0); + std.debug.assert(syn_opts_linux.len == 20); + std.debug.assert(syn_opts_windows.len == 12); + std.debug.assert(syn_opts_macos.len == 24); +} + test "header sizes are wire-exact" { try std.testing.expectEqual(@as(usize, 14), @sizeOf(EthHdr)); try std.testing.expectEqual(@as(usize, 20), @sizeOf(Ipv4Hdr)); @@ -253,3 +452,80 @@ 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); } + +test "the Linux SYN option chain decodes to the authoritative JA4T kind list 2-4-8-1-3" { + var kinds: [16]u8 = undefined; + const n = optionKinds(OsProfile.linux.options(), &kinds); + try std.testing.expectEqualSlices(u8, &.{ 2, 4, 8, 1, 3 }, kinds[0..n]); +} + +test "the Windows SYN option chain omits the timestamp (kinds 2-1-3-1-1-4)" { + var kinds: [16]u8 = undefined; + const n = optionKinds(OsProfile.windows.options(), &kinds); + try std.testing.expectEqualSlices(u8, &.{ 2, 1, 3, 1, 1, 4 }, kinds[0..n]); + for (kinds[0..n]) |k| try std.testing.expect(k != 8); +} + +test "the macOS SYN option chain carries the timestamp before SACK (kinds 2-1-3-1-1-8-4)" { + var kinds: [16]u8 = undefined; + const n = optionKinds(OsProfile.macos.options(), &kinds); + try std.testing.expectEqualSlices(u8, &.{ 2, 1, 3, 1, 1, 8, 4 }, kinds[0..n]); +} + +test "the masscan profile sends exactly one MSS option and the fingerprintable 1024 window" { + var kinds: [16]u8 = undefined; + const n = optionKinds(OsProfile.masscan.options(), &kinds); + try std.testing.expectEqualSlices(u8, &.{2}, kinds[0..n]); + try std.testing.expectEqual(@as(u16, 1024), OsProfile.masscan.window()); +} + +test "the bare profile sends no options" { + try std.testing.expectEqual(@as(usize, 0), OsProfile.none.options().len); +} + +test "every OS profile advertises the ethernet MSS 1460" { + for ([_]OsProfile{ .masscan, .linux, .windows, .macos }) |p| { + const opts = p.options(); + try std.testing.expectEqual(opt_mss, opts[0]); + try std.testing.expectEqual(@as(u16, 1460), std.mem.readInt(u16, opts[2..4], .big)); + } +} + +test "the timestamp offset points at four zero bytes inside the option chain" { + inline for ([_]OsProfile{ .linux, .macos }) |p| { + const off = p.tsValOffset().?; + const opts = p.options(); + try std.testing.expectEqual(opt_ts, opts[off - 2]); + try std.testing.expectEqual(opt_ts_len, opts[off - 1]); + try std.testing.expectEqual(@as(u32, 0), std.mem.readInt(u32, opts[off..][0..4], .big)); + } + try std.testing.expect(OsProfile.windows.tsValOffset() == null); +} + +test "scan-type probe flags match the RFC 793 flag combinations" { + try std.testing.expectEqual(TcpFlag.syn, ScanType.syn.probeFlags()); + try std.testing.expectEqual(TcpFlag.fin, ScanType.fin.probeFlags()); + try std.testing.expectEqual(@as(u8, 0), ScanType.null_scan.probeFlags()); + try std.testing.expectEqual(TcpFlag.fin | TcpFlag.psh | TcpFlag.urg, ScanType.xmas.probeFlags()); + try std.testing.expectEqual(TcpFlag.fin | TcpFlag.ack, ScanType.maimon.probeFlags()); + try std.testing.expectEqual(TcpFlag.ack, ScanType.ack.probeFlags()); + try std.testing.expectEqual(TcpFlag.ack, ScanType.window.probeFlags()); +} + +test "ack-flag scans carry the cookie in the ack field, seq-scans in the seq field" { + for ([_]ScanType{ .maimon, .ack, .window }) |st| try std.testing.expect(st.cookieInAck()); + for ([_]ScanType{ .syn, .fin, .null_scan, .xmas }) |st| try std.testing.expect(!st.cookieInAck()); +} + +test "seqConsumed reflects whether the probe advances the sequence space" { + for ([_]ScanType{ .syn, .fin, .xmas }) |st| try std.testing.expectEqual(@as(u32, 1), st.seqConsumed()); + for ([_]ScanType{ .null_scan, .maimon, .ack, .window }) |st| try std.testing.expectEqual(@as(u32, 0), st.seqConsumed()); +} + +test "scan-type and OS-profile parsers round-trip the CLI spellings" { + try std.testing.expectEqual(ScanType.null_scan, ScanType.parse("null").?); + try std.testing.expectEqual(ScanType.window, ScanType.parse("window").?); + try std.testing.expect(ScanType.parse("bogus") == null); + try std.testing.expectEqual(OsProfile.macos, OsProfile.parse("macos").?); + try std.testing.expect(OsProfile.parse("bogus") == null); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/ratelimit.zig b/PROJECTS/advanced/zig-stateless-scanner/src/ratelimit.zig index 100e9662..cd0a529b 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/ratelimit.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/ratelimit.zig @@ -4,12 +4,30 @@ const std = @import("std"); const NS_PER_SEC: u64 = 1_000_000_000; +const max_gap_ns: f64 = 3.6e12; + +pub const Jitter = struct { + prng: std.Random.DefaultPrng, + mean_ns: f64, + + pub fn init(seed: u64, mean_ns: u64) Jitter { + return .{ .prng = std.Random.DefaultPrng.init(seed), .mean_ns = @floatFromInt(mean_ns) }; + } + + pub fn nextGapNs(self: *Jitter) u64 { + const u = self.prng.random().float(f64); + const safe_u = if (u <= 0.0) std.math.floatMin(f64) else u; + const gap = -@log(safe_u) * self.mean_ns; + return @intFromFloat(std.math.clamp(gap, 0.0, max_gap_ns)); + } +}; pub const TokenBucket = struct { step_ns: u64, cap_ns: u64, bank_ns: u64, last_ns: u64, + jitter: ?Jitter = null, pub fn init(rate_pps: u64, capacity: u64) TokenBucket { const step = if (rate_pps == 0) NS_PER_SEC else NS_PER_SEC / rate_pps; @@ -22,6 +40,20 @@ pub const TokenBucket = struct { }; } + pub fn withJitter(self: TokenBucket, seed: u64) TokenBucket { + var b = self; + b.jitter = Jitter.init(seed, self.step_ns); + return b; + } + + pub fn prime(self: *TokenBucket, now_ns: u64) void { + self.last_ns = now_ns; + } + + pub fn refund(self: *TokenBucket, tokens: u64) void { + self.bank_ns = @min(self.bank_ns +| tokens *| self.step_ns, self.cap_ns); + } + pub fn takeBatch(self: *TokenBucket, now_ns: u64, want: u64) u64 { if (now_ns > self.last_ns) { const elapsed = now_ns - self.last_ns; @@ -66,3 +98,53 @@ test "zero rate degrades to one-token-per-second, never divides by zero" { try std.testing.expectEqual(@as(u64, 1), tb.takeBatch(NS_PER_SEC, 10)); try std.testing.expectEqual(@as(u64, 4), tb.takeBatch(NS_PER_SEC * 10, 10)); } + +test "cold prime starts the bank empty so a low-rate scan does not front-load a burst" { + var tb = TokenBucket.init(1000, 64); + tb.prime(5_000_000_000); + try std.testing.expectEqual(@as(u64, 0), tb.takeBatch(5_000_000_000, 100)); + try std.testing.expectEqual(@as(u64, 1), tb.takeBatch(5_001_000_000, 100)); +} + +test "refund returns unused tokens to the bank, clamped at capacity" { + var tb = TokenBucket.init(1000, 10); + try std.testing.expectEqual(@as(u64, 10), tb.takeBatch(1_000_000_000, 10)); + try std.testing.expectEqual(@as(u64, 0), tb.takeBatch(1_000_000_000, 10)); + tb.refund(4); + try std.testing.expectEqual(@as(u64, 4), tb.takeBatch(1_000_000_000, 10)); + tb.refund(1000); + try std.testing.expectEqual(@as(u64, 10), tb.takeBatch(1_000_000_000, 100)); +} + +test "withJitter attaches a Poisson pacer keyed to the configured step" { + const tb = TokenBucket.init(1000, 1000).withJitter(0xABCDEF); + try std.testing.expect(tb.jitter != null); + try std.testing.expectEqual(@as(f64, 1_000_000.0), tb.jitter.?.mean_ns); +} + +test "Poisson gaps average to the mean and are not constant" { + var jit = Jitter.init(0x5EED_1234, 1_000_000); + const n: usize = 40_000; + var total: u128 = 0; + const first: u64 = jit.nextGapNs(); + var saw_different = false; + total += first; + var i: usize = 1; + while (i < n) : (i += 1) { + const g = jit.nextGapNs(); + total += g; + if (g != first) saw_different = true; + } + const mean = @as(f64, @floatFromInt(@as(u64, @intCast(total / n)))); + try std.testing.expect(saw_different); + try std.testing.expect(mean > 900_000.0 and mean < 1_100_000.0); +} + +test "Poisson gap survives the u=0 edge without dividing into infinity" { + var jit = Jitter.init(1, 500); + var i: usize = 0; + while (i < 1000) : (i += 1) { + const g = jit.nextGapNs(); + try std.testing.expect(g <= @as(u64, @intFromFloat(max_gap_ns))); + } +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig b/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig index a37d9a26..f120c86f 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig @@ -13,6 +13,7 @@ const rx = @import("rx"); const dedup = @import("dedup"); const netutil = @import("netutil"); const output = @import("output"); +const stealth = @import("stealth"); const default_iface = "lo"; const default_rate: u64 = 10_000; @@ -39,6 +40,14 @@ const need_cap_hint = const concurrency_hint = "scan: this system cannot launch concurrent TX/RX (needs >= 2 worker threads).\n"; +const authorized_warning = + "scan: stealth/evasion features require explicit authorization.\n" ++ + "Re-run with --authorized-scan ONLY against systems you own or are\n" ++ + "contractually authorized to test. Unauthorized scanning is a crime\n" ++ + "under the CFAA and equivalent statutes worldwide.\n\n" ++ + " stealth flags: --os-template --scan-type --jitter --source-port-rotation --decoys --suppress-rst\n\n" ++ + stealth.omitted_help ++ "\n"; + const TxSink = struct { backend: *packet_io.Backend, sent: *output.Counter, @@ -80,8 +89,8 @@ fn txWorkerUdp(engine: *targets.Engine, tmpl: *const udp.UdpTemplate, bucket: *r 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); +fn rxWorkerTcp(receiver: *rx.Receiver, clf: rx.TcpClassifier, dd: *dedup.Dedup, sink: *rx.QueueSink, rx_done: *std.atomic.Value(bool)) void { + rx.run(receiver, clf, dd, sink); rx_done.store(true, .release); } @@ -156,6 +165,46 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e try derr.flush(); return; }; + + var scfg = stealth.parse(allocator, io, args) catch |e| switch (e) { + error.AuthorizationRequired => { + try derr.writeAll(authorized_warning); + try derr.flush(); + return; + }, + error.BadOsTemplate => { + try derr.writeAll("scan: --os-template must be none, masscan, linux, windows, or macos\n"); + try derr.flush(); + return; + }, + error.BadScanType => { + try derr.writeAll("scan: --scan-type must be syn, fin, null, xmas, maimon, ack, or window\n"); + try derr.flush(); + return; + }, + error.BadJitterMode => { + try derr.writeAll("scan: --jitter must be poisson or none\n"); + try derr.flush(); + return; + }, + error.BadDecoySpec => { + try derr.writeAll("scan: --decoys must be comma-separated IPv4 addresses and/or RND:N\n"); + try derr.flush(); + return; + }, + error.TooManyDecoys => { + try derr.print("scan: at most {d} decoys allowed\n", .{stealth.max_decoys}); + try derr.flush(); + return; + }, + error.OutOfMemory => return e, + }; + defer scfg.deinit(allocator); + + if (is_udp and (scfg.profile != .none or scfg.scan != .syn or scfg.rotate or scfg.decoys.len > 0 or scfg.suppress_rst)) { + try derr.writeAll(" note: --os-template/--scan-type/--source-port-rotation/--decoys/--suppress-rst apply to TCP scans; ignored for --udp\n"); + } + 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"; @@ -191,15 +240,23 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e 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; - const dash_total = @min(count, eng.total); + const frames_per_probe: u64 = if (is_udp) 1 else 1 + @as(u64, @intCast(scfg.decoys.len)); + const dash_total = @min(count, eng.total) *| frames_per_probe; const ck = try cookie.Cookie.random(io); + const rot_span: u16 = if (scfg.rotate) @intCast(@min(@as(u32, scfg.rotate_span), 65536 - @as(u32, src_port))) else 0; const tcp_tmpl = template.SynTemplate.init(.{ .src_mac = src_mac, .dst_mac = gw_mac, .src_ip = src_ip, .src_port = src_port, .cookie = ck, + .profile = scfg.profile, + .scan = scfg.scan, + .rotate = scfg.rotate, + .rotate_base = src_port, + .rotate_span = rot_span, + .decoys = scfg.decoys, }); const udp_tmpl = udp.UdpTemplate.init(.{ .src_mac = src_mac, @@ -210,6 +267,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e .cookie = ck, }); var bucket = ratelimit.TokenBucket.init(rate, rate); + if (scfg.jitter) bucket = bucket.withJitter(seed); var backend = packet_io.select(allocator, ifname, backend_choice, .{}, .{}, derr) catch |err| switch (err) { error.NeedCapNetRaw => { @@ -227,11 +285,36 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e defer backend.close(); try derr.print(" using {s}\n", .{packet_io.kindLabel(backend.kind())}); + if (scfg.profile != .none or scfg.scan != .syn or scfg.jitter or scfg.rotate or scfg.decoys.len > 0) { + try derr.print(" stealth: template={s} scan={s} jitter={s} rotate={s} decoys={d}\n", .{ + @tagName(scfg.profile), + @tagName(scfg.scan), + if (scfg.jitter) "on" else "off", + if (scfg.rotate) "on" else "off", + scfg.decoys.len, + }); + } + + var supp: ?stealth.RstSuppressor = null; + if (scfg.suppress_rst and !is_udp) { + const lo = src_port; + const hi = if (scfg.rotate) src_port +| (rot_span -| 1) else src_port; + supp = stealth.RstSuppressor.install(allocator, io, src_ip, lo, hi) catch |e| blk: { + try derr.print(" note: RST-suppression unavailable ({s}); continuing without it\n", .{@errorName(e)}); + break :blk null; + }; + } + defer if (supp) |*s| s.teardown(); + if (supp) |*s| { + var hbuf: [160]u8 = undefined; + try derr.print(" RST-suppression active (cleanup if hard-killed: {s})\n", .{s.cleanupHint(&hbuf)}); + } + var tx_done = std.atomic.Value(bool).init(false); var rx_done = std.atomic.Value(bool).init(false); const drain_window_ns: u64 = @as(u64, @intCast(@max(wait_ms, 0))) * ns_per_ms; - const est_tx_ns: u64 = if (rate > 0) (count / rate) *| ns_per_sec else rx_hard_cap_floor_ns; + 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; @@ -280,7 +363,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e 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 }); + io.concurrent(rxWorkerTcp, .{ &receiver, rx.TcpClassifier{ .ck = ck, .scan = scfg.scan }, &dd, &rx_sink, &rx_done }); var rx_fut = rx_res catch { _ = tx_fut.await(io); try derr.writeAll(concurrency_hint); @@ -315,10 +398,12 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e 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) { @@ -334,9 +419,9 @@ 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, probe_label, 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, unfiltered_n); if (is_udp) { - const answered = open_n + closed_n + filtered_n; + const answered = open_n + closed_n + filtered_n + unfiltered_n; try output.renderUnanswered(derr, err_level, sent -| answered); } try derr.flush(); diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/stealth.zig b/PROJECTS/advanced/zig-stateless-scanner/src/stealth.zig new file mode 100644 index 00000000..04c12471 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/stealth.zig @@ -0,0 +1,341 @@ +// ©AngelaMos | 2026 +// stealth.zig + +const std = @import("std"); +const linux = std.os.linux; +const packet = @import("packet"); +const netutil = @import("netutil"); + +pub const default_rotate_span: u16 = 8192; +pub const max_decoys: usize = 16; +pub const random_ip_attempts: usize = 64; + +const routable_fallback_ip: u32 = 0x01010101; + +pub const ParseError = error{ + AuthorizationRequired, + BadOsTemplate, + BadScanType, + BadJitterMode, + BadDecoySpec, + TooManyDecoys, + OutOfMemory, +}; + +pub const RstError = error{ + IptablesSpawnFailed, + IptablesFailed, + OutOfMemory, +}; + +pub const Config = struct { + authorized: bool = false, + profile: packet.OsProfile = .none, + scan: packet.ScanType = .syn, + jitter: bool = false, + rotate: bool = false, + rotate_span: u16 = default_rotate_span, + suppress_rst: bool = false, + decoys: []const u32 = &.{}, + + pub fn deinit(self: *Config, allocator: std.mem.Allocator) void { + if (self.decoys.len > 0) allocator.free(self.decoys); + self.decoys = &.{}; + } +}; + +pub fn parse(allocator: std.mem.Allocator, io: std.Io, args: []const []const u8) ParseError!Config { + const os_flag = netutil.getFlag(args, "--os-template"); + const scan_flag = netutil.getFlag(args, "--scan-type"); + const jitter_flag = netutil.getFlag(args, "--jitter"); + const rotate_flag = netutil.hasFlag(args, "--source-port-rotation"); + const decoy_flag = netutil.getFlag(args, "--decoys"); + const suppress_flag = netutil.hasFlag(args, "--suppress-rst"); + + const requested = os_flag != null or scan_flag != null or jitter_flag != null or + rotate_flag or decoy_flag != null or suppress_flag; + + if (!requested) return .{}; + if (!netutil.hasFlag(args, "--authorized-scan")) return error.AuthorizationRequired; + + var cfg = Config{ .authorized = true, .rotate = rotate_flag, .suppress_rst = suppress_flag }; + errdefer cfg.deinit(allocator); + + if (os_flag) |t| cfg.profile = packet.OsProfile.parse(t) orelse return error.BadOsTemplate; + if (scan_flag) |t| cfg.scan = packet.ScanType.parse(t) orelse return error.BadScanType; + if (jitter_flag) |t| { + if (std.mem.eql(u8, t, "poisson")) { + cfg.jitter = true; + } else if (std.mem.eql(u8, t, "none")) { + cfg.jitter = false; + } else return error.BadJitterMode; + } + if (decoy_flag) |spec| cfg.decoys = try parseDecoys(allocator, io, spec); + + return cfg; +} + +fn parseDecoys(allocator: std.mem.Allocator, io: std.Io, spec: []const u8) ParseError![]const u32 { + var list: std.ArrayList(u32) = .empty; + errdefer list.deinit(allocator); + + var it = std.mem.splitScalar(u8, spec, ','); + while (it.next()) |tok| { + if (tok.len == 0) continue; + if (list.items.len >= max_decoys) return error.TooManyDecoys; + if (std.mem.startsWith(u8, tok, "RND:")) { + const n = std.fmt.parseInt(usize, tok[4..], 10) catch return error.BadDecoySpec; + var made: usize = 0; + while (made < n) : (made += 1) { + if (list.items.len >= max_decoys) return error.TooManyDecoys; + try list.append(allocator, randomNonBogon(io)); + } + } else { + const ip = netutil.parseIpv4(tok) catch return error.BadDecoySpec; + try list.append(allocator, ip); + } + } + if (list.items.len == 0) return error.BadDecoySpec; + return list.toOwnedSlice(allocator); +} + +fn randomNonBogon(io: std.Io) u32 { + var attempts: usize = 0; + while (attempts < random_ip_attempts) : (attempts += 1) { + var b: [4]u8 = undefined; + io.randomSecure(&b) catch continue; + const ip = std.mem.readInt(u32, &b, .big); + if (!isBogonV4(ip)) return ip; + } + return routable_fallback_ip; +} + +fn inNet(ip: u32, net: u32, bits: u5) bool { + const sh: u5 = @intCast(32 - @as(u32, bits)); + const mask: u32 = ~@as(u32, 0) << sh; + return (ip & mask) == (net & mask); +} + +pub fn isBogonV4(ip: u32) bool { + if (ip >> 28 == 0xE) return true; + if (ip >> 28 == 0xF) return true; + return inNet(ip, 0x00000000, 8) or + inNet(ip, 0x0A000000, 8) or + inNet(ip, 0x64400000, 10) or + inNet(ip, 0x7F000000, 8) or + inNet(ip, 0xA9FE0000, 16) or + inNet(ip, 0xAC100000, 12) or + inNet(ip, 0xC0000000, 24) or + inNet(ip, 0xC0000200, 24) or + inNet(ip, 0xC0586300, 24) or + inNet(ip, 0xC0A80000, 16) or + inNet(ip, 0xC6120000, 15) or + inNet(ip, 0xC6336400, 24) or + inNet(ip, 0xCB007100, 24); +} + +const pr_cap_ambient: i32 = 47; +const pr_cap_ambient_raise: usize = 2; +const cap_net_admin: usize = 12; + +fn raiseAmbientNetAdmin() void { + _ = linux.prctl(pr_cap_ambient, pr_cap_ambient_raise, cap_net_admin, 0, 0); +} + +pub fn ipToStr(buf: *[15]u8, ip: u32) []const u8 { + return std.fmt.bufPrint(buf, "{d}.{d}.{d}.{d}", .{ + (ip >> 24) & 0xff, + (ip >> 16) & 0xff, + (ip >> 8) & 0xff, + ip & 0xff, + }) catch unreachable; +} + +pub const RstSuppressor = struct { + allocator: std.mem.Allocator, + io: std.Io, + ip_str: []u8, + range_str: []u8, + installed: bool, + + pub fn install(allocator: std.mem.Allocator, io: std.Io, src_ip: u32, lo: u16, hi: u16) RstError!RstSuppressor { + var ipbuf: [15]u8 = undefined; + const ip_str = try allocator.dupe(u8, ipToStr(&ipbuf, src_ip)); + errdefer allocator.free(ip_str); + const range_str = try std.fmt.allocPrint(allocator, "{d}:{d}", .{ lo, hi }); + errdefer allocator.free(range_str); + + var self = RstSuppressor{ + .allocator = allocator, + .io = io, + .ip_str = ip_str, + .range_str = range_str, + .installed = false, + }; + + raiseAmbientNetAdmin(); + self.runIptables("-D") catch {}; + try self.runIptables("-I"); + self.installed = true; + return self; + } + + fn runIptables(self: *RstSuppressor, action: []const u8) RstError!void { + const args = [_][]const u8{ + "iptables", action, "OUTPUT", "-p", "tcp", + "-s", self.ip_str, "--sport", self.range_str, + "--tcp-flags", "RST", "RST", "-j", "DROP", + }; + const res = std.process.run(self.allocator, self.io, .{ .argv = &args }) catch return error.IptablesSpawnFailed; + defer self.allocator.free(res.stdout); + defer self.allocator.free(res.stderr); + switch (res.term) { + .exited => |code| if (code != 0) return error.IptablesFailed, + else => return error.IptablesFailed, + } + } + + pub fn cleanupHint(self: *const RstSuppressor, buf: []u8) []const u8 { + return std.fmt.bufPrint(buf, "iptables -D OUTPUT -p tcp -s {s} --sport {s} --tcp-flags RST RST -j DROP", .{ self.ip_str, self.range_str }) catch ""; + } + + pub fn teardown(self: *RstSuppressor) void { + if (self.installed) { + self.runIptables("-D") catch {}; + self.installed = false; + } + self.allocator.free(self.ip_str); + self.allocator.free(self.range_str); + } +}; + +pub const omitted_help = + \\ deliberately omitted (obsolete in 2026; rationale + citations in learn/ + AUDIT-M8): + \\ idle/zombie scan modern OSes randomize IP-ID; the side channel is dead + \\ fragmentation Snort 3.x and Suricata fully reassemble before matching + \\ TTL manipulation inline IPS normalize TTL; FortiGuard ships a signature + \\ MAC / source routing L2-only or RFC 5095-deprecated; never crosses a hop + \\ bad-checksum probe a firewall-reveal recon trick, not evasion +; + +const test_key = [16]u8{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, +}; + +fn testIo() std.Io.Threaded { + return std.Io.Threaded.init(std.testing.allocator, .{}); +} + +test "no stealth flags yields the inert default config" { + var threaded = testIo(); + defer threaded.deinit(); + const args = [_][]const u8{ "scan", "--target", "10.0.0.0/24" }; + var cfg = try parse(std.testing.allocator, threaded.io(), &args); + defer cfg.deinit(std.testing.allocator); + try std.testing.expect(!cfg.authorized); + try std.testing.expectEqual(packet.OsProfile.none, cfg.profile); + try std.testing.expectEqual(packet.ScanType.syn, cfg.scan); + try std.testing.expect(!cfg.jitter and !cfg.rotate and !cfg.suppress_rst); +} + +test "any stealth flag without --authorized-scan is refused" { + var threaded = testIo(); + defer threaded.deinit(); + inline for (.{ + &[_][]const u8{ "scan", "--os-template", "linux" }, + &[_][]const u8{ "scan", "--scan-type", "fin" }, + &[_][]const u8{ "scan", "--jitter", "poisson" }, + &[_][]const u8{ "scan", "--source-port-rotation" }, + &[_][]const u8{ "scan", "--decoys", "8.8.8.8" }, + &[_][]const u8{ "scan", "--suppress-rst" }, + }) |args| { + try std.testing.expectError(error.AuthorizationRequired, parse(std.testing.allocator, threaded.io(), args)); + } +} + +test "authorized stealth parses every knob" { + var threaded = testIo(); + defer threaded.deinit(); + const args = [_][]const u8{ + "scan", "--authorized-scan", "--os-template", "windows", + "--scan-type", "ack", "--jitter", "poisson", + "--source-port-rotation", "--suppress-rst", + }; + var cfg = try parse(std.testing.allocator, threaded.io(), &args); + defer cfg.deinit(std.testing.allocator); + try std.testing.expect(cfg.authorized); + try std.testing.expectEqual(packet.OsProfile.windows, cfg.profile); + try std.testing.expectEqual(packet.ScanType.ack, cfg.scan); + try std.testing.expect(cfg.jitter and cfg.rotate and cfg.suppress_rst); +} + +test "bad stealth values are rejected with distinct errors" { + var threaded = testIo(); + defer threaded.deinit(); + const io = threaded.io(); + try std.testing.expectError(error.BadOsTemplate, parse(std.testing.allocator, io, &[_][]const u8{ "scan", "--authorized-scan", "--os-template", "plan9" })); + try std.testing.expectError(error.BadScanType, parse(std.testing.allocator, io, &[_][]const u8{ "scan", "--authorized-scan", "--scan-type", "banana" })); + try std.testing.expectError(error.BadJitterMode, parse(std.testing.allocator, io, &[_][]const u8{ "scan", "--authorized-scan", "--jitter", "chaos" })); + try std.testing.expectError(error.BadDecoySpec, parse(std.testing.allocator, io, &[_][]const u8{ "scan", "--authorized-scan", "--decoys", "999.1.1.1" })); +} + +test "explicit decoys parse to their addresses" { + var threaded = testIo(); + defer threaded.deinit(); + const args = [_][]const u8{ "scan", "--authorized-scan", "--decoys", "8.8.8.8,1.1.1.1" }; + var cfg = try parse(std.testing.allocator, threaded.io(), &args); + defer cfg.deinit(std.testing.allocator); + try std.testing.expectEqualSlices(u32, &.{ 0x08080808, 0x01010101 }, cfg.decoys); +} + +test "RND decoys are non-bogon and bounded" { + var threaded = testIo(); + defer threaded.deinit(); + const args = [_][]const u8{ "scan", "--authorized-scan", "--decoys", "RND:8" }; + var cfg = try parse(std.testing.allocator, threaded.io(), &args); + defer cfg.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 8), cfg.decoys.len); + for (cfg.decoys) |ip| try std.testing.expect(!isBogonV4(ip)); + + try std.testing.expectError(error.TooManyDecoys, parse(std.testing.allocator, threaded.io(), &[_][]const u8{ "scan", "--authorized-scan", "--decoys", "RND:99" })); +} + +test "isBogonV4 flags reserved space and passes public addresses" { + try std.testing.expect(isBogonV4(0x00000001)); + try std.testing.expect(isBogonV4(0x0A000001)); + try std.testing.expect(isBogonV4(0x7F000001)); + try std.testing.expect(isBogonV4(0xC0A80001)); + try std.testing.expect(isBogonV4(0xAC100001)); + try std.testing.expect(isBogonV4(0xA9FE0001)); + try std.testing.expect(isBogonV4(0x64400001)); + try std.testing.expect(isBogonV4(0xE0000001)); + try std.testing.expect(isBogonV4(0xFFFFFFFF)); + try std.testing.expect(isBogonV4(0xC0586301)); + try std.testing.expect(!isBogonV4(0x08080808)); + try std.testing.expect(!isBogonV4(0x01010101)); + try std.testing.expect(!isBogonV4(0x2D2D2D2D)); +} + +test "ipToStr renders dotted quads" { + var buf: [15]u8 = undefined; + try std.testing.expectEqualStrings("10.0.0.1", ipToStr(&buf, 0x0A000001)); + try std.testing.expectEqualStrings("255.255.255.255", ipToStr(&buf, 0xFFFFFFFF)); +} + +test "the RST cleanup hint is an exact iptables delete line" { + var buf: [15]u8 = undefined; + var rbuf: [16]u8 = undefined; + const ip_str = std.testing.allocator.dupe(u8, ipToStr(&buf, 0x0A000001)) catch unreachable; + defer std.testing.allocator.free(ip_str); + const range_str = std.fmt.bufPrint(&rbuf, "{d}:{d}", .{ 40000, 48191 }) catch unreachable; + const owned_range = std.testing.allocator.dupe(u8, range_str) catch unreachable; + defer std.testing.allocator.free(owned_range); + var supp = RstSuppressor{ .allocator = std.testing.allocator, .io = undefined, .ip_str = ip_str, .range_str = owned_range, .installed = false }; + var hintbuf: [128]u8 = undefined; + const hint = supp.cleanupHint(&hintbuf); + try std.testing.expect(std.mem.indexOf(u8, hint, "iptables -D OUTPUT") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "10.0.0.1") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "40000:48191") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "--tcp-flags RST RST -j DROP") != null); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/template.zig b/PROJECTS/advanced/zig-stateless-scanner/src/template.zig index e86f082c..c83898cf 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/template.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/template.zig @@ -8,21 +8,44 @@ const cookie = @import("cookie"); const ethertype_ipv4: u16 = 0x0800; const ipv4_version_ihl: u8 = 0x45; const ip_proto_tcp: u8 = 6; -const ip_total_len: u16 = 40; const ip_flag_dont_fragment: u16 = 0x4000; const default_ttl: u8 = 64; -const tcp_data_offset: u8 = 0x50; -const tcp_flag_syn: u8 = 0x02; -const default_window: u16 = 1024; +const ip_id_mix: u32 = 0x9e3779b1; + +const eth_len: usize = 14; +const ip_len: usize = 20; +const tcp_len: usize = 20; +const l4_off: usize = eth_len + ip_len; +const opts_off: usize = l4_off + tcp_len; + +const ip_id_off: usize = eth_len + 4; +const ip_checksum_off: usize = eth_len + 10; +const ip_src_off: usize = eth_len + 12; +const ip_dst_off: usize = eth_len + 16; +const tcp_src_off: usize = l4_off; +const tcp_dst_off: usize = l4_off + 2; +const tcp_seq_off: usize = l4_off + 4; +const tcp_ack_off: usize = l4_off + 8; +const tcp_data_off_off: usize = l4_off + 12; +const tcp_flags_off: usize = l4_off + 13; +const tcp_window_off: usize = l4_off + 14; +const tcp_checksum_off: usize = l4_off + 16; pub const SynTemplate = struct { - pub const frame_len: usize = 54; - pub const max_frame_len: usize = frame_len; + pub const max_frame_len: usize = opts_off + packet.max_syn_options_len; - base: [frame_len]u8, + base: [max_frame_len]u8, + frame_len: usize, + src_ip: u32, src_ip_be: u32, src_port: u16, cookie: cookie.Cookie, + scan: packet.ScanType, + vary_ip_id: bool, + rotate: bool, + rotate_base: u16, + rotate_span: u16, + decoys: []const u32, pub const Config = struct { src_mac: [6]u8, @@ -30,22 +53,32 @@ pub const SynTemplate = struct { src_ip: u32, src_port: u16, cookie: cookie.Cookie, + profile: packet.OsProfile = .none, + scan: packet.ScanType = .syn, + rotate: bool = false, + rotate_base: u16 = 0, + rotate_span: u16 = 0, + decoys: []const u32 = &.{}, }; pub fn init(cfg: Config) SynTemplate { - var base: [frame_len]u8 = undefined; + 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_ipv4), }; - @memcpy(base[0..14], std.mem.asBytes(ð)); + @memcpy(base[0..eth_len], std.mem.asBytes(ð)); const ip = packet.Ipv4Hdr{ .version_ihl = ipv4_version_ihl, .tos = 0, - .total_len = std.mem.nativeToBig(u16, ip_total_len), + .total_len = std.mem.nativeToBig(u16, @intCast(ip_len + tcp_total)), .id = 0, .flags_frag = std.mem.nativeToBig(u16, ip_flag_dont_fragment), .ttl = default_ttl, @@ -54,46 +87,99 @@ pub const SynTemplate = struct { .src = std.mem.nativeToBig(u32, cfg.src_ip), .dst = 0, }; - @memcpy(base[14..34], std.mem.asBytes(&ip)); + @memcpy(base[eth_len..l4_off], std.mem.asBytes(&ip)); const tcp = packet.TcpHdr{ .src_port = std.mem.nativeToBig(u16, cfg.src_port), .dst_port = 0, .seq = 0, .ack = 0, - .data_off_ns = tcp_data_offset, - .flags = tcp_flag_syn, - .window = std.mem.nativeToBig(u16, default_window), + .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[34..54], std.mem.asBytes(&tcp)); + @memcpy(base[l4_off..opts_off], std.mem.asBytes(&tcp)); + + if (opts.len > 0) @memcpy(base[opts_off .. opts_off + opts.len], opts); return .{ .base = base, + .frame_len = opts_off + opts.len, + .src_ip = cfg.src_ip, .src_ip_be = std.mem.nativeToBig(u32, cfg.src_ip), .src_port = cfg.src_port, .cookie = cfg.cookie, + .scan = cfg.scan, + .vary_ip_id = cfg.profile.variesIpId(), + .rotate = cfg.rotate, + .rotate_base = cfg.rotate_base, + .rotate_span = cfg.rotate_span, + .decoys = cfg.decoys, }; } - pub fn stamp(self: *const SynTemplate, out: *[frame_len]u8, dst_ip: u32, dst_port: u16) usize { - @memcpy(out, &self.base); + pub fn variantCount(self: *const SynTemplate) usize { + return 1 + self.decoys.len; + } - std.mem.writeInt(u32, out[30..34], dst_ip, .big); - std.mem.writeInt(u16, out[24..26], 0, .big); - const ip_ck = packet.checksum(out[14..34]); - std.mem.writeInt(u16, out[24..26], ip_ck, .big); + pub fn srcPortFor(self: *const SynTemplate, dst_ip: u32, dst_port: u16) u16 { + return if (self.rotate) + self.cookie.udpSrcPort(dst_ip, dst_port, self.src_ip, self.rotate_base, self.rotate_span) + else + self.src_port; + } - const src_ip = std.mem.bigToNative(u32, self.src_ip_be); - const seq = self.cookie.seq(dst_ip, dst_port, src_ip, self.src_port); - std.mem.writeInt(u16, out[36..38], dst_port, .big); - std.mem.writeInt(u32, out[38..42], seq, .big); - std.mem.writeInt(u16, out[50..52], 0, .big); + pub fn stamp(self: *const SynTemplate, out: *[max_frame_len]u8, dst_ip: u32, dst_port: u16) usize { + const n = self.frame_len; + @memcpy(out[0..n], self.base[0..n]); + + if (self.vary_ip_id) { + const id: u16 = @truncate((dst_ip *% ip_id_mix) ^ @as(u32, dst_port)); + std.mem.writeInt(u16, out[ip_id_off..][0..2], id, .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.srcPortFor(dst_ip, dst_port); + std.mem.writeInt(u16, out[tcp_src_off..][0..2], src_port, .big); + std.mem.writeInt(u16, out[tcp_dst_off..][0..2], dst_port, .big); + + const ck = self.cookie.seq(dst_ip, dst_port, self.src_ip, src_port); + if (self.scan.cookieInAck()) { + std.mem.writeInt(u32, out[tcp_seq_off..][0..4], 0, .big); + std.mem.writeInt(u32, out[tcp_ack_off..][0..4], ck, .big); + } else { + std.mem.writeInt(u32, out[tcp_seq_off..][0..4], ck, .big); + std.mem.writeInt(u32, out[tcp_ack_off..][0..4], 0, .big); + } + + std.mem.writeInt(u16, out[tcp_checksum_off..][0..2], 0, .big); 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; + const tcp_ck = packet.tcpChecksum(self.src_ip_be, dst_be, out[l4_off..n]); + std.mem.writeInt(u16, out[tcp_checksum_off..][0..2], tcp_ck, .big); + return n; + } + + pub fn stampVariant(self: *const SynTemplate, out: *[max_frame_len]u8, dst_ip: u32, dst_port: u16, variant: usize) usize { + const n = self.stamp(out, dst_ip, dst_port); + if (variant == 0) return n; + + const decoy_src = self.decoys[variant - 1]; + const decoy_src_be = std.mem.nativeToBig(u32, decoy_src); + std.mem.writeInt(u32, out[ip_src_off..][0..4], decoy_src, .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); + + std.mem.writeInt(u16, out[tcp_checksum_off..][0..2], 0, .big); + const dst_be = std.mem.nativeToBig(u32, dst_ip); + const tcp_ck = packet.tcpChecksum(decoy_src_be, dst_be, out[l4_off..n]); + std.mem.writeInt(u16, out[tcp_checksum_off..][0..2], tcp_ck, .big); + return n; } }; @@ -102,55 +188,207 @@ const test_key = [16]u8{ 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, }; -test "stamped frame is 54 bytes with self-verifying IP and TCP checksums" { - const ck = cookie.Cookie.init(test_key); - const tmpl = SynTemplate.init(.{ +fn testTemplate(profile: packet.OsProfile, scan: packet.ScanType) SynTemplate { + return SynTemplate.init(.{ .src_mac = .{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 }, .dst_mac = .{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x02 }, .src_ip = 0x0a000001, .src_port = 40000, - .cookie = ck, + .cookie = cookie.Cookie.init(test_key), + .profile = profile, + .scan = scan, }); - var frame: [SynTemplate.frame_len]u8 = undefined; - _ = tmpl.stamp(&frame, 0x08080808, 443); +} - try std.testing.expectEqual(@as(usize, 54), frame.len); +test "the bare profile stamps a 54-byte frame with self-verifying IP and TCP checksums" { + const tmpl = testTemplate(.none, .syn); + var frame: [SynTemplate.max_frame_len]u8 = undefined; + const len = tmpl.stamp(&frame, 0x08080808, 443); + + try std.testing.expectEqual(@as(usize, 54), len); try std.testing.expectEqual(@as(u16, 0), packet.checksum(frame[14..34])); const ip_src = std.mem.nativeToBig(u32, 0x0a000001); const ip_dst = std.mem.nativeToBig(u32, 0x08080808); - try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum(ip_src, ip_dst, frame[34..54])); + try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum(ip_src, ip_dst, frame[34..len])); } test "stamp writes the destination and the SipHash seq" { - const ck = cookie.Cookie.init(test_key); - const tmpl = SynTemplate.init(.{ - .src_mac = .{0} ** 6, - .dst_mac = .{0} ** 6, - .src_ip = 0x0a000001, - .src_port = 40000, - .cookie = ck, - }); - var frame: [SynTemplate.frame_len]u8 = undefined; + const tmpl = testTemplate(.none, .syn); + var frame: [SynTemplate.max_frame_len]u8 = undefined; _ = 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)); + const ck = cookie.Cookie.init(test_key); const want_seq = ck.seq(0x08080808, 443, 0x0a000001, 40000); try std.testing.expectEqual(want_seq, std.mem.readInt(u32, frame[38..42], .big)); } test "two different targets produce two different seqs" { + const tmpl = testTemplate(.none, .syn); + var a: [SynTemplate.max_frame_len]u8 = undefined; + var b: [SynTemplate.max_frame_len]u8 = undefined; + _ = tmpl.stamp(&a, 0x08080808, 443); + _ = tmpl.stamp(&b, 0x08080808, 80); + try std.testing.expect(!std.mem.eql(u8, a[38..42], b[38..42])); +} + +test "the Linux profile stamps a 74-byte frame carrying the option chain, checksums self-verify" { + const tmpl = testTemplate(.linux, .syn); + var frame: [SynTemplate.max_frame_len]u8 = undefined; + const len = tmpl.stamp(&frame, 0x08080808, 443); + + try std.testing.expectEqual(@as(usize, 54 + 20), len); + try std.testing.expectEqual(@as(u8, 0xa0), frame[tcp_data_off_off]); + try std.testing.expectEqual(@as(u16, 60), std.mem.readInt(u16, frame[16..18], .big)); + try std.testing.expectEqual(@as(u16, 64240), std.mem.readInt(u16, frame[tcp_window_off..][0..2], .big)); + try std.testing.expectEqualSlices(u8, packet.OsProfile.linux.options(), frame[54..len]); + + try std.testing.expectEqual(@as(u16, 0), packet.checksum(frame[14..34])); + const ip_src = std.mem.nativeToBig(u32, 0x0a000001); + const ip_dst = std.mem.nativeToBig(u32, 0x08080808); + try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum(ip_src, ip_dst, frame[34..len])); +} + +test "windows and macos profiles produce well-formed variable-length frames" { + inline for (.{ + .{ packet.OsProfile.windows, @as(usize, 54 + 12), @as(u8, 0x80) }, + .{ packet.OsProfile.macos, @as(usize, 54 + 24), @as(u8, 0xb0) }, + }) |case| { + const tmpl = testTemplate(case[0], .syn); + var frame: [SynTemplate.max_frame_len]u8 = undefined; + const len = tmpl.stamp(&frame, 0x01020304, 22); + try std.testing.expectEqual(case[1], len); + try std.testing.expectEqual(case[2], frame[tcp_data_off_off]); + try std.testing.expectEqual(@as(u16, @intCast(len - 14)), std.mem.readInt(u16, frame[16..18], .big)); + const ip_src = std.mem.nativeToBig(u32, 0x0a000001); + const ip_dst = std.mem.nativeToBig(u32, 0x01020304); + try std.testing.expectEqual(@as(u16, 0), packet.checksum(frame[14..34])); + try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum(ip_src, ip_dst, frame[34..len])); + } +} + +test "the SYN flag byte follows the scan type" { + const flags_off = tcp_flags_off; + var frame: [SynTemplate.max_frame_len]u8 = undefined; + _ = testTemplate(.none, .fin).stamp(&frame, 0x08080808, 80); + try std.testing.expectEqual(packet.TcpFlag.fin, frame[flags_off]); + _ = testTemplate(.none, .xmas).stamp(&frame, 0x08080808, 80); + try std.testing.expectEqual(packet.TcpFlag.fin | packet.TcpFlag.psh | packet.TcpFlag.urg, frame[flags_off]); + _ = testTemplate(.none, .null_scan).stamp(&frame, 0x08080808, 80); + try std.testing.expectEqual(@as(u8, 0), frame[flags_off]); +} + +test "an ack-flag scan carries the cookie in the ack field and leaves seq zero" { + const tmpl = testTemplate(.none, .ack); + var frame: [SynTemplate.max_frame_len]u8 = undefined; + _ = tmpl.stamp(&frame, 0x08080808, 80); const ck = cookie.Cookie.init(test_key); + const want = ck.seq(0x08080808, 80, 0x0a000001, 40000); + try std.testing.expectEqual(want, std.mem.readInt(u32, frame[42..46], .big)); + try std.testing.expectEqual(@as(u32, 0), std.mem.readInt(u32, frame[38..42], .big)); +} + +test "source-port rotation stays in range and keeps the seq cookie consistent with the written port" { const tmpl = SynTemplate.init(.{ .src_mac = .{0} ** 6, .dst_mac = .{0} ** 6, .src_ip = 0x0a000001, .src_port = 40000, - .cookie = ck, + .cookie = cookie.Cookie.init(test_key), + .rotate = true, + .rotate_base = 40000, + .rotate_span = 8192, }); - var a: [SynTemplate.frame_len]u8 = undefined; - var b: [SynTemplate.frame_len]u8 = undefined; - _ = tmpl.stamp(&a, 0x08080808, 443); - _ = tmpl.stamp(&b, 0x08080808, 80); - try std.testing.expect(!std.mem.eql(u8, a[38..42], b[38..42])); + var frame: [SynTemplate.max_frame_len]u8 = undefined; + _ = tmpl.stamp(&frame, 0x08080808, 443); + + const sport = std.mem.readInt(u16, frame[34..36], .big); + try std.testing.expect(sport >= 40000 and sport < 48192); + + const ck = cookie.Cookie.init(test_key); + const want_seq = ck.seq(0x08080808, 443, 0x0a000001, sport); + try std.testing.expectEqual(want_seq, std.mem.readInt(u32, frame[38..42], .big)); +} + +test "two different targets rotate to two different source ports" { + const tmpl = SynTemplate.init(.{ + .src_mac = .{0} ** 6, + .dst_mac = .{0} ** 6, + .src_ip = 0x0a000001, + .src_port = 40000, + .cookie = cookie.Cookie.init(test_key), + .rotate = true, + .rotate_base = 40000, + .rotate_span = 8192, + }); + var a: [SynTemplate.max_frame_len]u8 = undefined; + var b: [SynTemplate.max_frame_len]u8 = undefined; + _ = tmpl.stamp(&a, 0x08080808, 443); + _ = tmpl.stamp(&b, 0x09090909, 443); + try std.testing.expect(!std.mem.eql(u8, a[34..36], b[34..36])); +} + +test "variantCount counts the real probe plus every decoy" { + const decoys = [_]u32{ 0x01010101, 0x02020202, 0x03030303 }; + const tmpl = SynTemplate.init(.{ + .src_mac = .{0} ** 6, + .dst_mac = .{0} ** 6, + .src_ip = 0x0a000001, + .src_port = 40000, + .cookie = cookie.Cookie.init(test_key), + .decoys = &decoys, + }); + try std.testing.expectEqual(@as(usize, 4), tmpl.variantCount()); + const plain = testTemplate(.none, .syn); + try std.testing.expectEqual(@as(usize, 1), plain.variantCount()); +} + +test "decoy variants carry a spoofed source with self-verifying IP and TCP checksums" { + const decoys = [_]u32{ 0xC0A80063, 0x08080404 }; + const tmpl = SynTemplate.init(.{ + .src_mac = .{ 0x02, 0, 0, 0, 0, 1 }, + .dst_mac = .{ 0x02, 0, 0, 0, 0, 2 }, + .src_ip = 0x0a000001, + .src_port = 40000, + .cookie = cookie.Cookie.init(test_key), + .decoys = &decoys, + }); + var real: [SynTemplate.max_frame_len]u8 = undefined; + var decoy: [SynTemplate.max_frame_len]u8 = undefined; + + const rn = tmpl.stampVariant(&real, 0x08080808, 443, 0); + try std.testing.expectEqual(@as(u32, 0x0a000001), std.mem.readInt(u32, real[26..30], .big)); + + const dn = tmpl.stampVariant(&decoy, 0x08080808, 443, 1); + try std.testing.expectEqual(rn, dn); + try std.testing.expectEqual(@as(u32, 0xC0A80063), std.mem.readInt(u32, decoy[26..30], .big)); + + try std.testing.expectEqual(@as(u16, 0), packet.checksum(decoy[14..34])); + const decoy_src_be = std.mem.nativeToBig(u32, 0xC0A80063); + const dst_be = std.mem.nativeToBig(u32, 0x08080808); + try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum(decoy_src_be, dst_be, decoy[34..dn])); + + try std.testing.expect(!std.mem.eql(u8, real[26..30], decoy[26..30])); + try std.testing.expectEqualSlices(u8, real[30..34], decoy[30..34]); +} + +test "windows and macos profiles vary the IP id per target while linux and bare keep it zero" { + var a: [SynTemplate.max_frame_len]u8 = undefined; + var b: [SynTemplate.max_frame_len]u8 = undefined; + + inline for (.{ packet.OsProfile.windows, packet.OsProfile.macos }) |p| { + const tmpl = testTemplate(p, .syn); + _ = tmpl.stamp(&a, 0x08080808, 443); + _ = tmpl.stamp(&b, 0x09090909, 443); + const id_a = std.mem.readInt(u16, a[18..20], .big); + const id_b = std.mem.readInt(u16, b[18..20], .big); + try std.testing.expect(id_a != id_b); + try std.testing.expectEqual(@as(u16, 0), packet.checksum(a[14..34])); + } + + _ = testTemplate(.linux, .syn).stamp(&a, 0x08080808, 443); + try std.testing.expectEqual(@as(u16, 0), std.mem.readInt(u16, a[18..20], .big)); + _ = testTemplate(.none, .syn).stamp(&a, 0x08080808, 443); + try std.testing.expectEqual(@as(u16, 0), std.mem.readInt(u16, a[18..20], .big)); } diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig b/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig index 21f62296..1f1c87df 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig @@ -8,6 +8,15 @@ const ratelimit = @import("ratelimit"); const cookie = @import("cookie"); const packet = @import("packet"); +fn submitFrame(sink: anytype, frame: []const u8) bool { + if (!sink.submit(frame)) { + @branchHint(.unlikely); + sink.kick(); + return sink.submit(frame); + } + return true; +} + pub fn run( engine: *targets.Engine, tmpl: anytype, @@ -17,38 +26,83 @@ pub fn run( max_packets: u64, deadline_ns: u64, ) u64 { - _ = bucket.takeBatch(clock.now(), 0); + if (bucket.jitter != null) return runJittered(engine, tmpl, bucket, sink, clock, max_packets, deadline_ns); + + bucket.prime(clock.now()); var sent: u64 = 0; + var probes: u64 = 0; + var cursor: usize = 0; var frame: [@TypeOf(tmpl.*).max_frame_len]u8 = undefined; + const vc = tmpl.variantCount(); var pending: ?targets.Target = engine.next(); - while (pending != null and sent < max_packets) { + while (pending != null and probes < max_packets) { const now_ns = clock.now(); if (now_ns >= deadline_ns) break; - const granted = bucket.takeBatch(now_ns, max_packets - sent); + const granted = bucket.takeBatch(now_ns, (max_packets - probes) *| vc); if (granted == 0) { clock.sleepNs(bucket.step_ns); continue; } - var n: u64 = 0; - while (n < granted) : (n += 1) { + var used: u64 = 0; + while (used < granted and probes < max_packets) { const t = pending orelse break; - const len = tmpl.stamp(&frame, t.ip, t.port); - if (!sink.submit(frame[0..len])) { - @branchHint(.unlikely); - sink.kick(); - if (!sink.submit(frame[0..len])) break; - } + const len = tmpl.stampVariant(&frame, t.ip, t.port, cursor); + if (!submitFrame(sink, frame[0..len])) break; + used += 1; sent += 1; - pending = engine.next(); - if (pending == null) break; + cursor += 1; + if (cursor == vc) { + cursor = 0; + probes += 1; + pending = engine.next(); + } } + if (used < granted) bucket.refund(granted - used); sink.kick(); } sink.kick(); return sent; } +fn runJittered( + engine: *targets.Engine, + tmpl: anytype, + bucket: *ratelimit.TokenBucket, + sink: anytype, + clock: anytype, + max_packets: u64, + deadline_ns: u64, +) u64 { + var sent: u64 = 0; + var probes: u64 = 0; + var cursor: usize = 0; + var frame: [@TypeOf(tmpl.*).max_frame_len]u8 = undefined; + const vc = tmpl.variantCount(); + + var pending: ?targets.Target = engine.next(); + while (pending != null and probes < max_packets) { + if (clock.now() >= deadline_ns) break; + const t = pending.?; + const len = tmpl.stampVariant(&frame, t.ip, t.port, cursor); + if (!submitFrame(sink, frame[0..len])) { + clock.sleepNs(bucket.step_ns); + continue; + } + sent += 1; + cursor += 1; + sink.kick(); + if (cursor < vc) continue; + cursor = 0; + probes += 1; + pending = engine.next(); + if (pending == null) break; + clock.sleepNs(bucket.jitter.?.nextGapNs() *| vc); + } + sink.kick(); + return sent; +} + const FakeClock = struct { t: u64 = 0, fn now(self: *FakeClock) u64 { @@ -170,3 +224,157 @@ test "run bails at the deadline when the sink never drains (stall watchdog)" { try std.testing.expectEqual(@as(u64, 0), sent); try std.testing.expect(sink.kicks >= 1); } + +test "decoys emit the real probe plus every spoofed source for each target" { + const test_key = [_]u8{0} ** 16; + const cidrs = [_]targets.Range{try targets.parseCidr("8.8.8.0/30")}; + const ports = [_]u16{80}; + var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 9); + defer eng.deinit(); + const total = eng.total; + + const decoys = [_]u32{ 0x01010101, 0x02020202 }; + const tmpl = template.SynTemplate.init(.{ + .src_mac = .{0} ** 6, + .dst_mac = .{0} ** 6, + .src_ip = 0x0a000001, + .src_port = 40000, + .cookie = cookie.Cookie.init(test_key), + .decoys = &decoys, + }); + var tb = ratelimit.TokenBucket.init(1000, 1000); + var clock = FakeClock{}; + var sink = FakeSink{ .allocator = std.testing.allocator }; + defer sink.frames.deinit(std.testing.allocator); + + const sent = run(&eng, &tmpl, &tb, &sink, &clock, total, std.math.maxInt(u64)); + try std.testing.expectEqual(total * 3, sent); + try std.testing.expectEqual(@as(usize, @intCast(total * 3)), sink.frames.items.len); + + var real_count: usize = 0; + for (sink.frames.items) |*f| { + if (std.mem.readInt(u32, f[26..30], .big) == 0x0a000001) real_count += 1; + } + try std.testing.expectEqual(@as(usize, @intCast(total)), real_count); +} + +test "jittered pacing covers every target and advances the clock by the sleeps" { + const test_key = [_]u8{0} ** 16; + const cidrs = [_]targets.Range{try targets.parseCidr("8.8.8.0/29")}; + const ports = [_]u16{80}; + var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 4); + defer eng.deinit(); + const total = eng.total; + + const tmpl = template.SynTemplate.init(.{ + .src_mac = .{0} ** 6, + .dst_mac = .{0} ** 6, + .src_ip = 0x0a000001, + .src_port = 40000, + .cookie = cookie.Cookie.init(test_key), + }); + var tb = ratelimit.TokenBucket.init(1000, 1000).withJitter(0xC0FFEE); + var clock = FakeClock{}; + var sink = FakeSink{ .allocator = std.testing.allocator }; + defer sink.frames.deinit(std.testing.allocator); + + const before = clock.t; + const sent = run(&eng, &tmpl, &tb, &sink, &clock, total, std.math.maxInt(u64)); + try std.testing.expectEqual(total, sent); + try std.testing.expect(clock.t > before); +} + +const SlowClock = struct { + t: u64 = 0, + fn now(self: *SlowClock) u64 { + self.t += 1_000; + return self.t; + } + fn sleepNs(self: *SlowClock, ns: u64) void { + self.t += ns; + } +}; + +const CountSink = struct { + count: u64 = 0, + fn submit(self: *CountSink, _: []const u8) bool { + self.count += 1; + return true; + } + fn kick(_: *CountSink) void {} +}; + +test "decoy scans keep making progress past the initial token burst (no livelock)" { + const test_key = [_]u8{0} ** 16; + const cidrs = [_]targets.Range{try targets.parseCidr("8.8.0.0/20")}; + const ports = [_]u16{80}; + var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 11); + defer eng.deinit(); + const total = eng.total; + + const decoys = [_]u32{ 0x01010101, 0x02020202 }; + const tmpl = template.SynTemplate.init(.{ + .src_mac = .{0} ** 6, + .dst_mac = .{0} ** 6, + .src_ip = 0x0a000001, + .src_port = 40000, + .cookie = cookie.Cookie.init(test_key), + .decoys = &decoys, + }); + var tb = ratelimit.TokenBucket.init(1000, 1000); + var clock = SlowClock{}; + var sink = CountSink{}; + + const sent = run(&eng, &tmpl, &tb, &sink, &clock, total, 6_000_000_000); + try std.testing.expectEqual(sink.count, sent); + try std.testing.expect(sent > 3000); +} + +const BoundedSink = struct { + frames: std.ArrayList([54]u8) = .empty, + allocator: std.mem.Allocator, + held: usize = 0, + cap: usize, + fn submit(self: *BoundedSink, frame: []const u8) bool { + if (self.held >= self.cap) return false; + self.frames.append(self.allocator, frame[0..54].*) catch return false; + self.held += 1; + return true; + } + fn kick(self: *BoundedSink) void { + self.held = 0; + } +}; + +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")}; + const ports = [_]u16{80}; + var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 13); + defer eng.deinit(); + const total = eng.total; + + const decoys = [_]u32{ 0x01010101, 0x02020202 }; + const tmpl = template.SynTemplate.init(.{ + .src_mac = .{0} ** 6, + .dst_mac = .{0} ** 6, + .src_ip = 0x0a000001, + .src_port = 40000, + .cookie = cookie.Cookie.init(test_key), + .decoys = &decoys, + }); + var tb = ratelimit.TokenBucket.init(1000, 1000); + var clock = FakeClock{}; + var sink = BoundedSink{ .allocator = std.testing.allocator, .cap = 2 }; + defer sink.frames.deinit(std.testing.allocator); + + const sent = run(&eng, &tmpl, &tb, &sink, &clock, total, std.math.maxInt(u64)); + try std.testing.expectEqual(total * 3, sent); + try std.testing.expectEqual(@as(usize, @intCast(total * 3)), sink.frames.items.len); + + var real: usize = 0; + for (sink.frames.items) |*f| { + if (std.mem.readInt(u32, f[26..30], .big) == 0x0a000001) real += 1; + } + try std.testing.expectEqual(@as(usize, @intCast(total)), real); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig b/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig index 786dcb4c..737350d9 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig @@ -9,6 +9,12 @@ const packet_io = @import("packet_io"); const cookie = @import("cookie"); const tx = @import("tx"); const netutil = @import("netutil"); +const stealth = @import("stealth"); + +const authorized_warning = + "tx: stealth/evasion features require --authorized-scan. Use ONLY on systems you\n" ++ + "own or are authorized to test; unauthorized scanning is a crime (CFAA et al.).\n\n" ++ + stealth.omitted_help ++ "\n"; const getFlag = netutil.getFlag; const parseIpv4 = netutil.parseIpv4; @@ -53,6 +59,21 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8) ! seed = std.mem.readInt(u64, &seed_bytes, .little); } + var scfg = stealth.parse(allocator, io, args) catch |e| switch (e) { + error.AuthorizationRequired => { + try out.writeAll(authorized_warning); + try out.flush(); + return; + }, + error.OutOfMemory => return e, + else => { + try out.print("tx: invalid stealth flag ({s})\n", .{@errorName(e)}); + try out.flush(); + return; + }, + }; + defer scfg.deinit(allocator); + const cidr = try targets.parseCidr(target_text); var eng = try targets.Engine.init(allocator, &.{cidr}, ports, seed); defer eng.deinit(); @@ -60,14 +81,22 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8) ! const count = if (getFlag(args, "--count")) |c| try std.fmt.parseInt(u64, c, 10) else eng.total; const ck = try cookie.Cookie.random(io); + const rot_span: u16 = if (scfg.rotate) @intCast(@min(@as(u32, scfg.rotate_span), 65536 - @as(u32, src_port))) else 0; const tmpl = template.SynTemplate.init(.{ .src_mac = src_mac, .dst_mac = gw_mac, .src_ip = src_ip, .src_port = src_port, .cookie = ck, + .profile = scfg.profile, + .scan = scfg.scan, + .rotate = scfg.rotate, + .rotate_base = src_port, + .rotate_span = rot_span, + .decoys = scfg.decoys, }); var bucket = ratelimit.TokenBucket.init(rate, rate); + if (scfg.jitter) bucket = bucket.withJitter(seed); const backend_choice = packet_io.parseChoice(getFlag(args, "--backend")) orelse { try out.writeAll("tx: --backend must be one of auto, xdp, afpacket\n"); @@ -90,6 +119,27 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8) ! defer backend.close(); try out.print("tx: using {s}\n", .{packet_io.kindLabel(backend.kind())}); + if (scfg.profile != .none or scfg.scan != .syn or scfg.jitter or scfg.rotate or scfg.decoys.len > 0) { + try out.print("tx: stealth template={s} scan={s} jitter={s} rotate={s} decoys={d}\n", .{ + @tagName(scfg.profile), + @tagName(scfg.scan), + if (scfg.jitter) "on" else "off", + if (scfg.rotate) "on" else "off", + scfg.decoys.len, + }); + } + + var supp: ?stealth.RstSuppressor = null; + if (scfg.suppress_rst) { + const lo = src_port; + const hi = if (scfg.rotate) src_port +| (rot_span -| 1) else src_port; + supp = stealth.RstSuppressor.install(allocator, io, src_ip, lo, hi) catch |e| blk: { + try out.print("tx: RST-suppression unavailable ({s}); continuing without it\n", .{@errorName(e)}); + break :blk null; + }; + } + defer if (supp) |*s| s.teardown(); + var clock = RealClock{}; const t0 = clock.now(); const est_tx_ns: u64 = if (rate > 0) (count / rate) *| ns_per_sec else tx_budget_floor_ns; diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/udp.zig b/PROJECTS/advanced/zig-stateless-scanner/src/udp.zig index 7250c2a3..47526f60 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/udp.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/udp.zig @@ -113,6 +113,15 @@ pub const UdpTemplate = struct { return frame_len; } + + pub fn variantCount(_: *const UdpTemplate) usize { + return 1; + } + + pub fn stampVariant(self: *const UdpTemplate, out: *[max_frame_len]u8, dst_ip: u32, dst_port: u16, variant: usize) usize { + _ = variant; + return self.stamp(out, dst_ip, dst_port); + } }; const test_key = [16]u8{