/src/libtorrent/fuzzers/src/socks5_udp.cpp
Line | Count | Source |
1 | | /* |
2 | | |
3 | | Copyright (c) 2026, Arvid Norberg |
4 | | All rights reserved. |
5 | | |
6 | | You may use, distribute and modify this code under the terms of the BSD license, |
7 | | see LICENSE file. |
8 | | */ |
9 | | |
10 | | // Fuzzer for the SOCKS5 UDP path (UDP ASSOCIATE). It exercises: |
11 | | // |
12 | | // - The control plane: the socks5 state machine that lives inside |
13 | | // udp_socket.cpp, driving every supported tunnel configuration |
14 | | // (no-auth vs. user/password, send_local_ep on/off, IPv4 vs. |
15 | | // DOMAINNAME bind address in the UDP ASSOCIATE response, and the |
16 | | // proxy_peer/proxy_tracker_connections flags). |
17 | | // |
18 | | // - The data plane: udp_socket::wrap() and udp_socket::unwrap() |
19 | | // receive arbitrary fuzzer bytes once the tunnel is active. Wrap |
20 | | // is exercised both by endpoint and by hostname (and across both |
21 | | // IPv4 and IPv6 destinations); unwrap is exercised by injecting a |
22 | | // UDP packet from a fake SOCKS5 relay socket bound to the address |
23 | | // advertised in the tunnel handshake. |
24 | | // |
25 | | // Per-input wire format: |
26 | | // |
27 | | // Byte 0 : configuration flags (see flag_* constants below) |
28 | | // Bytes 1.. : raw bytes used as the body of: |
29 | | // - the UDP packet injected from the fake relay |
30 | | // (drives unwrap() through udp_socket::read()) |
31 | | // - the optional send()/send_hostname() calls |
32 | | // (drives wrap() in both endpoint and hostname forms) |
33 | | // |
34 | | // The TCP transcript that the fake SOCKS5 proxy server returns is canned |
35 | | // (built deterministically from the flags) so that the tunnel reaches the |
36 | | // active state and both wrap and unwrap actually run. |
37 | | |
38 | | #include <array> |
39 | | #include <cassert> |
40 | | #include <chrono> |
41 | | #include <cstdint> |
42 | | #include <cstdio> |
43 | | #include <cstring> |
44 | | #include <memory> |
45 | | #include <optional> |
46 | | #include <thread> |
47 | | #include <vector> |
48 | | #include <string> |
49 | | |
50 | | #include "libtorrent/io_context.hpp" |
51 | | #include "libtorrent/socket.hpp" |
52 | | #include "libtorrent/error_code.hpp" |
53 | | #include "libtorrent/settings_pack.hpp" |
54 | | #include "libtorrent/span.hpp" |
55 | | #include "libtorrent/address.hpp" |
56 | | |
57 | | #include "libtorrent/aux_/udp_socket.hpp" |
58 | | #include "libtorrent/aux_/listen_socket_handle.hpp" |
59 | | #include "libtorrent/aux_/alert_manager.hpp" |
60 | | #include "libtorrent/aux_/resolver_interface.hpp" |
61 | | #include "libtorrent/aux_/proxy_settings.hpp" |
62 | | #include "libtorrent/aux_/session_impl.hpp" // for aux::listen_socket_t |
63 | | |
64 | | #include "libtorrent/aux_/disable_warnings_push.hpp" |
65 | | #include <boost/asio/write.hpp> |
66 | | #include "libtorrent/aux_/disable_warnings_pop.hpp" |
67 | | |
68 | | namespace { |
69 | | |
70 | | // Configuration flag bits in the first input byte. |
71 | | constexpr std::uint8_t flag_socks5_pw = 0x01; // auth = socks5_pw (else socks5) |
72 | | constexpr std::uint8_t flag_with_credentials = 0x02; // populate username/password |
73 | | constexpr std::uint8_t flag_send_local_ep = 0x04; // udp_socket send_local_ep argument |
74 | | constexpr std::uint8_t flag_proxy_peer = 0x08; // proxy_peer_connections |
75 | | constexpr std::uint8_t flag_proxy_tracker = 0x10; // proxy_tracker_connections |
76 | | constexpr std::uint8_t flag_atyp_domain = |
77 | | 0x20; // UDP ASSOCIATE response uses DOMAINNAME atyp (else IPv4) |
78 | | constexpr std::uint8_t flag_server_offers_pw = |
79 | | 0x40; // server's method-selection METHOD = 0x02 (vs 0x00) |
80 | | constexpr std::uint8_t flag_server_auth_fail = |
81 | | 0x80; // sub-negotiation STATUS = 0x01 fail (vs 0x00 success) |
82 | | |
83 | | // A minimal synchronous resolver that returns an inline result. asio's real |
84 | | // resolver dispatches lookups through an internal thread, which adds latency |
85 | | // and shutdown coordination that the fuzzer does not need; "127.0.0.1" is the |
86 | | // only hostname we ever ask about, and make_address() handles that without |
87 | | // touching the network. |
88 | | struct fuzz_resolver final : lt::aux::resolver_interface |
89 | | { |
90 | | void async_resolve(std::string const& host, lt::aux::resolver_flags, callback_t h) override |
91 | 72 | { |
92 | 72 | lt::error_code ec; |
93 | 72 | std::vector<lt::address> ips; |
94 | 72 | lt::address const a = lt::make_address(host, ec); |
95 | 72 | if (!ec) ips.push_back(a); |
96 | 72 | h(ec, ips); |
97 | 72 | } |
98 | 0 | void abort() override {} |
99 | 0 | void set_cache_timeout(lt::seconds) override {} |
100 | | }; |
101 | | |
102 | | // Pump the io_context until idle. Bounded so a misbehaving handler chain |
103 | | // can never wedge the fuzzer. Returns true if the iteration budget was |
104 | | // exhausted (i.e. the queue was still non-empty), false if the queue |
105 | | // drained naturally. For the workloads this fuzzer drives, the bounded |
106 | | // exit should never trigger -- so the caller treats it as a hard error. |
107 | | bool drain(lt::io_context& ios, int const max_iters = 200) |
108 | 144 | { |
109 | 264 | for (int i = 0; i < max_iters; ++i) |
110 | 264 | { |
111 | 264 | ios.restart(); |
112 | 264 | if (ios.poll() == 0) return false; |
113 | 264 | } |
114 | 0 | return true; |
115 | 144 | } |
116 | | |
117 | | void drain_or_die(lt::io_context& ios, int const max_iters = 200) |
118 | 144 | { |
119 | 144 | if (drain(ios, max_iters)) |
120 | 0 | { |
121 | 0 | std::fprintf(stderr, |
122 | 0 | "socks5_udp fuzzer: drain() exhausted %d iterations without" |
123 | 0 | " emptying the io_context -- handler chain bug?\n", |
124 | 0 | max_iters); |
125 | 0 | assert(false && "drain() exhausted iteration budget"); |
126 | 0 | } |
127 | 144 | } |
128 | | |
129 | | // Build the canned TCP transcript that the fake SOCKS5 proxy will write back |
130 | | // in response to the udp_socket's outgoing handshake. The server's choices |
131 | | // (which auth method to offer, whether sub-negotiation succeeds) are |
132 | | // independent of the client's configuration so that the fuzzer can explore |
133 | | // the disagreement matrix -- e.g. client expects pw auth but server picks |
134 | | // no auth, or sub-negotiation returns failure. When server's choices line |
135 | | // up with the client's, the tunnel reaches the active state with |
136 | | // m_udp_proxy_addr = (127.0.0.1, relay_port) and subsequent relay packets |
137 | | // get past the source-IP match in udp_socket::read(). |
138 | | std::vector<std::uint8_t> build_proxy_transcript(bool const server_offers_pw, |
139 | | bool const server_auth_fail, |
140 | | bool const atyp_domain, |
141 | | std::uint16_t const relay_port) |
142 | 72 | { |
143 | 72 | std::vector<std::uint8_t> t; |
144 | | |
145 | | // Method-selection response. |
146 | 72 | t.push_back(0x05); // VER = SOCKS5 |
147 | 72 | t.push_back(server_offers_pw ? 0x02 : 0x00); // METHOD = user/pw or no auth |
148 | | |
149 | | // Username/password sub-negotiation response (only emitted if the |
150 | | // server told the client to authenticate). The client only reads |
151 | | // these bytes when it actually entered the sub-negotiation phase; |
152 | | // otherwise the bytes pile up in the connection and are discarded |
153 | | // when the client closes the socket. |
154 | 72 | if (server_offers_pw) |
155 | 36 | { |
156 | 36 | t.push_back(0x01); // VER = 1 |
157 | 36 | t.push_back(server_auth_fail ? 0x01 : 0x00); // STATUS |
158 | 36 | } |
159 | | |
160 | | // UDP ASSOCIATE response. atyp=1 (IPv4) or atyp=3 (DOMAINNAME). |
161 | 72 | t.push_back(0x05); // VER |
162 | 72 | t.push_back(0x00); // REP = succeeded |
163 | 72 | t.push_back(0x00); // RSV |
164 | 72 | if (atyp_domain) |
165 | 39 | { |
166 | 39 | t.push_back(0x03); // ATYP = DOMAINNAME |
167 | 39 | std::string const name = "127.0.0.1"; |
168 | 39 | t.push_back(static_cast<std::uint8_t>(name.size())); |
169 | 39 | t.insert(t.end(), name.begin(), name.end()); |
170 | 39 | } |
171 | 33 | else |
172 | 33 | { |
173 | 33 | t.push_back(0x01); // ATYP = IPv4 |
174 | 33 | t.push_back(0x7f); |
175 | 33 | t.push_back(0x00); |
176 | 33 | t.push_back(0x00); |
177 | 33 | t.push_back(0x01); // 127.0.0.1 |
178 | 33 | } |
179 | 72 | t.push_back(static_cast<std::uint8_t>(relay_port >> 8)); |
180 | 72 | t.push_back(static_cast<std::uint8_t>(relay_port & 0xff)); |
181 | | |
182 | 72 | return t; |
183 | 72 | } |
184 | | |
185 | | } // anonymous namespace |
186 | | |
187 | | extern "C" int LLVMFuzzerTestOneInput(std::uint8_t const* data, std::size_t size) |
188 | 72 | { |
189 | 72 | if (size < 1) return 0; |
190 | | |
191 | 72 | std::uint8_t const flags = data[0]; |
192 | 72 | std::uint8_t const* body = data + 1; |
193 | 72 | std::size_t const body_len = size - 1; |
194 | | |
195 | 72 | bool const socks5_pw = (flags & flag_socks5_pw) != 0; |
196 | 72 | bool const with_credentials = (flags & flag_with_credentials) != 0; |
197 | 72 | bool const send_local_ep = (flags & flag_send_local_ep) != 0; |
198 | 72 | bool const proxy_peer = (flags & flag_proxy_peer) != 0; |
199 | 72 | bool const proxy_tracker = (flags & flag_proxy_tracker) != 0; |
200 | 72 | bool const atyp_domain = (flags & flag_atyp_domain) != 0; |
201 | 72 | bool const server_offers_pw = (flags & flag_server_offers_pw) != 0; |
202 | 72 | bool const server_auth_fail = (flags & flag_server_auth_fail) != 0; |
203 | | |
204 | 72 | lt::io_context ios; |
205 | 72 | lt::aux::alert_manager alerts(100); |
206 | 72 | fuzz_resolver resolver; |
207 | | |
208 | | // listen_socket_t supplies the local endpoint and can_route() that the |
209 | | // socks5 state machine queries. We only need a v4 loopback address; the |
210 | | // netmask is wide enough that can_route() accepts the proxy's address. |
211 | 72 | auto listen_sock = std::make_shared<lt::aux::listen_socket_t>(); |
212 | 72 | listen_sock->local_endpoint = lt::tcp::endpoint(lt::make_address_v4("127.0.0.1"), 0); |
213 | 72 | listen_sock->netmask = lt::make_address_v4("255.0.0.0"); |
214 | 72 | lt::aux::listen_socket_handle ls(listen_sock); |
215 | | |
216 | 72 | lt::error_code ec; |
217 | | |
218 | | // Fake SOCKS5 proxy server (TCP). |
219 | 72 | lt::tcp::acceptor proxy_tcp(ios); |
220 | 72 | proxy_tcp.open(lt::tcp::v4(), ec); |
221 | 72 | if (ec) return 0; |
222 | 72 | proxy_tcp.bind(lt::tcp::endpoint(lt::make_address_v4("127.0.0.1"), 0), ec); |
223 | 72 | if (ec) return 0; |
224 | 72 | proxy_tcp.listen(boost::asio::socket_base::max_listen_connections, ec); |
225 | 72 | if (ec) return 0; |
226 | 72 | std::uint16_t const proxy_port = proxy_tcp.local_endpoint().port(); |
227 | | |
228 | | // Fake SOCKS5 UDP relay -- "the proxy's UDP-side socket". UDP packets |
229 | | // from this endpoint look authentic to the udp_socket because the socks5 |
230 | | // handshake response (built below) advertises this exact port. |
231 | 72 | lt::udp::socket relay(ios); |
232 | 72 | relay.open(lt::udp::v4(), ec); |
233 | 72 | if (ec) return 0; |
234 | 72 | relay.bind(lt::udp::endpoint(lt::make_address_v4("127.0.0.1"), 0), ec); |
235 | 72 | if (ec) return 0; |
236 | 72 | relay.non_blocking(true, ec); |
237 | 72 | if (ec) return 0; |
238 | | // Sanity check: the source-IP comparison in udp_socket::read() |
239 | | // depends on relay's outgoing packets carrying 127.0.0.1 as the |
240 | | // source. That is only guaranteed when relay is actually bound to |
241 | | // 127.0.0.1; assert it so a future regression here fails loud. |
242 | 72 | assert(relay.local_endpoint().address() == lt::make_address_v4("127.0.0.1")); |
243 | 72 | std::uint16_t const relay_port = relay.local_endpoint().port(); |
244 | | |
245 | | // The udp_socket under test. |
246 | 72 | lt::aux::udp_socket us(ios, ls); |
247 | 72 | us.open(lt::udp::v4(), ec); |
248 | 72 | if (ec) return 0; |
249 | 72 | us.bind(lt::udp::endpoint(lt::make_address_v4("127.0.0.1"), 0), ec); |
250 | 72 | if (ec) return 0; |
251 | | |
252 | | // Set up the proxy-side acceptor: when the udp_socket's socks5 state |
253 | | // machine connects, write our canned transcript. |
254 | | // |
255 | | // transcript and proxy_side are owned by shared_ptrs that the handler |
256 | | // chain captures by value, so their storage outlives any pending async |
257 | | // operation regardless of how cleanup is reordered. async_write keeps |
258 | | // a (ptr, size) buffer descriptor into transcript's storage until the |
259 | | // op completes or is cancelled, so transcript must remain valid that |
260 | | // long -- the shared_ptr in the completion handler guarantees it. |
261 | 72 | auto transcript = std::make_shared<std::vector<std::uint8_t> const>( |
262 | 72 | build_proxy_transcript(server_offers_pw, server_auth_fail, atyp_domain, relay_port)); |
263 | 72 | auto proxy_side = std::make_shared<std::optional<lt::tcp::socket>>(); |
264 | 72 | proxy_tcp.async_accept([transcript, proxy_side](lt::error_code const& aec, lt::tcp::socket s) { |
265 | 72 | if (aec) return; |
266 | 72 | proxy_side->emplace(std::move(s)); |
267 | 72 | boost::asio::async_write(**proxy_side, |
268 | 72 | boost::asio::buffer(*transcript), |
269 | 72 | [transcript](lt::error_code const&, std::size_t) {}); |
270 | 72 | }); |
271 | | |
272 | | // Configure the proxy and kick off the handshake. |
273 | 72 | lt::aux::proxy_settings ps; |
274 | 72 | ps.hostname = "127.0.0.1"; |
275 | 72 | ps.port = proxy_port; |
276 | 72 | ps.type = socks5_pw ? lt::settings_pack::socks5_pw : lt::settings_pack::socks5; |
277 | 72 | if (with_credentials) |
278 | 33 | { |
279 | 33 | ps.username = "fuzz-user"; |
280 | 33 | ps.password = "fuzz-pass"; |
281 | 33 | } |
282 | 72 | ps.proxy_peer_connections = proxy_peer; |
283 | 72 | ps.proxy_tracker_connections = proxy_tracker; |
284 | | |
285 | 72 | us.set_proxy_settings(ps, alerts, resolver, send_local_ep); |
286 | | |
287 | | // Drive the handshake to completion (or to an error). The fuzz_resolver |
288 | | // returns inline so the only async work is the loopback TCP traffic. |
289 | 72 | drain_or_die(ios); |
290 | | |
291 | | // Data plane: drive unwrap() with arbitrary bytes from the relay. |
292 | 72 | bool packet_in_flight = false; |
293 | 72 | if (us.active_socks5() && body_len > 0) |
294 | 32 | { |
295 | 32 | relay.send_to(boost::asio::buffer(body, body_len), us.local_endpoint(), 0, ec); |
296 | 32 | packet_in_flight = !ec; |
297 | 32 | } |
298 | | |
299 | | // Pull the packet through udp_socket::read(). This is the production |
300 | | // code path that calls unwrap() on every packet whose source matches |
301 | | // the proxy's advertised relay endpoint. read() does a synchronous |
302 | | // receive_from() on a non-blocking socket, so it may return |
303 | | // would_block if the kernel hasn't placed the packet on the receive |
304 | | // queue yet. Retry briefly to absorb that delivery jitter, but only |
305 | | // when we know a packet is actually in flight -- otherwise the vast |
306 | | // majority of inputs (those that never reach active_socks5()) would |
307 | | // pay the full sleep budget for nothing. |
308 | | // Note: udp_socket::read() returning 0 here is the common case, not |
309 | | // an error. socks5_unwrap() rejects any packet that isn't shaped |
310 | | // like a valid SOCKS5 UDP header, and most random fuzzer payloads |
311 | | // aren't -- that rejection is exactly the unwrap() coverage we want. |
312 | | // We can't distinguish "kernel drop" / "source IP mismatch" / |
313 | | // "unwrap rejected" from outside read(), so we don't try. |
314 | 72 | if (packet_in_flight) |
315 | 24 | { |
316 | 24 | std::array<lt::aux::udp_socket::packet, 4> pkts; |
317 | 312 | for (int i = 0; i < 16; ++i) |
318 | 294 | { |
319 | 294 | lt::error_code rec; |
320 | 294 | int const n = us.read(pkts, rec); |
321 | 294 | if (n > 0) break; |
322 | 288 | if (rec != boost::asio::error::would_block && rec != boost::asio::error::try_again) |
323 | 0 | break; |
324 | 288 | using namespace std::chrono_literals; |
325 | 288 | std::this_thread::sleep_for(100us); |
326 | 288 | } |
327 | 24 | } |
328 | | |
329 | | // Data plane: drive wrap() through send() and send_hostname() with the |
330 | | // fuzz buffer as the payload. All three forms run on every input so |
331 | | // every wrap variant gets coverage from every byte mutation. When |
332 | | // active_socks5() is true these end up at our relay; the kernel queues |
333 | | // the writes so the buffers stay alive past the call site. |
334 | 72 | { |
335 | 72 | lt::span<char const> const payload( |
336 | 72 | reinterpret_cast<char const*>(body), static_cast<std::ptrdiff_t>(body_len)); |
337 | | |
338 | | // IPv4 destination through wrap-by-endpoint. |
339 | 72 | { |
340 | 72 | lt::udp::endpoint dest(lt::make_address_v4("8.8.8.8"), 1234); |
341 | 72 | us.send(dest, payload, ec); |
342 | 72 | } |
343 | | // IPv6 destination through wrap-by-endpoint: exercises the v6 |
344 | | // branch of write_endpoint() and the dont_fragment v4-only check. |
345 | 72 | { |
346 | 72 | lt::udp::endpoint dest6(lt::make_address_v6("::1"), 1234); |
347 | 72 | us.send(dest6, payload, ec); |
348 | 72 | } |
349 | | // Hostname destination through wrap-by-hostname. |
350 | 72 | us.send_hostname("example.com", 1234, payload, ec); |
351 | 72 | } |
352 | | |
353 | | // Cleanup. close() on the udp_socket also closes its inner socks5 |
354 | | // connection; any read/write handlers still pending get cancelled and |
355 | | // drain() runs them so they don't fire across invocations. |
356 | 72 | us.close(); |
357 | 72 | if (proxy_side->has_value()) (**proxy_side).close(ec); |
358 | 72 | proxy_tcp.close(ec); |
359 | 72 | relay.close(ec); |
360 | 72 | drain_or_die(ios, 500); |
361 | | |
362 | 72 | return 0; |
363 | 72 | } |