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
17pub const Message = union(enum(u16)) {
18 relay: Message.Relay = 0x003C,
19 connection: Message.Connection = 0x00E9,
20 _,
21
22 pub const Relay = struct {
23 dest: Dest,
24 checksum: [2]u8 = undefined,
25 payload: []const u8,
26
27 pub const Dest = struct {
28 bytes: [relay_dest_len]u8,
29
30 /// Asserts bytes is less than or equal to 4 bytes
31 pub fn fromBytes(bytes: []const u8) Dest {
32 var buf: [4]u8 = @splat(0);
33 std.debug.assert(bytes.len <= buf.len);
34 @memcpy(buf[0..bytes.len], bytes);
35 return .{ .bytes = buf };
36 }
37 };
38
39 /// Asserts that buf is large enough to fit the relay message.
40 pub fn toBytes(self: Relay, buf: []u8) []u8 {
41 var out: Writer = .fixed(buf);
42 out.writeInt(u16, @intFromEnum(Message.relay), .big) catch unreachable;
43 out.writeInt(u16, @intCast(self.payload.len + 4), .big) catch unreachable; // Length field, but unread. Will switch to checksum
44 out.writeAll(&self.dest.bytes) catch unreachable;
45 out.writeAll(self.payload) catch unreachable;
46 return out.buffered();
47 }
48
49 // test toBytes {
50 // var buf: [1024]u8 = undefined;
51 // const relay: Relay = .init(
52 // .fromBytes(&.{ 172, 18, 1, 30 }),
53 // // zig fmt: off
54 // &[_]u8{
55 // 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x20, 0x65, 0x76, 0x65,
56 // 0x6e, 0x74, 0x20, 0x6c, 0x6f, 0x67, 0x67, 0x65, 0x64
57 // },
58 // // zig fmt: on
59 // );
60 // // zig fmt: off
61 // var expected = [_]u8{
62 // 0x00, 0x3c, 0x00, 0x17, 0xac, 0x12, 0x01, 0x1e, 0x72,
63 // 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x20, 0x65, 0x76, 0x65,
64 // 0x6e, 0x74, 0x20, 0x6c, 0x6f, 0x67, 0x67, 0x65, 0x64
65 // };
66 // // zig fmt: on
67 // try expectEqualMessageBuffers(&expected, relay.toBytes(&buf));
68 // }
69 };
70
71 pub const Connection = struct {
72 src: u16,
73 dest: u16,
74 seq: u32,
75 id: u32,
76 reserved: u8 = undefined,
77 options: Options = .{},
78 payload: []const u8,
79
80 /// Option values.
81 /// Currently used!
82 pub const Options = packed struct(u8) {
83 opt1: bool = false,
84 opt2: bool = false,
85 opt3: bool = false,
86 opt4: bool = false,
87 opt5: bool = false,
88 opt6: bool = false,
89 opt7: bool = false,
90 management: bool = false,
91 };
92
93 /// Asserts that buf is large enough to fit the connection message.
94 pub fn toBytes(self: Connection, buf: []u8) []u8 {
95 var out: Writer = .fixed(buf);
96 out.writeInt(u16, @intFromEnum(Message.connection), .big) catch unreachable;
97 out.writeInt(u16, @intCast(self.payload.len + 14), .big) catch unreachable; // Saprus length field, unread.
98 out.writeInt(u16, self.src, .big) catch unreachable;
99 out.writeInt(u16, self.dest, .big) catch unreachable;
100 out.writeInt(u32, self.seq, .big) catch unreachable;
101 out.writeInt(u32, self.id, .big) catch unreachable;
102 out.writeByte(self.reserved) catch unreachable;
103 out.writeStruct(self.options, .big) catch unreachable;
104 out.writeAll(self.payload) catch unreachable;
105 return out.buffered();
106 }
107
108 /// If the current message is a management message, return what kind.
109 /// Else return null.
110 pub fn management(self: Connection) ParseError!?Management {
111 const b64_dec = std.base64.standard.Decoder;
112 if (self.options.management) {
113 var buf: [1]u8 = undefined;
114 _ = b64_dec.decode(&buf, self.payload) catch return error.InvalidMessage;
115
116 return switch (buf[0]) {
117 'P' => .ping,
118 'p' => .pong,
119 else => error.UnknownSaprusType,
120 };
121 }
122 return null;
123 }
124
125 pub const Management = enum {
126 ping,
127 pong,
128 };
129 };
130
131 pub fn toBytes(self: Message, buf: []u8) []u8 {
132 return switch (self) {
133 inline .relay, .connection => |m| m.toBytes(buf),
134 else => unreachable,
135 };
136 }
137
138 pub fn parse(bytes: []const u8) ParseError!Message {
139 var in: Reader = .fixed(bytes);
140 const @"type" = in.takeEnum(std.meta.Tag(Message), .big) catch |err| switch (err) {
141 error.InvalidEnumTag => return error.UnknownSaprusType,
142 else => return error.InvalidMessage,
143 };
144 const checksum = in.takeArray(2) catch return error.InvalidMessage;
145 switch (@"type") {
146 .relay => {
147 const dest: Relay.Dest = .fromBytes(
148 in.takeArray(relay_dest_len) catch return error.InvalidMessage,
149 );
150 const payload = in.buffered();
151 return .{
152 .relay = .{
153 .dest = dest,
154 .checksum = checksum.*,
155 .payload = payload,
156 },
157 };
158 },
159 .connection => {
160 const src = in.takeInt(u16, .big) catch return error.InvalidMessage;
161 const dest = in.takeInt(u16, .big) catch return error.InvalidMessage;
162 const seq = in.takeInt(u32, .big) catch return error.InvalidMessage;
163 const id = in.takeInt(u32, .big) catch return error.InvalidMessage;
164 const reserved = in.takeByte() catch return error.InvalidMessage;
165 const options = in.takeStruct(Connection.Options, .big) catch return error.InvalidMessage;
166 const payload = in.buffered();
167 return .{
168 .connection = .{
169 .src = src,
170 .dest = dest,
171 .seq = seq,
172 .id = id,
173 .reserved = reserved,
174 .options = options,
175 .payload = payload,
176 },
177 };
178 },
179 else => return error.NotImplementedSaprusType,
180 }
181 }
182
183 test parse {
184 _ = try parse(&[_]u8{ 0x00, 0x3c, 0x00, 0x17, 0xac, 0x12, 0x01, 0x1e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x20, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x20, 0x6c, 0x6f, 0x67, 0x67, 0x65, 0x64 });
185
186 {
187 const expected: Message = .{
188 .connection = .{
189 .src = 12416,
190 .dest = 61680,
191 .seq = 0,
192 .id = 0,
193 .reserved = 0,
194 .options = @bitCast(@as(u8, 100)),
195 .payload = &[_]u8{ 0x69, 0x61, 0x6d, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74 },
196 },
197 };
198 const actual = try parse(&[_]u8{ 0x00, 0xe9, 0x00, 0x18, 0x30, 0x80, 0xf0, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64, 0x69, 0x61, 0x6d, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74 });
199
200 try std.testing.expectEqualDeep(expected, actual);
201 }
202 }
203
204 test "Round trip" {
205 {
206 const expected = [_]u8{ 0x0, 0xe9, 0x0, 0x15, 0x30, 0x80, 0xf0, 0xf0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x64, 0x36, 0x3a, 0x3a, 0x64, 0x61, 0x74, 0x61 };
207 const msg = (try parse(&expected)).connection;
208 var res_buf: [expected.len + 1]u8 = undefined; // + 1 to test subslice result.
209 const res = msg.toBytes(&res_buf);
210 try expectEqualMessageBuffers(&expected, res);
211 }
212 }
213
214 // Skip checking the length / checksum, because that is undefined.
215 fn expectEqualMessageBuffers(expected: []const u8, actual: []const u8) !void {
216 try std.testing.expectEqualSlices(u8, expected[0..2], actual[0..2]);
217 try std.testing.expectEqualSlices(u8, expected[4..], actual[4..]);
218 }
219
220 pub const TypeError = error{
221 NotImplementedSaprusType,
222 UnknownSaprusType,
223 };
224 pub const ParseError = TypeError || error{
225 InvalidMessage,
226 };
227};
228
229const relay_dest_len = 4;
230
231const std = @import("std");
232const Allocator = std.mem.Allocator;
233const Writer = std.Io.Writer;
234const Reader = std.Io.Reader;
235
236test {
237 std.testing.refAllDecls(@This());
238}