/src/libssh/tests/fuzz/ssh_scp_fuzzer.c
Line | Count | Source |
1 | | /* |
2 | | * Copyright 2026 libssh authors |
3 | | * |
4 | | * Licensed under the Apache License, Version 2.0 (the "License"); |
5 | | * you may not use this file except in compliance with the License. |
6 | | * You may obtain a copy of the License at |
7 | | * |
8 | | * http://www.apache.org/licenses/LICENSE-2.0 |
9 | | * |
10 | | * Unless required by applicable law or agreed to in writing, software |
11 | | * distributed under the License is distributed on an "AS IS" BASIS, |
12 | | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
13 | | * See the License for the specific language governing permissions and |
14 | | * limitations under the License. |
15 | | */ |
16 | | |
17 | | #include <assert.h> |
18 | | #include <pthread.h> |
19 | | #include <stdbool.h> |
20 | | #include <stdint.h> |
21 | | #include <stdio.h> |
22 | | #include <stdlib.h> |
23 | | #include <string.h> |
24 | | #include <sys/socket.h> |
25 | | #include <sys/time.h> |
26 | | #include <unistd.h> |
27 | | |
28 | | #define LIBSSH_STATIC 1 |
29 | | #include <libssh/libssh.h> |
30 | | #include <libssh/scp.h> |
31 | | |
32 | | #include "nallocinc.c" |
33 | | #include "ssh_server_mock.h" |
34 | | |
35 | | static void _fuzz_finalize(void) |
36 | 3 | { |
37 | 3 | ssh_finalize(); |
38 | 3 | } |
39 | | |
40 | | int LLVMFuzzerInitialize(int *argc, char ***argv) |
41 | 43 | { |
42 | 43 | (void)argc; |
43 | 43 | nalloc_init(*argv[0]); |
44 | 43 | ssh_init(); |
45 | 43 | atexit(_fuzz_finalize); |
46 | 43 | ssh_mock_write_hostkey(SSH_MOCK_HOSTKEY_PATH); |
47 | 43 | return 0; |
48 | 43 | } |
49 | | |
50 | | static const char *const k_ciphers[] = { |
51 | | "none", |
52 | | "aes128-ctr", |
53 | | "aes256-ctr", |
54 | | "aes128-cbc", |
55 | | }; |
56 | | |
57 | | static const char *const k_hmacs[] = { |
58 | | "none", |
59 | | "hmac-sha1", |
60 | | "hmac-sha2-256", |
61 | | }; |
62 | | |
63 | | /* |
64 | | * Wrap fuzzer bytes as a valid SCP server record stream so the client |
65 | | * survives ssh_scp_init's initial-ACK gate and reaches the deeper SCP |
66 | | * code paths (record parsing, accept/deny, transfer flow, recursive |
67 | | * push). Without this wrapping the fuzzer almost never satisfies the |
68 | | * "\x00C<mode> <size> <name>\n" prefix the SCP layer expects, and |
69 | | * coverage stalls in the early-protocol record-parser rejection paths. |
70 | | * |
71 | | * The 4 envelope bytes consumed here are still fuzzer-controlled, so |
72 | | * libFuzzer mutations explore C/D/T/E variants, mismatched sizes, and |
73 | | * unusual modes from inside the wrap: |
74 | | * |
75 | | * data[0..1] SCP mode (12 bits) |
76 | | * data[2] bit 0-1: variant select (0=C, 1=D, 2=T, 3=E) |
77 | | * bit 2: optional trailing server-ACK after payload |
78 | | * data[3] declared transfer size in the C-record header |
79 | | * data[4..] raw payload bytes appended after the SCP header |
80 | | * |
81 | | * Coverage of invalid SCP record parsing is NOT given up by this |
82 | | * shaping: ssh_server_fuzzer and ssh_client_fuzzer pump unstructured |
83 | | * bytes through the SSH transport and reach the SCP record parser's |
84 | | * rejection paths from that direction. |
85 | | */ |
86 | | static size_t |
87 | | scp_wrap(const uint8_t *data, size_t size, uint8_t *out, size_t out_cap) |
88 | 240 | { |
89 | 240 | uint16_t mode; |
90 | 240 | uint8_t variant; |
91 | 240 | uint8_t declared_size; |
92 | 240 | size_t payload_sz; |
93 | 240 | size_t cap_left; |
94 | 240 | size_t total; |
95 | 240 | int n; |
96 | | |
97 | 240 | if (size < 4 || out_cap == 0) { |
98 | 0 | return 0; |
99 | 0 | } |
100 | 240 | mode = ((uint16_t)data[0] << 8 | data[1]) & 07777; |
101 | 240 | variant = data[2] & 0x03; |
102 | 240 | declared_size = data[3]; |
103 | | |
104 | 240 | switch (variant) { |
105 | 175 | case 0: |
106 | 175 | n = snprintf((char *)out, |
107 | 175 | out_cap, |
108 | 175 | "%cC%04o %u f\n", |
109 | 175 | 0, |
110 | 175 | mode, |
111 | 175 | declared_size); |
112 | 175 | break; |
113 | 21 | case 1: |
114 | 21 | n = snprintf((char *)out, out_cap, "%cD%04o 0 d\n", 0, mode); |
115 | 21 | break; |
116 | 17 | case 2: |
117 | 17 | n = snprintf((char *)out, out_cap, "%cT0 0 0 0\n", 0); |
118 | 17 | break; |
119 | 27 | default: |
120 | 27 | n = snprintf((char *)out, out_cap, "%cE\n", 0); |
121 | 27 | break; |
122 | 240 | } |
123 | 240 | if (n < 0 || (size_t)n >= out_cap) { |
124 | 0 | return 0; |
125 | 0 | } |
126 | | |
127 | 240 | payload_sz = size - 4; |
128 | 240 | cap_left = out_cap - (size_t)n; |
129 | 240 | if (payload_sz > cap_left) { |
130 | 52 | payload_sz = cap_left; |
131 | 52 | } |
132 | 240 | memcpy(out + n, data + 4, payload_sz); |
133 | 240 | total = (size_t)n + payload_sz; |
134 | | /* Optional server final ACK; bit chosen by fuzzer to cover both paths */ |
135 | 240 | if (variant == 0 && (data[2] & 0x04) && total < out_cap) { |
136 | 135 | out[total++] = '\x00'; |
137 | 135 | } |
138 | 240 | return total; |
139 | 240 | } |
140 | | |
141 | | /* Run one SCP fuzzing iteration against the mock server */ |
142 | | static int test_scp_with_cipher(const uint8_t *data, |
143 | | size_t size, |
144 | | const char *cipher, |
145 | | const char *hmac) |
146 | 240 | { |
147 | 240 | bool thread_started = false; |
148 | 240 | int socket_fds[2] = {-1, -1}; |
149 | 240 | ssh_session client_session = NULL; |
150 | 240 | ssh_scp scp = NULL, scp_recursive = NULL; |
151 | 240 | char buf[256] = {0}; |
152 | 240 | pthread_t srv_thread; |
153 | 240 | int rc; |
154 | 240 | long timeout = 1; |
155 | 240 | bool no = false; |
156 | 240 | struct timeval tv = {.tv_sec = 2, .tv_usec = 0}; |
157 | | |
158 | | /* Configure mock SSH server with fuzzer data */ |
159 | 240 | struct ssh_mock_server_config server_config = { |
160 | 240 | .protocol_data = data, |
161 | 240 | .protocol_data_size = size, |
162 | 240 | .exec_callback = ssh_mock_send_raw_data, |
163 | 240 | .subsystem_callback = NULL, |
164 | 240 | .callback_userdata = NULL, |
165 | 240 | .cipher = cipher, |
166 | 240 | .hmac = hmac, |
167 | 240 | .server_socket = -1, |
168 | 240 | .client_socket = -1, |
169 | 240 | .server_ready = false, |
170 | 240 | .server_error = false, |
171 | 240 | .shutdown_requested = false, |
172 | 240 | }; |
173 | | |
174 | 240 | if (socketpair(AF_UNIX, SOCK_STREAM, 0, socket_fds) != 0) { |
175 | 0 | goto cleanup; |
176 | 0 | } |
177 | | |
178 | | /* Set socket timeouts to prevent indefinite blocking */ |
179 | 240 | setsockopt(socket_fds[0], SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); |
180 | 240 | setsockopt(socket_fds[0], SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); |
181 | 240 | setsockopt(socket_fds[1], SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); |
182 | 240 | setsockopt(socket_fds[1], SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); |
183 | | |
184 | 240 | server_config.server_socket = socket_fds[0]; |
185 | 240 | server_config.client_socket = socket_fds[1]; |
186 | | |
187 | 240 | if (ssh_mock_server_start(&server_config, &srv_thread) != 0) { |
188 | 0 | goto cleanup; |
189 | 0 | } |
190 | 240 | thread_started = true; |
191 | | |
192 | 240 | client_session = ssh_new(); |
193 | 240 | if (client_session == NULL) { |
194 | 0 | goto cleanup; |
195 | 0 | } |
196 | | |
197 | | /* Configure client; bail on the first failing option-set so we don't |
198 | | * run the rest of the iteration in a partially-configured state. |
199 | | * SSH_OPTIONS_PROCESS_CONFIG is set to false so the fuzzer doesn't read |
200 | | * ~/.ssh/config or /etc/ssh/ssh_config — keeps results deterministic |
201 | | * across environments. SSH_OPTIONS_TIMEOUT is 1 second. */ |
202 | 240 | rc = ssh_options_set(client_session, SSH_OPTIONS_FD, &socket_fds[1]); |
203 | 240 | if (rc != SSH_OK) { |
204 | 0 | goto cleanup; |
205 | 0 | } |
206 | 240 | rc = ssh_options_set(client_session, SSH_OPTIONS_HOST, "localhost"); |
207 | 240 | if (rc != SSH_OK) { |
208 | 0 | goto cleanup; |
209 | 0 | } |
210 | 240 | rc = ssh_options_set(client_session, SSH_OPTIONS_USER, "fuzz"); |
211 | 240 | if (rc != SSH_OK) { |
212 | 0 | goto cleanup; |
213 | 0 | } |
214 | 240 | rc = ssh_options_set(client_session, SSH_OPTIONS_CIPHERS_C_S, cipher); |
215 | 240 | if (rc != SSH_OK) { |
216 | 0 | goto cleanup; |
217 | 0 | } |
218 | 240 | rc = ssh_options_set(client_session, SSH_OPTIONS_CIPHERS_S_C, cipher); |
219 | 240 | if (rc != SSH_OK) { |
220 | 0 | goto cleanup; |
221 | 0 | } |
222 | 240 | rc = ssh_options_set(client_session, SSH_OPTIONS_HMAC_C_S, hmac); |
223 | 240 | if (rc != SSH_OK) { |
224 | 0 | goto cleanup; |
225 | 0 | } |
226 | 240 | rc = ssh_options_set(client_session, SSH_OPTIONS_HMAC_S_C, hmac); |
227 | 240 | if (rc != SSH_OK) { |
228 | 0 | goto cleanup; |
229 | 0 | } |
230 | 240 | rc = ssh_options_set(client_session, SSH_OPTIONS_PROCESS_CONFIG, &no); |
231 | 240 | if (rc != SSH_OK) { |
232 | 0 | goto cleanup; |
233 | 0 | } |
234 | 240 | rc = ssh_options_set(client_session, SSH_OPTIONS_TIMEOUT, &timeout); |
235 | 240 | if (rc != SSH_OK) { |
236 | 0 | goto cleanup; |
237 | 0 | } |
238 | | |
239 | 240 | if (ssh_connect(client_session) != SSH_OK) { |
240 | 0 | goto cleanup; |
241 | 0 | } |
242 | | |
243 | 240 | if (ssh_userauth_none(client_session, NULL) != SSH_AUTH_SUCCESS) { |
244 | 0 | goto cleanup; |
245 | 0 | } |
246 | | |
247 | 240 | scp = ssh_scp_new(client_session, SSH_SCP_READ, "/tmp/fuzz"); |
248 | 240 | if (scp == NULL) { |
249 | 0 | goto cleanup; |
250 | 0 | } |
251 | | |
252 | 240 | if (ssh_scp_init(scp) != SSH_OK) { |
253 | 239 | goto cleanup; |
254 | 239 | } |
255 | | |
256 | 1 | if (size > 0) { |
257 | 1 | size_t copy_size = size < sizeof(buf) ? size : sizeof(buf); |
258 | 1 | memcpy(buf, data, copy_size); |
259 | 1 | } |
260 | | |
261 | | /* Fuzz all SCP API functions in read mode */ |
262 | 1 | ssh_scp_pull_request(scp); |
263 | 1 | ssh_scp_request_get_filename(scp); |
264 | 1 | ssh_scp_request_get_permissions(scp); |
265 | 1 | ssh_scp_request_get_size64(scp); |
266 | 1 | ssh_scp_request_get_size(scp); |
267 | 1 | ssh_scp_request_get_warning(scp); |
268 | 1 | ssh_scp_accept_request(scp); |
269 | 1 | ssh_scp_deny_request(scp, "Denied by fuzzer"); |
270 | 1 | ssh_scp_read(scp, buf, sizeof(buf)); |
271 | | |
272 | | /* Final fuzz of scp pull request after all the calls */ |
273 | 1 | ssh_scp_pull_request(scp); |
274 | | |
275 | | /* Fuzz SCP in write/upload + recursive directory mode. */ |
276 | 1 | scp_recursive = ssh_scp_new(client_session, |
277 | 1 | SSH_SCP_WRITE | SSH_SCP_RECURSIVE, |
278 | 1 | "/tmp/fuzz-recursive"); |
279 | 1 | if (scp_recursive != NULL) { |
280 | 1 | if (ssh_scp_init(scp_recursive) == SSH_OK) { |
281 | 0 | ssh_scp_push_directory(scp_recursive, "fuzz-dir", 0755); |
282 | 0 | ssh_scp_push_file(scp_recursive, "fuzz-file", sizeof(buf), 0644); |
283 | 0 | ssh_scp_write(scp_recursive, buf, sizeof(buf)); |
284 | 0 | ssh_scp_leave_directory(scp_recursive); |
285 | 0 | } |
286 | 1 | } |
287 | | |
288 | 240 | cleanup: |
289 | | /* Signal server thread to exit */ |
290 | 240 | server_config.shutdown_requested = true; |
291 | | |
292 | | /* Close sockets */ |
293 | 240 | if (socket_fds[0] >= 0) |
294 | 240 | close(socket_fds[0]); |
295 | 240 | if (socket_fds[1] >= 0) |
296 | 240 | close(socket_fds[1]); |
297 | | |
298 | | /* Cleanup client objects */ |
299 | 240 | if (scp_recursive != NULL) { |
300 | 1 | ssh_scp_close(scp_recursive); |
301 | 1 | ssh_scp_free(scp_recursive); |
302 | 1 | } |
303 | 240 | if (scp) { |
304 | 240 | ssh_scp_close(scp); |
305 | 240 | ssh_scp_free(scp); |
306 | 240 | } |
307 | 240 | if (client_session) { |
308 | 240 | ssh_disconnect(client_session); |
309 | 240 | ssh_free(client_session); |
310 | 240 | } |
311 | | |
312 | | /* Server thread exits via shutdown_requested + 2s socket timeout */ |
313 | 240 | if (thread_started) { |
314 | 240 | pthread_join(srv_thread, NULL); |
315 | 240 | } |
316 | | |
317 | 240 | return 0; |
318 | 1 | } |
319 | | |
320 | | /* |
321 | | * Fuzzer input layout (not passed directly to the SSH transport; it is |
322 | | * decoded here, then fed through scp_wrap): |
323 | | * |
324 | | * data[0] cipher index, taken modulo length of k_ciphers |
325 | | * data[1] HMAC index, taken modulo length of k_hmacs |
326 | | * data[2..] handed to scp_wrap, which uses the first 4 bytes as the |
327 | | * SCP envelope (mode, variant, declared size, optional ACK |
328 | | * toggle) and the rest as the file-content payload. |
329 | | * |
330 | | * Inputs shorter than 6 bytes are rejected so every iteration has at |
331 | | * least the two selector bytes plus one full envelope for scp_wrap. |
332 | | */ |
333 | | int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) |
334 | 245 | { |
335 | 245 | uint8_t wrapped[4096]; |
336 | 245 | size_t wrapped_size = 0; |
337 | 245 | const char *cipher = NULL; |
338 | 245 | const char *hmac = NULL; |
339 | | |
340 | 245 | if (size < 6) { |
341 | 5 | return 0; |
342 | 5 | } |
343 | | |
344 | 245 | assert(nalloc_start(data, size) > 0); |
345 | | |
346 | 240 | cipher = k_ciphers[data[0] % (sizeof(k_ciphers) / sizeof(k_ciphers[0]))]; |
347 | 240 | hmac = k_hmacs[data[1] % (sizeof(k_hmacs) / sizeof(k_hmacs[0]))]; |
348 | | |
349 | 240 | wrapped_size = scp_wrap(data + 2, size - 2, wrapped, sizeof(wrapped)); |
350 | 240 | if (wrapped_size > 0) { |
351 | 240 | test_scp_with_cipher(wrapped, wrapped_size, cipher, hmac); |
352 | 240 | } |
353 | | |
354 | 240 | nalloc_end(); |
355 | 240 | return 0; |
356 | 240 | } |