Adversarial C2 Protocol Implemented in Zig
1// Copyright 2026 Robby Zambito
2//
3// This file is part of zaprus.
4//
5// Zaprus is free software: you can redistribute it and/or modify it under the
6// terms of the GNU General Public License as published by the Free Software
7// Foundation, either version 3 of the License, or (at your option) any later
8// version.
9//
10// Zaprus is distributed in the hope that it will be useful, but WITHOUT ANY
11// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12// A PARTICULAR PURPOSE. See the GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License along with
15// Zaprus. If not, see <https://www.gnu.org/licenses/>.
16
17//! A client is used to handle interactions with the network.
18
19const base64_enc = std.base64.standard.Encoder;
20const base64_dec = std.base64.standard.Decoder;
21
22const Client = @This();
23
24const max_message_size = 2048;
25
26pub const max_payload_len = RawSocket.max_payload_len;
27
28socket: RawSocket,
29
30pub fn init() !Client {
31 const socket: RawSocket = try .init();
32 return .{
33 .socket = socket,
34 };
35}
36
37pub fn deinit(self: *Client) void {
38 self.socket.deinit();
39 self.* = undefined;
40}
41
42/// Sends a fire and forget message over the network.
43/// This function asserts that `payload` fits within a single packet.
44pub fn sendRelay(self: *Client, io: Io, payload: []const u8, dest: [4]u8) !void {
45 const io_source: std.Random.IoSource = .{ .io = io };
46 const rand = io_source.interface();
47
48 var headers: EthIpUdp = .{
49 .src_mac = .fromBytes(self.socket.mac),
50 .ip = .{
51 .id = rand.int(u16),
52 .src_addr = .fromBytes(.{ 0, 0, 0, 0 }), //rand.int(u32),
53 .dst_addr = .fromBytes(.{ 255, 255, 255, 255 }),
54 .len = undefined,
55 },
56 .udp = .{
57 .src_port = rand.intRangeAtMost(u16, 1025, std.math.maxInt(u16)),
58 .dst_port = 8888,
59 .len = undefined,
60 },
61 };
62
63 const relay: SaprusMessage = .{
64 .relay = .{
65 .dest = .fromBytes(&dest),
66 .payload = payload,
67 },
68 };
69
70 var relay_buf: [max_message_size - (@bitSizeOf(EthIpUdp) / 8)]u8 = undefined;
71 const relay_bytes = relay.toBytes(&relay_buf);
72 headers.setPayloadLen(relay_bytes.len);
73
74 var msg_buf: [max_message_size]u8 = undefined;
75 var msg_w: Writer = .fixed(&msg_buf);
76 msg_w.writeAll(&headers.toBytes()) catch unreachable;
77 msg_w.writeAll(relay_bytes) catch unreachable;
78 const full_msg = msg_w.buffered();
79
80 try self.socket.send(full_msg);
81}
82
83/// Attempts to establish a new connection with the sentinel.
84pub fn connect(self: Client, io: Io, payload: []const u8) (error{ BpfAttachFailed, Timeout } || SaprusMessage.ParseError)!SaprusConnection {
85 const io_source: std.Random.IoSource = .{ .io = io };
86 const rand = io_source.interface();
87
88 var headers: EthIpUdp = .{
89 .src_mac = .fromBytes(self.socket.mac),
90 .ip = .{
91 .id = rand.int(u16),
92 .src_addr = .fromBytes(.{ 0, 0, 0, 0 }), //rand.int(u32),
93 .dst_addr = .fromBytes(.{ 255, 255, 255, 255 }),
94 .len = undefined,
95 },
96 .udp = .{
97 .src_port = rand.intRangeAtMost(u16, 1025, std.math.maxInt(u16)),
98 .dst_port = 8888,
99 .len = undefined,
100 },
101 };
102
103 // udp dest port should not be 8888 after first
104 const udp_dest_port = rand.intRangeAtMost(u16, 9000, std.math.maxInt(u16));
105 var connection: SaprusMessage = .{
106 .connection = .{
107 .src = rand.intRangeAtMost(u16, 1025, std.math.maxInt(u16)),
108 .dest = rand.intRangeAtMost(u16, 1025, std.math.maxInt(u16)), // Ignored, but good noise
109 .seq = undefined,
110 .id = undefined,
111 .payload = payload,
112 },
113 };
114
115 log.debug("Setting bpf filter to port {}", .{connection.connection.src});
116 self.socket.attachSaprusPortFilter(null, connection.connection.src) catch |err| {
117 log.err("Failed to set port filter: {t}", .{err});
118 return err;
119 };
120 log.debug("bpf set", .{});
121
122 var connection_buf: [2048]u8 = undefined;
123 var connection_bytes = connection.toBytes(&connection_buf);
124 headers.setPayloadLen(connection_bytes.len);
125
126 log.debug("Building full message", .{});
127 var msg_buf: [2048]u8 = undefined;
128 var msg_w: Writer = .fixed(&msg_buf);
129 msg_w.writeAll(&headers.toBytes()) catch unreachable;
130 msg_w.writeAll(connection_bytes) catch unreachable;
131 var full_msg = msg_w.buffered();
132 log.debug("Built full message. Sending message", .{});
133
134 try self.socket.send(full_msg);
135 var res_buf: [4096]u8 = undefined;
136
137 log.debug("Awaiting handshake response", .{});
138 // Ignore response from sentinel, just accept that we got one.
139 const full_handshake_res = try self.socket.receive(&res_buf);
140 const handshake_res = saprusParse(full_handshake_res[42..]) catch |err| {
141 log.err("Parse error: {t}", .{err});
142 return err;
143 };
144 self.socket.attachSaprusPortFilter(handshake_res.connection.src, handshake_res.connection.dest) catch |err| {
145 log.err("Failed to set port filter: {t}", .{err});
146 return err;
147 };
148 connection.connection.dest = handshake_res.connection.src;
149 connection_bytes = connection.toBytes(&connection_buf);
150
151 headers.udp.dst_port = udp_dest_port;
152 headers.ip.id = rand.int(u16);
153 headers.setPayloadLen(connection_bytes.len);
154
155 log.debug("Building final handshake message", .{});
156
157 msg_w.end = 0;
158
159 msg_w.writeAll(&headers.toBytes()) catch unreachable;
160 msg_w.writeAll(connection_bytes) catch unreachable;
161 full_msg = msg_w.buffered();
162
163 try self.socket.send(full_msg);
164
165 return .{
166 .socket = self.socket,
167 .headers = headers,
168 .connection = connection,
169 };
170}
171
172const RawSocket = @import("./RawSocket.zig");
173
174const SaprusMessage = @import("message.zig").Message;
175const saprusParse = SaprusMessage.parse;
176const SaprusConnection = @import("Connection.zig");
177const EthIpUdp = @import("./EthIpUdp.zig").EthIpUdp;
178
179const std = @import("std");
180const Io = std.Io;
181const Writer = std.Io.Writer;
182const log = std.log;