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.
This commit is contained in:
CarterPerez-dev 2026-07-03 05:54:17 -04:00
parent bb46250087
commit 6e3bfccba7
12 changed files with 1519 additions and 86 deletions

View File

@ -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);

View File

@ -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);
}

View File

@ -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 <when> 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 <os> SYN fingerprint none|masscan|linux|windows|macos
\\ --scan-type <t> syn|fin|null|xmas|maimon|ack|window (default syn)
\\ --jitter <mode> poisson | none: exponential inter-packet timing
\\ --source-port-rotation vary the source port per probe (cookie still matches)
\\ --decoys <list> 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)
\\

View File

@ -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');
}

View File

@ -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);
}

View File

@ -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)));
}
}

View File

@ -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();

View File

@ -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);
}

View File

@ -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(&eth));
@memcpy(base[0..eth_len], std.mem.asBytes(&eth));
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));
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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{