940 lines
38 KiB
Zig
940 lines
38 KiB
Zig
// ©AngelaMos | 2026
|
|
// scancmd.zig
|
|
|
|
const std = @import("std");
|
|
const targets = @import("targets");
|
|
const template = @import("template");
|
|
const udp = @import("udp");
|
|
const ratelimit = @import("ratelimit");
|
|
const packet_io = @import("packet_io");
|
|
const cookie = @import("cookie");
|
|
const tx = @import("tx");
|
|
const rx = @import("rx");
|
|
const dedup = @import("dedup");
|
|
const netutil = @import("netutil");
|
|
const output = @import("output");
|
|
const stealth = @import("stealth");
|
|
const service = @import("service");
|
|
const connect = @import("connect");
|
|
const rawprobe = @import("rawprobe");
|
|
const ndp = @import("ndp");
|
|
|
|
const default_iface = "lo";
|
|
const default_rate: u64 = 10_000;
|
|
const default_src_port: u16 = 40_000;
|
|
const default_udp_src_span: u16 = 8_192;
|
|
const default_wait_ms: i32 = 2_000;
|
|
const ns_per_ms: u64 = 1_000_000;
|
|
const ns_per_sec: u64 = 1_000_000_000;
|
|
const default_tcp_ports = [_]u16{80};
|
|
const default_udp_ports = [_]u16{ 53, 123, 161 };
|
|
const dedup_capacity: usize = 1024;
|
|
const queue_capacity: usize = 2048;
|
|
const drain_batch: usize = 256;
|
|
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;
|
|
const rx_hard_cap_floor_ns: u64 = 60 * ns_per_sec;
|
|
const min_dashboard_cols: u16 = 64;
|
|
|
|
const need_cap_hint =
|
|
"scan: need CAP_NET_RAW + CAP_NET_ADMIN. Grant once, then re-run (no sudo):\n" ++
|
|
" sudo setcap cap_net_raw,cap_net_admin=eip ./zig-out/bin/zingela\nSkipping.\n";
|
|
|
|
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,
|
|
|
|
pub fn submit(self: *TxSink, frame: []const u8) bool {
|
|
if (self.backend.submit(frame)) {
|
|
_ = self.sent.fetchAdd(1, .monotonic);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
pub fn kick(self: *TxSink) void {
|
|
self.backend.kick();
|
|
}
|
|
};
|
|
|
|
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,
|
|
bucket: *ratelimit.TokenBucket,
|
|
sink: *TxSink,
|
|
max_packets: u64,
|
|
budget_ns: u64,
|
|
tx_done: *std.atomic.Value(bool),
|
|
) u64 {
|
|
var clock = netutil.RealClock{};
|
|
const deadline_ns = clock.now() +| budget_ns;
|
|
const sent = tx.run(engine, tmpl, bucket, sink, &clock, max_packets, deadline_ns);
|
|
tx_done.store(true, .release);
|
|
return sent;
|
|
}
|
|
|
|
fn txWorkerTcp(engine: *targets.Engine, tmpl: *const template.SynTemplate, bucket: *ratelimit.TokenBucket, sink: *TxSink, max_packets: u64, budget_ns: u64, tx_done: *std.atomic.Value(bool)) u64 {
|
|
return txWorkerImpl(engine, tmpl, bucket, sink, max_packets, budget_ns, tx_done);
|
|
}
|
|
|
|
fn txWorkerUdp(engine: *targets.Engine, tmpl: *const udp.UdpTemplate, bucket: *ratelimit.TokenBucket, sink: *TxSink, max_packets: u64, budget_ns: u64, tx_done: *std.atomic.Value(bool)) u64 {
|
|
return txWorkerImpl(engine, tmpl, bucket, sink, max_packets, budget_ns, tx_done);
|
|
}
|
|
|
|
fn rxWorkerTcp(receiver: *rx.Receiver, 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);
|
|
}
|
|
|
|
fn rxWorkerUdp(receiver: *rx.Receiver, ck: cookie.Cookie, base: u16, span: u16, dd: *dedup.Dedup, sink: *rx.QueueSink, rx_done: *std.atomic.Value(bool)) void {
|
|
rx.run(receiver, rx.UdpClassifier{ .ck = ck, .base = base, .span = span }, dd, sink);
|
|
rx_done.store(true, .release);
|
|
}
|
|
|
|
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),
|
|
allocator: std.mem.Allocator,
|
|
stats: *output.Stats,
|
|
json_out: ?*std.Io.Writer,
|
|
proto: []const u8,
|
|
) void {
|
|
for (batch) |r| {
|
|
found.append(allocator, r) catch continue;
|
|
stats.record(r.state);
|
|
if (json_out) |w| output.emitJson(w, r, proto) catch {};
|
|
}
|
|
}
|
|
|
|
fn drainQueue(
|
|
io: std.Io,
|
|
queue: *std.Io.Queue(rx.Result),
|
|
buf: []rx.Result,
|
|
found: *std.ArrayList(rx.Result),
|
|
allocator: std.mem.Allocator,
|
|
stats: *output.Stats,
|
|
json_out: ?*std.Io.Writer,
|
|
proto: []const u8,
|
|
) void {
|
|
while (true) {
|
|
const n = queue.get(io, buf, 0) catch return;
|
|
if (n == 0) return;
|
|
absorb(buf[0..n], found, allocator, stats, json_out, proto);
|
|
if (n < buf.len) return;
|
|
}
|
|
}
|
|
|
|
fn terminalCols(fd: i32) ?u16 {
|
|
var ws: std.posix.winsize = undefined;
|
|
const rc = std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws));
|
|
if (std.os.linux.errno(rc) != .SUCCESS) return null;
|
|
if (ws.col == 0) return null;
|
|
return ws.col;
|
|
}
|
|
|
|
fn txWorkerV6(engine: *targets.Engine6, tmpl: *const template.SynTemplate6, bucket: *ratelimit.TokenBucket, sink: *TxSink, max_packets: u64, budget_ns: u64, tx_done: *std.atomic.Value(bool)) u64 {
|
|
var clock = netutil.RealClock{};
|
|
const deadline_ns = clock.now() +| budget_ns;
|
|
const sent = tx.runV6(engine, tmpl, bucket, sink, &clock, max_packets, deadline_ns);
|
|
tx_done.store(true, .release);
|
|
return sent;
|
|
}
|
|
|
|
fn rxWorkerV6(receiver: *rx.Receiver, clf: rx.TcpClassifier6, dd: *dedup.Dedup, sink: *rx.QueueSink, rx_done: *std.atomic.Value(bool)) void {
|
|
rx.run(receiver, clf, dd, sink);
|
|
rx_done.store(true, .release);
|
|
}
|
|
|
|
fn resolveV6Gateway(io: std.Io, args: []const []const u8, ifname: []const u8, src_ip6: [16]u8, src_mac: [6]u8, derr: *std.Io.Writer) !?[6]u8 {
|
|
_ = io;
|
|
if (netutil.getFlag(args, "--gw-mac")) |m| return try netutil.parseMac(m);
|
|
const next_hop = if (netutil.getFlag(args, "--gw-ip6")) |g| try netutil.parseIpv6(g) else netutil.defaultGateway6(ifname) orelse {
|
|
try derr.writeAll("scan: IPv6 raw scan needs a next hop: pass --gw-mac <mac>, or --gw-ip6 <addr> to resolve it via NDP\n");
|
|
try derr.flush();
|
|
return null;
|
|
};
|
|
return ndp.resolve(ifname, src_ip6, src_mac, next_hop, 1500) catch |e| {
|
|
try derr.print("scan: NDP neighbor resolution failed ({s}); pass --gw-mac explicitly\n", .{@errorName(e)});
|
|
try derr.flush();
|
|
return null;
|
|
};
|
|
}
|
|
|
|
fn runV6Scan(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, env: *std.process.Environ.Map, target_text: []const u8, ifname: []const u8, rate: u64, backend_choice: packet_io.Choice, derr: *std.Io.Writer) !void {
|
|
var obuf: [4096]u8 = undefined;
|
|
var ow = std.Io.File.stdout().writer(io, &obuf);
|
|
const out = &ow.interface;
|
|
|
|
if (netutil.hasFlag(args, "--udp")) {
|
|
try derr.writeAll("scan: IPv6 UDP scanning is not supported yet; run a TCP SYN scan or --connect\n");
|
|
try derr.flush();
|
|
return;
|
|
}
|
|
if (netutil.getFlag(args, "--scan-type") != null or netutil.getFlag(args, "--os-template") != null or
|
|
netutil.getFlag(args, "--decoys") != null or netutil.getFlag(args, "--jitter") != null or
|
|
netutil.hasFlag(args, "--source-port-rotation"))
|
|
{
|
|
try derr.writeAll(" note: stealth/scan-type options are not yet wired for IPv6; running a plain SYN scan\n");
|
|
}
|
|
|
|
const ports = if (netutil.getFlag(args, "--ports")) |p| try netutil.parsePorts(allocator, p) else try allocator.dupe(u16, &default_tcp_ports);
|
|
const src_port = if (netutil.getFlag(args, "--src-port")) |p| try std.fmt.parseInt(u16, p, 10) else default_src_port;
|
|
const wait_ms = if (netutil.getFlag(args, "--wait")) |w| try std.fmt.parseInt(i32, w, 10) else default_wait_ms;
|
|
const json = netutil.hasFlag(args, "--json");
|
|
const max_hosts = if (netutil.getFlag(args, "--max-hosts")) |m| try std.fmt.parseInt(u64, m, 10) else targets.default_max_hosts6;
|
|
|
|
var seed: u64 = undefined;
|
|
if (netutil.getFlag(args, "--seed")) |s| {
|
|
seed = try std.fmt.parseInt(u64, s, 10);
|
|
} else {
|
|
var seed_bytes: [8]u8 = undefined;
|
|
try io.randomSecure(&seed_bytes);
|
|
seed = std.mem.readInt(u64, &seed_bytes, .little);
|
|
}
|
|
|
|
const src_ip6 = if (netutil.getFlag(args, "--src-ip6")) |s| try netutil.parseIpv6(s) else netutil.resolveSrcIp6(ifname) catch {
|
|
try derr.print("scan: no IPv6 source address on '{s}' (assign one, or pass --src-ip6 <addr>)\n", .{ifname});
|
|
try derr.flush();
|
|
return;
|
|
};
|
|
const src_mac = try netutil.resolveSrcMac(ifname);
|
|
const gw_mac = (try resolveV6Gateway(io, args, ifname, src_ip6, src_mac, derr)) orelse return;
|
|
|
|
const cidr = targets.parseCidr6(target_text) catch |e| {
|
|
try derr.print("scan: invalid IPv6 target '{s}' ({s})\n", .{ target_text, @errorName(e) });
|
|
try derr.flush();
|
|
return;
|
|
};
|
|
var eng = targets.Engine6.init(allocator, cidr, ports, seed, max_hosts) catch |e| switch (e) {
|
|
error.PrefixTooLarge => {
|
|
try derr.print("scan: IPv6 prefix '{s}' is too large to enumerate (over --max-hosts {d}); narrow the prefix or raise --max-hosts\n", .{ target_text, max_hosts });
|
|
try derr.flush();
|
|
return;
|
|
},
|
|
else => return e,
|
|
};
|
|
defer eng.deinit();
|
|
const count = if (netutil.getFlag(args, "--count")) |c| try std.fmt.parseInt(u64, c, 10) else eng.total;
|
|
|
|
const ck = try cookie.Cookie.random(io);
|
|
const tmpl = template.SynTemplate6.init(.{
|
|
.src_mac = src_mac,
|
|
.dst_mac = gw_mac,
|
|
.src_ip = src_ip6,
|
|
.src_port = src_port,
|
|
.cookie = ck,
|
|
});
|
|
var bucket = ratelimit.TokenBucket.init(rate, rate);
|
|
|
|
const choice = output.parseColorChoice(netutil.getFlag(args, "--color"));
|
|
const out_level = output.envLevel(io, std.Io.File.stdout(), env, choice);
|
|
const err_level = output.envLevel(io, std.Io.File.stderr(), env, choice);
|
|
const stderr_tty = std.Io.File.stderr().isTty(io) catch false;
|
|
const wide_enough = if (terminalCols(2)) |c| c >= min_dashboard_cols else true;
|
|
const interactive = stderr_tty and wide_enough;
|
|
|
|
const dash_total = @min(count, eng.total);
|
|
const drain_window_ns: u64 = @as(u64, @intCast(@max(wait_ms, 0))) * ns_per_ms;
|
|
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;
|
|
|
|
var backend = packet_io.select(allocator, ifname, backend_choice, .{}, .{}, derr) catch |err| switch (err) {
|
|
error.NeedCapNetRaw => {
|
|
try derr.writeAll(need_cap_hint);
|
|
try derr.flush();
|
|
return;
|
|
},
|
|
error.XdpNotCompiledIn => {
|
|
try derr.writeAll("scan: --backend xdp needs a build with -Dxdp\n");
|
|
try derr.flush();
|
|
return;
|
|
},
|
|
else => return err,
|
|
};
|
|
defer backend.close();
|
|
try derr.print(" using {s}\n", .{packet_io.kindLabel(backend.kind())});
|
|
|
|
var tx_done = std.atomic.Value(bool).init(false);
|
|
var rx_done = std.atomic.Value(bool).init(false);
|
|
|
|
var receiver = rx.Receiver.open(ifname, rx.ETH_P_IPV6, &tx_done, drain_window_ns, hard_cap_ns) catch |err| switch (err) {
|
|
error.NeedCapNetRaw => {
|
|
try derr.writeAll(need_cap_hint);
|
|
try derr.flush();
|
|
return;
|
|
},
|
|
else => return err,
|
|
};
|
|
defer receiver.close();
|
|
|
|
var dd = try dedup.Dedup.init(allocator, dedup_capacity);
|
|
defer dd.deinit();
|
|
var qbuf: [queue_capacity]rx.Result = undefined;
|
|
var queue = std.Io.Queue(rx.Result).init(&qbuf);
|
|
|
|
var found_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer found_arena.deinit();
|
|
const found_alloc = found_arena.allocator();
|
|
var found: std.ArrayList(rx.Result) = .empty;
|
|
|
|
var stats: output.Stats = .{};
|
|
const json_out: ?*std.Io.Writer = if (json) out else null;
|
|
|
|
try derr.print("zingela SYN scan (IPv6) target {s} iface {s} rate {d} pps ports {d}\n", .{ target_text, ifname, rate, ports.len });
|
|
try derr.flush();
|
|
|
|
var clock = netutil.RealClock{};
|
|
const t0 = clock.now();
|
|
|
|
var tx_sink = TxSink{ .backend = &backend, .sent = &stats.sent.v };
|
|
var rx_sink = rx.QueueSink{ .queue = &queue, .io = io };
|
|
|
|
var tx_fut = (io.concurrent(txWorkerV6, .{ &eng, &tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done })) catch {
|
|
try derr.writeAll(concurrency_hint);
|
|
try derr.flush();
|
|
return;
|
|
};
|
|
var rx_fut = (io.concurrent(rxWorkerV6, .{ &receiver, rx.TcpClassifier6{ .ck = ck }, &dd, &rx_sink, &rx_done })) catch {
|
|
_ = tx_fut.await(io);
|
|
try derr.writeAll(concurrency_hint);
|
|
try derr.flush();
|
|
return;
|
|
};
|
|
|
|
var dash = output.Dashboard.init(err_level, interactive, dash_total);
|
|
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 last_render: u64 = 0;
|
|
|
|
while (!(tx_done.load(.acquire) and rx_done.load(.acquire))) {
|
|
drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, "tcp");
|
|
if (json) out.flush() catch {};
|
|
const now = clock.now();
|
|
if (last_render == 0 or now -| last_render >= render_interval_ns) {
|
|
dash.render(derr, &stats, now -| t0) catch {};
|
|
last_render = now;
|
|
}
|
|
clock.sleepNs(drain_tick_ns);
|
|
}
|
|
|
|
const sent = tx_fut.await(io);
|
|
rx_fut.await(io);
|
|
queue.close(io);
|
|
drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, "tcp");
|
|
if (json) out.flush() catch {};
|
|
dash.render(derr, &stats, clock.now() -| t0) catch {};
|
|
|
|
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) {
|
|
if (found.items.len > 0) {
|
|
std.mem.sort(rx.Result, found.items, {}, output.ipPortLess);
|
|
try out.writeByte('\n');
|
|
try output.renderTable(out, out_level, found.items);
|
|
try out.flush();
|
|
} else {
|
|
try derr.writeAll(" no open, closed, or filtered responses observed\n");
|
|
if (sent > 0) try derr.writeAll(" (if a cloud/VM hypervisor is filtering raw sends upstream, retry with --connect)\n");
|
|
}
|
|
}
|
|
|
|
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, "SYN", ifname, elapsed_s, open_n, closed_n, filtered_n, unfiltered_n, 0);
|
|
try derr.flush();
|
|
}
|
|
|
|
fn runConnect(
|
|
io: std.Io,
|
|
allocator: std.mem.Allocator,
|
|
args: []const []const u8,
|
|
env: *std.process.Environ.Map,
|
|
target_text: []const u8,
|
|
ifname: []const u8,
|
|
rate: u64,
|
|
is_udp: bool,
|
|
json: bool,
|
|
derr: *std.Io.Writer,
|
|
) !void {
|
|
if (is_udp) {
|
|
try derr.writeAll("scan: connect mode is TCP-only; --udp is not supported with --backend connect\n");
|
|
try derr.flush();
|
|
return;
|
|
}
|
|
if (netutil.hasFlag(args, "--banners")) {
|
|
try derr.writeAll(" note: --banners is unavailable in connect mode; reporting open/closed/filtered only\n");
|
|
}
|
|
|
|
const ports = if (netutil.getFlag(args, "--ports")) |p|
|
|
try netutil.parsePorts(allocator, p)
|
|
else
|
|
try allocator.dupe(u16, &default_tcp_ports);
|
|
|
|
var seed: u64 = undefined;
|
|
if (netutil.getFlag(args, "--seed")) |s| {
|
|
seed = try std.fmt.parseInt(u64, s, 10);
|
|
} else {
|
|
var seed_bytes: [8]u8 = undefined;
|
|
try io.randomSecure(&seed_bytes);
|
|
seed = std.mem.readInt(u64, &seed_bytes, .little);
|
|
}
|
|
|
|
const concurrency = if (netutil.getFlag(args, "--concurrency")) |c| try std.fmt.parseInt(usize, c, 10) else connect.default_concurrency;
|
|
const timeout_ms = if (netutil.getFlag(args, "--connect-timeout")) |t| try std.fmt.parseInt(u64, t, 10) else connect.default_timeout_ms;
|
|
const max_hosts = if (netutil.getFlag(args, "--max-hosts")) |m| try std.fmt.parseInt(u64, m, 10) else targets.default_max_hosts6;
|
|
|
|
const choice = output.parseColorChoice(netutil.getFlag(args, "--color"));
|
|
const out_level = output.envLevel(io, std.Io.File.stdout(), env, choice);
|
|
const err_level = output.envLevel(io, std.Io.File.stderr(), env, choice);
|
|
const stderr_tty = std.Io.File.stderr().isTty(io) catch false;
|
|
const wide_enough = if (terminalCols(2)) |c| c >= min_dashboard_cols else true;
|
|
const interactive = stderr_tty and wide_enough;
|
|
try derr.flush();
|
|
|
|
if (std.mem.indexOfScalar(u8, target_text, ':') != null) {
|
|
const cidr = targets.parseCidr6(target_text) catch |e| {
|
|
try derr.print("scan: invalid IPv6 target '{s}' ({s})\n", .{ target_text, @errorName(e) });
|
|
try derr.flush();
|
|
return;
|
|
};
|
|
var eng = targets.Engine6.init(allocator, cidr, ports, seed, max_hosts) catch |e| switch (e) {
|
|
error.PrefixTooLarge => {
|
|
try derr.print("scan: IPv6 prefix '{s}' is too large to enumerate (over --max-hosts {d}); narrow the prefix or raise --max-hosts\n", .{ target_text, max_hosts });
|
|
try derr.flush();
|
|
return;
|
|
},
|
|
else => return e,
|
|
};
|
|
defer eng.deinit();
|
|
const count = if (netutil.getFlag(args, "--count")) |c| try std.fmt.parseInt(u64, c, 10) else eng.total;
|
|
try connect.run(allocator, .{
|
|
.source = .{ .v6 = &eng },
|
|
.count = count,
|
|
.total = @min(count, eng.total),
|
|
.rate = rate,
|
|
.concurrency = concurrency,
|
|
.timeout_ns = timeout_ms *| ns_per_ms,
|
|
.out_level = out_level,
|
|
.err_level = err_level,
|
|
.interactive = interactive,
|
|
.json = json,
|
|
.target_text = target_text,
|
|
.iface = ifname,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const cidr = try targets.parseCidr(target_text);
|
|
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;
|
|
|
|
try connect.run(allocator, .{
|
|
.source = .{ .v4 = &eng },
|
|
.count = count,
|
|
.total = @min(count, eng.total),
|
|
.rate = rate,
|
|
.concurrency = concurrency,
|
|
.timeout_ns = timeout_ms *| ns_per_ms,
|
|
.out_level = out_level,
|
|
.err_level = err_level,
|
|
.interactive = interactive,
|
|
.json = json,
|
|
.target_text = target_text,
|
|
.iface = ifname,
|
|
});
|
|
}
|
|
|
|
pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, env: *std.process.Environ.Map) !void {
|
|
var obuf: [4096]u8 = undefined;
|
|
var ow = std.Io.File.stdout().writer(io, &obuf);
|
|
const out = &ow.interface;
|
|
|
|
var ebuf: [4096]u8 = undefined;
|
|
var ew = std.Io.File.stderr().writer(io, &ebuf);
|
|
const derr = &ew.interface;
|
|
|
|
const target_text = netutil.getFlag(args, "--target") orelse {
|
|
try derr.writeAll("scan: --target <cidr> is required (e.g. --target 10.0.0.0/24)\n");
|
|
try derr.flush();
|
|
return;
|
|
};
|
|
const ifname = netutil.getFlag(args, "--iface") orelse default_iface;
|
|
const rate = if (netutil.getFlag(args, "--rate")) |r| try std.fmt.parseInt(u64, r, 10) else default_rate;
|
|
const src_port = if (netutil.getFlag(args, "--src-port")) |p| try std.fmt.parseInt(u16, p, 10) else default_src_port;
|
|
const wait_ms = if (netutil.getFlag(args, "--wait")) |w| try std.fmt.parseInt(i32, w, 10) else default_wait_ms;
|
|
const json = netutil.hasFlag(args, "--json");
|
|
const is_udp = netutil.hasFlag(args, "--udp");
|
|
const banners_flag = netutil.hasFlag(args, "--banners");
|
|
|
|
const backend_arg = netutil.getFlag(args, "--backend");
|
|
const connect_mode = netutil.hasFlag(args, "--connect") or
|
|
(backend_arg != null and std.mem.eql(u8, backend_arg.?, "connect"));
|
|
if (connect_mode) {
|
|
return runConnect(io, allocator, args, env, target_text, ifname, rate, is_udp, json, derr);
|
|
}
|
|
|
|
const backend_choice = packet_io.parseChoice(backend_arg) orelse {
|
|
try derr.writeAll("scan: --backend must be one of auto, xdp, afpacket, connect\n");
|
|
try derr.flush();
|
|
return;
|
|
};
|
|
|
|
if (backend_choice == .auto) {
|
|
const raw_status = rawprobe.probe(ifname);
|
|
if (raw_status != .ok) {
|
|
try derr.print(" raw sends unavailable on '{s}' ({s}); falling back to unprivileged connect scan\n", .{ ifname, raw_status.reason() });
|
|
try derr.writeAll(" (force the raw engine with --backend afpacket, or select it directly with --connect)\n");
|
|
try derr.flush();
|
|
return runConnect(io, allocator, args, env, target_text, ifname, rate, is_udp, json, derr);
|
|
}
|
|
}
|
|
|
|
if (std.mem.indexOfScalar(u8, target_text, ':') != null) {
|
|
return runV6Scan(io, allocator, args, env, target_text, ifname, rate, backend_choice, derr);
|
|
}
|
|
|
|
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");
|
|
}
|
|
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)));
|
|
const proto_json: []const u8 = if (is_udp) "udp" else "tcp";
|
|
const probe_label: []const u8 = if (is_udp) "UDP" else "SYN";
|
|
|
|
const ports = if (netutil.getFlag(args, "--ports")) |p|
|
|
try netutil.parsePorts(allocator, p)
|
|
else if (is_udp)
|
|
try allocator.dupe(u16, &default_udp_ports)
|
|
else
|
|
try allocator.dupe(u16, &default_tcp_ports);
|
|
const gw_mac = if (netutil.getFlag(args, "--gw-mac")) |m| try netutil.parseMac(m) else [_]u8{0} ** 6;
|
|
const src_ip = if (netutil.getFlag(args, "--src-ip")) |s| try netutil.parseIpv4(s) else try netutil.resolveSrcIp(ifname);
|
|
const src_mac = try netutil.resolveSrcMac(ifname);
|
|
|
|
const choice = output.parseColorChoice(netutil.getFlag(args, "--color"));
|
|
const out_level = output.envLevel(io, std.Io.File.stdout(), env, choice);
|
|
const err_level = output.envLevel(io, std.Io.File.stderr(), env, choice);
|
|
const stderr_tty = std.Io.File.stderr().isTty(io) catch false;
|
|
const wide_enough = if (terminalCols(2)) |c| c >= min_dashboard_cols else true;
|
|
const interactive = stderr_tty and wide_enough;
|
|
|
|
var seed: u64 = undefined;
|
|
if (netutil.getFlag(args, "--seed")) |s| {
|
|
seed = try std.fmt.parseInt(u64, s, 10);
|
|
} else {
|
|
var seed_bytes: [8]u8 = undefined;
|
|
try io.randomSecure(&seed_bytes);
|
|
seed = std.mem.readInt(u64, &seed_bytes, .little);
|
|
}
|
|
|
|
const cidr = try targets.parseCidr(target_text);
|
|
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 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,
|
|
.dst_mac = gw_mac,
|
|
.src_ip = src_ip,
|
|
.src_port_base = udp_base,
|
|
.src_port_span = udp_span,
|
|
.cookie = ck,
|
|
});
|
|
var bucket = ratelimit.TokenBucket.init(rate, rate);
|
|
if (scfg.jitter) bucket = bucket.withJitter(seed);
|
|
|
|
var backend = packet_io.select(allocator, ifname, backend_choice, .{}, .{}, derr) catch |err| switch (err) {
|
|
error.NeedCapNetRaw => {
|
|
try derr.writeAll(need_cap_hint);
|
|
try derr.flush();
|
|
return;
|
|
},
|
|
error.XdpNotCompiledIn => {
|
|
try derr.writeAll("scan: --backend xdp needs a build with -Dxdp\n");
|
|
try derr.flush();
|
|
return;
|
|
},
|
|
else => return err,
|
|
};
|
|
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 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})", .{@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;
|
|
};
|
|
}
|
|
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) (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;
|
|
|
|
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, rx.ETH_P_IP, &tx_done, drain_window_ns, hard_cap_ns) catch |err| switch (err) {
|
|
error.NeedCapNetRaw => {
|
|
try derr.writeAll(need_cap_hint);
|
|
try derr.flush();
|
|
return;
|
|
},
|
|
else => return err,
|
|
};
|
|
defer receiver.close();
|
|
|
|
var dd = try dedup.Dedup.init(allocator, dedup_capacity);
|
|
defer dd.deinit();
|
|
|
|
var qbuf: [queue_capacity]rx.Result = undefined;
|
|
var queue = std.Io.Queue(rx.Result).init(&qbuf);
|
|
|
|
var found_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
|
defer found_arena.deinit();
|
|
const found_alloc = found_arena.allocator();
|
|
var found: std.ArrayList(rx.Result) = .empty;
|
|
|
|
var stats: output.Stats = .{};
|
|
const json_out: ?*std.Io.Writer = if (json) out else null;
|
|
|
|
try derr.print("zingela {s} scan target {s} iface {s} rate {d} pps ports {d}\n", .{ probe_label, target_text, ifname, rate, ports.len });
|
|
try derr.flush();
|
|
|
|
var clock = netutil.RealClock{};
|
|
const t0 = clock.now();
|
|
|
|
var tx_sink = TxSink{ .backend = &backend, .sent = &stats.sent.v };
|
|
var rx_sink = rx.QueueSink{ .queue = &queue, .io = io };
|
|
|
|
const tx_res = if (is_udp)
|
|
io.concurrent(txWorkerUdp, .{ &eng, &udp_tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done })
|
|
else
|
|
io.concurrent(txWorkerTcp, .{ &eng, &tcp_tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done });
|
|
var tx_fut = tx_res catch {
|
|
try derr.writeAll(concurrency_hint);
|
|
try derr.flush();
|
|
return;
|
|
};
|
|
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, 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);
|
|
try derr.flush();
|
|
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) 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) {
|
|
dash.render(derr, &stats, now -| t0) catch {};
|
|
last_render = now;
|
|
}
|
|
clock.sleepNs(drain_tick_ns);
|
|
}
|
|
|
|
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 {};
|
|
|
|
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) {
|
|
if (found.items.len > 0) {
|
|
std.mem.sort(rx.Result, found.items, {}, output.ipPortLess);
|
|
try out.writeByte('\n');
|
|
try output.renderTable(out, out_level, found.items);
|
|
try out.flush();
|
|
} else {
|
|
try derr.writeAll(" no open, closed, or filtered responses observed\n");
|
|
if (sent > 0) try derr.writeAll(" (if a cloud/VM hypervisor is filtering raw sends upstream, retry with --connect)\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, 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);
|
|
}
|
|
try derr.flush();
|
|
}
|