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:
parent
bb46250087
commit
6e3bfccba7
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
\\
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Reference in New Issue