diff --git a/PROJECTS/advanced/zig-stateless-scanner/build.zig b/PROJECTS/advanced/zig-stateless-scanner/build.zig index 810d2e39..08735c5e 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/build.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/build.zig @@ -10,7 +10,7 @@ pub fn build(b: *std.Build) void { const xdp_enabled = b.option(bool, "xdp", "Enable the AF_XDP TX backend (pure-syscall, no libxdp; needs CAP_NET_ADMIN at runtime)") orelse false; const opts = b.addOptions(); - opts.addOption([]const u8, "version", "0.0.0-m8"); + opts.addOption([]const u8, "version", "0.0.0-m9"); opts.addOption(bool, "xdp", xdp_enabled); const build_config_mod = opts.createModule(); @@ -67,6 +67,36 @@ pub fn build(b: *std.Build) void { template_mod.addImport("packet", packet_mod); template_mod.addImport("cookie", cookie_mod); + const segment_mod = b.createModule(.{ + .root_source_file = b.path("src/segment.zig"), + .target = target, + .optimize = optimize, + }); + segment_mod.addImport("packet", packet_mod); + + const regex_mod = b.createModule(.{ + .root_source_file = b.path("src/regex.zig"), + .target = target, + .optimize = optimize, + }); + + const probe_mod = b.createModule(.{ + .root_source_file = b.path("src/probe.zig"), + .target = target, + .optimize = optimize, + }); + probe_mod.addImport("regex", regex_mod); + + const service_mod = b.createModule(.{ + .root_source_file = b.path("src/service.zig"), + .target = target, + .optimize = optimize, + }); + service_mod.addImport("packet", packet_mod); + service_mod.addImport("cookie", cookie_mod); + service_mod.addImport("segment", segment_mod); + service_mod.addImport("probe", probe_mod); + const payloads_mod = b.createModule(.{ .root_source_file = b.path("src/payloads.zig"), .target = target, @@ -198,6 +228,7 @@ pub fn build(b: *std.Build) void { scancmd_mod.addImport("netutil", netutil_mod); scancmd_mod.addImport("output", output_mod); scancmd_mod.addImport("stealth", stealth_mod); + scancmd_mod.addImport("service", service_mod); const exe = b.addExecutable(.{ .name = "zingela", @@ -228,7 +259,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, stealth_mod, output_mod, scancmd_mod }; + const test_mods = [_]*std.Build.Module{ packet_mod, cli_mod, smoke_mod, cookie_mod, numtheory_mod, targets_mod, ratelimit_mod, template_mod, segment_mod, regex_mod, probe_mod, service_mod, payloads_mod, udp_mod, afpacket_mod, xdp_mod, afxdp_mod, packet_io_mod, tx_mod, txcmd_mod, classify_mod, dedup_mod, rx_mod, netutil_mod, stealth_mod, output_mod, scancmd_mod }; for (test_mods) |mod| { const t = b.addTest(.{ .root_module = mod }); const rt = b.addRunArtifact(t); diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig index 5fe2d19b..6bf50e68 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig @@ -61,6 +61,9 @@ pub fn printHelp(io: std.Io, env: *std.process.Environ.Map) !void { \\scan-only options: \\ --udp UDP scan: per-protocol payloads, ICMP type3/code3 = closed, \\ silent ports reported honestly as open|filtered + \\ --banners SYN-scan only: phase-2 service/banner grab on open ports + \\ (NULL probe + HTTP GET, TLS detected not decrypted, no JA4); + \\ auto-installs a scoped RST-drop so the grab survives the kernel \\ --wait receive drain window after transmit (default 2000) \\ --json emit NDJSON results to stdout (visuals go to stderr) \\ --color auto | always | never (default auto) diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/output.zig b/PROJECTS/advanced/zig-stateless-scanner/src/output.zig index a875324b..5ec5309d 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/output.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/output.zig @@ -31,6 +31,7 @@ pub const Stats = struct { closed: Padded = .{}, filtered: Padded = .{}, unfiltered: Padded = .{}, + banners: Padded = .{}, pub fn record(self: *Stats, st: State) void { _ = self.found.v.fetchAdd(1, .monotonic); @@ -240,6 +241,7 @@ pub const Dashboard = struct { interactive: bool, total: u64, drawn: bool = false, + banners_mode: bool = false, const body_lines: usize = 5; @@ -270,9 +272,11 @@ pub const Dashboard = struct { if (!self.interactive) { try out.print( - "[up {d:.0}s] sent {d} / {d} found {d} (open {d} closed {d} filtered {d}) {d:.2} kpps\n", - .{ elapsed_s, sent, self.total, found, op, cl, fi, kpps }, + "[up {d:.0}s] sent {d} / {d} found {d} (open {d} closed {d} filtered {d})", + .{ elapsed_s, sent, self.total, found, op, cl, fi }, ); + if (self.banners_mode) try out.print(" banners {d}", .{s.banners.v.load(.monotonic)}); + try out.print(" {d:.2} kpps\n", .{kpps}); try out.flush(); return; } @@ -441,6 +445,107 @@ pub fn ipPortLess(_: void, a: Result, b: Result) bool { return a.port < b.port; } +pub const ServiceRow = struct { + ip: u32, + port: u16, + service: []const u8, + info: []const u8, + tls: bool, +}; + +const w_service: usize = 11; +const w_info: usize = 34; + +fn serviceRule(out: *std.Io.Writer, level: ColorLevel, left: []const u8, mid: []const u8, right: []const u8) !void { + try setFg(out, level, chrome_gray); + try out.writeAll(left); + try repeat(out, box_h, w_host + 2); + try out.writeAll(mid); + try repeat(out, box_h, w_port + 2); + try out.writeAll(mid); + try repeat(out, box_h, w_service + 2); + try out.writeAll(mid); + try repeat(out, box_h, w_info + 2); + try out.writeAll(right); + try resetFg(out, level); + try out.writeByte('\n'); +} + +fn renderCell(out: *std.Io.Writer, level: ColorLevel, text: []const u8, width: usize, color: Rgb) !void { + const n = @min(text.len, width); + try setFg(out, level, color); + try out.writeAll(text[0..n]); + try resetFg(out, level); + try pad(out, width - n); +} + +pub fn renderServices(out: *std.Io.Writer, level: ColorLevel, rows: []const ServiceRow) !void { + try out.writeAll(" "); + try serviceRule(out, level, "\u{250c}", "\u{252c}", "\u{2510}"); + + try out.writeAll(" "); + try span(out, level, chrome_gray, "\u{2502} "); + try renderCell(out, level,"HOST", w_host, bright_white); + try span(out, level, chrome_gray, " \u{2502} "); + try renderCell(out, level,"PORT", w_port, bright_white); + try span(out, level, chrome_gray, " \u{2502} "); + try renderCell(out, level,"SERVICE", w_service, bright_white); + try span(out, level, chrome_gray, " \u{2502} "); + try renderCell(out, level,"VERSION / INFO", w_info, bright_white); + try span(out, level, chrome_gray, " \u{2502}"); + try out.writeByte('\n'); + + try out.writeAll(" "); + try serviceRule(out, level, "\u{251c}", "\u{253c}", "\u{2524}"); + + for (rows) |r| { + var ipbuf: [15]u8 = undefined; + var ipw = std.Io.Writer.fixed(&ipbuf); + try writeIp(&ipw, r.ip); + + var portbuf: [5]u8 = undefined; + const port_str = std.fmt.bufPrint(&portbuf, "{d}", .{r.port}) catch unreachable; + + const info_disp = if (r.info.len > 0) r.info else if (r.tls) "(encrypted)" else ""; + + try out.writeAll(" "); + try span(out, level, chrome_gray, "\u{2502} "); + try renderCell(out, level,ipbuf[0..ipw.end], w_host, bright_white); + try span(out, level, chrome_gray, " \u{2502} "); + try pad(out, w_port - port_str.len); + try setFg(out, level, bright_white); + try out.writeAll(port_str); + try resetFg(out, level); + try span(out, level, chrome_gray, " \u{2502} "); + try renderCell(out, level,r.service, w_service, neon_green); + try span(out, level, chrome_gray, " \u{2502} "); + try renderCell(out, level,info_disp, w_info, bright_white); + try span(out, level, chrome_gray, " \u{2502}"); + try out.writeByte('\n'); + } + + try out.writeAll(" "); + try serviceRule(out, level, "\u{2514}", "\u{2534}", "\u{2518}"); +} + +fn jsonEscape(out: *std.Io.Writer, s: []const u8) !void { + for (s) |c| switch (c) { + '"' => try out.writeAll("\\\""), + '\\' => try out.writeAll("\\\\"), + else => try out.writeByte(c), + }; +} + +pub fn emitServiceJson(out: *std.Io.Writer, row: ServiceRow) !void { + try out.writeAll("{\"ip\":\""); + try writeIp(out, row.ip); + try out.print("\",\"port\":{d},\"service\":\"", .{row.port}); + try jsonEscape(out, row.service); + try out.writeAll("\",\"info\":\""); + try jsonEscape(out, row.info); + try out.print("\",\"tls\":{s}}}\n", .{if (row.tls) "true" else "false"}); +} + pub fn renderSummary( out: *std.Io.Writer, level: ColorLevel, @@ -452,6 +557,7 @@ pub fn renderSummary( closed: u64, filtered: u64, unfiltered: u64, + banners: u64, ) !void { try out.writeAll(" "); try span(out, level, violet_mid, gutter_bar); @@ -483,6 +589,12 @@ pub fn renderSummary( try writeThousands(out, unfiltered); try span(out, level, chrome_gray, " unfiltered"); } + if (banners > 0) { + try span(out, level, chrome_gray, " \u{2192} "); + try setFg(out, level, neon_green); + try writeThousands(out, banners); + try span(out, level, chrome_gray, " banners"); + } try resetFg(out, level); try out.writeByte('\n'); } @@ -617,3 +729,40 @@ test "ipPortLess orders by ip then port" { try std.testing.expect(ipPortLess({}, a, c)); try std.testing.expect(!ipPortLess({}, a, b)); } + +test "emitServiceJson emits an escaped, greppable service record" { + var buf: [256]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + try emitServiceJson(&w, .{ .ip = 0x0a000005, .port = 22, .service = "ssh", .info = "OpenSSH_9.6p1", .tls = false }); + try std.testing.expectEqualStrings( + "{\"ip\":\"10.0.0.5\",\"port\":22,\"service\":\"ssh\",\"info\":\"OpenSSH_9.6p1\",\"tls\":false}\n", + buf[0..w.end], + ); +} + +test "emitServiceJson escapes quotes and backslashes in the info field" { + var buf: [256]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + try emitServiceJson(&w, .{ .ip = 0x0a000006, .port = 443, .service = "ssl/tls", .info = "a\"b\\c", .tls = true }); + const text = buf[0..w.end]; + try std.testing.expect(std.mem.indexOf(u8, text, "\\\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\\\\") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"tls\":true") != null); +} + +test "renderServices lists each host, service, and version without escapes in plain mode" { + var buf: [4096]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + const rows = [_]ServiceRow{ + .{ .ip = 0x0a000005, .port = 22, .service = "ssh", .info = "OpenSSH_9.6p1", .tls = false }, + .{ .ip = 0x0a000006, .port = 443, .service = "ssl/tls", .info = "", .tls = true }, + }; + try renderServices(&w, .none, &rows); + const text = buf[0..w.end]; + try std.testing.expect(std.mem.indexOf(u8, text, "10.0.0.5") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "ssh") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "OpenSSH_9.6p1") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "ssl/tls") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "(encrypted)") != null); + try std.testing.expect(std.mem.indexOf(u8, text, esc) == null); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/probe.zig b/PROJECTS/advanced/zig-stateless-scanner/src/probe.zig new file mode 100644 index 00000000..bf46ba5f --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/probe.zig @@ -0,0 +1,202 @@ +// ©AngelaMos | 2026 +// probe.zig + +const std = @import("std"); +const regex = @import("regex"); + +pub const max_info: usize = 96; +pub const max_banner: usize = 512; + +pub const http_get = "GET / HTTP/1.0\r\n\r\n"; + +const http_ports = [_]u16{ 80, 81, 591, 3000, 5000, 8000, 8008, 8080, 8081, 8888, 9000 }; + +pub fn probeFor(port: u16) []const u8 { + for (http_ports) |p| { + if (p == port) return http_get; + } + return ""; +} + +pub const ServiceInfo = struct { + service: []const u8 = "unknown", + info: [max_info]u8 = [_]u8{0} ** max_info, + info_len: usize = 0, + tls: bool = false, + + pub fn infoSlice(self: *const ServiceInfo) []const u8 { + return self.info[0..self.info_len]; + } +}; + +const CompiledRule = struct { + prefix: []const u8, + service: []const u8, + re: ?regex.Regex, +}; + +fn rule(comptime prefix: []const u8, comptime service: []const u8, comptime pattern: []const u8, comptime flags: regex.Regex.Flags) CompiledRule { + return .{ + .prefix = prefix, + .service = service, + .re = if (pattern.len == 0) null else (regex.Regex.compile(pattern, flags) catch @compileError("probe: bad pattern " ++ pattern)), + }; +} + +const rules = blk: { + @setEvalBranchQuota(400_000); + break :blk [_]CompiledRule{ + rule("SSH-", "ssh", "^SSH-[\\d.]+-([^\\r\\n]+)", .{}), + rule("HTTP/", "http", "[Ss]erver:[ \\t]*([^\\r\\n]+)", .{}), + rule("+OK", "pop3", "^\\+OK ([^\\r\\n]+)", .{}), + rule("* OK", "imap", "^\\* OK ([^\\r\\n]+)", .{}), + rule("RFB ", "vnc", "^RFB (\\d+\\.\\d+)", .{}), + rule("\xff", "telnet", "", .{}), + }; +}; + +fn startsWith(bytes: []const u8, prefix: []const u8) bool { + return bytes.len >= prefix.len and std.mem.eql(u8, bytes[0..prefix.len], prefix); +} + +pub fn isTls(bytes: []const u8) bool { + if (bytes.len < 3) return false; + const content = bytes[0]; + return (content == 0x16 or content == 0x15) and bytes[1] == 0x03 and bytes[2] <= 0x04; +} + +fn sanitizeInto(out: *ServiceInfo, src: []const u8) void { + var n: usize = 0; + for (src) |c| { + if (n >= max_info) break; + out.info[n] = if (c >= 0x20 and c < 0x7f) c else '.'; + n += 1; + } + out.info_len = n; +} + +fn firstLine(bytes: []const u8) []const u8 { + const cut = std.mem.indexOfAny(u8, bytes, "\r\n") orelse bytes.len; + return bytes[0..cut]; +} + +fn extractInfo(out: *ServiceInfo, re: *const regex.Regex, bytes: []const u8) void { + var caps: regex.Captures = .{}; + if (re.search(bytes, &caps) != null) { + if (caps.group(bytes, 1)) |g| sanitizeInto(out, g); + } +} + +fn resolve220(bytes: []const u8) []const u8 { + if (std.mem.indexOf(u8, bytes, "SMTP") != null or std.mem.indexOf(u8, bytes, "ESMTP") != null) return "smtp"; + if (std.mem.indexOf(u8, bytes, "FTP") != null) return "ftp"; + return "ftp"; +} + +pub fn classify(bytes: []const u8, out: *ServiceInfo) void { + out.* = .{}; + if (bytes.len == 0) return; + + if (isTls(bytes)) { + out.service = "ssl/tls"; + out.tls = true; + return; + } + + if (startsWith(bytes, "220-") or startsWith(bytes, "220 ")) { + out.service = resolve220(bytes); + sanitizeInto(out, firstLine(bytes)); + return; + } + + for (rules) |r| { + if (!startsWith(bytes, r.prefix)) continue; + out.service = r.service; + if (r.re) |*re| extractInfo(out, re, bytes) else sanitizeInto(out, firstLine(bytes)); + return; + } + + out.service = "unknown"; + sanitizeInto(out, firstLine(bytes)); +} + +// ---- tests ---- + +test "probeFor sends an HTTP GET on web ports and nothing elsewhere" { + try std.testing.expectEqualStrings(http_get, probeFor(80)); + try std.testing.expectEqualStrings(http_get, probeFor(8080)); + try std.testing.expectEqualStrings("", probeFor(22)); + try std.testing.expectEqualStrings("", probeFor(443)); +} + +test "SSH banner classifies as ssh and extracts the software string" { + var si: ServiceInfo = .{}; + classify("SSH-2.0-OpenSSH_9.6p1 Debian-3\r\n", &si); + try std.testing.expectEqualStrings("ssh", si.service); + try std.testing.expectEqualStrings("OpenSSH_9.6p1 Debian-3", si.infoSlice()); + try std.testing.expect(!si.tls); +} + +test "HTTP response classifies as http and pulls the Server header" { + var si: ServiceInfo = .{}; + classify("HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\nContent-Length: 5\r\n\r\nhello", &si); + try std.testing.expectEqualStrings("http", si.service); + try std.testing.expectEqualStrings("nginx/1.24.0", si.infoSlice()); +} + +test "SMTP and FTP both greet with 220 but disambiguate by content" { + var si: ServiceInfo = .{}; + classify("220 mail.example.com ESMTP Postfix\r\n", &si); + try std.testing.expectEqualStrings("smtp", si.service); + classify("220 ProFTPD Server ready\r\n", &si); + try std.testing.expectEqualStrings("ftp", si.service); +} + +test "POP3 and IMAP greetings classify by their prefixes" { + var si: ServiceInfo = .{}; + classify("+OK Dovecot ready.\r\n", &si); + try std.testing.expectEqualStrings("pop3", si.service); + try std.testing.expectEqualStrings("Dovecot ready.", si.infoSlice()); + classify("* OK [CAPABILITY IMAP4rev1] Dovecot ready\r\n", &si); + try std.testing.expectEqualStrings("imap", si.service); +} + +test "a TLS record is detected without decrypting it" { + var si: ServiceInfo = .{}; + classify(&[_]u8{ 0x16, 0x03, 0x03, 0x00, 0x50, 0x02 }, &si); + try std.testing.expectEqualStrings("ssl/tls", si.service); + try std.testing.expect(si.tls); +} + +test "an unrecognized banner is reported as unknown with its first line" { + var si: ServiceInfo = .{}; + classify("GARBAGE PROTOCOL v3\r\nsecond line\r\n", &si); + try std.testing.expectEqualStrings("unknown", si.service); + try std.testing.expectEqualStrings("GARBAGE PROTOCOL v3", si.infoSlice()); +} + +test "a hostile banner cannot inject terminal escapes into the info field" { + var si: ServiceInfo = .{}; + classify("SSH-2.0-\x1b[31mEVIL\x1b[0m\x07\r\n", &si); + try std.testing.expectEqualStrings("ssh", si.service); + try std.testing.expect(std.mem.indexOfScalar(u8, si.infoSlice(), 0x1b) == null); + try std.testing.expect(std.mem.indexOfScalar(u8, si.infoSlice(), 0x07) == null); + try std.testing.expectEqualStrings(".[31mEVIL.[0m.", si.infoSlice()); +} + +test "the info field is length-capped even for a very long banner" { + var si: ServiceInfo = .{}; + var big: [400]u8 = undefined; + @memcpy(big[0..4], "+OK "); + @memset(big[4..], 'A'); + classify(&big, &si); + try std.testing.expectEqualStrings("pop3", si.service); + try std.testing.expect(si.info_len <= max_info); +} + +test "empty input yields the inert default" { + var si: ServiceInfo = .{}; + classify("", &si); + try std.testing.expectEqualStrings("unknown", si.service); + try std.testing.expectEqual(@as(usize, 0), si.info_len); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/regex.zig b/PROJECTS/advanced/zig-stateless-scanner/src/regex.zig new file mode 100644 index 00000000..20f5a3b3 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/regex.zig @@ -0,0 +1,695 @@ +// ©AngelaMos | 2026 +// regex.zig + +const std = @import("std"); + +pub const max_insts: usize = 160; +pub const max_nodes: usize = 160; +pub const max_classes: usize = 16; +pub const max_caps: usize = 8; +pub const max_repeat: usize = 512; +pub const default_budget: u32 = 200_000; +pub const max_depth: usize = 8192; + +const cap_slots: usize = (max_caps + 1) * 2; + +pub const CompileError = error{ + ProgramTooBig, + TooManyNodes, + TooManyClasses, + TooManyCaptures, + UnbalancedParen, + UnbalancedClass, + BadQuantifier, + QuantifierOnGroup, + TrailingBackslash, + EmptyRepeat, +}; + +const Op = enum { char, any, class, match, jmp, split, save, bol, eol }; + +const Inst = struct { + op: Op, + a: u32 = 0, + b: u32 = 0, +}; + +const Class = struct { + neg: bool = false, + bits: [32]u8 = [_]u8{0} ** 32, + + fn set(self: *Class, byte: u8) void { + self.bits[byte >> 3] |= (@as(u8, 1) << @intCast(byte & 7)); + } + + fn setRange(self: *Class, lo: u8, hi: u8) void { + var b: usize = lo; + while (b <= hi) : (b += 1) self.set(@intCast(b)); + if (hi == 255) self.set(255); + } + + fn has(self: *const Class, byte: u8) bool { + const in = (self.bits[byte >> 3] & (@as(u8, 1) << @intCast(byte & 7))) != 0; + return in != self.neg; + } +}; + +const NodeKind = enum { empty, char, any, class, bol, eol, concat, alt, star, plus, quest, repeat, group }; + +const Node = struct { + kind: NodeKind, + ch: u8 = 0, + class_idx: u16 = 0, + left: u16 = 0, + right: u16 = 0, + greedy: bool = true, + min: u16 = 0, + max: u16 = 0, + cap: i16 = -1, +}; + +const unbounded: u16 = 0xffff; + +pub const Captures = struct { + slots: [cap_slots]u32 = [_]u32{unset} ** cap_slots, + + const unset: u32 = 0xffff_ffff; + + pub fn group(self: *const Captures, input: []const u8, idx: usize) ?[]const u8 { + const s = idx * 2; + if (s + 1 >= cap_slots) return null; + const a = self.slots[s]; + const b = self.slots[s + 1]; + if (a == unset or b == unset or b < a or b > input.len) return null; + return input[a..b]; + } +}; + +pub const Regex = struct { + insts: [max_insts]Inst = [_]Inst{.{ .op = .match }} ** max_insts, + n_insts: usize = 0, + classes: [max_classes]Class = [_]Class{.{}} ** max_classes, + n_classes: usize = 0, + n_caps: usize = 0, + fold: bool = false, + dotall: bool = false, + + pub const Flags = struct { + fold: bool = false, + dotall: bool = false, + }; + + pub fn compile(pattern: []const u8, flags: Flags) CompileError!Regex { + var b = Builder{ + .pat = pattern, + .fold = flags.fold, + }; + var re: Regex = .{ .fold = flags.fold, .dotall = flags.dotall }; + const root = try b.parseAlt(&re); + if (b.pos != pattern.len) return error.UnbalancedParen; + try b.emit(&re, root); + try re.push(.{ .op = .match }); + re.n_caps = b.n_caps; + return re; + } + + fn push(self: *Regex, inst: Inst) CompileError!void { + if (self.n_insts >= max_insts) return error.ProgramTooBig; + self.insts[self.n_insts] = inst; + self.n_insts += 1; + } + + fn addClass(self: *Regex, cls: Class) CompileError!u16 { + if (self.n_classes >= max_classes) return error.TooManyClasses; + const idx = self.n_classes; + self.classes[idx] = cls; + self.n_classes += 1; + return @intCast(idx); + } + + fn foldByte(self: *const Regex, c: u8) u8 { + return if (self.fold) std.ascii.toLower(c) else c; + } + + fn step(self: *const Regex, input: []const u8, pc0: usize, sp0: usize, caps: *Captures, budget: *u32, depth: usize) ?usize { + if (depth > max_depth) return null; + var pc = pc0; + var sp = sp0; + while (true) { + if (budget.* == 0) return null; + budget.* -= 1; + const in = self.insts[pc]; + switch (in.op) { + .char => { + if (sp < input.len and self.foldByte(input[sp]) == @as(u8, @intCast(in.a))) { + pc += 1; + sp += 1; + } else return null; + }, + .any => { + if (sp < input.len and (self.dotall or input[sp] != '\n')) { + pc += 1; + sp += 1; + } else return null; + }, + .class => { + if (sp < input.len and self.classes[in.a].has(input[sp])) { + pc += 1; + sp += 1; + } else return null; + }, + .bol => { + if (sp == 0) { + pc += 1; + } else return null; + }, + .eol => { + if (sp == input.len) { + pc += 1; + } else return null; + }, + .save => { + if (in.a < cap_slots) caps.slots[in.a] = @intCast(sp); + pc += 1; + }, + .jmp => pc = in.a, + .split => { + const snap = caps.*; + if (self.step(input, in.a, sp, caps, budget, depth + 1)) |e| return e; + caps.* = snap; + pc = in.b; + }, + .match => return sp, + } + } + } + + pub fn search(self: *const Regex, input: []const u8, caps: *Captures) ?usize { + var budget: u32 = default_budget; + var start: usize = 0; + while (start <= input.len) : (start += 1) { + caps.* = .{}; + if (self.step(input, 0, start, caps, &budget, 0)) |end| { + caps.slots[0] = @intCast(start); + caps.slots[1] = @intCast(end); + return end; + } + if (budget == 0) return null; + } + return null; + } +}; + +const Builder = struct { + pat: []const u8, + pos: usize = 0, + fold: bool, + n_nodes: u16 = 0, + nodes: [max_nodes]Node = undefined, + n_caps: usize = 0, + + fn peek(self: *const Builder) ?u8 { + return if (self.pos < self.pat.len) self.pat[self.pos] else null; + } + + fn add(self: *Builder, node: Node) CompileError!u16 { + if (self.n_nodes >= max_nodes) return error.TooManyNodes; + const idx = self.n_nodes; + self.nodes[idx] = node; + self.n_nodes += 1; + return idx; + } + + fn parseAlt(self: *Builder, re: *Regex) CompileError!u16 { + var left = try self.parseConcat(re); + while (self.peek() == @as(u8, '|')) { + self.pos += 1; + const right = try self.parseConcat(re); + left = try self.add(.{ .kind = .alt, .left = left, .right = right }); + } + return left; + } + + fn parseConcat(self: *Builder, re: *Regex) CompileError!u16 { + var acc: ?u16 = null; + while (self.peek()) |c| { + if (c == '|' or c == ')') break; + const piece = try self.parsePiece(re); + acc = if (acc) |a| try self.add(.{ .kind = .concat, .left = a, .right = piece }) else piece; + } + return acc orelse try self.add(.{ .kind = .empty }); + } + + fn parsePiece(self: *Builder, re: *Regex) CompileError!u16 { + const atom = try self.parseAtom(re); + const c = self.peek() orelse return atom; + switch (c) { + '*', '+', '?' => { + self.pos += 1; + const greedy = !(self.peek() == @as(u8, '?')); + if (!greedy) self.pos += 1; + const kind: NodeKind = switch (c) { + '*' => .star, + '+' => .plus, + else => .quest, + }; + return self.add(.{ .kind = kind, .left = atom, .greedy = greedy }); + }, + '{' => return self.parseRepeat(atom), + else => return atom, + } + } + + fn parseRepeat(self: *Builder, atom: u16) CompileError!u16 { + if (self.nodes[atom].kind == .group or self.nodes[atom].kind == .alt or self.nodes[atom].kind == .concat) + return error.QuantifierOnGroup; + self.pos += 1; + const min = try self.readNumber(); + var max: u16 = min; + if (self.peek() == @as(u8, ',')) { + self.pos += 1; + if (self.peek() == @as(u8, '}')) { + max = unbounded; + } else { + max = try self.readNumber(); + } + } + if (self.peek() != @as(u8, '}')) return error.BadQuantifier; + self.pos += 1; + if (max != unbounded and max < min) return error.BadQuantifier; + if (max != unbounded and max > max_repeat) return error.BadQuantifier; + const greedy = !(self.peek() == @as(u8, '?')); + if (!greedy) self.pos += 1; + return self.add(.{ .kind = .repeat, .left = atom, .min = min, .max = max, .greedy = greedy }); + } + + fn readNumber(self: *Builder) CompileError!u16 { + var n: u32 = 0; + var seen = false; + while (self.peek()) |c| { + if (c < '0' or c > '9') break; + n = n * 10 + (c - '0'); + if (n > max_repeat) n = max_repeat + 1; + self.pos += 1; + seen = true; + } + if (!seen) return error.BadQuantifier; + return @intCast(@min(n, @as(u32, unbounded))); + } + + fn parseAtom(self: *Builder, re: *Regex) CompileError!u16 { + const c = self.peek() orelse return self.add(.{ .kind = .empty }); + switch (c) { + '(' => { + self.pos += 1; + var cap: i16 = -1; + if (self.pos + 1 < self.pat.len and self.pat[self.pos] == '?' and self.pat[self.pos + 1] == ':') { + self.pos += 2; + } else { + self.n_caps += 1; + if (self.n_caps > max_caps) return error.TooManyCaptures; + cap = @intCast(self.n_caps); + } + const inner = try self.parseAlt(re); + if (self.peek() != @as(u8, ')')) return error.UnbalancedParen; + self.pos += 1; + return self.add(.{ .kind = .group, .left = inner, .cap = cap }); + }, + '[' => return self.parseClass(re), + '.' => { + self.pos += 1; + return self.add(.{ .kind = .any }); + }, + '^' => { + self.pos += 1; + return self.add(.{ .kind = .bol }); + }, + '$' => { + self.pos += 1; + return self.add(.{ .kind = .eol }); + }, + '\\' => return self.parseEscape(re), + else => { + self.pos += 1; + return self.add(.{ .kind = .char, .ch = c }); + }, + } + } + + fn parseEscape(self: *Builder, re: *Regex) CompileError!u16 { + self.pos += 1; + const c = self.peek() orelse return error.TrailingBackslash; + self.pos += 1; + switch (c) { + 'd', 'D', 'w', 'W', 's', 'S' => { + const ci = try self.addShorthand(re, c); + return self.add(.{ .kind = .class, .class_idx = ci }); + }, + 'n' => return self.add(.{ .kind = .char, .ch = '\n' }), + 'r' => return self.add(.{ .kind = .char, .ch = '\r' }), + 't' => return self.add(.{ .kind = .char, .ch = '\t' }), + '0' => return self.add(.{ .kind = .char, .ch = 0 }), + else => return self.add(.{ .kind = .char, .ch = c }), + } + } + + fn addShorthand(self: *Builder, re: *Regex, c: u8) CompileError!u16 { + _ = self; + var cls = Class{}; + fillShorthand(&cls, c); + return re.addClass(cls); + } + + fn parseClass(self: *Builder, re: *Regex) CompileError!u16 { + self.pos += 1; + var cls = Class{}; + if (self.peek() == @as(u8, '^')) { + cls.neg = true; + self.pos += 1; + } + var first = true; + while (true) { + const c = self.peek() orelse return error.UnbalancedClass; + if (c == ']' and !first) { + self.pos += 1; + break; + } + first = false; + if (c == '\\') { + self.pos += 1; + const e = self.peek() orelse return error.TrailingBackslash; + self.pos += 1; + switch (e) { + 'd', 'D', 'w', 'W', 's', 'S' => fillShorthand(&cls, e), + 'n' => self.classSet(&cls, '\n'), + 'r' => self.classSet(&cls, '\r'), + 't' => self.classSet(&cls, '\t'), + else => self.classSet(&cls, e), + } + continue; + } + self.pos += 1; + if (self.peek() == @as(u8, '-') and self.pos + 1 < self.pat.len and self.pat[self.pos + 1] != ']') { + self.pos += 1; + const hi = self.peek().?; + self.pos += 1; + const lo_c = c; + if (hi < lo_c) return error.UnbalancedClass; + cls.setRange(lo_c, hi); + if (self.fold) { + self.foldRange(&cls, lo_c, hi); + } + } else { + self.classSet(&cls, c); + } + } + const ci = try re.addClass(cls); + return self.add(.{ .kind = .class, .class_idx = ci }); + } + + fn classSet(self: *Builder, cls: *Class, c: u8) void { + cls.set(c); + if (self.fold) { + cls.set(std.ascii.toLower(c)); + cls.set(std.ascii.toUpper(c)); + } + } + + fn foldRange(self: *Builder, cls: *Class, lo: u8, hi: u8) void { + _ = self; + var b: usize = lo; + while (b <= hi) : (b += 1) { + const ch: u8 = @intCast(b); + cls.set(std.ascii.toLower(ch)); + cls.set(std.ascii.toUpper(ch)); + } + } + + fn emit(self: *Builder, re: *Regex, node_idx: u16) CompileError!void { + const node = self.nodes[node_idx]; + switch (node.kind) { + .empty => {}, + .char => { + const ch = if (re.fold) std.ascii.toLower(node.ch) else node.ch; + try re.push(.{ .op = .char, .a = ch }); + }, + .any => try re.push(.{ .op = .any }), + .class => try re.push(.{ .op = .class, .a = node.class_idx }), + .bol => try re.push(.{ .op = .bol }), + .eol => try re.push(.{ .op = .eol }), + .concat => { + try self.emit(re, node.left); + try self.emit(re, node.right); + }, + .alt => { + const isplit = re.n_insts; + try re.push(.{ .op = .split }); + const l1 = re.n_insts; + try self.emit(re, node.left); + const ijmp = re.n_insts; + try re.push(.{ .op = .jmp }); + const l2 = re.n_insts; + try self.emit(re, node.right); + const end = re.n_insts; + re.insts[isplit].a = @intCast(l1); + re.insts[isplit].b = @intCast(l2); + re.insts[ijmp].a = @intCast(end); + }, + .group => { + if (node.cap >= 0) { + const c: usize = @intCast(node.cap); + try re.push(.{ .op = .save, .a = @intCast(c * 2) }); + try self.emit(re, node.left); + try re.push(.{ .op = .save, .a = @intCast(c * 2 + 1) }); + } else { + try self.emit(re, node.left); + } + }, + .quest => try self.emitQuest(re, node.left, node.greedy), + .star => try self.emitStar(re, node.left, node.greedy), + .plus => try self.emitPlus(re, node.left, node.greedy), + .repeat => try self.emitRepeat(re, node.left, node.min, node.max, node.greedy), + } + } + + fn emitQuest(self: *Builder, re: *Regex, child: u16, greedy: bool) CompileError!void { + const isplit = re.n_insts; + try re.push(.{ .op = .split }); + const l1 = re.n_insts; + try self.emit(re, child); + const end = re.n_insts; + setSplit(re, isplit, l1, end, greedy); + } + + fn emitStar(self: *Builder, re: *Regex, child: u16, greedy: bool) CompileError!void { + const isplit = re.n_insts; + try re.push(.{ .op = .split }); + const l1 = re.n_insts; + try self.emit(re, child); + try re.push(.{ .op = .jmp, .a = @intCast(isplit) }); + const end = re.n_insts; + setSplit(re, isplit, l1, end, greedy); + } + + fn emitPlus(self: *Builder, re: *Regex, child: u16, greedy: bool) CompileError!void { + const l1 = re.n_insts; + try self.emit(re, child); + const isplit = re.n_insts; + try re.push(.{ .op = .split }); + const end = re.n_insts; + setSplit(re, isplit, l1, end, greedy); + } + + fn emitRepeat(self: *Builder, re: *Regex, child: u16, min: u16, max: u16, greedy: bool) CompileError!void { + var k: usize = 0; + while (k < min) : (k += 1) try self.emit(re, child); + if (max == unbounded) { + try self.emitStar(re, child, greedy); + return; + } + const opt = max - min; + var splits: [max_repeat]usize = undefined; + var i: usize = 0; + while (i < opt) : (i += 1) { + splits[i] = re.n_insts; + try re.push(.{ .op = .split }); + try self.emit(re, child); + } + const end = re.n_insts; + i = 0; + while (i < opt) : (i += 1) setSplit(re, splits[i], splits[i] + 1, end, greedy); + } +}; + +fn setSplit(re: *Regex, idx: usize, body: usize, exit: usize, greedy: bool) void { + if (greedy) { + re.insts[idx].a = @intCast(body); + re.insts[idx].b = @intCast(exit); + } else { + re.insts[idx].a = @intCast(exit); + re.insts[idx].b = @intCast(body); + } +} + +fn isDigit(b: u8) bool { + return b >= '0' and b <= '9'; +} + +fn isWord(b: u8) bool { + return (b >= '0' and b <= '9') or (b >= 'a' and b <= 'z') or (b >= 'A' and b <= 'Z') or b == '_'; +} + +fn isSpace(b: u8) bool { + return b == ' ' or b == '\t' or b == '\n' or b == '\r' or b == 0x0c or b == 0x0b; +} + +fn fillShorthand(cls: *Class, c: u8) void { + var b: usize = 0; + while (b <= 255) : (b += 1) { + const ch: u8 = @intCast(b); + const in = switch (c) { + 'd' => isDigit(ch), + 'D' => !isDigit(ch), + 'w' => isWord(ch), + 'W' => !isWord(ch), + 's' => isSpace(ch), + 'S' => !isSpace(ch), + else => false, + }; + if (in) cls.set(ch); + if (b == 255) break; + } +} + +// ---- tests ---- + +fn expectMatch(pattern: []const u8, input: []const u8) !void { + const re = try Regex.compile(pattern, .{}); + var caps: Captures = .{}; + try std.testing.expect(re.search(input, &caps) != null); +} + +fn expectNoMatch(pattern: []const u8, input: []const u8) !void { + const re = try Regex.compile(pattern, .{}); + var caps: Captures = .{}; + try std.testing.expect(re.search(input, &caps) == null); +} + +test "literal, dot, and anchors" { + try expectMatch("abc", "xxabcyy"); + try expectNoMatch("abc", "abx"); + try expectMatch("a.c", "azc"); + try expectNoMatch("a.c", "a\nc"); + try expectMatch("^abc", "abcdef"); + try expectNoMatch("^abc", "zabc"); + try expectMatch("abc$", "zzabc"); + try expectNoMatch("abc$", "abcz"); +} + +test "star, plus, quest greedy" { + try expectMatch("ab*c", "ac"); + try expectMatch("ab*c", "abbbbc"); + try expectMatch("ab+c", "abc"); + try expectNoMatch("ab+c", "ac"); + try expectMatch("ab?c", "ac"); + try expectMatch("ab?c", "abc"); + try expectNoMatch("ab?c", "abbc"); +} + +test "character classes with ranges, negation, and shorthands" { + try expectMatch("[a-f]+", "0deadbeef1"); + try expectMatch("[^0-9]", "a"); + try expectNoMatch("^[^0-9]$", "5"); + try expectMatch("\\d+", "port 8080 open"); + try expectMatch("^\\w+$", "host_name99"); + try expectNoMatch("^\\w+$", "has space"); + try expectMatch("a\\s+b", "a \t b"); +} + +test "alternation and grouping" { + try expectMatch("cat|dog|bird", "i have a dog"); + try expectNoMatch("^(cat|dog)$", "fish"); + try expectMatch("^(ab)+$", "ababab"); + try expectNoMatch("^(ab)+$", "aba"); +} + +test "bounded repeat {n}, {n,}, {n,m}" { + try expectMatch("^a{3}$", "aaa"); + try expectNoMatch("^a{3}$", "aa"); + try expectMatch("^a{2,}$", "aaaaa"); + try expectNoMatch("^a{2,}$", "a"); + try expectMatch("^a{2,4}$", "aaa"); + try expectNoMatch("^a{2,4}$", "aaaaa"); + try expectMatch("^\\d{1,3}\\.\\d{1,3}$", "10.255"); +} + +test "captures extract submatches" { + const re = try Regex.compile("^SSH-([\\d.]+)-(\\S+)", .{}); + var caps: Captures = .{}; + const banner = "SSH-2.0-OpenSSH_9.6p1\r\n"; + try std.testing.expect(re.search(banner, &caps) != null); + try std.testing.expectEqualStrings("2.0", caps.group(banner, 1).?); + try std.testing.expectEqualStrings("OpenSSH_9.6p1", caps.group(banner, 2).?); +} + +test "case-insensitive flag folds literals and ranges" { + const re = try Regex.compile("server: ([a-z]+)", .{ .fold = true }); + var caps: Captures = .{}; + const hdr = "Server: Nginx"; + try std.testing.expect(re.search(hdr, &caps) != null); + try std.testing.expectEqualStrings("Nginx", caps.group(hdr, 1).?); +} + +test "dotall flag lets dot cross newlines" { + var caps: Captures = .{}; + const plain = try Regex.compile("a.b", .{}); + try std.testing.expect(plain.search("a\nb", &caps) == null); + const dotall = try Regex.compile("a.b", .{ .dotall = true }); + try std.testing.expect(dotall.search("a\nb", &caps) != null); +} + +test "lazy quantifier stops at the first opportunity" { + const re = try Regex.compile("<(.+?)>", .{}); + var caps: Captures = .{}; + const s = ""; + try std.testing.expect(re.search(s, &caps) != null); + try std.testing.expectEqualStrings("a", caps.group(s, 1).?); +} + +test "the step budget makes a pathological input fail closed instead of hanging" { + const re = try Regex.compile("(a+)+$", .{}); + var caps: Captures = .{}; + const evil = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX"; + try std.testing.expect(re.search(evil, &caps) == null); +} + +test "the recursion-depth cap fails closed on a match deeper than the stack allows" { + var input: [max_depth + 100]u8 = undefined; + @memset(&input, 'a'); + const re = try Regex.compile("^a*$", .{}); + var caps: Captures = .{}; + try std.testing.expect(re.search(&input, &caps) == null); + try std.testing.expect(re.search("aaaa", &caps) != null); +} + +test "escaped metacharacters are literal" { + try expectMatch("^\\d+\\.\\d+$", "3.14"); + try expectNoMatch("^\\d+\\.\\d+$", "3x14"); + try expectMatch("a\\+b", "a+b"); +} + +test "empty pattern matches, unbalanced paren rejected" { + try expectMatch("", "anything"); + try std.testing.expectError(error.UnbalancedParen, Regex.compile("(ab", .{})); + try std.testing.expectError(error.QuantifierOnGroup, Regex.compile("(ab){2}", .{})); +} + +test "compiles at comptime into an embeddable program" { + const re = comptime blk: { + @setEvalBranchQuota(20000); + break :blk Regex.compile("^220[- ]", .{}) catch unreachable; + }; + var caps: Captures = .{}; + try std.testing.expect(re.search("220 smtp.example.com ESMTP", &caps) != null); + try std.testing.expect(re.search("500 error", &caps) == null); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig b/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig index f120c86f..f507b26a 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig @@ -14,6 +14,7 @@ const dedup = @import("dedup"); const netutil = @import("netutil"); const output = @import("output"); const stealth = @import("stealth"); +const service = @import("service"); const default_iface = "lo"; const default_rate: u64 = 10_000; @@ -27,6 +28,8 @@ const default_udp_ports = [_]u16{ 53, 123, 161 }; const dedup_capacity: usize = 1024; const queue_capacity: usize = 2048; const drain_batch: usize = 256; +const finding_queue_capacity: usize = 256; +const finding_drain_batch: usize = 64; const drain_tick_ns: u64 = 50 * ns_per_ms; const render_tick_interactive_ns: u64 = 125 * ns_per_ms; const render_tick_plain_ns: u64 = 1_000 * ns_per_ms; @@ -65,6 +68,22 @@ const TxSink = struct { } }; +const FindingSink = struct { + queue: *std.Io.Queue(service.Finding), + io: std.Io, + + fn emitImpl(ctx: *anyopaque, f: service.Finding) void { + const self: *FindingSink = @ptrCast(@alignCast(ctx)); + self.queue.putOne(self.io, f) catch {}; + } + + const vtable = service.Sink.Vtable{ .emit = emitImpl }; + + pub fn sink(self: *FindingSink) service.Sink { + return .{ .ctx = self, .vtable = &vtable }; + } +}; + fn txWorkerImpl( engine: *targets.Engine, tmpl: anytype, @@ -99,6 +118,63 @@ fn rxWorkerUdp(receiver: *rx.Receiver, ck: cookie.Cookie, base: u16, span: u16, rx_done.store(true, .release); } +fn svcWorker( + engine: *service.Engine, + socket: *service.Socket, + sink: service.Sink, + tx_done: *std.atomic.Value(bool), + drain_window_ns: u64, + hard_cap_ns: u64, + svc_done: *std.atomic.Value(bool), +) void { + service.run(engine, socket, sink, tx_done, drain_window_ns, hard_cap_ns); + svc_done.store(true, .release); +} + +fn drainFindings( + io: std.Io, + queue: *std.Io.Queue(service.Finding), + buf: []service.Finding, + findings: *std.ArrayList(service.Finding), + dd: *dedup.Dedup, + allocator: std.mem.Allocator, + json_out: ?*std.Io.Writer, +) void { + while (true) { + const n = queue.get(io, buf, 0) catch return; + if (n == 0) return; + for (buf[0..n]) |f| { + if (!dd.insert(service.connKey(f.ip, f.port))) continue; + findings.append(allocator, f) catch continue; + if (json_out) |w| output.emitServiceJson(w, .{ + .ip = f.ip, + .port = f.port, + .service = f.info.service, + .info = f.info.infoSlice(), + .tls = f.info.tls, + }) catch {}; + } + if (n < buf.len) return; + } +} + +fn findingLess(_: void, a: service.Finding, b: service.Finding) bool { + if (a.ip != b.ip) return a.ip < b.ip; + return a.port < b.port; +} + +fn renderServiceTable(out: *std.Io.Writer, level: output.ColorLevel, allocator: std.mem.Allocator, items: []service.Finding) !void { + const rows = try allocator.alloc(output.ServiceRow, items.len); + for (items, 0..) |*f, i| rows[i] = .{ + .ip = f.ip, + .port = f.port, + .service = f.info.service, + .info = f.info.infoSlice(), + .tls = f.info.tls, + }; + try output.renderServices(out, level, rows); +} + fn absorb( batch: []const rx.Result, found: *std.ArrayList(rx.Result), @@ -160,6 +236,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e const wait_ms = if (netutil.getFlag(args, "--wait")) |w| try std.fmt.parseInt(i32, w, 10) else default_wait_ms; const json = netutil.hasFlag(args, "--json"); const is_udp = netutil.hasFlag(args, "--udp"); + const banners_flag = netutil.hasFlag(args, "--banners"); const backend_choice = packet_io.parseChoice(netutil.getFlag(args, "--backend")) orelse { try derr.writeAll("scan: --backend must be one of auto, xdp, afpacket\n"); try derr.flush(); @@ -204,6 +281,13 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e 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"); } + if (is_udp and banners_flag) { + try derr.writeAll(" note: --banners is a TCP feature; ignored for --udp\n"); + } + const banners = banners_flag and !is_udp and scfg.scan == .syn; + if (banners_flag and !is_udp and scfg.scan != .syn) { + try derr.writeAll(" note: --banners needs a full handshake; ignored for non-SYN scan types\n"); + } const udp_base: u16 = src_port; const udp_span: u16 = @intCast(@min(@as(u32, default_udp_src_span), 65536 - @as(u32, udp_base))); @@ -296,11 +380,13 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e } var supp: ?stealth.RstSuppressor = null; - if (scfg.suppress_rst and !is_udp) { + if ((scfg.suppress_rst or banners) 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)}); + try derr.print(" note: RST-suppression unavailable ({s})", .{@errorName(e)}); + if (banners) try derr.writeAll("; banner grabs may be unreliable (the kernel RSTs the SYN-ACK)"); + try derr.writeByte('\n'); break :blk null; }; } @@ -318,6 +404,43 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e 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; + const banner_wait_ns: u64 = service.default_banner_wait_ns; + const svc_drain_window_ns: u64 = @max(drain_window_ns, banner_wait_ns +| ns_per_sec); + const svc_hard_cap_ns: u64 = tx_budget_ns +| svc_drain_window_ns; + + var banners_active = banners; + var svc_socket: service.Socket = undefined; + var svc_socket_open = false; + var svc_engine: ?*service.Engine = null; + if (banners_active) { + if (service.Socket.open(ifname)) |s| { + svc_socket = s; + svc_socket_open = true; + errdefer svc_socket.close(); + const svc_eng = try allocator.create(service.Engine); + svc_eng.* = service.Engine.init(.{ + .cookie = ck, + .our_ip = src_ip, + .src_mac = src_mac, + .gw_mac = gw_mac, + .banner_wait_ns = banner_wait_ns, + }); + svc_engine = svc_eng; + } else |e| { + try derr.print(" note: banner engine socket unavailable ({s}); continuing port-scan only\n", .{@errorName(e)}); + banners_active = false; + } + } + defer if (svc_socket_open) svc_socket.close(); + + var fqbuf: [finding_queue_capacity]service.Finding = undefined; + var finding_queue = std.Io.Queue(service.Finding).init(&fqbuf); + var finding_sink = FindingSink{ .queue = &finding_queue, .io = io }; + var svc_done = std.atomic.Value(bool).init(true); + var findings: std.ArrayList(service.Finding) = .empty; + var fdedup = try dedup.Dedup.init(allocator, dedup_capacity); + defer fdedup.deinit(); + var receiver = rx.Receiver.open(ifname, &tx_done, drain_window_ns, hard_cap_ns) catch |err| switch (err) { error.NeedCapNetRaw => { try derr.writeAll(need_cap_hint); @@ -371,13 +494,36 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e return; }; + const SvcFuture = @typeInfo(@TypeOf(io.concurrent(svcWorker, .{ + svc_engine orelse undefined, &svc_socket, finding_sink.sink(), &tx_done, svc_drain_window_ns, svc_hard_cap_ns, &svc_done, + }))).error_union.payload; + var svc_fut: ?SvcFuture = null; + if (banners_active) { + svc_done.store(false, .release); + if (io.concurrent(svcWorker, .{ + svc_engine.?, &svc_socket, finding_sink.sink(), &tx_done, svc_drain_window_ns, svc_hard_cap_ns, &svc_done, + })) |f| { + svc_fut = f; + } else |_| { + svc_done.store(true, .release); + banners_active = false; + try derr.writeAll(" note: banner engine could not launch a worker thread; continuing port-scan only\n"); + } + } + var dash = output.Dashboard.init(err_level, interactive, dash_total); + dash.banners_mode = banners_active; const render_interval_ns: u64 = if (interactive) render_tick_interactive_ns else render_tick_plain_ns; var drain_buf: [drain_batch]rx.Result = undefined; + var finding_buf: [finding_drain_batch]service.Finding = undefined; var last_render: u64 = 0; - while (!(tx_done.load(.acquire) and rx_done.load(.acquire))) { + while (!(tx_done.load(.acquire) and rx_done.load(.acquire) and svc_done.load(.acquire))) { drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, proto_json); + if (banners_active) { + drainFindings(io, &finding_queue, finding_buf[0..], &findings, &fdedup, found_alloc, json_out); + stats.banners.v.store(svc_engine.?.banners.load(.monotonic), .monotonic); + } if (json) out.flush() catch {}; const now = clock.now(); if (last_render == 0 or now -| last_render >= render_interval_ns) { @@ -389,8 +535,14 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e const sent = tx_fut.await(io); rx_fut.await(io); + if (svc_fut) |*f| f.await(io); queue.close(io); drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, proto_json); + if (banners_active) { + finding_queue.close(io); + drainFindings(io, &finding_queue, finding_buf[0..], &findings, &fdedup, found_alloc, json_out); + stats.banners.v.store(svc_engine.?.banners.load(.monotonic), .monotonic); + } if (json) out.flush() catch {}; dash.render(derr, &stats, clock.now() -| t0) catch {}; @@ -415,11 +567,21 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e } else { try derr.writeAll(" no open, closed, or filtered responses observed\n"); } + if (findings.items.len > 0) { + std.mem.sort(service.Finding, findings.items, {}, findingLess); + try out.writeByte('\n'); + try renderServiceTable(out, out_level, found_alloc, findings.items); + try out.flush(); + } } 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, unfiltered_n); + try output.renderSummary(derr, err_level, sent, probe_label, ifname, elapsed_s, open_n, closed_n, filtered_n, unfiltered_n, stats.banners.v.load(.monotonic)); + if (banners_active) { + const drops = svc_engine.?.drops.load(.monotonic); + if (drops > 0) try derr.print(" note: {d} banner connection(s) dropped under backpressure (conn-table full at {d})\n", .{ drops, service.default_capacity }); + } if (is_udp) { const answered = open_n + closed_n + filtered_n + unfiltered_n; try output.renderUnanswered(derr, err_level, sent -| answered); diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/segment.zig b/PROJECTS/advanced/zig-stateless-scanner/src/segment.zig new file mode 100644 index 00000000..72a4eff2 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/segment.zig @@ -0,0 +1,183 @@ +// ©AngelaMos | 2026 +// segment.zig + +const std = @import("std"); +const packet = @import("packet"); + +const eth_len: usize = 14; +const ip_len: usize = 20; +const tcp_len: usize = 20; +const l4_off: usize = eth_len + ip_len; +const payload_off: usize = l4_off + tcp_len; + +const ip_checksum_off: usize = eth_len + 10; +const tcp_checksum_off: usize = l4_off + 16; + +const ethertype_ipv4: u16 = 0x0800; +const ipv4_version_ihl: u8 = 0x45; +const ip_proto_tcp: u8 = 6; +const ip_flag_dont_fragment: u16 = 0x4000; +const default_ttl: u8 = 64; +const tcp_data_off_5words: u8 = 0x50; +const default_window: u16 = 65535; + +pub const max_payload: usize = 128; +pub const max_len: usize = payload_off + max_payload; + +pub const Params = struct { + src_mac: [6]u8, + dst_mac: [6]u8, + src_ip: u32, + dst_ip: u32, + src_port: u16, + dst_port: u16, + seq: u32, + ack: u32, + flags: u8, + window: u16 = default_window, + payload: []const u8 = &.{}, +}; + +pub fn build(out: *[max_len]u8, p: Params) usize { + std.debug.assert(p.payload.len <= max_payload); + const total = payload_off + p.payload.len; + + const eth = packet.EthHdr{ + .dst = p.dst_mac, + .src = p.src_mac, + .ethertype = std.mem.nativeToBig(u16, ethertype_ipv4), + }; + @memcpy(out[0..eth_len], std.mem.asBytes(ð)); + + const ip = packet.Ipv4Hdr{ + .version_ihl = ipv4_version_ihl, + .tos = 0, + .total_len = std.mem.nativeToBig(u16, @intCast(ip_len + tcp_len + p.payload.len)), + .id = 0, + .flags_frag = std.mem.nativeToBig(u16, ip_flag_dont_fragment), + .ttl = default_ttl, + .protocol = ip_proto_tcp, + .checksum = 0, + .src = std.mem.nativeToBig(u32, p.src_ip), + .dst = std.mem.nativeToBig(u32, p.dst_ip), + }; + @memcpy(out[eth_len..l4_off], std.mem.asBytes(&ip)); + const ip_ck = packet.checksum(out[eth_len..l4_off]); + std.mem.writeInt(u16, out[ip_checksum_off..][0..2], ip_ck, .big); + + const tcp = packet.TcpHdr{ + .src_port = std.mem.nativeToBig(u16, p.src_port), + .dst_port = std.mem.nativeToBig(u16, p.dst_port), + .seq = std.mem.nativeToBig(u32, p.seq), + .ack = std.mem.nativeToBig(u32, p.ack), + .data_off_ns = tcp_data_off_5words, + .flags = p.flags, + .window = std.mem.nativeToBig(u16, p.window), + .checksum = 0, + .urgent = 0, + }; + @memcpy(out[l4_off..payload_off], std.mem.asBytes(&tcp)); + if (p.payload.len > 0) @memcpy(out[payload_off..total], p.payload); + + const src_be = std.mem.nativeToBig(u32, p.src_ip); + const dst_be = std.mem.nativeToBig(u32, p.dst_ip); + const tcp_ck = packet.tcpChecksum(src_be, dst_be, out[l4_off..total]); + std.mem.writeInt(u16, out[tcp_checksum_off..][0..2], tcp_ck, .big); + + return total; +} + +const test_src_mac = [6]u8{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 }; +const test_dst_mac = [6]u8{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x02 }; +const test_src_ip: u32 = 0x0a000001; +const test_dst_ip: u32 = 0x08080808; + +fn selfVerifies(out: *[max_len]u8, n: usize) !void { + try std.testing.expectEqual(@as(u16, 0), packet.checksum(out[eth_len..l4_off])); + const src_be = std.mem.nativeToBig(u32, test_src_ip); + const dst_be = std.mem.nativeToBig(u32, test_dst_ip); + try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum(src_be, dst_be, out[l4_off..n])); +} + +test "a bare ACK is 54 bytes with self-verifying IP and TCP checksums" { + var out: [max_len]u8 = undefined; + const n = build(&out, .{ + .src_mac = test_src_mac, + .dst_mac = test_dst_mac, + .src_ip = test_src_ip, + .dst_ip = test_dst_ip, + .src_port = 40000, + .dst_port = 22, + .seq = 0x1000_0001, + .ack = 0xCAFE_BABF, + .flags = packet.TcpFlag.ack, + }); + try std.testing.expectEqual(@as(usize, 54), n); + try selfVerifies(&out, n); +} + +test "seq, ack, ports, and flags land at the wire offsets" { + var out: [max_len]u8 = undefined; + const n = build(&out, .{ + .src_mac = test_src_mac, + .dst_mac = test_dst_mac, + .src_ip = test_src_ip, + .dst_ip = test_dst_ip, + .src_port = 40001, + .dst_port = 443, + .seq = 0xDEAD_BEEF, + .ack = 0x0102_0304, + .flags = packet.TcpFlag.rst | packet.TcpFlag.ack, + }); + try std.testing.expectEqual(@as(u16, 40001), std.mem.readInt(u16, out[l4_off..][0..2], .big)); + try std.testing.expectEqual(@as(u16, 443), std.mem.readInt(u16, out[l4_off + 2 ..][0..2], .big)); + try std.testing.expectEqual(@as(u32, 0xDEAD_BEEF), std.mem.readInt(u32, out[l4_off + 4 ..][0..4], .big)); + try std.testing.expectEqual(@as(u32, 0x0102_0304), std.mem.readInt(u32, out[l4_off + 8 ..][0..4], .big)); + try std.testing.expectEqual(@as(u8, 0x50), out[l4_off + 12]); + try std.testing.expectEqual(packet.TcpFlag.rst | packet.TcpFlag.ack, out[l4_off + 13]); + try selfVerifies(&out, n); +} + +test "a PSH-ACK with a probe payload carries the bytes and self-verifies" { + const probe = "GET / HTTP/1.0\r\n\r\n"; + var out: [max_len]u8 = undefined; + const n = build(&out, .{ + .src_mac = test_src_mac, + .dst_mac = test_dst_mac, + .src_ip = test_src_ip, + .dst_ip = test_dst_ip, + .src_port = 40002, + .dst_port = 80, + .seq = 0x2000_0002, + .ack = 0x3000_0003, + .flags = packet.TcpFlag.psh | packet.TcpFlag.ack, + .payload = probe, + }); + try std.testing.expectEqual(@as(usize, 54 + probe.len), n); + try std.testing.expectEqualSlices(u8, probe, out[payload_off..n]); + try std.testing.expectEqual(@as(u16, @intCast(ip_len + tcp_len + probe.len)), std.mem.readInt(u16, out[eth_len + 2 ..][0..2], .big)); + try selfVerifies(&out, n); +} + +test "the ethernet header carries both MACs and the IPv4 ethertype" { + var out: [max_len]u8 = undefined; + _ = build(&out, .{ + .src_mac = test_src_mac, + .dst_mac = test_dst_mac, + .src_ip = test_src_ip, + .dst_ip = test_dst_ip, + .src_port = 40000, + .dst_port = 22, + .seq = 1, + .ack = 2, + .flags = packet.TcpFlag.ack, + }); + try std.testing.expectEqualSlices(u8, &test_dst_mac, out[0..6]); + try std.testing.expectEqualSlices(u8, &test_src_mac, out[6..12]); + try std.testing.expectEqual(@as(u16, 0x0800), std.mem.readInt(u16, out[12..14], .big)); +} + +comptime { + std.debug.assert(payload_off == 54); + std.debug.assert(max_len == 54 + max_payload); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/service.zig b/PROJECTS/advanced/zig-stateless-scanner/src/service.zig new file mode 100644 index 00000000..24e6ff68 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/service.zig @@ -0,0 +1,775 @@ +// ©AngelaMos | 2026 +// service.zig + +const std = @import("std"); +const linux = std.os.linux; +const packet = @import("packet"); +const cookie = @import("cookie"); +const segment = @import("segment"); +const probe = @import("probe"); + +pub const default_capacity: u16 = 8192; +pub const default_banner_wait_ns: u64 = 4 * std.time.ns_per_s; + +const ETH_HDR_LEN: usize = 14; +const ETH_OFF_TYPE: usize = 12; +const ETHERTYPE_IPV4: u16 = 0x0800; +const IP_MIN_IHL: usize = 20; +const IP_IHL_MASK: u8 = 0x0f; +const IP_OFF_TOTAL_LEN: usize = 2; +const IP_OFF_PROTO: usize = 9; +const IP_OFF_SRC: usize = 12; +const IP_OFF_DST: usize = 16; +const IPPROTO_TCP: u8 = 6; + +const TCP_OFF_SPORT: usize = 0; +const TCP_OFF_DPORT: usize = 2; +const TCP_OFF_SEQ: usize = 4; +const TCP_OFF_ACK: usize = 8; +const TCP_OFF_DATAOFF: usize = 12; +const TCP_OFF_FLAGS: usize = 13; +const TCP_MIN_LEN: usize = 20; + +fn ihlBytes(first_byte: u8) usize { + return @as(usize, first_byte & IP_IHL_MASK) * 4; +} + +pub const View = struct { + ip_src: u32, + ip_dst: u32, + sport: u16, + dport: u16, + seq: u32, + ack: u32, + flags: u8, + payload: []const u8, +}; + +pub fn parse(frame: []const u8) ?View { + if (frame.len < ETH_HDR_LEN + IP_MIN_IHL) return null; + if (std.mem.readInt(u16, frame[ETH_OFF_TYPE..][0..2], .big) != ETHERTYPE_IPV4) return null; + + const ip = ETH_HDR_LEN; + const ihl = ihlBytes(frame[ip]); + if (ihl < IP_MIN_IHL or frame.len < ip + ihl) return null; + if (frame[ip + IP_OFF_PROTO] != IPPROTO_TCP) return null; + + const total_len = std.mem.readInt(u16, frame[ip + IP_OFF_TOTAL_LEN ..][0..2], .big); + const ip_src = std.mem.readInt(u32, frame[ip + IP_OFF_SRC ..][0..4], .big); + const ip_dst = std.mem.readInt(u32, frame[ip + IP_OFF_DST ..][0..4], .big); + + const tcp = ip + ihl; + if (frame.len < tcp + TCP_MIN_LEN) return null; + const data_off = ihlBytes(frame[tcp + TCP_OFF_DATAOFF] >> 4); + if (data_off < TCP_MIN_LEN) return null; + + const payload_start = tcp + data_off; + const ip_end = @min(ip + @as(usize, total_len), frame.len); + const payload: []const u8 = if (payload_start < ip_end and payload_start <= frame.len) + frame[payload_start..@min(ip_end, frame.len)] + else + &.{}; + + return .{ + .ip_src = ip_src, + .ip_dst = ip_dst, + .sport = std.mem.readInt(u16, frame[tcp + TCP_OFF_SPORT ..][0..2], .big), + .dport = std.mem.readInt(u16, frame[tcp + TCP_OFF_DPORT ..][0..2], .big), + .seq = std.mem.readInt(u32, frame[tcp + TCP_OFF_SEQ ..][0..4], .big), + .ack = std.mem.readInt(u32, frame[tcp + TCP_OFF_ACK ..][0..4], .big), + .flags = frame[tcp + TCP_OFF_FLAGS], + .payload = payload, + }; +} + +pub fn connKey(ip: u32, port: u16) u64 { + return (@as(u64, ip) << 16) | port; +} + +fn keyIp(key: u64) u32 { + return @intCast(key >> 16); +} + +fn keyPort(key: u64) u16 { + return @truncate(key); +} + +const nil: u16 = 0xffff; + +pub fn ConnTable(comptime cap: u16) type { + std.debug.assert(cap < nil); + return struct { + const Self = @This(); + + const Node = struct { + key: u64 = 0, + our_port: u16 = 0, + server_isn: u32 = 0, + deadline: u64 = 0, + next: u16 = nil, + }; + + buckets: [cap]u16 = [_]u16{nil} ** cap, + pool: [cap]Node = [_]Node{.{}} ** cap, + free_head: u16, + count: u16 = 0, + seed: u64 = 0, + + pub fn init(seed: u64) Self { + var self = Self{ .free_head = 0, .seed = seed }; + var i: u16 = 0; + while (i < cap) : (i += 1) { + self.pool[i].next = if (i + 1 < cap) i + 1 else nil; + } + return self; + } + + fn bucketOf(self: *const Self, key: u64) usize { + const mixed = (key ^ self.seed ^ (key >> 17)) *% 0x9e3779b97f4a7c15; + return @intCast((mixed >> 40) % cap); + } + + pub fn get(self: *Self, key: u64) ?*Node { + var idx = self.buckets[self.bucketOf(key)]; + while (idx != nil) : (idx = self.pool[idx].next) { + if (self.pool[idx].key == key) return &self.pool[idx]; + } + return null; + } + + pub fn insert(self: *Self, key: u64, our_port: u16, server_isn: u32, deadline: u64) bool { + if (self.get(key)) |node| { + node.our_port = our_port; + node.server_isn = server_isn; + node.deadline = deadline; + return true; + } + if (self.free_head == nil) return false; + const idx = self.free_head; + self.free_head = self.pool[idx].next; + const b = self.bucketOf(key); + self.pool[idx] = .{ .key = key, .our_port = our_port, .server_isn = server_isn, .deadline = deadline, .next = self.buckets[b] }; + self.buckets[b] = idx; + self.count += 1; + return true; + } + + pub fn remove(self: *Self, key: u64) bool { + const b = self.bucketOf(key); + var idx = self.buckets[b]; + var prev: u16 = nil; + while (idx != nil) { + if (self.pool[idx].key == key) { + if (prev == nil) self.buckets[b] = self.pool[idx].next else self.pool[prev].next = self.pool[idx].next; + self.pool[idx].next = self.free_head; + self.free_head = idx; + self.count -= 1; + return true; + } + prev = idx; + idx = self.pool[idx].next; + } + return false; + } + + pub fn sweepExpired(self: *Self, now: u64, ctx: anytype) void { + var b: usize = 0; + while (b < cap) : (b += 1) { + var idx = self.buckets[b]; + var prev: u16 = nil; + while (idx != nil) { + const nx = self.pool[idx].next; + if (self.pool[idx].deadline <= now) { + ctx.onExpired(self.pool[idx].key, self.pool[idx].our_port); + if (prev == nil) self.buckets[b] = nx else self.pool[prev].next = nx; + self.pool[idx].next = self.free_head; + self.free_head = idx; + self.count -= 1; + } else { + prev = idx; + } + idx = nx; + } + } + } + }; +} + +pub const Finding = struct { + ip: u32, + port: u16, + info: probe.ServiceInfo, +}; + +pub const Sender = struct { + ctx: *anyopaque, + vtable: *const Vtable, + + pub const Vtable = struct { + send: *const fn (*anyopaque, []const u8) void, + }; + + pub fn send(self: Sender, frame: []const u8) void { + self.vtable.send(self.ctx, frame); + } +}; + +pub const Sink = struct { + ctx: *anyopaque, + vtable: *const Vtable, + + pub const Vtable = struct { + emit: *const fn (*anyopaque, Finding) void, + }; + + pub fn emit(self: Sink, f: Finding) void { + self.vtable.emit(self.ctx, f); + } +}; + +pub const Config = struct { + cookie: cookie.Cookie, + our_ip: u32, + src_mac: [6]u8, + gw_mac: [6]u8, + banner_wait_ns: u64 = default_banner_wait_ns, +}; + +const F_FIN: u8 = packet.TcpFlag.fin; +const F_SYN: u8 = packet.TcpFlag.syn; +const F_RST: u8 = packet.TcpFlag.rst; +const F_PSH: u8 = packet.TcpFlag.psh; +const F_ACK: u8 = packet.TcpFlag.ack; + +pub const Engine = struct { + const Table = ConnTable(default_capacity); + + cfg: Config, + table: Table, + banners: std.atomic.Value(u64) = .{ .raw = 0 }, + probed: std.atomic.Value(u64) = .{ .raw = 0 }, + drops: std.atomic.Value(u64) = .{ .raw = 0 }, + + pub fn init(cfg: Config) Engine { + const seed = cfg.cookie.generate(0, 0, 0, 0); + return .{ .cfg = cfg, .table = Table.init(seed) }; + } + + fn sendSegment(self: *Engine, sender: Sender, dst_ip: u32, dst_port: u16, src_port: u16, seq: u32, ack: u32, flags: u8, payload: []const u8) void { + var buf: [segment.max_len]u8 = undefined; + const n = segment.build(&buf, .{ + .src_mac = self.cfg.src_mac, + .dst_mac = self.cfg.gw_mac, + .src_ip = self.cfg.our_ip, + .dst_ip = dst_ip, + .src_port = src_port, + .dst_port = dst_port, + .seq = seq, + .ack = ack, + .flags = flags, + .payload = payload, + }); + sender.send(buf[0..n]); + } + + fn onSynAck(self: *Engine, v: View, key: u64, now_ns: u64, sender: Sender) void { + const our_port = v.dport; + const c = self.cfg.cookie.seq(v.ip_src, v.sport, self.cfg.our_ip, our_port); + if (v.ack != c +% 1) return; + + if (self.table.get(key) == null) { + if (!self.table.insert(key, our_port, v.seq, now_ns +% self.cfg.banner_wait_ns)) { + _ = self.drops.fetchAdd(1, .monotonic); + return; + } + _ = self.probed.fetchAdd(1, .monotonic); + } + + const payload = probe.probeFor(v.sport); + const flags: u8 = if (payload.len > 0) F_PSH | F_ACK else F_ACK; + self.sendSegment(sender, v.ip_src, v.sport, our_port, c +% 1, v.seq +% 1, flags, payload); + } + + fn onData(self: *Engine, v: View, key: u64, sender: Sender, sink: Sink) void { + const node = self.table.get(key) orelse return; + if (v.dport != node.our_port) return; + if (v.seq != node.server_isn +% 1) return; + const our_port = node.our_port; + + var si: probe.ServiceInfo = .{}; + probe.classify(v.payload, &si); + sink.emit(.{ .ip = v.ip_src, .port = v.sport, .info = si }); + _ = self.banners.fetchAdd(1, .monotonic); + + const c = self.cfg.cookie.seq(v.ip_src, v.sport, self.cfg.our_ip, our_port); + const probe_len: u32 = @intCast(probe.probeFor(v.sport).len); + const our_seq = c +% 1 +% probe_len; + const their_next = v.seq +% @as(u32, @intCast(v.payload.len)); + self.sendSegment(sender, v.ip_src, v.sport, our_port, our_seq, their_next, F_RST | F_ACK, ""); + _ = self.table.remove(key); + } + + pub fn onFrame(self: *Engine, frame: []const u8, now_ns: u64, sender: Sender, sink: Sink) void { + const v = parse(frame) orelse return; + if (v.ip_dst != self.cfg.our_ip) return; + const key = connKey(v.ip_src, v.sport); + + const syn = (v.flags & F_SYN) != 0; + const ack = (v.flags & F_ACK) != 0; + const rst = (v.flags & F_RST) != 0; + const fin = (v.flags & F_FIN) != 0; + + if (syn and ack and !rst) { + self.onSynAck(v, key, now_ns, sender); + } else if (ack and !syn and !rst and v.payload.len > 0) { + self.onData(v, key, sender, sink); + } else if (rst or fin) { + _ = self.table.remove(key); + } + } + + const SweepCtx = struct { + engine: *Engine, + sender: Sender, + + fn onExpired(self: *SweepCtx, key: u64, our_port: u16) void { + const eng = self.engine; + const ip = keyIp(key); + const port = keyPort(key); + const c = eng.cfg.cookie.seq(ip, port, eng.cfg.our_ip, our_port); + const probe_len: u32 = @intCast(probe.probeFor(port).len); + eng.sendSegment(self.sender, ip, port, our_port, c +% 1 +% probe_len, 0, F_RST, ""); + } + }; + + pub fn sweep(self: *Engine, now_ns: u64, sender: Sender) void { + var ctx = SweepCtx{ .engine = self, .sender = sender }; + self.table.sweepExpired(now_ns, &ctx); + } + + pub fn flushAll(self: *Engine, sender: Sender) void { + self.sweep(std.math.maxInt(u64), sender); + } + + pub fn inFlight(self: *const Engine) u16 { + return self.table.count; + } +}; + +pub const OpenError = error{ + NeedCapNetRaw, + SocketFailed, + IfIndexFailed, + BindFailed, +}; + +const POLL_TICK_MS: i32 = 100; + +pub const Socket = struct { + fd: i32, + sll: linux.sockaddr.ll, + + pub fn open(ifname: []const u8) OpenError!Socket { + const rc_sock = linux.socket( + linux.AF.PACKET, + linux.SOCK.RAW, + std.mem.nativeToBig(u16, @as(u16, linux.ETH.P.IP)), + ); + switch (linux.errno(rc_sock)) { + .SUCCESS => {}, + .PERM, .ACCES => return error.NeedCapNetRaw, + else => return error.SocketFailed, + } + const fd: i32 = @intCast(rc_sock); + errdefer _ = linux.close(fd); + + var ifr = std.mem.zeroes(linux.ifreq); + if (ifname.len >= ifr.ifrn.name.len) return error.IfIndexFailed; + @memcpy(ifr.ifrn.name[0..ifname.len], ifname); + if (linux.errno(linux.ioctl(fd, linux.SIOCGIFINDEX, @intFromPtr(&ifr))) != .SUCCESS) + return error.IfIndexFailed; + const ifindex: i32 = ifr.ifru.ivalue; + + var ignore_outgoing: u32 = 1; + _ = linux.setsockopt(fd, linux.SOL.PACKET, linux.PACKET.IGNORE_OUTGOING, std.mem.asBytes(&ignore_outgoing), @sizeOf(u32)); + + var sll = std.mem.zeroes(linux.sockaddr.ll); + sll.family = linux.AF.PACKET; + sll.protocol = std.mem.nativeToBig(u16, @as(u16, linux.ETH.P.IP)); + sll.ifindex = ifindex; + if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS) + return error.BindFailed; + + return .{ .fd = fd, .sll = sll }; + } + + pub fn recv(self: *Socket, buf: []u8, timeout_ms: i32) ?usize { + var pfd = [_]linux.pollfd{.{ .fd = self.fd, .events = linux.POLL.IN, .revents = 0 }}; + const pr = linux.poll(&pfd, 1, timeout_ms); + switch (linux.errno(pr)) { + .SUCCESS => {}, + else => return null, + } + if (pr == 0) return null; + const rc = linux.recvfrom(self.fd, buf.ptr, buf.len, 0, null, null); + switch (linux.errno(rc)) { + .SUCCESS => return @intCast(rc), + else => return null, + } + } + + fn sendImpl(ctx: *anyopaque, frame: []const u8) void { + const self: *Socket = @ptrCast(@alignCast(ctx)); + _ = linux.sendto(self.fd, frame.ptr, frame.len, 0, @ptrCast(&self.sll), @sizeOf(linux.sockaddr.ll)); + } + + const send_vtable = Sender.Vtable{ .send = sendImpl }; + + pub fn sender(self: *Socket) Sender { + return .{ .ctx = self, .vtable = &send_vtable }; + } + + pub fn close(self: *Socket) void { + _ = linux.close(self.fd); + } +}; + +fn monoNow() u64 { + var ts: linux.timespec = undefined; + _ = linux.clock_gettime(.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec)); +} + +const RECV_BUF_LEN: usize = 2048; + +pub fn run( + engine: *Engine, + socket: *Socket, + sink: Sink, + tx_done: *std.atomic.Value(bool), + drain_window_ns: u64, + hard_cap_ns: u64, +) void { + const sender = socket.sender(); + var buf: [RECV_BUF_LEN]u8 = undefined; + const start = monoNow(); + const hard_deadline = start +| hard_cap_ns; + var drain_anchor: ?u64 = null; + var last_sweep: u64 = start; + const sweep_interval_ns: u64 = 50 * std.time.ns_per_ms; + + while (true) { + var now = monoNow(); + if (now >= hard_deadline) break; + if (tx_done.load(.acquire)) { + if (drain_anchor == null) drain_anchor = now; + const past_window = now >= drain_anchor.? + drain_window_ns; + if (past_window and engine.inFlight() == 0) break; + } + + if (socket.recv(&buf, POLL_TICK_MS)) |n| { + now = monoNow(); + engine.onFrame(buf[0..n], now, sender, sink); + } + const sweep_now = monoNow(); + if (sweep_now -| last_sweep >= sweep_interval_ns) { + engine.sweep(sweep_now, sender); + last_sweep = sweep_now; + } + } + + engine.flushAll(sender); +} + +// ---- tests ---- + +const test_key = [16]u8{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, +}; + +const our_ip: u32 = 0x0a000001; +const our_mac = [6]u8{ 0x02, 0, 0, 0, 0, 0x01 }; +const gw_mac = [6]u8{ 0x02, 0, 0, 0, 0, 0x02 }; +const server_ip: u32 = 0xac200002; + +fn testCfg() Config { + return .{ .cookie = cookie.Cookie.init(test_key), .our_ip = our_ip, .src_mac = our_mac, .gw_mac = gw_mac }; +} + +const FakeSender = struct { + frames: std.ArrayList([]u8), + allocator: std.mem.Allocator, + + fn init(allocator: std.mem.Allocator) FakeSender { + return .{ .frames = .empty, .allocator = allocator }; + } + + fn deinit(self: *FakeSender) void { + for (self.frames.items) |f| self.allocator.free(f); + self.frames.deinit(self.allocator); + } + + fn sendImpl(ctx: *anyopaque, frame: []const u8) void { + const self: *FakeSender = @ptrCast(@alignCast(ctx)); + const copy = self.allocator.dupe(u8, frame) catch return; + self.frames.append(self.allocator, copy) catch {}; + } + + const vtable = Sender.Vtable{ .send = sendImpl }; + + fn sender(self: *FakeSender) Sender { + return .{ .ctx = self, .vtable = &vtable }; + } +}; + +const FakeSink = struct { + findings: std.ArrayList(Finding), + allocator: std.mem.Allocator, + + fn init(allocator: std.mem.Allocator) FakeSink { + return .{ .findings = .empty, .allocator = allocator }; + } + + fn deinit(self: *FakeSink) void { + self.findings.deinit(self.allocator); + } + + fn emitImpl(ctx: *anyopaque, f: Finding) void { + const self: *FakeSink = @ptrCast(@alignCast(ctx)); + self.findings.append(self.allocator, f) catch {}; + } + + const vtable = Sink.Vtable{ .emit = emitImpl }; + + fn sink(self: *FakeSink) Sink { + return .{ .ctx = self, .vtable = &vtable }; + } +}; + +fn buildServerFrame(buf: *[segment.max_len]u8, sport: u16, dport: u16, seq: u32, ack: u32, flags: u8, payload: []const u8) []const u8 { + const n = segment.build(buf, .{ + .src_mac = gw_mac, + .dst_mac = our_mac, + .src_ip = server_ip, + .dst_ip = our_ip, + .src_port = sport, + .dst_port = dport, + .seq = seq, + .ack = ack, + .flags = flags, + .payload = payload, + }); + return buf[0..n]; +} + +test "ConnTable inserts, finds, removes, and reports full" { + var t = ConnTable(4).init(0); + try std.testing.expect(t.insert(connKey(1, 10), 40000, 0x1000, 100)); + try std.testing.expect(t.insert(connKey(2, 20), 40001, 0x2000, 200)); + try std.testing.expect(t.insert(connKey(3, 30), 40002, 0x3000, 300)); + try std.testing.expect(t.insert(connKey(4, 40), 40003, 0x4000, 400)); + try std.testing.expect(!t.insert(connKey(5, 50), 40004, 0x5000, 500)); + + try std.testing.expect(t.get(connKey(2, 20)) != null); + try std.testing.expectEqual(@as(u16, 40001), t.get(connKey(2, 20)).?.our_port); + try std.testing.expect(t.remove(connKey(2, 20))); + try std.testing.expect(t.get(connKey(2, 20)) == null); + try std.testing.expect(t.insert(connKey(5, 50), 40004, 0x5000, 500)); + try std.testing.expectEqual(@as(u16, 4), t.count); +} + +const CountCtx = struct { + n: usize = 0, + fn onExpired(self: *CountCtx, key: u64, our_port: u16) void { + _ = key; + _ = our_port; + self.n += 1; + } +}; + +test "ConnTable sweepExpired removes only entries past their deadline" { + var t = ConnTable(8).init(0); + _ = t.insert(connKey(1, 10), 40000, 0x1000, 100); + _ = t.insert(connKey(2, 20), 40001, 0x2000, 500); + _ = t.insert(connKey(3, 30), 40002, 0x3000, 100); + var ctx = CountCtx{}; + t.sweepExpired(300, &ctx); + try std.testing.expectEqual(@as(usize, 2), ctx.n); + try std.testing.expectEqual(@as(u16, 1), t.count); + try std.testing.expect(t.get(connKey(2, 20)) != null); + try std.testing.expect(t.get(connKey(1, 10)) == null); +} + +test "a valid SYN-ACK completes the handshake with an ACK and tracks the connection" { + var fs = FakeSender.init(std.testing.allocator); + defer fs.deinit(); + var fk = FakeSink.init(std.testing.allocator); + defer fk.deinit(); + + var eng = Engine.init(testCfg()); + const ck = cookie.Cookie.init(test_key); + const c = ck.seq(server_ip, 22, our_ip, 40000); + + var buf: [segment.max_len]u8 = undefined; + const synack = buildServerFrame(&buf, 22, 40000, 0xCAFE0000, c +% 1, F_SYN | F_ACK, ""); + eng.onFrame(synack, 1000, fs.sender(), fk.sink()); + + try std.testing.expectEqual(@as(usize, 1), fs.frames.items.len); + const ackv = parse(fs.frames.items[0]).?; + try std.testing.expectEqual(@as(u32, c +% 1), ackv.seq); + try std.testing.expectEqual(@as(u32, 0xCAFE0000 +% 1), ackv.ack); + try std.testing.expect((ackv.flags & F_ACK) != 0 and (ackv.flags & F_SYN) == 0); + try std.testing.expectEqual(@as(u16, 1), eng.inFlight()); + try std.testing.expectEqual(@as(u64, 1), eng.probed.load(.monotonic)); +} + +test "a SYN-ACK with a forged ack is rejected and never tracked" { + var fs = FakeSender.init(std.testing.allocator); + defer fs.deinit(); + var fk = FakeSink.init(std.testing.allocator); + defer fk.deinit(); + + var eng = Engine.init(testCfg()); + var buf: [segment.max_len]u8 = undefined; + const synack = buildServerFrame(&buf, 22, 40000, 0xCAFE0000, 0xDEADBEEF, F_SYN | F_ACK, ""); + eng.onFrame(synack, 1000, fs.sender(), fk.sink()); + + try std.testing.expectEqual(@as(usize, 0), fs.frames.items.len); + try std.testing.expectEqual(@as(u16, 0), eng.inFlight()); +} + +test "a data segment on a tracked connection yields a finding and a closing RST" { + var fs = FakeSender.init(std.testing.allocator); + defer fs.deinit(); + var fk = FakeSink.init(std.testing.allocator); + defer fk.deinit(); + + var eng = Engine.init(testCfg()); + const ck = cookie.Cookie.init(test_key); + const c = ck.seq(server_ip, 22, our_ip, 40000); + + var b1: [segment.max_len]u8 = undefined; + const synack = buildServerFrame(&b1, 22, 40000, 0xCAFE0000, c +% 1, F_SYN | F_ACK, ""); + eng.onFrame(synack, 1000, fs.sender(), fk.sink()); + + var b2: [segment.max_len]u8 = undefined; + const banner = "SSH-2.0-OpenSSH_9.6p1\r\n"; + const data = buildServerFrame(&b2, 22, 40000, 0xCAFE0001, c +% 1, F_PSH | F_ACK, banner); + eng.onFrame(data, 1100, fs.sender(), fk.sink()); + + try std.testing.expectEqual(@as(usize, 1), fk.findings.items.len); + const f = fk.findings.items[0]; + try std.testing.expectEqual(server_ip, f.ip); + try std.testing.expectEqual(@as(u16, 22), f.port); + try std.testing.expectEqualStrings("ssh", f.info.service); + try std.testing.expectEqualStrings("OpenSSH_9.6p1", f.info.infoSlice()); + + try std.testing.expectEqual(@as(usize, 2), fs.frames.items.len); + const rstv = parse(fs.frames.items[1]).?; + try std.testing.expect((rstv.flags & F_RST) != 0); + try std.testing.expectEqual(@as(u16, 0), eng.inFlight()); + try std.testing.expectEqual(@as(u64, 1), eng.banners.load(.monotonic)); +} + +test "a data segment with a forged sequence number is rejected (anti-injection)" { + var fs = FakeSender.init(std.testing.allocator); + defer fs.deinit(); + var fk = FakeSink.init(std.testing.allocator); + defer fk.deinit(); + + var eng = Engine.init(testCfg()); + const ck = cookie.Cookie.init(test_key); + const c = ck.seq(server_ip, 22, our_ip, 40000); + + var b1: [segment.max_len]u8 = undefined; + const synack = buildServerFrame(&b1, 22, 40000, 0xCAFE0000, c +% 1, F_SYN | F_ACK, ""); + eng.onFrame(synack, 1000, fs.sender(), fk.sink()); + + var b2: [segment.max_len]u8 = undefined; + const forged = buildServerFrame(&b2, 22, 40000, 0xDEADBEEF, c +% 1, F_PSH | F_ACK, "SSH-2.0-EVIL_INJECTED\r\n"); + eng.onFrame(forged, 1100, fs.sender(), fk.sink()); + + try std.testing.expectEqual(@as(usize, 0), fk.findings.items.len); + try std.testing.expectEqual(@as(u16, 1), eng.inFlight()); + + var b3: [segment.max_len]u8 = undefined; + const genuine = buildServerFrame(&b3, 22, 40000, 0xCAFE0001, c +% 1, F_PSH | F_ACK, "SSH-2.0-RealBanner\r\n"); + eng.onFrame(genuine, 1200, fs.sender(), fk.sink()); + try std.testing.expectEqual(@as(usize, 1), fk.findings.items.len); + try std.testing.expectEqualStrings("ssh", fk.findings.items[0].info.service); + try std.testing.expectEqualStrings("RealBanner", fk.findings.items[0].info.infoSlice()); +} + +test "a data segment for an untracked connection is ignored" { + var fs = FakeSender.init(std.testing.allocator); + defer fs.deinit(); + var fk = FakeSink.init(std.testing.allocator); + defer fk.deinit(); + + var eng = Engine.init(testCfg()); + var buf: [segment.max_len]u8 = undefined; + const data = buildServerFrame(&buf, 22, 40000, 0xCAFE0001, 0x1234, F_PSH | F_ACK, "hello"); + eng.onFrame(data, 1100, fs.sender(), fk.sink()); + + try std.testing.expectEqual(@as(usize, 0), fk.findings.items.len); + try std.testing.expectEqual(@as(usize, 0), fs.frames.items.len); +} + +test "an HTTP port sends a GET probe on the completing ACK" { + var fs = FakeSender.init(std.testing.allocator); + defer fs.deinit(); + var fk = FakeSink.init(std.testing.allocator); + defer fk.deinit(); + + var eng = Engine.init(testCfg()); + const ck = cookie.Cookie.init(test_key); + const c = ck.seq(server_ip, 80, our_ip, 40000); + + var buf: [segment.max_len]u8 = undefined; + const synack = buildServerFrame(&buf, 80, 40000, 0x1000, c +% 1, F_SYN | F_ACK, ""); + eng.onFrame(synack, 1000, fs.sender(), fk.sink()); + + const ackv = parse(fs.frames.items[0]).?; + try std.testing.expect((ackv.flags & F_PSH) != 0); + try std.testing.expect(std.mem.indexOf(u8, ackv.payload, "GET /") != null); +} + +test "an expired connection is swept with a RST and the drop counter tracks backpressure" { + var fs = FakeSender.init(std.testing.allocator); + defer fs.deinit(); + + var eng = Engine.init(testCfg()); + const ck = cookie.Cookie.init(test_key); + const c = ck.seq(server_ip, 22, our_ip, 40000); + var buf: [segment.max_len]u8 = undefined; + var fk = FakeSink.init(std.testing.allocator); + defer fk.deinit(); + const synack = buildServerFrame(&buf, 22, 40000, 0xCAFE0000, c +% 1, F_SYN | F_ACK, ""); + eng.onFrame(synack, 1000, fs.sender(), fk.sink()); + try std.testing.expectEqual(@as(u16, 1), eng.inFlight()); + + eng.sweep(1000 + default_banner_wait_ns + 1, fs.sender()); + try std.testing.expectEqual(@as(u16, 0), eng.inFlight()); + const rstv = parse(fs.frames.items[fs.frames.items.len - 1]).?; + try std.testing.expect((rstv.flags & F_RST) != 0); +} + +test "parse strips ethernet padding using the IP total-length field" { + var frame = [_]u8{0} ** 64; + std.mem.writeInt(u16, frame[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV4, .big); + frame[ETH_HDR_LEN] = 0x45; + const total_len: u16 = 20 + 20 + 3; + std.mem.writeInt(u16, frame[ETH_HDR_LEN + IP_OFF_TOTAL_LEN ..][0..2], total_len, .big); + frame[ETH_HDR_LEN + IP_OFF_PROTO] = IPPROTO_TCP; + std.mem.writeInt(u32, frame[ETH_HDR_LEN + IP_OFF_SRC ..][0..4], server_ip, .big); + std.mem.writeInt(u32, frame[ETH_HDR_LEN + IP_OFF_DST ..][0..4], our_ip, .big); + const tcp = ETH_HDR_LEN + IP_MIN_IHL; + frame[tcp + TCP_OFF_DATAOFF] = 0x50; + frame[tcp + TCP_MIN_LEN] = 'a'; + frame[tcp + TCP_MIN_LEN + 1] = 'b'; + frame[tcp + TCP_MIN_LEN + 2] = 'c'; + + const v = parse(&frame).?; + try std.testing.expectEqualStrings("abc", v.payload); +}