[PATCH 0/5] Add AFL++ fuzzing support for passt
This series adds integrated AFL++ fuzzing support for passt, extending the earlier work by AbdAlRahman Gad with persistent mode, bidirectional protocol fuzzing, and real TCP connection coverage via a companion test server. Architecture ------------ The fuzzer runs passt in AFL++ persistent mode (__AFL_LOOP) with shared memory fuzzing. A separate test server process connects to passt's UNIX socket and listens on 127.0.0.1:9999 for real TCP connections: Deterministic wrappers replace clock_gettime, getrandom, getsockopt, and recv-family calls to eliminate kernel-level non-determinism. Per-iteration reset of the flow table, epoll instance, and clock. *** BLURB HERE *** Anshu Kumari (5): fuzz: Add deterministic wrappers for system calls fuzz: Add flow type guards for fuzzing stability fuzz: Bypass isolation and adapt sockets for AFL++ fuzz: Add AFL++ persistent mode fuzz loop fuzz: Add test server for bidirectional protocol fuzzing Makefile | 28 +- fuzz-server.c | 490 +++++++++++++++++++++++++++++++++ fuzz.c | 275 ++++++++++++++++++ fuzz.h | 62 +++++ fuzzing/README.fuzzing.md | 95 +++++++ fuzzing/testcase_dir/empty.bin | Bin 0 -> 12 bytes icmp.c | 14 +- isolation.c | 11 + passt.c | 189 +++++++++++++ passt.h | 4 + tap.c | 21 ++ tcp.c | 19 +- tcp_buf.c | 1 + tcp_splice.c | 10 + udp.c | 29 +- udp_flow.c | 5 + util.c | 10 + 17 files changed, 1251 insertions(+), 12 deletions(-) create mode 100644 fuzz-server.c create mode 100644 fuzz.c create mode 100644 fuzz.h create mode 100644 fuzzing/README.fuzzing.md create mode 100644 fuzzing/testcase_dir/empty.bin -- 2.55.0
Few components which needs to be disabled to support AFL++
to work:
- isolation.c: Skip isolation and seccomp sandboxing that breaks AFL++
pipes, namespaces, and ASan mmap/mprotect operations.
- util.c / tap.c: Switch UNIX socket to SOCK_SEQPACKET to preserve frame
boundaries, simplifying tap_passt_input() to a single recv() and removing
vnet_len framing.
- passt.h: Use /tmp/passt_fuzz_%i.socket to avoid path collisions with
production instances.
- tcp_buf.c: Include fuzz.h to route recvmsg() through deterministic wrappers.
Signed-off-by: Anshu Kumari
Under FUZZING, AFL++ can inject arbitrary epoll event types
from its shared memory buffer. When an event references a flow
table entry whose type doesn't match the handler, the existing
assert() crashes the process eventually masking the real bugs.
If there is no flow at the start of fuzzing then also we are
just returning early instead of hitting crashes. Allowing the
fuzzer to explore other code path.
Replace assert() with NULL returns in the flow-lookup functions
when compiled with -DFUZZING:
- tcp.c: conn_at_sidx(), tcp_timer_handler(), tcp_sock_handler()
- tcp_splice.c: conn_at_sidx(), tcp_splice_sock_handler()
- udp.c: udp_sock_handler(), udp_sock_to_sock(),
udp_buf_sock_to_tap(), udp_sock_fwd() error path
- udp_flow.c: udp_at_sidx()
- icmp.c: ping_at_sidx(), icmp_sock_handler()
Signed-off-by: Anshu Kumari
Add fuzz.h and fuzz.c with wrapper implementations for
clock_gettime(), getrandom(), getsockopt(), recv(),
recvmsg(), recvfrom() and recvmmsg().
Under -DFUZZING, these macros replace the real system
calls across the codebase:
- fuzz_clock_gettime(): returns a deterministic clock that
advances by 1 microsecond per call.
- fuzz_getrandom(): fills buffers with a fixed 0x41 pattern.
- fuzz_getsockopt(): returns static values for TCP_INFO,
SO_ERROR, SO_RCVBUF, SO_SNDBUF.
- fuzz_recv/recvmsg/recvfrom/recvmmsg(): for fd_tap, calls
the real syscall; for all other fds, returns data from
AFL++ shared memory buffer
These wrappers eliminate kernel-level non-determinism during
AFL++ fuzzing.
Signed-off-by: Anshu Kumari
Add the AFL++ persistent mode fuzz loop to passt.c main().
The loop uses __AFL_LOOP() for in-process iteration and
__AFL_FUZZ_TESTCASE_BUF for shared memory fuzzing.
Each iteration:
- Resets deterministic clock, flow table, and epoll instance.
- Drains stale data from the TAP socket.
- Reads an epoll event from the AFL++ buffer.
- For TAP events: constructs a packet with fixed L2/L3/L4
headers and injects it via tap_add_packet() + tap_handler().
- Exchanges a turn flag with the test server for
bidirectional flow over the UNIX socket.
- Calls passt_worker() to process the event.
- Polls for host-side TCP events via epoll_wait().
- Runs post_handler() for deferred work.
Added the 'make fuzz' target which builds passt with
afl-clang-fast, -DFUZZING, -DNDEBUG, and AddressSanitizer.
Signed-off-by: Anshu Kumari
Add fuzz-server that acts as passt's network peer
during fuzzing. It connects to passt's UNIX socket
and listens on 127.0.0.1:9999 for TCP connections.
UNIX socket path: responds to ARP requests and TCP SYNs with
stateless replies (swapped addresses, fixed ISN). Responses
are XOR'd with AFL++ shared memory data so the fuzzer can
mutate server behavior.
TCP loopback path: accepts connections on port 9999, reads
data, XOR-mutates with AFL++ shared memory, and echoes it
back. Uses SO_LINGER(0) for immediate RST on close to
avoid TIME_WAIT port exhaustion.
Turn-based synchronization via mmap'd flag in /dev/shm
coordinates frame exchange between passt and the server.
Also adds:
- fuzzing/testcase_dir/empty.bin
- fuzzing/README.fuzzing.md
Signed-off-by: Anshu Kumari
On Wed, Aug 12, 2026 at 12:56:24PM +0530, Anshu Kumari wrote:
Add fuzz.h and fuzz.c with wrapper implementations for clock_gettime(), getrandom(), getsockopt(), recv(), recvmsg(), recvfrom() and recvmmsg().
Under -DFUZZING, these macros replace the real system calls across the codebase:
- fuzz_clock_gettime(): returns a deterministic clock that advances by 1 microsecond per call. - fuzz_getrandom(): fills buffers with a fixed 0x41 pattern. - fuzz_getsockopt(): returns static values for TCP_INFO, SO_ERROR, SO_RCVBUF, SO_SNDBUF. - fuzz_recv/recvmsg/recvfrom/recvmmsg(): for fd_tap, calls the real syscall; for all other fds, returns data from AFL++ shared memory buffer
These wrappers eliminate kernel-level non-determinism during AFL++ fuzzing.
I certainly think the general approach of replacing the system call interactions with deterministic version is a good one (btw, I believe this approach is known as Deterministic Simulation Testing). Ideally I'd like to see more of these wrappers based on data from the AFL++ buffer - that way we can use AFL++'s coverage directed fuzzing to find more possible bad paths. Still, starting with a limited set and expanding from there is reasonable.
Signed-off-by: Anshu Kumari
--- Makefile | 14 +-- fuzz.c | 275 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ fuzz.h | 62 +++++++++++++ 3 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 fuzz.c create mode 100644 fuzz.h diff --git a/Makefile b/Makefile index b315242..fe1df58 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ PASST_SRCS = arch.c arp.c bitmap.c checksum.c conf.c dhcp.c dhcpv6.c \ isolation.c lineread.c log.c mld.c ndp.c netlink.c migrate.c packet.c \ parse.c passt.c pasta.c pcap.c pif.c repair.c serialise.c tap.c tcp.c \ tcp_buf.c tcp_splice.c tcp_vu.c udp.c udp_flow.c udp_vu.c util.c \ - vhost_user.c virtio.c vu_common.c + vhost_user.c virtio.c vu_common.c fuzz.c PASST_REPAIR_SRCS = passt-repair.c PESTO_SRCS = pesto.c bitmap.c fwd_rule.c inany.c ip.c lineread.c parse.c \ serialise.c @@ -47,12 +47,12 @@ SRCS = $(PASST_SRCS) $(PASST_REPAIR_SRCS) $(PESTO_SRCS) MANPAGES = passt.1 pasta.1 pesto.1 passt-repair.1
PASST_HEADERS = arch.h arp.h bitmap.h checksum.h conf.h dhcp.h dhcpv6.h \ - epoll_ctl.h flow.h fwd.h fwd_rule.h flow_table.h icmp.h icmp_flow.h \ - inany.h iov.h ip.h isolation.h lineread.h linux_dep.h log.h migrate.h \ - ndp.h netlink.h packet.h parse.h passt.h pasta.h pcap.h pif.h repair.h \ - serialise.h siphash.h tap.h tcp.h tcp_buf.h tcp_conn.h tcp_internal.h \ - tcp_splice.h tcp_vu.h udp.h udp_flow.h udp_internal.h udp_vu.h util.h \ - vhost_user.h virtio.h vu_common.h + epoll_ctl.h flow.h fwd.h fwd_rule.h flow_table.h fuzz.h icmp.h \ + icmp_flow.h inany.h iov.h ip.h isolation.h lineread.h linux_dep.h \ + log.h migrate.h ndp.h netlink.h packet.h parse.h passt.h pasta.h \ + pcap.h pif.h repair.h serialise.h siphash.h tap.h tcp.h tcp_buf.h \ + tcp_conn.h tcp_internal.h tcp_splice.h tcp_vu.h udp.h udp_flow.h \ + udp_internal.h udp_vu.h util.h vhost_user.h virtio.h vu_common.h PASST_REPAIR_HEADERS = linux_dep.h PESTO_HEADERS = bitmap.h common.h fwd_rule.h inany.h ip.h log.h parse.h \ pesto.h serialise.h diff --git a/fuzz.c b/fuzz.c new file mode 100644 index 0000000..a1f6c01 --- /dev/null +++ b/fuzz.c @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +/* fuzz.c - AFL++ fuzzing support: deterministic wrappers for + * clock_gettime(), getrandom(), getsockopt(), recv(), + * recvmsg(), recvfrom() and recvmmsg() + * + * Copyright Red Hat + * Author: Anshu Kumari
+ */ + +#ifdef FUZZING
A possible alternative to wrapping the entire .c file in a #ifdef would be to exclude it from the build in the Makefile in the !FUZZING case.
+#include
+#include +#include +#include +#include "passt.h" +#include "fuzz.h" + +/* Undo macros so definitions here call the real syscalls */ +#undef clock_gettime +#undef getrandom +#undef getsockopt +#undef recv +#undef recvmsg +#undef recvfrom +#undef recvmmsg + +const unsigned char *fuzz_recv_data; +int fuzz_recv_data_len; + +#define FUZZ_CLOCK_BASE_SEC 10000 + +static struct timespec fuzz_clock; + +/** + * fuzz_clock_reset() - Reset clock to fixed baseline + * + * Called at the start of every __AFL_LOOP iteration so + * the clock is identical regardless of iteration number. + */ +void fuzz_clock_reset(void) +{ + fuzz_clock.tv_sec = FUZZ_CLOCK_BASE_SEC; + fuzz_clock.tv_nsec = 0; +} + +/** + * fuzz_clock_gettime() - Return deterministic time + * @clk: Clock ID + * @tp: Output timespec + * + * Return: 0 (always succeeds) + */ +int fuzz_clock_gettime(clockid_t clk, struct timespec *tp) +{ + (void)clk; + *tp = fuzz_clock; + + /* increment the timestamp by 1 micro sec monotonically */ + fuzz_clock.tv_nsec += 1000; + if (fuzz_clock.tv_nsec >= 1000000000) { + fuzz_clock.tv_sec++; + fuzz_clock.tv_nsec -= 1000000000; + } + return 0; +}
Since we have logic that depends on specific elapsed times, eventually it would certainly be nice to have this influenced by the fuzzer as well. To avoid problems with time going backwards, the obvious way to do that would be to read a a number of ns to advance by from the shared memory. But again, this 1µs per call approach is pretty good for a first cut.
+ +/** + * fuzz_getrandom() - Return static deterministic bytes + * @buf: Output buffer + * @buflen: Bytes to fill + * @flags: Ignored + * + * Fills buffer with a repeating 0x41 pattern. Every call with the + * same length returns identical bytes, eliminating randomness + * + * Return: buflen (always succeeds) + */ +ssize_t fuzz_getrandom(void *buf, size_t buflen, unsigned int flags) +{ + (void)flags; + memset(buf, 0x41, buflen); + return buflen; +}
It would be nice in principle to fuzz this, but we only use getrandom() in a handful of places, so it's not a high priority.
+ +/** + * fuzz_getsockopt() - Deterministic getsockopt wrapper + * @fd: Socket file descriptor + * @level: Protocol level + * @optname: Option name + * @optval: Output buffer + * @optlen: In/out option length + * + * For TCP_INFO, SO_ERROR, SO_RCVBUF, SO_SNDBUF returns determinstic + * values. For all other options: calls the real getsockopt. + * + * Return: 0 on success, -1 on error + */ +int fuzz_getsockopt(int fd, int level, int optname, void *optval, + socklen_t *optlen) +{ + if (level == SOL_SOCKET) { + if (optname == SO_ERROR) { + *(int *)optval = 0; + return 0; + } + if (optname == SO_RCVBUF || optname == SO_SNDBUF) { + *(int *)optval = 212992; /* default linux buff size */ + return 0; + } + } + + /* intercept SOL_TCP option: TCP_INFO */ + if (level == SOL_TCP && optname == TCP_INFO) { + size_t fill = *optlen; + + memset(optval, 0, fill); + + if (fill >= sizeof(struct tcp_info)) { + struct tcp_info *ti = optval; + + ti->tcpi_state = 1; /* TCP_ESTABLISHED */ + ti->tcpi_rto = 200000; /* 200ms */ + ti->tcpi_rtt = 1000; /* 1ms RTT */ + ti->tcpi_rttvar = 500; + ti->tcpi_snd_mss = 1460; + ti->tcpi_rcv_mss = 1460; + ti->tcpi_snd_cwnd = 10; + ti->tcpi_advmss = 1460; + ti->tcpi_pmtu = 1500; + + *optlen = sizeof(struct tcp_info);
Hm. Unconditionally setting *optlen to sizeof(struct tcp_info) means we'll never report the Linux extension fields for TCP_INFO. I believe we do have fall back logic to handle that, but that means the fuzzer won't be exercising the same paths that we use most of the time. Again, this is fine to get something going, but I think expanding the fuzzing of TCP_INFO should be one of the highest priorities after the basics are working: we make a *lot* of decisions which affect control flow based on TCP_INFO (including many Linux extension fields). We've had a bunch of subtle TCP bugs caused here as well, so it's exactly the sort of place that fuzzing would be beneficial.
+ } + + return 0; + } + + return getsockopt(fd, level, optname, optval, optlen); +} + +/** + * fuzz_recv() - recv wrapper + * @fd: File descriptor + * @buf: Output buffer + * @len: Max bytes + * @flags: recv flags (passed through for fd_tap) + * + * real recv() for fd_tap, AFL++ data for everything else + * + * Return: bytes read, or -1 + */ +ssize_t fuzz_recv(int fd, void *buf, size_t len, int flags)
You might be able to avoid some duplicated code by implementing some of these wrappers in terms of each other: fuzz_recv() in terms of fuzz_recvfrom() in terms of fuzz_recvmsg().
+{ + size_t n; + + /* fd is TAP socket for UNIX connection */ + if (fd == passt_ctx.fd_tap) + return recv(fd, buf, len, flags); + + if (!fuzz_recv_data || fuzz_recv_data_len <= 0) { + errno = EAGAIN; + return -1;
I haven't looked at the rest of the series yet, so I'm not sure how fuzz_recv_data gets populated. Because when fuzzing we're in a test harness environment, it would also be acceptable to block waiting for more data from the fuzzer here.
+ } + + n = (len < (size_t)fuzz_recv_data_len) ? + len : (size_t)fuzz_recv_data_len;
We have an existing MIN macro.
+ memcpy(buf, fuzz_recv_data, n); + fuzz_recv_data += n; + fuzz_recv_data_len -= n;
This suggests there's a fixed amount of data in the buffer. So it might also be ok to treat running out of fuzz data as an EOF. I'd also consider allowing the fuzzer to generate errors or short reads() from recv as a fairly high priority. Those are reasonably likely thing in real life, so it would be good to be able to exercise those paths from the fuzzer.
+ return n; +} + +/** + * fuzz_recvmsg() - recvmsg wrapper + * @fd: File descriptor + * @msg: Message header + * @flags: recvmsg flags + * + * For fd_tap: calls real recvmsg. For all other fds: fills each + * buffer sequentially from the AFL++ shared memory stream. + * + * Return: total bytes read across all iovecs, or -1 + */ +ssize_t fuzz_recvmsg(int fd, struct msghdr *msg, int flags) +{ + size_t total = 0; + size_t i; + + if (fd == passt_ctx.fd_tap) + return recvmsg(fd, msg, flags); + + if (!fuzz_recv_data || fuzz_recv_data_len <= 0) { + errno = EAGAIN; + return -1; + } + + for (i = 0; i < (size_t)msg->msg_iovlen && + fuzz_recv_data_len > 0; i++) { + size_t n = msg->msg_iov[i].iov_len; + + if ((int)n > fuzz_recv_data_len) + n = fuzz_recv_data_len; + memcpy(msg->msg_iov[i].iov_base, fuzz_recv_data, n); + fuzz_recv_data += n; + fuzz_recv_data_len -= n; + total += n;
I think you can use iov_from_buf() to simplify this. I think you also need to populate msg_name (if non-NULL). Eventually it would be good to fuzz that, but we can probably start off with just a fixed value. For UDP we do also use msg_control for IP_PKTINFO so that will need to be populated too. Like msg_name, you may be able to just use a fixed value to start with, but it would be good to fuzz it eventually.
+ } + + return total; +} + +/** + * fuzz_recvfrom() - recvfrom wrapper + * @fd: File descriptor + * @buf: Output buffer + * @len: Max bytes + * @flags: recv flags + * @src: Source address output + * @addrlen: Source address length + * + * For fd_tap: calls real recvfrom(). For all other fds: zeroes the + * source address (so callers see a deterministic sender) and + * delegates to fuzz_recv() for the payload. + * + * Return: bytes read, or -1 + */ +ssize_t fuzz_recvfrom(int fd, void *buf, size_t len, int flags, + struct sockaddr *src, socklen_t *addrlen) +{ + if (fd == passt_ctx.fd_tap) + return recvfrom(fd, buf, len, flags, src, addrlen); + + if (src && addrlen) + memset(src, 0, *addrlen); + + return fuzz_recv(fd, buf, len, flags); +} + +/** + * fuzz_recvmmsg() - recvmmsg wrapper + * @fd: File descriptor + * @mmh: Array of mmsghdr structures to fill + * @vlen: Number of mmsghdr entries available + * @flags: recv flags + * @timeout: Timeout + * + * For fd_tap: calls real recvmmsg(). For all other fds: fills only + * the first message from the AFL++ buffer via fuzz_recvmsg() and + * returns 1. + * + * Return: number of messages received (0 or 1), or -1 + */ +int fuzz_recvmmsg(int fd, struct mmsghdr *mmh, unsigned int vlen, + int flags, struct timespec *timeout) +{ + ssize_t n; + + if (fd == passt_ctx.fd_tap) + return recvmmsg(fd, mmh, vlen, flags, timeout); + + if (!vlen || !fuzz_recv_data || fuzz_recv_data_len <= 0) { + errno = EAGAIN; + return -1; + } + + n = fuzz_recvmsg(fd, &mmh[0].msg_hdr, flags); + if (n < 0) + return -1; + + mmh[0].msg_len = n; + return 1; +} + +#endif
Comment to say what the #endif matches from way above would be helpful.
diff --git a/fuzz.h b/fuzz.h new file mode 100644 index 0000000..3f834a0 --- /dev/null +++ b/fuzz.h @@ -0,0 +1,62 @@ +//SPDX-License-Identifier: GPL-2.0-or-later + +/* fuzz.h - AFL++ fuzzing support for passt + * + * Copyright Red Hat + * Author: Anshu Kumari
+ */ + +#ifndef FUZZ_H +#define FUZZ_H + +#ifdef FUZZING
I'd probably recommend against guarding the entire .h file with a #ifdef. Even if most of this is unnecessary when not fuzzing, it's generally harmless declarations. Obviously putting the actual macro wrappers in place must still be #ifdef FUZZING.
+ +#include
+#include +#include +#include +#include + +int fuzz_clock_gettime(clockid_t clk, struct timespec *tp); +void fuzz_clock_reset(void); +ssize_t fuzz_getrandom(void *buf, size_t buflen, unsigned int flags); +int fuzz_getsockopt(int fd, int level, int optname, void *optval, + socklen_t *optlen); + +#define clock_gettime(clk, tp) fuzz_clock_gettime(clk, tp) +#define getrandom(buf, len, flags) fuzz_getrandom(buf, len, flags) +#define getsockopt(fd, level, name, val, len) \ + fuzz_getsockopt(fd, level, name, val, len) + +/* AFL++ buf layout: [0..11] epoll_event, [12..65547] recv payload */ +#define FUZZ_RECV_OFF 12 +#define FUZZ_RECV_MAX (64 * 1024)
Huh.. only 64kiB for all of our recv()s, that's not a lot.
+ +extern const unsigned char *fuzz_recv_data; +extern int fuzz_recv_data_len; + +ssize_t fuzz_recv(int fd, void *buf, size_t len, int flags); +ssize_t fuzz_recvmsg(int fd, struct msghdr *msg, int flags); +ssize_t fuzz_recvfrom(int fd, void *buf, size_t len, int flags, + struct sockaddr *src, socklen_t *addrlen); +int fuzz_recvmmsg(int fd, struct mmsghdr *mmh, unsigned int vlen, + int flags, struct timespec *timeout); + +/* Override existing wrappers for recvfrom() present inside util.h*/ +#undef recvfrom + +#define recv(fd, buf, len, flags) fuzz_recv(fd, buf, len, flags) +#define recvmsg(fd, msg, flags) fuzz_recvmsg(fd, msg, flags) +#define recvfrom(fd, buf, len, flags, src, sl) \ + fuzz_recvfrom(fd, buf, len, flags, src, sl) +#define recvmmsg(fd, mmh, vlen, flags, timeout) \ + fuzz_recvmmsg(fd, mmh, vlen, flags, timeout) + +#define FUZZ_TURN_PATH "/dev/shm/passt_fuzz_turn" + +struct fuzz_turn {
Explanatory comment would be helpful for this.
+ uint32_t turn; +}; + +#endif +#endif
Comments indicating what these match, please. -- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
On Wed, Aug 12, 2026 at 12:56:25PM +0530, Anshu Kumari wrote:
Under FUZZING, AFL++ can inject arbitrary epoll event types from its shared memory buffer. When an event references a flow table entry whose type doesn't match the handler, the existing assert() crashes the process eventually masking the real bugs.
This makes sense to me up until the "eventually masking the real bugs". IIUC the way AFL works is that it will use the coverage feedback to adjust its input - so even if many inputs result in a early crash due to mismatching types, it should be able to discover inputs that get past that into more interesting territory. Is it just a question of trying to filter our all the uninteresting crashes from flow type mismatches? Or to put it another way, distinguishing between bugs and correct abnormal exits due to something going wrong?
If there is no flow at the start of fuzzing then also we are just returning early instead of hitting crashes.
I don't really understand what you mean by this.
Allowing the fuzzer to explore other code path.
Replace assert() with NULL returns in the flow-lookup functions when compiled with -DFUZZING:
- tcp.c: conn_at_sidx(), tcp_timer_handler(), tcp_sock_handler() - tcp_splice.c: conn_at_sidx(), tcp_splice_sock_handler() - udp.c: udp_sock_handler(), udp_sock_to_sock(), udp_buf_sock_to_tap(), udp_sock_fwd() error path - udp_flow.c: udp_at_sidx() - icmp.c: ping_at_sidx(), icmp_sock_handler()
So, in looking at this, I'm bearing in mind the distinction between what should be a die() and what should be an assert(). Generally if something external did something wrong we can't cope with, it's a die() - and that's not a bug. If we did something wrong it's an assert() and that is a bug. The tricky case is where the "external" thing is the kernel. We obviously have to trust it to some extent, so if we get something we never expect to get from a kernel interace is that a die() or an assert(). The reasoning for these being assert()s is that if we get something unexpected here, by far the most likely cause is that we did something wrong when we set the epoll reference, rather than the kernel returning garbage. Fuzzing, at least with the current draft setup is breaking that assumption because the simulated kernel *is* returning garbage a lot of the time. So, we either need to constrain generation of epoll events to "plausible" ones, or safely ignore bad events. AIUI this patch is taking the second approach. That basic approach makes sense to me, but I'm not loving the way it works out in practice. It's fairly ugly and invasive, for starters. But, hat concerns me more though, is that at the points you're removing the assert()s it's not very obvious what new other paths the early returns will trigger. Because those are paths we'll never reach in real workloads, they're also not that interesting to fuzz. I can think of two possible approaches that might be this nicer. 1) IIUC the way the coverage driven fuzzing works, in the assert() cases we don't really need to carry on looking for other bugs. It should be ok to exit immediately as long as that is flagged as "we exited because something went wrong externally" not "we exited because we hit a bug". The coverage driven engine should then be able to go back and generate new inputs that explore other paths. I don't really know the interfaces used to communicate with AFL++, but could we replace these assert()s with say fuzz_assert(), which still exits, but signals to the fuzzer that this exit is not a bug? Is changing the abort() to a die() #ifdef FUZZING enough to do that? 2) We could move epoll event validation to immediately after the epoll_wait(). We esentially want to filter generated epoll events to plausible ones - at least meaning that the returned reference is one of the ones we epoll_add()ed. In one way, that's awkward, because we have to have a single point with all the validation logic, which might vary across various different branches. On the other hand, it makes the invasiveness much more localised, and it's clearer what a validation failure will do, whether that's exit in a non-bug way, or just ignore this event and go on to the next one.
Signed-off-by: Anshu Kumari
--- icmp.c | 14 +++++++++++++- tcp.c | 19 ++++++++++++++++++- tcp_splice.c | 10 ++++++++++ udp.c | 29 +++++++++++++++++++++++++++-- udp_flow.c | 5 +++++ 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/icmp.c b/icmp.c index 0fe2366..cdfa253 100644 --- a/icmp.c +++ b/icmp.c @@ -39,6 +39,7 @@ #include "icmp.h" #include "flow_table.h" #include "epoll_ctl.h" +#include "fuzz.h"
#define ICMP_ECHO_TIMEOUT 60 /* s, timeout for ICMP socket activity */ #define ICMP_NUM_IDS (1U << 16) @@ -58,7 +59,12 @@ static struct icmp_ping_flow *ping_at_sidx(flow_sidx_t sidx) if (!flow) return NULL;
+#ifdef FUZZING + if (flow->f.type != FLOW_PING4 && flow->f.type != FLOW_PING6) + return NULL; +#else assert(flow->f.type == FLOW_PING4 || flow->f.type == FLOW_PING6); +#endif return &flow->ping; }
@@ -72,7 +78,13 @@ void icmp_sock_handler(const struct ctx *c, union epoll_ref ref, const struct timespec *now) { struct icmp_ping_flow *pingf = ping_at_sidx(ref.flowside); - const struct flowside *ini = &pingf->f.side[INISIDE]; + const struct flowside *ini; + +#ifdef FUZZING + if (!pingf) + return; +#endif + ini = &pingf->f.side[INISIDE]; union sockaddr_inany sr; socklen_t sl = sizeof(sr); char buf[USHRT_MAX]; diff --git a/tcp.c b/tcp.c index 3b78d2e..612c884 100644 --- a/tcp.c +++ b/tcp.c @@ -316,6 +316,7 @@ #include "tcp_buf.h" #include "tcp_vu.h" #include "epoll_ctl.h" +#include "fuzz.h"
/* * The size of TCP header (including options) is given by doff (Data Offset) @@ -456,7 +457,12 @@ static struct tcp_tap_conn *conn_at_sidx(flow_sidx_t sidx) if (!flow) return NULL;
+#ifdef FUZZING + if (flow->f.type != FLOW_TCP) + return NULL; +#else assert(flow->f.type == FLOW_TCP); +#endif return &flow->tcp; }
@@ -2681,7 +2687,14 @@ void tcp_timer_handler(const struct ctx *c, union epoll_ref ref, const struct timespec *now) { struct itimerspec check_armed = { { 0 }, { 0 } }; - struct tcp_tap_conn *conn = &FLOW(ref.flow)->tcp; + struct tcp_tap_conn *conn; + +#ifdef FUZZING + if (ref.flow >= FLOW_MAX || + FLOW(ref.flow)->f.type != FLOW_TCP) + return; +#endif + conn = &FLOW(ref.flow)->tcp;
assert(!c->no_tcp); assert(conn->f.type == FLOW_TCP); @@ -2752,6 +2765,10 @@ void tcp_sock_handler(const struct ctx *c, union epoll_ref ref, { struct tcp_tap_conn *conn = conn_at_sidx(ref.flowside);
+#ifdef FUZZING + if (!conn) + return; +#endif assert(!c->no_tcp); assert(pif_at_sidx(ref.flowside) != PIF_TAP);
diff --git a/tcp_splice.c b/tcp_splice.c index 4b01f1a..005ecd1 100644 --- a/tcp_splice.c +++ b/tcp_splice.c @@ -105,7 +105,12 @@ static struct tcp_splice_conn *conn_at_sidx(flow_sidx_t sidx) if (!flow) return NULL;
+#ifdef FUZZING + if (flow->f.type != FLOW_TCP_SPLICE) + return NULL; +#else assert(flow->f.type == FLOW_TCP_SPLICE); +#endif return &flow->tcp_splice; }
@@ -594,6 +599,11 @@ void tcp_splice_sock_handler(struct ctx *c, union epoll_ref ref, struct tcp_splice_conn *conn = conn_at_sidx(ref.flowside); unsigned evsidei = ref.flowside.sidei;
+#ifdef FUZZING + if (!conn) + return; +#endif + assert(conn->f.type == FLOW_TCP_SPLICE);
if (conn->events == SPLICE_CLOSED) diff --git a/udp.c b/udp.c index 505e554..9431353 100644 --- a/udp.c +++ b/udp.c @@ -118,6 +118,7 @@ #include "udp_internal.h" #include "udp_vu.h" #include "epoll_ctl.h" +#include "fuzz.h"
#define UDP_MAX_FRAMES 32 /* max # of frames to receive at once */
@@ -807,9 +808,15 @@ static void udp_sock_to_sock(const struct ctx *c, int from_s, int n, const struct flowside *toside = flowside_at_sidx(tosidx); const struct udp_flow *uflow = udp_at_sidx(tosidx); uint8_t topif = pif_at_sidx(tosidx); - int to_s = uflow->s[tosidx.sidei]; + int to_s; int i;
+#ifdef FUZZING + if (!uflow) + return; +#endif + to_s = uflow->s[tosidx.sidei]; + if ((n = udp_sock_recv(c, from_s, udp_mh_recv, n)) <= 0) return;
@@ -836,9 +843,15 @@ static void udp_buf_sock_to_tap(const struct ctx *c, int s, int n, { const struct flowside *toside = flowside_at_sidx(tosidx); struct udp_flow *uflow = udp_at_sidx(tosidx); - uint8_t *omac = uflow->f.tap_omac; + uint8_t *omac; int i;
+#ifdef FUZZING + if (!uflow) + return; +#endif + omac = uflow->f.tap_omac; + if ((n = udp_sock_recv(c, s, udp_mh_recv, n)) <= 0) return;
@@ -901,10 +914,18 @@ void udp_sock_fwd(const struct ctx *c, int s, int rule_hint, } else if (flow_sidx_valid(tosidx)) { struct udp_flow *uflow = udp_at_sidx(tosidx);
+#ifdef FUZZING + if (!uflow) { + discard = true; + continue; + } +#endif + flow_err_ratelimit( uflow, now, "No support for forwarding UDP from %s to %s", pif_name(frompif), pif_name(topif)); + discard = true; } else { warn_ratelimit(now, "Discarding datagram without flow"); @@ -949,6 +970,10 @@ void udp_sock_handler(const struct ctx *c, union epoll_ref ref, { struct udp_flow *uflow = udp_at_sidx(ref.flowside);
+#ifdef FUZZING + if (!uflow) + return; +#endif assert(!c->no_udp && uflow);
if (events & EPOLLERR) { diff --git a/udp_flow.c b/udp_flow.c index f59649f..6c5b010 100644 --- a/udp_flow.c +++ b/udp_flow.c @@ -31,7 +31,12 @@ struct udp_flow *udp_at_sidx(flow_sidx_t sidx) if (!flow) return NULL;
+#ifdef FUZZING + if (flow->f.type != FLOW_UDP) + return NULL; +#else assert(flow->f.type == FLOW_UDP); +#endif return &flow->udp; }
-- 2.55.0
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
On Wed, Aug 12, 2026 at 12:56:26PM +0530, Anshu Kumari wrote:
Few components which needs to be disabled to support AFL++ to work:
- isolation.c: Skip isolation and seccomp sandboxing that breaks AFL++ pipes, namespaces, and ASan mmap/mprotect operations. - util.c / tap.c: Switch UNIX socket to SOCK_SEQPACKET to preserve frame boundaries, simplifying tap_passt_input() to a single recv() and removing vnet_len framing.
Most of these changes are pretty trivial, but this one is not. I'd suggest moving this into its own patch for clarity, and so it can get a more detailed rationale / explanation in the commit message. Because the test server is using its own SEQPACKET protocol, somewhat similar to, but not identical with the qemu socket protocol, you're essentially adding a new tap backend for fuzzing. That's a reasonable approach, but I think it would be clearer to treat it as that, rather than as a weird special case of the normal passt tap backend.
- passt.h: Use /tmp/passt_fuzz_%i.socket to avoid path collisions with production instances. - tcp_buf.c: Include fuzz.h to route recvmsg() through deterministic wrappers.
Signed-off-by: Anshu Kumari
--- isolation.c | 11 +++++++++++ passt.h | 4 ++++ tap.c | 21 +++++++++++++++++++++ tcp_buf.c | 1 + util.c | 10 ++++++++++ 5 files changed, 47 insertions(+) diff --git a/isolation.c b/isolation.c index a30b329..61fc76a 100644 --- a/isolation.c +++ b/isolation.c @@ -208,6 +208,9 @@ static int move_root(void) */ void isolate_initial(void) { +#ifdef FUZZING + return; +#endif
Rather than just eliminating the isolate_*() routines entirely, I'd prefer to selectively disable the specific parts that block fuzzing.
uint64_t keep;
/* We want to keep CAP_NET_BIND_SERVICE in the initial @@ -389,6 +392,10 @@ void isolate_user(const struct ctx *c, uid_t uid, gid_t gid, bool use_userns, */ int isolate_prefork(const struct ctx *c) { +#ifdef FUZZING + (void)c; + return 0; +#endif int flags = CLONE_NEWIPC | CLONE_NEWNS | CLONE_NEWUTS; uint64_t ns_caps = 0;
@@ -466,6 +473,10 @@ int isolate_prefork(const struct ctx *c) */ void isolate_postfork(const struct ctx *c) { +#ifdef FUZZING + (void)c; + return; +#endif struct sock_fprog prog;
prctl(PR_SET_DUMPABLE, 0); diff --git a/passt.h b/passt.h index 51ccd4f..141c9f8 100644 --- a/passt.h +++ b/passt.h @@ -7,7 +7,11 @@ #define PASST_H
#define UNIX_SOCK_MAX 100 +#ifdef FUZZING +#define UNIX_SOCK_PATH "/tmp/passt_fuzz_%i.socket" +#else #define UNIX_SOCK_PATH "/tmp/passt_%i.socket" +#endif
Good idea.
union epoll_ref;
diff --git a/tap.c b/tap.c index dfa66c7..f32c9ad 100644 --- a/tap.c +++ b/tap.c @@ -14,6 +14,7 @@ */
#include
+#include #include #include #include @@ -61,6 +62,7 @@ #include "vhost_user.h" #include "vu_common.h" #include "epoll_ctl.h" +#include "fuzz.h" /* Maximum allowed frame lengths (including L2 header) */
@@ -144,8 +146,10 @@ void tap_send_single(const struct ctx *c, const void *data, size_t l2len)
switch (c->mode) { case MODE_PASST: +#ifndef FUZZING iov[iovcnt] = IOV_OF_LVALUE(vnet_len); iovcnt++; +#endif
Right, I think this might be clearer as a new 'case MODE_FUZZ:'.
/* fall through */ case MODE_PASTA: iov[iovcnt].iov_base = (void *)data; @@ -1231,6 +1235,22 @@ static void tap_passt_input(struct ctx *c, const struct timespec *now)
tap_flush_pools();
+#ifdef FUZZING + /* SOCK_SEQPACKET: each recv returns exactly one frame */
And I think this would be clearer as a new tap_fuzz_input().
+ do { + n = recv(c->fd_tap, pkt_buf, sizeof(pkt_buf), MSG_DONTWAIT); + } while ((n < 0) && errno == EINTR); + + if (n > 0 && n >= (ssize_t)sizeof(struct ethhdr)) {
I suggest removing the length check: that way the fuzzer can also look for any bugs we might have if we ever get undersized frames from the tap interface.
+ struct iov_tail data; + + data = IOV_TAIL_FROM_BUF(pkt_buf, n, 0); + tap_add_packet(c, &data, now); + } else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + tap_sock_reset(c);
I don't think we really care about reset and recovery for the fuzzing case, so a die() would probably suffice here.
+ return; + }
+#else if (partial_len) { /* We have a partial frame from an earlier pass. Move it to the * start of the buffer, top up with new data, then process all @@ -1281,6 +1301,7 @@ static void tap_passt_input(struct ctx *c, const struct timespec *now)
partial_len = n; partial_frame = p; +#endif
Whenever a #if is more than a handful of lines, it's generally helpful to put a comment on the #endif so you can tell what the #if was conditional on without having to scroll up a bunch.
tap_handler(c, now); } diff --git a/tcp_buf.c b/tcp_buf.c index 72c4541..eb28abe 100644 --- a/tcp_buf.c +++ b/tcp_buf.c @@ -32,6 +32,7 @@ #include "tcp_conn.h" #include "tcp_internal.h" #include "tcp_buf.h" +#include "fuzz.h"
#define TCP_FRAMES_MEM 128 #define TCP_FRAMES \ diff --git a/util.c b/util.c index 28c32e4..7f29c3b 100644 --- a/util.c +++ b/util.c @@ -36,6 +36,7 @@ #include "epoll_ctl.h" #include "pasta.h" #include "serialise.h" +#include "fuzz.h" #ifdef HAS_GETRANDOM #include
#endif @@ -229,7 +230,11 @@ int sock_l4_dualstack_any(const struct ctx *c, enum epoll_type type, */ int sock_unix(char *sock_path) { +#ifdef FUZZING + int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); +#else int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); +#endif
Special casing what's ostensibly a general helper to open unix sockets is a bit nasty - it's relying on the fact that the only Unix socket that we're really using is the one for tap. Treating fuzz as a different tap backend would address this too.
struct sockaddr_un addr = { .sun_family = AF_UNIX, }; @@ -248,8 +253,13 @@ int sock_unix(char *sock_path) UNIX_SOCK_PATH, i)) die_perror("Can't build UNIX domain socket path");
+#ifdef FUZZING + ex = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_NONBLOCK | SOCK_CLOEXEC, + 0); +#else ex = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); +#endif if (ex < 0) die_perror("Failed to check for UNIX domain conflicts");
-- 2.55.0
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
Not a complete review, just a few notes, mostly about the general
concept:
On Wed, 12 Aug 2026 12:56:27 +0530
Anshu Kumari
Add the AFL++ persistent mode fuzz loop to passt.c main(). The loop uses __AFL_LOOP() for in-process iteration and __AFL_FUZZ_TESTCASE_BUF for shared memory fuzzing.
Each iteration: - Resets deterministic clock, flow table, and epoll instance. - Drains stale data from the TAP socket. - Reads an epoll event from the AFL++ buffer. - For TAP events: constructs a packet with fixed L2/L3/L4 headers and injects it via tap_add_packet() + tap_handler(). - Exchanges a turn flag with the test server for bidirectional flow over the UNIX socket.
This complexity could probably be avoided if you switch to a model where the test server is just operating on the host side of things (accepting TCP connections and replying). More on that in a bit as a comment to 5/5.
- Calls passt_worker() to process the event. - Polls for host-side TCP events via epoll_wait(). - Runs post_handler() for deferred work.
Added the 'make fuzz' target which builds passt with afl-clang-fast, -DFUZZING, -DNDEBUG, and AddressSanitizer.
Signed-off-by: Anshu Kumari
--- Makefile | 8 +++ passt.c | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/Makefile b/Makefile index fe1df58..8e4121e 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,14 @@ valgrind: BASE_CPPFLAGS += -DVALGRIND valgrind: BASE_CFLAGS += -g valgrind: all
+FUZZ_CC ?= afl-clang-fast + +.PHONY: fuzz + +fuzz: + $(MAKE) clean + $(MAKE) CC="$(FUZZ_CC)" CPPFLAGS="-DFUZZING -DNDEBUG" CFLAGS="-g -fsanitize=address" passt + .PHONY: clean clean: $(RM) $(BIN) *~ *.o seccomp.h seccomp_repair.h seccomp_pesto.h pasta.1 \ diff --git a/passt.c b/passt.c index 5054551..e026eb2 100644 --- a/passt.c +++ b/passt.c @@ -35,6 +35,7 @@ #include
#include #include +#include #include "util.h" #include "passt.h" @@ -54,12 +55,56 @@ #include "repair.h" #include "netlink.h" #include "epoll_ctl.h" +#include "flow_table.h" +#include "fuzz.h"
#define NUM_EPOLL_EVENTS 8
#define TIMER_INTERVAL_ MIN(TCP_TIMER_INTERVAL, FWD_PORT_SCAN_INTERVAL) #define TIMER_INTERVAL MIN(TIMER_INTERVAL_, FLOW_TIMER_INTERVAL)
+#ifdef FUZZING + +/* AFL++ persistent mode / shared memory fuzzing compatibility macros. */ +#ifndef __AFL_FUZZ_TESTCASE_LEN + ssize_t fuzz_len; + unsigned char fuzz_buf[1024 * 1024]; +# define __AFL_FUZZ_TESTCASE_LEN fuzz_len +# define __AFL_FUZZ_TESTCASE_BUF fuzz_buf +# define __AFL_FUZZ_INIT() void sync(void) +# define __AFL_LOOP(x) \ + ((fuzz_len = read(0, fuzz_buf, sizeof(fuzz_buf))) > 0 ? 1 : 0) +# define __AFL_INIT() sync() +#endif + +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_FUZZ_INIT(); +#endif + +static struct fuzz_turn *fuzz_turn_ptr; + +/** + * fuzz_turn_connect() - Map the turn flag shared memory + * + * Return: pointer to mapped turn flag, or NULL on failure + */ +static struct fuzz_turn *fuzz_turn_connect(void) +{ + struct fuzz_turn *t; + int fd; + + fd = open(FUZZ_TURN_PATH, O_RDWR); + if (fd < 0) + return NULL; + + t = mmap(NULL, sizeof(*t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + close(fd); + + return (t == MAP_FAILED) ? NULL : t; +} + +#endif + char pkt_buf[PKT_BUF_BYTES] __attribute__ ((aligned(PAGE_SIZE)));
struct ctx passt_ctx = { @@ -282,9 +327,17 @@ static void passt_worker(void *opaque, int nfds, struct epoll_event *events) icmp_sock_handler(c, ref, &now); break; case EPOLL_TYPE_VHOST_CMD: +#ifdef FUZZING + if (!c->vdev) + break; +#endif vu_control_handler(c->vdev, c->fd_tap, eventmask); break; case EPOLL_TYPE_VHOST_KICK: +#ifdef FUZZING + if (!c->vdev) + break; +#endif vu_kick_cb(c->vdev, ref, &now); break; case EPOLL_TYPE_REPAIR_LISTEN: @@ -450,6 +503,141 @@ int main(int argc, char **argv)
timer_init(c, &now);
+#ifdef FUZZING + fuzz_turn_ptr = fuzz_turn_connect(); + +#define FUZZ_LOOP_ITERATIONS 10000 +#define FUZZ_DRAIN_BUF_SIZE 1600 + +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_INIT(); +#endif + { + unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF; + + while (__AFL_LOOP(FUZZ_LOOP_ITERATIONS)) {
I think this loop is a useful implementation, as far as I understand the purpose is to avoid that AFL++ needs to restarts us at every new attempt. So I think it makes sense that you reset the state below. But, inside this loop, we need to allow AFL++ to send us arbitrary sequences of packets, not just inject a single one. Not much will happen with a single packet.
+ int len = __AFL_FUZZ_TESTCASE_LEN; + int injected = 0; + int pkt_len, round; + struct epoll_event ev; + union epoll_ref ref; + int min_pkt = sizeof(struct ethhdr) + + sizeof(struct iphdr) + + sizeof(struct tcphdr); + + if (len < (int)sizeof(ev)) + continue; + + /* Reset clock, flow table and epoll for each + * AFL++ iteration. + */ + fuzz_clock_reset(); + clock_gettime(CLOCK_MONOTONIC, &now); + timer_init(c, &now); + + flow_init(); + + /* Recreate epoll instance */ + close(c->epollfd); + c->epollfd = epoll_create1(EPOLL_CLOEXEC); + flow_epollid_register(EPOLLFD_ID_DEFAULT, c->epollfd); + + if (c->fd_tap >= 0) { + union epoll_ref tref = { + .type = EPOLL_TYPE_TAP_PASST, + .fd = c->fd_tap + }; + epoll_add(c->epollfd, + EPOLLIN | EPOLLRDHUP, tref); + + /* Drain stale socket data */ + char drain[FUZZ_DRAIN_BUF_SIZE]; + while (recv(c->fd_tap, drain, sizeof(drain), + MSG_DONTWAIT) > 0); + } + + /* Read epoll event from AFL++ buffer */ + memcpy(&ev, buf, sizeof(ev)); + ref = *((union epoll_ref *)&ev.data.u64);
This is needed to let AFL++ generate events. But if you call epoll_wait() below, with 'events' (which is not set from 'ev'), we won't actually use those events generated by AFL++. I guess you're only getting events from the test server. But I don't think that hardcoding a sequence of: - single packet from AFL++ (tap side) - four packets from the test server (all host side I guess?) will actually result in any meaningful exchange (including a TCP connection). By the way, in the approach I was suggesting, where AFL++ would act as guest and feeding data to us directly, while the test server would act as host / internet side (with data fed from AFL++), AFL++ would only generate tap-side events, so we would probably need to *add* those to 'ev' while also reacting to host-side events (for example the test server accepting a connection, or sending data over an accepted connection).
+ + /* Set recv payload in AFL++ shared memory */ + fuzz_recv_data = buf + FUZZ_RECV_OFF; + fuzz_recv_data_len = + (len > FUZZ_RECV_OFF + FUZZ_RECV_MAX) + ? FUZZ_RECV_MAX + : ((len > FUZZ_RECV_OFF) + ? len - FUZZ_RECV_OFF : 0); + + /* Inject fuzz packet for TAP events */ + if (ref.type == EPOLL_TYPE_TAP_PASST || + ref.type == EPOLL_TYPE_TAP_PASTA) { + struct iov_tail data; + struct ethhdr *eh; + struct iphdr *iph; + struct tcphdr *th; + + tap_flush_pools(); + memset(pkt_buf, 0, min_pkt); + + pkt_len = len - (int)sizeof(ev); + if (pkt_len > 0) + memcpy(pkt_buf, buf + sizeof(ev), + pkt_len); + if (pkt_len < min_pkt) + pkt_len = min_pkt; + + /* construct ethernet header */
I guess this whole path is needed to quickly get something working, but, eventually, we shouldn't need this. We need to give the possibility to AFL++ to give us multiple packets, and possibly (or especially) malformed ones. If it just generates payload, that looks relatively "safe" and is relatively unlikely to discover issues.
+ eh = (struct ethhdr *)pkt_buf; + memcpy(eh->h_dest, c->our_tap_mac, ETH_ALEN); + memcpy(eh->h_source, c->guest_mac, ETH_ALEN); + eh->h_proto = htons(ETH_P_IP); + + /* construct IPv4 header */ + iph = (struct iphdr *)(pkt_buf + sizeof(*eh)); + iph->version = 4; + iph->ihl = 5; + iph->protocol = IPPROTO_TCP; + iph->saddr = c->ip4.addr.s_addr; + iph->daddr = c->ip4.guest_gw.s_addr; + iph->tot_len = htons(pkt_len - sizeof(*eh)); + + /* Fix TCP Header */ + th = (struct tcphdr *)(pkt_buf + sizeof(*eh) + + sizeof(*iph)); + th->dest = htons(9999); + if (th->doff < 5) + th->doff = 5; + + data = IOV_TAIL_FROM_BUF(pkt_buf, pkt_len, 0); + tap_add_packet(c, &data, &now); + tap_handler(c, &now); + injected = 1; + } + + /* Turn exchange -- only if data was sent */ + if (injected && fuzz_turn_ptr) { + __atomic_store_n(&fuzz_turn_ptr->turn, 1, + __ATOMIC_RELEASE); + while (__atomic_load_n(&fuzz_turn_ptr->turn, + __ATOMIC_ACQUIRE) != 0); + } + + passt_worker(c, 1, &ev); + + /* Process host-side TCP events */ + for (round = 0; round < 4; round++) { + nfds = epoll_wait(c->epollfd, events, + NUM_EPOLL_EVENTS, 0); + if (nfds <= 0) + break; + passt_worker(c, nfds, events); + } + + post_handler(c, &now); + } + } + return 0; +#else loop: /* NOLINTBEGIN(bugprone-branch-clone): intervals can be the same */ /* cppcheck-suppress [duplicateValueTernary, unmatchedSuppression] */ @@ -461,4 +649,5 @@ loop: passt_worker(c, nfds, events);
goto loop; +#endif /* FUZZING */ }
-- Stefano
On Wed, 12 Aug 2026 12:56:28 +0530
Anshu Kumari
Add fuzz-server that acts as passt's network peer during fuzzing.
To me, this part makes sense. But this one:
It connects to passt's UNIX socket
much less, while:
and listens on 127.0.0.1:9999 for TCP connections.
this is the part that I expected instead. Otherwise it's not just passt's network peer, it's the guest as well.
UNIX socket path: responds to ARP requests and TCP SYNs with stateless replies (swapped addresses, fixed ISN). Responses are XOR'd with AFL++ shared memory data so the fuzzer can mutate server behavior.
This looks rather complicated to me. The approach I was suggesting with a test server is the following: ,- exchanges guest-side data with ------------. | ,---------|---------. | ,--| passt | | / '-.---------------^-' ,---|---. / | connect(), | accept(), | AFL++ |-- shares memory with ---| | send data, | reply with '---|---' \ | etc. | data, etc. | \ ,-v---------------'-. | '--| test server | | '---------|---------' '- exchanges host-side data with -------------' ...at least in its basic form. Eventually, the test server should be able to connect to passt itself (and we could call it "test peer" at that point). As far as I understood, it's not trivial to make the same instance of AFL++ share memory with two processes at the same time, so the memory-sharing path might need to take a more complicated turn, for example there could be a wrapper starting both passt and the test server and sharing memory with them, or passt could _additionally_ (using a special out-of-band fuzzing channel) share data from AFL++ with the test server. An example of communication below (but events don't necessarily need to be in this order, this is just an example). For simplicity, let's ignore the fact that AFL++ might not directly share memory with passt and test server, and assume there are three areas of memory that AFL++ directly controls: a. shared with passt: an array of struct epoll_event, 'ev' b. shared with passt: the kind of tap-side buffer you implemented in 4/5, 'buf' c. shared with the test server: a separate buffer, 'test_buf' Example: 1. AFL++ writes an EPOLLIN event in 'ev' with type EPOLL_TYPE_TAP_PASST, of some data in 'buf', and some data in 'test_buf' 2. AFL++ starts passt and the test server 3. passt reads the EPOLL_TYPE_TAP_PASST event from 'ev', reads data from 'buf' and hands it to passt_tap_handler() 4. this happens to be have Ethernet, IP, and TCP headers, with the SYN flag set, and destination address set to the address of the test server (we might want to force all this, at least initially, or give it as a hint to AFL++ somehow), so passt connects to the test server 5. the test server accepts the connection, and sends the contents of 'test_buf' on it (for the test server, this is directly payload, without headers, as they don't make sense there). I'm not sure if we should have a different set of events (maybe we need a "play script" for the server, in case?) 6. this generates an EPOLLOUT event for passt. It's not in 'ev', it's a regular epoll_wait() (I think we could have an epoll_wait() loop where we additionally read one event from 'ev' for every iteration, or something like that) 7. passt marks the connection as established and inserts it in the flow table 8. passt reads the data sent from the test server and generates whatever TCP data packet to the "guest" (it might simply be a sink) ...and this attempt ends here because AFL++ generated a single event for passt, but there could be more (this should also be decided by AFL++). Would something like this make sense? -- Stefano
On Wed, Aug 12, 2026 at 12:56:27PM +0530, Anshu Kumari wrote:
Add the AFL++ persistent mode fuzz loop to passt.c main(). The loop uses __AFL_LOOP() for in-process iteration and __AFL_FUZZ_TESTCASE_BUF for shared memory fuzzing.
Each iteration: - Resets deterministic clock, flow table, and epoll instance. - Drains stale data from the TAP socket. - Reads an epoll event from the AFL++ buffer. - For TAP events: constructs a packet with fixed L2/L3/L4 headers and injects it via tap_add_packet() + tap_handler(). - Exchanges a turn flag with the test server for bidirectional flow over the UNIX socket. - Calls passt_worker() to process the event. - Polls for host-side TCP events via epoll_wait(). - Runs post_handler() for deferred work.
Added the 'make fuzz' target which builds passt with afl-clang-fast, -DFUZZING, -DNDEBUG, and AddressSanitizer.
Stefano's concerns generally seconded (although I haven't really got my head around the role of the test server in either yours or his mind - I'll address that once I've read 5/5). The big concerns here are that to do interesting fuzzing we'll need a) sequences of multiple packets/packets and b) to fuzz-generate the headers, including malformed ones. AIUI, logically each fuzzer generated case could be run in a separate instance of passt: the __AFL_LOOP() stuff is an optimization to avoid the delay of a fresh startup on each cycle. Is that correct?
Signed-off-by: Anshu Kumari
--- Makefile | 8 +++ passt.c | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/Makefile b/Makefile index fe1df58..8e4121e 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,14 @@ valgrind: BASE_CPPFLAGS += -DVALGRIND valgrind: BASE_CFLAGS += -g valgrind: all
+FUZZ_CC ?= afl-clang-fast + +.PHONY: fuzz + +fuzz: + $(MAKE) clean + $(MAKE) CC="$(FUZZ_CC)" CPPFLAGS="-DFUZZING -DNDEBUG" CFLAGS="-g -fsanitize=address" passt
I'd recommend building the fuzzing binary under a different name, to make accidentally using the wrong one a bit less likely.
.PHONY: clean clean: $(RM) $(BIN) *~ *.o seccomp.h seccomp_repair.h seccomp_pesto.h pasta.1 \ diff --git a/passt.c b/passt.c index 5054551..e026eb2 100644 --- a/passt.c +++ b/passt.c @@ -35,6 +35,7 @@ #include
#include #include +#include #include "util.h" #include "passt.h" @@ -54,12 +55,56 @@ #include "repair.h" #include "netlink.h" #include "epoll_ctl.h" +#include "flow_table.h" +#include "fuzz.h"
#define NUM_EPOLL_EVENTS 8
#define TIMER_INTERVAL_ MIN(TCP_TIMER_INTERVAL, FWD_PORT_SCAN_INTERVAL) #define TIMER_INTERVAL MIN(TIMER_INTERVAL_, FLOW_TIMER_INTERVAL)
+#ifdef FUZZING + +/* AFL++ persistent mode / shared memory fuzzing compatibility macros. */ +#ifndef __AFL_FUZZ_TESTCASE_LEN + ssize_t fuzz_len; + unsigned char fuzz_buf[1024 * 1024]; +# define __AFL_FUZZ_TESTCASE_LEN fuzz_len +# define __AFL_FUZZ_TESTCASE_BUF fuzz_buf +# define __AFL_FUZZ_INIT() void sync(void) +# define __AFL_LOOP(x) \ + ((fuzz_len = read(0, fuzz_buf, sizeof(fuzz_buf))) > 0 ? 1 : 0)
This macro ignores its parameter. Is that intentional?
+# define __AFL_INIT() sync() +#endif + +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_FUZZ_INIT(); +#endif + +static struct fuzz_turn *fuzz_turn_ptr; + +/** + * fuzz_turn_connect() - Map the turn flag shared memory + * + * Return: pointer to mapped turn flag, or NULL on failure + */ +static struct fuzz_turn *fuzz_turn_connect(void) +{ + struct fuzz_turn *t; + int fd; + + fd = open(FUZZ_TURN_PATH, O_RDWR);
FUZZ_TURN_PATH was defined in 1/5 but only used here, which makes review harder. I'd suggest moving the definition to this patch.
+ if (fd < 0) + return NULL; + + t = mmap(NULL, sizeof(*t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + close(fd); + + return (t == MAP_FAILED) ? NULL : t; +} + +#endif + char pkt_buf[PKT_BUF_BYTES] __attribute__ ((aligned(PAGE_SIZE)));
struct ctx passt_ctx = { @@ -282,9 +327,17 @@ static void passt_worker(void *opaque, int nfds, struct epoll_event *events) icmp_sock_handler(c, ref, &now); break; case EPOLL_TYPE_VHOST_CMD: +#ifdef FUZZING + if (!c->vdev) + break; +#endif
This serves a very similar purpose to the checks in 2/5, and the comments I had there apply here as well. If we ignore an event here, it means we're now on a path that's not really interesting to fuzz. So instead of ignoring and carrying on, it would be better to mark this as "program died correctly" and proceed to the next case.
vu_control_handler(c->vdev, c->fd_tap, eventmask); break; case EPOLL_TYPE_VHOST_KICK: +#ifdef FUZZING + if (!c->vdev) + break; +#endif vu_kick_cb(c->vdev, ref, &now); break; case EPOLL_TYPE_REPAIR_LISTEN: @@ -450,6 +503,141 @@ int main(int argc, char **argv)
timer_init(c, &now);
+#ifdef FUZZING + fuzz_turn_ptr = fuzz_turn_connect(); + +#define FUZZ_LOOP_ITERATIONS 10000
AFAICT, this has no effect, since __AFL_LOOP() ignores its parameter.
+#define FUZZ_DRAIN_BUF_SIZE 1600 + +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_INIT();
Both the definition and use of __AFL_INIT() are conditional on __AFL_HAVE_MANUAL_CONTROL. Would it make more sense to define __AFL_INIT() as a no-op if !__AFL_HAVE_MANUAL_CONTROL to avoid a second #ifdef?
+#endif + { + unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF; + + while (__AFL_LOOP(FUZZ_LOOP_ITERATIONS)) { + int len = __AFL_FUZZ_TESTCASE_LEN; + int injected = 0; + int pkt_len, round; + struct epoll_event ev; + union epoll_ref ref; + int min_pkt = sizeof(struct ethhdr) + + sizeof(struct iphdr) + + sizeof(struct tcphdr); + + if (len < (int)sizeof(ev)) + continue; + + /* Reset clock, flow table and epoll for each + * AFL++ iteration. + */ + fuzz_clock_reset(); + clock_gettime(CLOCK_MONOTONIC, &now); + timer_init(c, &now); + + flow_init();
flow_init() wipes the table itself, but doesn't clean up any existing flows. If you're creating real external sockets, that means those will be leaked, which means you could well hit the file descriptor limit during a long fuzzing session. Also, it looks like flow_init() doesn't reset flow_first_free. Seeing the structure of the afl loop, I now have further thoughts on the assert()s you were suppressing earlier in the series. As I said, if we hit those we want to stop this fuzzing path - it's no longer interesting - but we don't want to mark it as a bug. A die() might accomplish that, but of course would mean restarting passt, bypassing the acceleration that __AFL_LOOP() is supposed to provide. Essentially what you want in those cases is to abort whatever you're doing and continue on to the next iteration of the AFL loop. This might make it one of the rare cases where setjmp() / longjmp() is a good idea.
+ /* Recreate epoll instance */ + close(c->epollfd); + c->epollfd = epoll_create1(EPOLL_CLOEXEC); + flow_epollid_register(EPOLLFD_ID_DEFAULT, c->epollfd); + + if (c->fd_tap >= 0) { + union epoll_ref tref = { + .type = EPOLL_TYPE_TAP_PASST, + .fd = c->fd_tap + }; + epoll_add(c->epollfd, + EPOLLIN | EPOLLRDHUP, tref); + + /* Drain stale socket data */ + char drain[FUZZ_DRAIN_BUF_SIZE]; + while (recv(c->fd_tap, drain, sizeof(drain), + MSG_DONTWAIT) > 0);
You could use MSG_TRUNC here to avoid the need for a drain buffer.
+ } + + /* Read epoll event from AFL++ buffer */ + memcpy(&ev, buf, sizeof(ev)); + ref = *((union epoll_ref *)&ev.data.u64); + + /* Set recv payload in AFL++ shared memory */ + fuzz_recv_data = buf + FUZZ_RECV_OFF; + fuzz_recv_data_len = + (len > FUZZ_RECV_OFF + FUZZ_RECV_MAX) + ? FUZZ_RECV_MAX + : ((len > FUZZ_RECV_OFF) + ? len - FUZZ_RECV_OFF : 0); + + /* Inject fuzz packet for TAP events */ + if (ref.type == EPOLL_TYPE_TAP_PASST || + ref.type == EPOLL_TYPE_TAP_PASTA) { + struct iov_tail data; + struct ethhdr *eh; + struct iphdr *iph; + struct tcphdr *th; + + tap_flush_pools(); + memset(pkt_buf, 0, min_pkt); + + pkt_len = len - (int)sizeof(ev);
How does this differ from fuzz_recv_data_len?
+ if (pkt_len > 0) + memcpy(pkt_buf, buf + sizeof(ev), + pkt_len); + if (pkt_len < min_pkt) + pkt_len = min_pkt; + + /* construct ethernet header */ + eh = (struct ethhdr *)pkt_buf; + memcpy(eh->h_dest, c->our_tap_mac, ETH_ALEN); + memcpy(eh->h_source, c->guest_mac, ETH_ALEN); + eh->h_proto = htons(ETH_P_IP); + + /* construct IPv4 header */ + iph = (struct iphdr *)(pkt_buf + sizeof(*eh)); + iph->version = 4; + iph->ihl = 5; + iph->protocol = IPPROTO_TCP; + iph->saddr = c->ip4.addr.s_addr; + iph->daddr = c->ip4.guest_gw.s_addr; + iph->tot_len = htons(pkt_len - sizeof(*eh)); + + /* Fix TCP Header */ + th = (struct tcphdr *)(pkt_buf + sizeof(*eh) + + sizeof(*iph)); + th->dest = htons(9999); + if (th->doff < 5) + th->doff = 5;
As Stefano also points out, this is constructing a fixed version of exactly the things we most want to fuzz.
+ data = IOV_TAIL_FROM_BUF(pkt_buf, pkt_len, 0); + tap_add_packet(c, &data, &now); + tap_handler(c, &now); + injected = 1; + } + + /* Turn exchange -- only if data was sent */ + if (injected && fuzz_turn_ptr) { + __atomic_store_n(&fuzz_turn_ptr->turn, 1, + __ATOMIC_RELEASE); + while (__atomic_load_n(&fuzz_turn_ptr->turn, + __ATOMIC_ACQUIRE) != 0); + }
I don't really understand what this 'turn' thing is doing.
+ + passt_worker(c, 1, &ev); + + /* Process host-side TCP events */ + for (round = 0; round < 4; round++) { + nfds = epoll_wait(c->epollfd, events, + NUM_EPOLL_EVENTS, 0); + if (nfds <= 0) + break; + passt_worker(c, nfds, events); + } + + post_handler(c, &now);
post_handler() is already called from passt_worker(), why do we need another call?
+ } + } + return 0; +#else loop: /* NOLINTBEGIN(bugprone-branch-clone): intervals can be the same */ /* cppcheck-suppress [duplicateValueTernary, unmatchedSuppression] */ @@ -461,4 +649,5 @@ loop: passt_worker(c, nfds, events);
goto loop; +#endif /* FUZZING */ } -- 2.55.0
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
On Thu, Aug 13, 2026 at 09:53:24AM +0200, Stefano Brivio wrote:
On Wed, 12 Aug 2026 12:56:28 +0530 Anshu Kumari
wrote: Add fuzz-server that acts as passt's network peer during fuzzing.
To me, this part makes sense. But this one:
It connects to passt's UNIX socket
much less, while:
and listens on 127.0.0.1:9999 for TCP connections.
this is the part that I expected instead. Otherwise it's not just passt's network peer, it's the guest as well.
UNIX socket path: responds to ARP requests and TCP SYNs with stateless replies (swapped addresses, fixed ISN). Responses are XOR'd with AFL++ shared memory data so the fuzzer can mutate server behavior.
This looks rather complicated to me.
It does.
The approach I was suggesting with a test server is the following:
,- exchanges guest-side data with ------------. | ,---------|---------. | ,--| passt | | / '-.---------------^-' ,---|---. / | connect(), | accept(), | AFL++ |-- shares memory with ---| | send data, | reply with '---|---' \ | etc. | data, etc. | \ ,-v---------------'-. | '--| test server | | '---------|---------' '- exchanges host-side data with -------------'
...at least in its basic form. Eventually, the test server should be able to connect to passt itself (and we could call it "test peer" at that point).
So, I agree that to meaningfully fuzz things, we want the fuzzer to be able to control data on both the guest and host side. I can see two basic approaches: A) Alter passt/pasta so that instead of directly communicating with external entities (either guest or host side) we use mocked versions which retrieve data from AFL. We can do that either at the system call level, or at a higher helper function level, the lower level we go, the more of the "normal" passt code we're exercising, but doing at a slightly higher level might be easier to implement B) Run passt/pasta in an environment where we can intercept the external transfers to a test server / test peer / test guest which in turn responds based on data from AFL. Both the current draft and the sketch diagram Stefano has provided are a hybrid of both approaches, so far I'm not seeing a clear advantage to that over going all one way or the other. (B) is quite easy to do guest side - we just connect a "test guest" to passt's socket. Approach B is much harder for host side. The current draft has a test server listening on a single address. But that means the fuzzing is fundamentally incapable of finding bugs involving talking to multiple peers at once. Worse, we have to constrain the construction of guest side data so that we talk to the test server not something else, and that's one of the things we most want to fuzz. To really take approach B for the host side we'd need to intercept *all* host side network traffic regardless of address. Probably easiest way to do that would be put the whole thing inside another netns. That outside netns would have a default route to a tap device, and on the other side of the tap device would be a test server serving up frames built from the fuzzer output. It's probably easier to co-ordinate if the host side and guest side test server is the same, so we'd have: ,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ | | | /- test netns ----------^-------------------|-------------\ | | | | ,--------. | | | | passt >-----------<unix socket>-----/ | | '--------' | \---------------------------------------------------------/ The test peer generates host side frames via the tap device, and guest side frames via the Unix socket. It could also be done with pasta, like this: ,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ packet | socket /- test netns ----------^-------------------|-------------\ | | | | ,--------. ,----------------|---. | | | pasta >-tap device-< guest netns * | | | '--------' '--------------------' | \---------------------------------------------------------/ The order it generates host vs. guest frames should also come from the fuzzer, not be fixed. At least theoretically, this is non-invasive: it could run with an unmodified passt/pasta. Except that - AFL would still need coverage feedback from passt/pasta - It wouldn't allow us to simulate odd timings (except by actually expending real time) - The various interposing layers will probably slow down fuzzing. So, I rather suspect it will work better to go fully to approach A: no test peer at all, instead passt itself is modified to use mocked versions of all the external syscalls to slurp data from AFL. The draft series already does this for epoll_wait() and recv*(), but we'd need to also do that for recv*() on the tap socket, connect(), accept(), TCP_INFO and probably others. We'd also need to mock "sending" calls, send(), write() and shutdown() at least - but those could probably be no-ops. This is more invasive, of course. It also means we need to deal with the case where AFL generates a syscall results that should be impossible - that should move onto the next case ASAP, but not be flagged as a passt bug. On the other hand, this approach should be fast, and since we can also mock clock_gettime(), the fuzzer can potentially find timer logic bugs that would only occur after hours or days in real time.
As far as I understood, it's not trivial to make the same instance of AFL++ share memory with two processes at the same time, so the memory-sharing path might need to take a more complicated turn, for example there could be a wrapper starting both passt and the test server and sharing memory with them, or passt could _additionally_ (using a special out-of-band fuzzing channel) share data from AFL++ with the test server.
An example of communication below (but events don't necessarily need to be in this order, this is just an example). For simplicity, let's ignore the fact that AFL++ might not directly share memory with passt and test server, and assume there are three areas of memory that AFL++ directly controls:
a. shared with passt: an array of struct epoll_event, 'ev'
b. shared with passt: the kind of tap-side buffer you implemented in 4/5, 'buf'
c. shared with the test server: a separate buffer, 'test_buf'
Example:
1. AFL++ writes an EPOLLIN event in 'ev' with type EPOLL_TYPE_TAP_PASST, of some data in 'buf', and some data in 'test_buf'
2. AFL++ starts passt and the test server
3. passt reads the EPOLL_TYPE_TAP_PASST event from 'ev', reads data from 'buf' and hands it to passt_tap_handler()
4. this happens to be have Ethernet, IP, and TCP headers, with the SYN flag set, and destination address set to the address of the test server (we might want to force all this, at least initially, or give it as a hint to AFL++ somehow), so passt connects to the test server
5. the test server accepts the connection, and sends the contents of 'test_buf' on it (for the test server, this is directly payload, without headers, as they don't make sense there). I'm not sure if we should have a different set of events (maybe we need a "play script" for the server, in case?)
6. this generates an EPOLLOUT event for passt. It's not in 'ev', it's a regular epoll_wait() (I think we could have an epoll_wait() loop where we additionally read one event from 'ev' for every iteration, or something like that)
7. passt marks the connection as established and inserts it in the flow table
8. passt reads the data sent from the test server and generates whatever TCP data packet to the "guest" (it might simply be a sink)
...and this attempt ends here because AFL++ generated a single event for passt, but there could be more (this should also be decided by AFL++).
Would something like this make sense?
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
On Wed, Aug 12, 2026 at 12:56:28PM +0530, Anshu Kumari wrote:
Add fuzz-server that acts as passt's network peer during fuzzing. It connects to passt's UNIX socket and listens on 127.0.0.1:9999 for TCP connections.
As discussed in my other reply, I'm not really convinced a test server makes sense at all, at least in this form. So, I won't have much to say on the details here.
UNIX socket path: responds to ARP requests and TCP SYNs with stateless replies (swapped addresses, fixed ISN). Responses are XOR'd with AFL++ shared memory data so the fuzzer can mutate server behavior.
What's the reason for having a fixed response that's then mutated, rather than just generating the response directly from AFL's data?
TCP loopback path: accepts connections on port 9999, reads data, XOR-mutates with AFL++ shared memory, and echoes it back. Uses SO_LINGER(0) for immediate RST on close to avoid TIME_WAIT port exhaustion.
Turn-based synchronization via mmap'd flag in /dev/shm coordinates frame exchange between passt and the server.
Also adds: - fuzzing/testcase_dir/empty.bin - fuzzing/README.fuzzing.md
Signed-off-by: Anshu Kumari
--- Makefile | 8 +- fuzz-server.c | 490 +++++++++++++++++++++++++++++++++ fuzzing/README.fuzzing.md | 95 +++++++ fuzzing/testcase_dir/empty.bin | Bin 0 -> 12 bytes 4 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 fuzz-server.c create mode 100644 fuzzing/README.fuzzing.md create mode 100644 fuzzing/testcase_dir/empty.bin diff --git a/Makefile b/Makefile index 8e4121e..4517bc7 100644 --- a/Makefile +++ b/Makefile @@ -125,17 +125,21 @@ valgrind: all
FUZZ_CC ?= afl-clang-fast
-.PHONY: fuzz +.PHONY: fuzz fuzz-server
fuzz: $(MAKE) clean $(MAKE) CC="$(FUZZ_CC)" CPPFLAGS="-DFUZZING -DNDEBUG" CFLAGS="-g -fsanitize=address" passt
Oh, sorry, forgot to mention this on the earlier patch: compiling with -DNDEBUG for fuzzing seems undesirable. Generally if fuzzed input can cause an assert() that's a bug, which we'd like to catch. -NDEBUG will suppress the assert()s, so we won't see it.
+fuzz-server: + $(CC) -D_GNU_SOURCE -O2 -o fuzz-server fuzz-server.c + .PHONY: clean clean: $(RM) $(BIN) *~ *.o seccomp.h seccomp_repair.h seccomp_pesto.h pasta.1 \ passt.tar passt.tar.gz *.deb *.rpm \ - passt.pid README.plain.md + passt.pid README.plain.md \ + fuzz-server
install: $(BIN) $(MANPAGES) docs mkdir -p $(DESTDIR)$(bindir) $(DESTDIR)$(man1dir) diff --git a/fuzz-server.c b/fuzz-server.c new file mode 100644 index 0000000..c9b4101 --- /dev/null +++ b/fuzz-server.c @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +/* fuzz-server.c - Test server for bidirectional fuzz testing of passt + * + * Connects to passt's UNIX socket as a client. + * Reads outbound frames, generates protocol responses, XORs them + * with AFL++ shared memory data, and writes them back. + * + * Build: make fuzz-server + * Run: ./fuzz-server (after passt.fuzz is listening) + * + * Copyright Red Hat + * Author: Anshu Kumari
+ */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define FUZZ_SOCK_PATH "/tmp/passt_fuzz_1.socket" +#define FUZZ_TURN_PATH "/dev/shm/passt_fuzz_turn" +#define FUZZ_XOR_OFF (12 + 64 * 1024) +#define MAX_FRAME 1600 +#define TCP_LISTEN_PORT 9999 +#define TCP_MAX_EVENTS 16 + +/** + * struct fuzz_turn - Turn-based synchronization flag (mmap'd shared memory) + * @turn: 0 = passt's turn, 1 = server's turn + */ +struct fuzz_turn { + uint32_t turn; +}; + +/** + * swap_eth_ip() - Swap Ethernet MACs and IPv4 addresses + * @pkt: Pointer to start of Ethernet frame + */ +static void swap_eth_ip(uint8_t *pkt) +{ + struct ethhdr *eth = (void *)pkt; + struct iphdr *iph = (void *)(eth + 1); + uint8_t tmp_mac[ETH_ALEN]; + uint8_t tmp_ip[4]; + + memcpy(tmp_mac, eth->h_source, ETH_ALEN); + memcpy(eth->h_source, eth->h_dest, ETH_ALEN); + memcpy(eth->h_dest, tmp_mac, ETH_ALEN); + + memcpy(tmp_ip, &iph->saddr, 4); + memcpy(&iph->saddr, &iph->daddr, 4); + memcpy(&iph->daddr, tmp_ip, 4); +} + +/** + * respond_arp() - Generate ARP reply for an ARP request + * @in: Incoming frame + * @in_len: Incoming frame length + * @out: Output buffer for response + * + * Swaps sender hardware and protocol addresses to form a + * valid ARP reply. Only responds to ARP requests (op=1). + * + * Return: response length, or 0 if not an ARP request + */ +static int respond_arp(const uint8_t *in, uint32_t in_len, uint8_t *out) +{ + /* Min ARP Frame size: eth(14) + ARP Header(8) + Payload(20) */ + if (in_len < 42) + return 0; + + const struct ethhdr *eth = (const void *)in; + const struct arphdr *arp = (const void *)(eth + 1); + + if (eth->h_proto != htons(ETH_P_ARP) || + arp->ar_op != htons(ARPOP_REQUEST)) + return 0; + + memcpy(out, in, in_len); + + struct ethhdr *out_eth = (void *)out; + struct arphdr *out_arp = (void *)(out_eth + 1); + + /* set MAC addresses for output buffer */ + memcpy(out_eth->h_dest, eth->h_source, ETH_ALEN); + memcpy(out_eth->h_source, eth->h_dest, ETH_ALEN); + + /* set ARP operation to REPLY */ + out_arp->ar_op = htons(ARPOP_REPLY); + + /* Swap ARP Sender and Target fields */ + uint8_t *sender = (uint8_t *)(out_arp + 1); + uint8_t *target = sender + 10; + uint8_t tmp[10]; + + memcpy(tmp, sender, 10); + memcpy(sender, target, 10); + memcpy(target, tmp, 10); + + return in_len; +} + +/** + * respond_tcp_syn() - Generate SYN-ACK for a TCP SYN + * @in: Incoming frame + * @in_len: Incoming frame length + * @out: Output buffer for response + * + * Swaps MAC/IP/port addresses and crafts a stateless SYN-ACK with + * a fixed ISN of 1000. Only responds to pure SYN (no ACK set). + * + * Return: response length, or 0 if not a TCP SYN + */ +static int respond_tcp_syn(const uint8_t *in, uint32_t in_len, uint8_t *out) +{ + const struct ethhdr *eth = (const void *)in; + const struct iphdr *iph; + const struct tcphdr *th; + struct tcphdr *out_th; + uint32_t ihl, hdr_len; + + /* Combined minimum length check for Eth + IP + TCP */ + if (in_len < sizeof(*eth) + sizeof(*iph) + sizeof(*th)) + return 0; + + if (eth->h_proto != htons(ETH_P_IP)) + return 0; + + iph = (const void *)(eth + 1); + ihl = iph->ihl * 4; + + if (iph->protocol != IPPROTO_TCP || + in_len < sizeof(*eth) + ihl + sizeof(*th)) + return 0; + + th = (const void *)((const uint8_t *)iph + ihl); + + /* Only respond to pure SYN packets (no ACK set) */ + if (!th->syn || th->ack) + return 0; + + hdr_len = sizeof(*eth) + ihl + sizeof(*th); + memcpy(out, in, hdr_len); + swap_eth_ip(out); + + /* Craft SYN-ACK response header */ + out_th = (struct tcphdr *)(out + sizeof(*eth) + ihl); + out_th->source = th->dest; + out_th->dest = th->source; + out_th->seq = htonl(1000); + out_th->ack_seq = htonl(ntohl(th->seq) + 1); + out_th->syn = 1; + out_th->ack = 1; + out_th->doff = 5; + out_th->check = 0; + + return hdr_len; +} + +/** + * generate_response() - Try all protocol responders on a frame + * @in: Incoming frame + * @in_len: Incoming frame length + * @out: Output buffer for response + * + * Tries ARP then TCP SYN responders in order. Returns the first + * successful response. + * + * Return: response length, or 0 if no responder matched + */ +static int generate_response(const uint8_t *in, uint32_t in_len, uint8_t *out) +{ + int len; + + if (in_len < sizeof(struct ethhdr)) + return 0; + + if ((len = respond_arp(in, in_len, out)) > 0) + return len; + if ((len = respond_tcp_syn(in, in_len, out)) > 0) + return len; + + return 0; +} + +/** + * map_afl_shm() - Map AFL++ shared memory for XOR mutation + * + * Reads __AFL_SHM_FUZZ_ID from the AFL++ environment + * variable and attaches the corresponding shared memory + * segment read-only. + * + * Return: pointer to mapped SHM, or NULL if not available + */ +static uint8_t *map_afl_shm(void) +{ + const char *id_str = getenv("__AFL_SHM_FUZZ_ID"); + int shm_id; + uint8_t *map; + + if (!id_str) + return NULL; + + shm_id = atoi(id_str); + map = shmat(shm_id, NULL, SHM_RDONLY); + if (map == (void *)-1) + return NULL; + + return map; +} + +/** + * apply_xor_mask() - XOR buffer with AFL++ shared memory data + * @buf: Buffer to mutate in place + * @len: Buffer length + * @afl_buf: AFL++ shared memory + * + * XORs each byte of @buf with the corresponding byte from the + * AFL++ buffer starting at offset FUZZ_XOR_OFF (65548). This + * lets AFL++ mutate server responses. + */ +static void apply_xor_mask(uint8_t *buf, int len, const uint8_t *afl_buf) +{ + int i; + + if (!afl_buf) + return; + + for (i = 0; i < len; i++) + buf[i] ^= afl_buf[FUZZ_XOR_OFF + i]; +} + +/* ---- TCP Host Listener (Loopback) ---- */ + +static int tcp_listen_fd = -1; +static int tcp_epfd = -1; + +/** + * tcp_listener_init() - Set up TCP listener on loopback port 9999 + * + * Creates an epoll instance and a non-blocking TCP listener on + * 127.0.0.1:9999 with SO_REUSEADDR. + * + * Return: 0 on success, -1 on failure + */ +static int tcp_listener_init(void) +{ + struct sockaddr_in sa = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = htons(TCP_LISTEN_PORT), + }; + struct epoll_event ev; + int opt = 1; + + tcp_epfd = epoll_create1(EPOLL_CLOEXEC); + tcp_listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); + if (tcp_epfd < 0 || tcp_listen_fd < 0) + return -1; + + setsockopt(tcp_listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + if (bind(tcp_listen_fd, (struct sockaddr *)&sa, sizeof(sa)) < 0 || + listen(tcp_listen_fd, 128) < 0) + return -1; + + ev.events = EPOLLIN; + ev.data.fd = tcp_listen_fd; + epoll_ctl(tcp_epfd, EPOLL_CTL_ADD, tcp_listen_fd, &ev); + + return 0; +} + +/** + * tcp_handle_accept() - Drain all pending TCP connections + * + * Sets SO_LINGER with l_linger=0 on every accepted connection, + * which does the immediate RST on connection close instead of + * waiting for connection to close on it's own. + */ +static void tcp_handle_accept(void) +{ + struct linger lg = { .l_onoff = 1, .l_linger = 0 }; + struct epoll_event ev; + int fd; + + /* Drain ALL pending connections in the backlog */ + while ((fd = accept4(tcp_listen_fd, NULL, NULL, + SOCK_NONBLOCK)) >= 0) { + setsockopt(fd, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg)); + + ev.events = EPOLLIN | EPOLLRDHUP | EPOLLHUP | EPOLLERR; + ev.data.fd = fd; + epoll_ctl(tcp_epfd, EPOLL_CTL_ADD, fd, &ev); + } +} + +/** + * tcp_handle_data() - Read TCP data, XOR-mutate, echo back + * @fd: Connected TCP socket + * @afl_buf: AFL++ shared memory for XOR mask + */ +static void tcp_handle_data(int fd, const uint8_t *afl_buf) +{ + uint8_t buf[MAX_FRAME]; + ssize_t n, written, ret; + + n = read(fd, buf, sizeof(buf)); + if (n <= 0) { + epoll_ctl(tcp_epfd, EPOLL_CTL_DEL, fd, NULL); + close(fd); + return; + } + + apply_xor_mask(buf, (uint32_t)n, afl_buf); + + /* Ensure all n bytes are written, handling short writes */ + written = 0; + while (written < n) { + ret = write(fd, buf + written, (size_t)(n - written)); + if (ret <= 0) { + if (ret < 0 && (errno == EAGAIN || + errno == EWOULDBLOCK)) + continue; + + epoll_ctl(tcp_epfd, EPOLL_CTL_DEL, fd, NULL); + close(fd); + return; + } + written += ret; + } +} + +/** + * tcp_process_events() - Non-blocking poll for TCP events + * @afl_buf: AFL++ shared memory + */ +static void tcp_process_events(const uint8_t *afl_buf) +{ + struct epoll_event events[TCP_MAX_EVENTS]; + int nfds, i; + + nfds = epoll_wait(tcp_epfd, events, TCP_MAX_EVENTS, 0); + + for (i = 0; i < nfds; i++) { + int fd = events[i].data.fd; + + if (fd == tcp_listen_fd) { + tcp_handle_accept(); + } else if (events[i].events & (EPOLLIN | EPOLLRDHUP)) { + tcp_handle_data(fd, afl_buf); + } else if (events[i].events & (EPOLLHUP | EPOLLERR)) { + epoll_ctl(tcp_epfd, EPOLL_CTL_DEL, fd, NULL); + close(fd); + } + } +} + +/** + * create_turn_flag() - Create and mmap the turn synchronization flag + * + * Creates FUZZ_TURN_PATH in /dev/shm and maps it + * as shared memory. Both passt and fuzz-server map this file to + * coordinate turn-based frame exchange via atomic load/store. + * + * Return: pointer to mapped turn struct, or NULL on failure + */ +static struct fuzz_turn *create_turn_flag(void) +{ + struct fuzz_turn *t; + int fd; + + fd = open(FUZZ_TURN_PATH, O_RDWR | O_CREAT | O_TRUNC, 0644); + if (fd < 0) + return NULL; + + if (ftruncate(fd, sizeof(struct fuzz_turn)) < 0) { + close(fd); + return NULL; + } + + t = mmap(NULL, sizeof(*t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + close(fd); + + if (t == MAP_FAILED) + return NULL; + + t->turn = 0; + return t; +} + +/** + * main() - Server entry point + * + * Outer loop reconnects to passt's UNIX socket on each AFL++ fork + * server restart. Inner loop handles turn-based TAP frame exchange + * and TCP events concurrently. + * + * Return: 0 on success, 1 on initialization failure + */ +int main(void) +{ + struct sockaddr_un addr = { .sun_family = AF_UNIX }; + uint8_t frame[MAX_FRAME], response[MAX_FRAME]; + struct fuzz_turn *turn; + uint8_t *afl_buf; + + strncpy(addr.sun_path, FUZZ_SOCK_PATH, sizeof(addr.sun_path) - 1); + + turn = create_turn_flag(); + if (!turn || tcp_listener_init() < 0) { + (void)fprintf(stderr, + "fuzz-server: tcp initialization failed\n"); + return 1; + } + + afl_buf = map_afl_shm(); + (void)fprintf(stderr, + "fuzz-server: ready, TCP listening on 127.0.0.1:%d\n", + TCP_LISTEN_PORT); + + /* + * Reconnect UNIX socket on passt restart — AFL++'s fork server + * restarts passt on each iteration. + */ + while (1) { + ssize_t n; + int sock; + + sock = socket(AF_UNIX, SOCK_SEQPACKET, 0); + if (sock < 0) + return 1; + + while (connect(sock, (struct sockaddr *)&addr, + sizeof(addr)) < 0) + usleep(10000); + + while (1) { + tcp_process_events(afl_buf); + + if (__atomic_load_n(&turn->turn, + __ATOMIC_ACQUIRE) == 1) { + n = recv(sock, frame, MAX_FRAME, + MSG_DONTWAIT); + if (n > 0) { + int resp_len; + + resp_len = generate_response( + frame, n, response); + if (resp_len > 0) { + apply_xor_mask(response, + resp_len, + afl_buf); + send(sock, response, + resp_len, MSG_NOSIGNAL); + } + } else if (n == 0 || + (n < 0 && errno != EAGAIN && + errno != EWOULDBLOCK)) { + __atomic_store_n(&turn->turn, 0, + __ATOMIC_RELEASE); + break; + } + + __atomic_store_n(&turn->turn, 0, + __ATOMIC_RELEASE); + } + } + + close(sock); + } + + return 0; +} diff --git a/fuzzing/README.fuzzing.md b/fuzzing/README.fuzzing.md new file mode 100644 index 0000000..f23e265 --- /dev/null +++ b/fuzzing/README.fuzzing.md @@ -0,0 +1,95 @@ +## Fuzzing passt with AFL++ + +### Prerequisites + +- AFL++ (afl-clang-fast, afl-fuzz) + +### Build + +``` +make fuzz +cp passt passt.fuzz +make fuzz-server +``` + +This produces: +- `passt.fuzz` -- instrumented with AFL++ and AddressSanitizer +- `fuzz-server` -- test server for bidirectional protocol fuzzing + +To use a specific AFL++ installation: + +``` +make FUZZ_CC=/path/to/afl-clang-fast fuzz +``` + +### Run + +Start the test server first, then the fuzzer: + +``` +# Terminal 1 -- test server: +./fuzz-server + +# Terminal 2 -- fuzzer: +afl-fuzz -i fuzzing/testcase_dir -o fuzzing/sync_dir \ + -- ./passt.fuzz --foreground +``` + +Multi-core (secondary instances share the corpus): + +``` +# Terminal 1 -- main instance: +afl-fuzz -M main -i fuzzing/testcase_dir -o fuzzing/sync_dir \ + -- ./passt.fuzz --foreground + +# Terminal 2 -- secondary with different power schedule: +afl-fuzz -S variant1 -p rare -i fuzzing/testcase_dir \ + -o fuzzing/sync_dir -- ./passt.fuzz --foreground +``` + +### Architecture + +The fuzzer uses AFL++ persistent mode with shared memory fuzzing. +Each iteration: + +1. Resets deterministic state (clock, flow table, epoll) +2. Reads an epoll event and packet data from AFL++ shared memory +3. For TAP events: injects a packet with fixed L2/L3/L4 headers + into the tap pipeline via tap_add_packet() + tap_handler(). + The TCP destination port is fixed to 9999 (the test server's + listening port). +4. Exchanges a turn flag with fuzz-server for bidirectional flow. +5. Calls passt_worker() to process the epoll event +6. Polls for host-side TCP events (connect completion, server data) + +The test server connects to passt's UNIX socket (SOCK_SEQPACKET) +and listens on 127.0.0.1:9999 for TCP connections. It responds to +ARP requests and TCP SYNs on the UNIX socket, and echoes TCP data +(XOR'd with AFL++ shared memory) on the loopback side. + +Deterministic wrappers in fuzz.c replace clock_gettime(), +getrandom(), getsockopt(TCP_INFO), and recv/recvmsg/recvfrom/ +recvmmsg to eliminate non-determinism from kernel state. The recv +wrappers return AFL++ buffer data for non-TAP file descriptors, +giving the fuzzer control over what passt "receives" from host +sockets. + +### Seed inputs + +`testcase_dir/empty.bin` is a 12-byte zero file +(sizeof(struct epoll_event)). AFL++ discovers packet formats +through mutation from this minimal seed. + +### Reproducing crashes + +``` +./passt.fuzz --foreground < \ + fuzzing/sync_dir/default/crashes/id:000000,... +``` + +Minimize a crash input: + +``` +afl-tmin -i fuzzing/sync_dir/default/crashes/id:000000,... \ + -o crash_minimized -- ./passt.fuzz --foreground +``` diff --git a/fuzzing/testcase_dir/empty.bin b/fuzzing/testcase_dir/empty.bin new file mode 100644 index 0000000000000000000000000000000000000000..ce58bc9f84b9623e708de4eb8427a57d9f9a160f GIT binary patch literal 12 KcmZQzKmY&$3;+QD literal 0 HcmV?d00001
-- 2.55.0
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
On Fri, 14 Aug 2026 15:40:35 +1000
David Gibson
On Thu, Aug 13, 2026 at 09:53:24AM +0200, Stefano Brivio wrote:
On Wed, 12 Aug 2026 12:56:28 +0530 Anshu Kumari
wrote: Add fuzz-server that acts as passt's network peer during fuzzing.
To me, this part makes sense. But this one:
It connects to passt's UNIX socket
much less, while:
and listens on 127.0.0.1:9999 for TCP connections.
this is the part that I expected instead. Otherwise it's not just passt's network peer, it's the guest as well.
UNIX socket path: responds to ARP requests and TCP SYNs with stateless replies (swapped addresses, fixed ISN). Responses are XOR'd with AFL++ shared memory data so the fuzzer can mutate server behavior.
This looks rather complicated to me.
It does.
The approach I was suggesting with a test server is the following:
,- exchanges guest-side data with ------------. | ,---------|---------. | ,--| passt | | / '-.---------------^-' ,---|---. / | connect(), | accept(), | AFL++ |-- shares memory with ---| | send data, | reply with '---|---' \ | etc. | data, etc. | \ ,-v---------------'-. | '--| test server | | '---------|---------' '- exchanges host-side data with -------------'
...at least in its basic form. Eventually, the test server should be able to connect to passt itself (and we could call it "test peer" at that point).
So, I agree that to meaningfully fuzz things, we want the fuzzer to be able to control data on both the guest and host side. I can see two basic approaches:
A) Alter passt/pasta so that instead of directly communicating with external entities (either guest or host side) we use mocked versions which retrieve data from AFL. We can do that either at the system call level, or at a higher helper function level, the lower level we go, the more of the "normal" passt code we're exercising, but doing at a slightly higher level might be easier to implement
B) Run passt/pasta in an environment where we can intercept the external transfers to a test server / test peer / test guest which in turn responds based on data from AFL.
Both the current draft and the sketch diagram Stefano has provided are a hybrid of both approaches, so far I'm not seeing a clear advantage to that over going all one way or the other.
Of course, B) would be cleaner (and not that complicated, see below), but we don't want to do that guest-side (sending data from a test guest) because we would lose the speed advantage of having shared memory on the path that _really_ matters for fuzzing (the guest is untrusted, the kernel isn't). That's something we already established a while ago when AbdAlRahman was working on it. We hadn't really looked into the host side yet, back then. So, host side: we can't do it (and it's much less important) because we need to use those sockets in the same way passt uses them. The guest side interface is a trivial recv(), the host side is something complicated with iovecs and everything.
(B) is quite easy to do guest side - we just connect a "test guest" to passt's socket. Approach B is much harder for host side. The current draft has a test server listening on a single address.
This is just to get something up and running though, it obviously needs to be changed later.
But that means the fuzzing is fundamentally incapable of finding bugs involving talking to multiple peers at once. Worse, we have to constrain the construction of guest side data so that we talk to the test server not something else, and that's one of the things we most want to fuzz.
To really take approach B for the host side we'd need to intercept *all* host side network traffic regardless of address. Probably easiest way to do that would be put the whole thing inside another netns. That outside netns would have a default route to a tap device, and on the other side of the tap device would be a test server serving up frames built from the fuzzer output.
As I was mentioning, this could be done in a network namespace without any interface, by making the test server listen to all ports and all addresses, with a non-local bind and a so-called AnyIP route. Tested: $ pasta -- sh -c 'ip route add local default dev lo; nc -l 1 & { sleep 1; echo x | nc -N 1.2.3.4 1; }' x
It's probably easier to co-ordinate if the host side and guest side test server is the same, so we'd have:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ | | | /- test netns ----------^-------------------|-------------\ | | | | ,--------. | | | | passt >-----------<unix socket>-----/ | | '--------' | \---------------------------------------------------------/
The test peer generates host side frames via the tap device, and guest side frames via the Unix socket.
The UNIX socket is something we want to avoid, it's really much slower compared to shared memory (we tried something like that) on the path where AFL++ is trying to mutate data fast (because it can hit a lot of different code paths with small changes, compared to changing socket-side payload).
It could also be done with pasta, like this:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ packet | socket /- test netns ----------^-------------------|-------------\ | | | | ,--------. ,----------------|---. | | | pasta >-tap device-< guest netns * | | | '--------' '--------------------' | \---------------------------------------------------------/
The order it generates host vs. guest frames should also come from the fuzzer, not be fixed. At least theoretically, this is non-invasive: it could run with an unmodified passt/pasta. Except that - AFL would still need coverage feedback from passt/pasta - It wouldn't allow us to simulate odd timings (except by actually expending real time) - The various interposing layers will probably slow down fuzzing.
So, I rather suspect it will work better to go fully to approach A: no test peer at all, instead passt itself is modified to use mocked versions of all the external syscalls to slurp data from AFL. The draft series already does this for epoll_wait() and recv*(), but we'd need to also do that for recv*() on the tap socket, connect(), accept(), TCP_INFO and probably others. We'd also need to mock "sending" calls, send(), write() and shutdown() at least - but those could probably be no-ops.
...except that by mocking all those we lose a lot of complexity where historically we had a ton of bugs. If we just mock recv() it's much less (well yes we had bugs there as well but it was like 3 or 4 over the entire project history).
This is more invasive, of course. It also means we need to deal with the case where AFL generates a syscall results that should be impossible - that should move onto the next case ASAP, but not be flagged as a passt bug. On the other hand, this approach should be fast, and since we can also mock clock_gettime(), the fuzzer can potentially find timer logic bugs that would only occur after hours or days in real time.
As far as I understood, it's not trivial to make the same instance of AFL++ share memory with two processes at the same time, so the memory-sharing path might need to take a more complicated turn, for example there could be a wrapper starting both passt and the test server and sharing memory with them, or passt could _additionally_ (using a special out-of-band fuzzing channel) share data from AFL++ with the test server.
An example of communication below (but events don't necessarily need to be in this order, this is just an example). For simplicity, let's ignore the fact that AFL++ might not directly share memory with passt and test server, and assume there are three areas of memory that AFL++ directly controls:
a. shared with passt: an array of struct epoll_event, 'ev'
b. shared with passt: the kind of tap-side buffer you implemented in 4/5, 'buf'
c. shared with the test server: a separate buffer, 'test_buf'
Example:
1. AFL++ writes an EPOLLIN event in 'ev' with type EPOLL_TYPE_TAP_PASST, of some data in 'buf', and some data in 'test_buf'
2. AFL++ starts passt and the test server
3. passt reads the EPOLL_TYPE_TAP_PASST event from 'ev', reads data from 'buf' and hands it to passt_tap_handler()
4. this happens to be have Ethernet, IP, and TCP headers, with the SYN flag set, and destination address set to the address of the test server (we might want to force all this, at least initially, or give it as a hint to AFL++ somehow), so passt connects to the test server
5. the test server accepts the connection, and sends the contents of 'test_buf' on it (for the test server, this is directly payload, without headers, as they don't make sense there). I'm not sure if we should have a different set of events (maybe we need a "play script" for the server, in case?)
6. this generates an EPOLLOUT event for passt. It's not in 'ev', it's a regular epoll_wait() (I think we could have an epoll_wait() loop where we additionally read one event from 'ev' for every iteration, or something like that)
7. passt marks the connection as established and inserts it in the flow table
8. passt reads the data sent from the test server and generates whatever TCP data packet to the "guest" (it might simply be a sink)
...and this attempt ends here because AFL++ generated a single event for passt, but there could be more (this should also be decided by AFL++).
Would something like this make sense?
-- Stefano
On Fri, Aug 14, 2026 at 09:35:59AM +0200, Stefano Brivio wrote:
On Fri, 14 Aug 2026 15:40:35 +1000 David Gibson
wrote: On Thu, Aug 13, 2026 at 09:53:24AM +0200, Stefano Brivio wrote:
On Wed, 12 Aug 2026 12:56:28 +0530 Anshu Kumari
wrote: Add fuzz-server that acts as passt's network peer during fuzzing.
To me, this part makes sense. But this one:
It connects to passt's UNIX socket
much less, while:
and listens on 127.0.0.1:9999 for TCP connections.
this is the part that I expected instead. Otherwise it's not just passt's network peer, it's the guest as well.
UNIX socket path: responds to ARP requests and TCP SYNs with stateless replies (swapped addresses, fixed ISN). Responses are XOR'd with AFL++ shared memory data so the fuzzer can mutate server behavior.
This looks rather complicated to me.
It does.
The approach I was suggesting with a test server is the following:
,- exchanges guest-side data with ------------. | ,---------|---------. | ,--| passt | | / '-.---------------^-' ,---|---. / | connect(), | accept(), | AFL++ |-- shares memory with ---| | send data, | reply with '---|---' \ | etc. | data, etc. | \ ,-v---------------'-. | '--| test server | | '---------|---------' '- exchanges host-side data with -------------'
...at least in its basic form. Eventually, the test server should be able to connect to passt itself (and we could call it "test peer" at that point).
So, I agree that to meaningfully fuzz things, we want the fuzzer to be able to control data on both the guest and host side. I can see two basic approaches:
A) Alter passt/pasta so that instead of directly communicating with external entities (either guest or host side) we use mocked versions which retrieve data from AFL. We can do that either at the system call level, or at a higher helper function level, the lower level we go, the more of the "normal" passt code we're exercising, but doing at a slightly higher level might be easier to implement
B) Run passt/pasta in an environment where we can intercept the external transfers to a test server / test peer / test guest which in turn responds based on data from AFL.
Both the current draft and the sketch diagram Stefano has provided are a hybrid of both approaches, so far I'm not seeing a clear advantage to that over going all one way or the other.
Of course, B) would be cleaner (and not that complicated, see below), but we don't want to do that guest-side (sending data from a test guest) because we would lose the speed advantage of having shared memory on the path that _really_ matters for fuzzing (the guest is untrusted, the kernel isn't).
I agree we lose the speed advantage, but I don't really see why that matters more on the guest side than the host. Yes, fuzzing the guest side matters more, but most guest side operations will induce passt to perform a host side operation, so the speed of the host side handling matters even if the guest side is what we care about fuzzing.
That's something we already established a while ago when AbdAlRahman was working on it. We hadn't really looked into the host side yet, back then.
So, host side: we can't do it (and it's much less important) because we need to use those sockets in the same way passt uses them.
I'm not sure what you mean. I outlined a way to do this below.
The guest side interface is a trivial recv(), the host side is something complicated with iovecs and everything.
I'm now not sure if you're saying this in relation to approach B, or approach A.
(B) is quite easy to do guest side - we just connect a "test guest" to passt's socket. Approach B is much harder for host side. The current draft has a test server listening on a single address.
This is just to get something up and running though, it obviously needs to be changed later.
I don't see how we are "up and running" if fuzzed packets from the guest induce passt to forward them to random host side addresses that we're not controlling - we won't generate reproducible results.
But that means the fuzzing is fundamentally incapable of finding bugs involving talking to multiple peers at once. Worse, we have to constrain the construction of guest side data so that we talk to the test server not something else, and that's one of the things we most want to fuzz.
To really take approach B for the host side we'd need to intercept *all* host side network traffic regardless of address. Probably easiest way to do that would be put the whole thing inside another netns. That outside netns would have a default route to a tap device, and on the other side of the tap device would be a test server serving up frames built from the fuzzer output.
As I was mentioning, this could be done in a network namespace without any interface, by making the test server listen to all ports and all addresses, with a non-local bind and a so-called AnyIP route. Tested:
$ pasta -- sh -c 'ip route add local default dev lo; nc -l 1 & { sleep 1; echo x | nc -N 1.2.3.4 1; }' x
True, but having the fuzzer synthesize L2 frames seems easier to me than having it directly synthesize the various socket operations the host side peer might perform.
It's probably easier to co-ordinate if the host side and guest side test server is the same, so we'd have:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ | | | /- test netns ----------^-------------------|-------------\ | | | | ,--------. | | | | passt >-----------<unix socket>-----/ | | '--------' | \---------------------------------------------------------/
The test peer generates host side frames via the tap device, and guest side frames via the Unix socket.
The UNIX socket is something we want to avoid, it's really much slower compared to shared memory (we tried something like that) on the path where AFL++ is trying to mutate data fast (because it can hit a lot of different code paths with small changes, compared to changing socket-side payload).
Right. That's why I conclude approch A is probably better further down. But even if we only care about fuzzing guest side, we'll usually incur the cost of operations on both sides, so I don't see that using shared memory is more important for guest side than host side interposition.
It could also be done with pasta, like this:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ packet | socket /- test netns ----------^-------------------|-------------\ | | | | ,--------. ,----------------|---. | | | pasta >-tap device-< guest netns * | | | '--------' '--------------------' | \---------------------------------------------------------/
The order it generates host vs. guest frames should also come from the fuzzer, not be fixed. At least theoretically, this is non-invasive: it could run with an unmodified passt/pasta. Except that - AFL would still need coverage feedback from passt/pasta - It wouldn't allow us to simulate odd timings (except by actually expending real time) - The various interposing layers will probably slow down fuzzing.
So, I rather suspect it will work better to go fully to approach A: no test peer at all, instead passt itself is modified to use mocked versions of all the external syscalls to slurp data from AFL. The draft series already does this for epoll_wait() and recv*(), but we'd need to also do that for recv*() on the tap socket, connect(), accept(), TCP_INFO and probably others. We'd also need to mock "sending" calls, send(), write() and shutdown() at least - but those could probably be no-ops.
...except that by mocking all those we lose a lot of complexity where historically we had a ton of bugs. If we just mock recv() it's much less (well yes we had bugs there as well but it was like 3 or 4 over the entire project history).
If we mock as close as possible to the syscall level, I don't see that bypass much of our complexity. To be clear, I'm suggesting mocks where both returned data and error codes are derived from the fuzzer, not just no-op stubs.
This is more invasive, of course. It also means we need to deal with the case where AFL generates a syscall results that should be impossible - that should move onto the next case ASAP, but not be flagged as a passt bug. On the other hand, this approach should be fast, and since we can also mock clock_gettime(), the fuzzer can potentially find timer logic bugs that would only occur after hours or days in real time.
As far as I understood, it's not trivial to make the same instance of AFL++ share memory with two processes at the same time, so the memory-sharing path might need to take a more complicated turn, for example there could be a wrapper starting both passt and the test server and sharing memory with them, or passt could _additionally_ (using a special out-of-band fuzzing channel) share data from AFL++ with the test server.
An example of communication below (but events don't necessarily need to be in this order, this is just an example). For simplicity, let's ignore the fact that AFL++ might not directly share memory with passt and test server, and assume there are three areas of memory that AFL++ directly controls:
a. shared with passt: an array of struct epoll_event, 'ev'
b. shared with passt: the kind of tap-side buffer you implemented in 4/5, 'buf'
c. shared with the test server: a separate buffer, 'test_buf'
Example:
1. AFL++ writes an EPOLLIN event in 'ev' with type EPOLL_TYPE_TAP_PASST, of some data in 'buf', and some data in 'test_buf'
2. AFL++ starts passt and the test server
3. passt reads the EPOLL_TYPE_TAP_PASST event from 'ev', reads data from 'buf' and hands it to passt_tap_handler()
4. this happens to be have Ethernet, IP, and TCP headers, with the SYN flag set, and destination address set to the address of the test server (we might want to force all this, at least initially, or give it as a hint to AFL++ somehow), so passt connects to the test server
5. the test server accepts the connection, and sends the contents of 'test_buf' on it (for the test server, this is directly payload, without headers, as they don't make sense there). I'm not sure if we should have a different set of events (maybe we need a "play script" for the server, in case?)
6. this generates an EPOLLOUT event for passt. It's not in 'ev', it's a regular epoll_wait() (I think we could have an epoll_wait() loop where we additionally read one event from 'ev' for every iteration, or something like that)
7. passt marks the connection as established and inserts it in the flow table
8. passt reads the data sent from the test server and generates whatever TCP data packet to the "guest" (it might simply be a sink)
...and this attempt ends here because AFL++ generated a single event for passt, but there could be more (this should also be decided by AFL++).
Would something like this make sense?
-- Stefano
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
On Fri, Aug 14, 2026 at 7:27 AM David Gibson
On Wed, Aug 12, 2026 at 12:56:27PM +0530, Anshu Kumari wrote:
Add the AFL++ persistent mode fuzz loop to passt.c main(). The loop uses __AFL_LOOP() for in-process iteration and __AFL_FUZZ_TESTCASE_BUF for shared memory fuzzing.
Each iteration: - Resets deterministic clock, flow table, and epoll instance. - Drains stale data from the TAP socket. - Reads an epoll event from the AFL++ buffer. - For TAP events: constructs a packet with fixed L2/L3/L4 headers and injects it via tap_add_packet() + tap_handler(). - Exchanges a turn flag with the test server for bidirectional flow over the UNIX socket. - Calls passt_worker() to process the event. - Polls for host-side TCP events via epoll_wait(). - Runs post_handler() for deferred work.
Added the 'make fuzz' target which builds passt with afl-clang-fast, -DFUZZING, -DNDEBUG, and AddressSanitizer.
Stefano's concerns generally seconded (although I haven't really got my head around the role of the test server in either yours or his mind - I'll address that once I've read 5/5).
The big concerns here are that to do interesting fuzzing we'll need a) sequences of multiple packets/packets and b) to fuzz-generate the headers, including malformed ones.
AIUI, logically each fuzzer generated case could be run in a separate instance of passt: the __AFL_LOOP() stuff is an optimization to avoid the delay of a fresh startup on each cycle. Is that correct?
yes, without __AFL_LOOP(), AFL++ forks a fresh passt for each input.
Signed-off-by: Anshu Kumari
--- Makefile | 8 +++ passt.c | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/Makefile b/Makefile index fe1df58..8e4121e 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,14 @@ valgrind: BASE_CPPFLAGS += -DVALGRIND valgrind: BASE_CFLAGS += -g valgrind: all
+FUZZ_CC ?= afl-clang-fast + +.PHONY: fuzz + +fuzz: + $(MAKE) clean + $(MAKE) CC="$(FUZZ_CC)" CPPFLAGS="-DFUZZING -DNDEBUG" CFLAGS="-g
-fsanitize=address" passt
I'd recommend building the fuzzing binary under a different name, to make accidentally using the wrong one a bit less likely.
.PHONY: clean clean: $(RM) $(BIN) *~ *.o seccomp.h seccomp_repair.h seccomp_pesto.h pasta.1 \ diff --git a/passt.c b/passt.c index 5054551..e026eb2 100644 --- a/passt.c +++ b/passt.c @@ -35,6 +35,7 @@ #include
#include #include +#include #include "util.h" #include "passt.h" @@ -54,12 +55,56 @@ #include "repair.h" #include "netlink.h" #include "epoll_ctl.h" +#include "flow_table.h" +#include "fuzz.h"
#define NUM_EPOLL_EVENTS 8
#define TIMER_INTERVAL_ MIN(TCP_TIMER_INTERVAL, FWD_PORT_SCAN_INTERVAL) #define TIMER_INTERVAL MIN(TIMER_INTERVAL_, FLOW_TIMER_INTERVAL)
+#ifdef FUZZING + +/* AFL++ persistent mode / shared memory fuzzing compatibility macros. */ +#ifndef __AFL_FUZZ_TESTCASE_LEN + ssize_t fuzz_len; + unsigned char fuzz_buf[1024 * 1024]; +# define __AFL_FUZZ_TESTCASE_LEN fuzz_len +# define __AFL_FUZZ_TESTCASE_BUF fuzz_buf +# define __AFL_FUZZ_INIT() void sync(void) +# define __AFL_LOOP(x) \ + ((fuzz_len = read(0, fuzz_buf, sizeof(fuzz_buf))) > 0 ? 1 : 0)
This macro ignores its parameter. Is that intentional?
Yes, this is intentional as it helps compile afl++ without afl-clang-fast. more about this: https://github.com/AFLplusplus/AFLplusplus/blob/stable/instrumentation/READM...
+# define __AFL_INIT() sync() +#endif + +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_FUZZ_INIT(); +#endif + +static struct fuzz_turn *fuzz_turn_ptr; + +/** + * fuzz_turn_connect() - Map the turn flag shared memory + * + * Return: pointer to mapped turn flag, or NULL on failure + */ +static struct fuzz_turn *fuzz_turn_connect(void) +{ + struct fuzz_turn *t; + int fd; + + fd = open(FUZZ_TURN_PATH, O_RDWR);
FUZZ_TURN_PATH was defined in 1/5 but only used here, which makes review harder. I'd suggest moving the definition to this patch.
Noted !!
+ if (fd < 0) + return NULL; + + t = mmap(NULL, sizeof(*t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + close(fd); + + return (t == MAP_FAILED) ? NULL : t; +} + +#endif + char pkt_buf[PKT_BUF_BYTES] __attribute__ ((aligned(PAGE_SIZE)));
struct ctx passt_ctx = { @@ -282,9 +327,17 @@ static void passt_worker(void *opaque, int nfds, struct epoll_event *events) icmp_sock_handler(c, ref, &now); break; case EPOLL_TYPE_VHOST_CMD: +#ifdef FUZZING + if (!c->vdev) + break; +#endif
This serves a very similar purpose to the checks in 2/5, and the comments I had there apply here as well. If we ignore an event here, it means we're now on a path that's not really interesting to fuzz. So instead of ignoring and carrying on, it would be better to mark this as "program died correctly" and proceed to the next case.
Noted.
vu_control_handler(c->vdev, c->fd_tap, eventmask); break; case EPOLL_TYPE_VHOST_KICK: +#ifdef FUZZING + if (!c->vdev) + break; +#endif vu_kick_cb(c->vdev, ref, &now); break; case EPOLL_TYPE_REPAIR_LISTEN: @@ -450,6 +503,141 @@ int main(int argc, char **argv)
timer_init(c, &now);
+#ifdef FUZZING + fuzz_turn_ptr = fuzz_turn_connect(); + +#define FUZZ_LOOP_ITERATIONS 10000
AFAICT, this has no effect, since __AFL_LOOP() ignores its parameter.
+#define FUZZ_DRAIN_BUF_SIZE 1600 + +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_INIT();
Both the definition and use of __AFL_INIT() are conditional on __AFL_HAVE_MANUAL_CONTROL. Would it make more sense to define __AFL_INIT() as a no-op if !__AFL_HAVE_MANUAL_CONTROL to avoid a second #ifdef?
I guess yes. Noted !!
+#endif + { + unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF; + + while (__AFL_LOOP(FUZZ_LOOP_ITERATIONS)) { + int len = __AFL_FUZZ_TESTCASE_LEN; + int injected = 0; + int pkt_len, round; + struct epoll_event ev; + union epoll_ref ref; + int min_pkt = sizeof(struct ethhdr) + + sizeof(struct iphdr) + + sizeof(struct tcphdr); + + if (len < (int)sizeof(ev)) + continue; + + /* Reset clock, flow table and epoll for each + * AFL++ iteration. + */ + fuzz_clock_reset(); + clock_gettime(CLOCK_MONOTONIC, &now); + timer_init(c, &now); + + flow_init();
flow_init() wipes the table itself, but doesn't clean up any existing flows. If you're creating real external sockets, that means those will be leaked, which means you could well hit the file descriptor limit during a long fuzzing session.
Also, it looks like flow_init() doesn't reset flow_first_free.
Seeing the structure of the afl loop, I now have further thoughts on the assert()s you were suppressing earlier in the series. As I said, if we hit those we want to stop this fuzzing path - it's no longer interesting - but we don't want to mark it as a bug. A die() might accomplish that, but of course would mean restarting passt, bypassing the acceleration that __AFL_LOOP() is supposed to provide.
Essentially what you want in those cases is to abort whatever you're doing and continue on to the next iteration of the AFL loop. This might make it one of the rare cases where setjmp() / longjmp() is a good idea.
+ /* Recreate epoll instance */ + close(c->epollfd); + c->epollfd = epoll_create1(EPOLL_CLOEXEC); + flow_epollid_register(EPOLLFD_ID_DEFAULT, c->epollfd); + + if (c->fd_tap >= 0) { + union epoll_ref tref = { + .type = EPOLL_TYPE_TAP_PASST, + .fd = c->fd_tap + }; + epoll_add(c->epollfd, + EPOLLIN | EPOLLRDHUP, tref); + + /* Drain stale socket data */ + char drain[FUZZ_DRAIN_BUF_SIZE]; + while (recv(c->fd_tap, drain, sizeof(drain), + MSG_DONTWAIT) > 0);
You could use MSG_TRUNC here to avoid the need for a drain buffer.
+ } + + /* Read epoll event from AFL++ buffer */ + memcpy(&ev, buf, sizeof(ev)); + ref = *((union epoll_ref *)&ev.data.u64); + + /* Set recv payload in AFL++ shared memory */ + fuzz_recv_data = buf + FUZZ_RECV_OFF; + fuzz_recv_data_len = + (len > FUZZ_RECV_OFF + FUZZ_RECV_MAX) + ? FUZZ_RECV_MAX + : ((len > FUZZ_RECV_OFF) + ? len - FUZZ_RECV_OFF : 0); + + /* Inject fuzz packet for TAP events */ + if (ref.type == EPOLL_TYPE_TAP_PASST || + ref.type == EPOLL_TYPE_TAP_PASTA) { + struct iov_tail data; + struct ethhdr *eh; + struct iphdr *iph; + struct tcphdr *th; + + tap_flush_pools(); + memset(pkt_buf, 0, min_pkt); + + pkt_len = len - (int)sizeof(ev);
How does this differ from fuzz_recv_data_len?
pkt_len has the size of the TAP packet injected. fuzz_recv_data_len contains the size of the recv payload available to the determinstic fuzz_recv()/fuzz_recvmsg() wrappers. It starts at offset 12 and can go upto 64KB.
+ if (pkt_len > 0) + memcpy(pkt_buf, buf + sizeof(ev), + pkt_len); + if (pkt_len < min_pkt) + pkt_len = min_pkt; + + /* construct ethernet header */ + eh = (struct ethhdr *)pkt_buf; + memcpy(eh->h_dest, c->our_tap_mac, ETH_ALEN); + memcpy(eh->h_source, c->guest_mac, ETH_ALEN); + eh->h_proto = htons(ETH_P_IP); + + /* construct IPv4 header */ + iph = (struct iphdr *)(pkt_buf + sizeof(*eh)); + iph->version = 4; + iph->ihl = 5; + iph->protocol = IPPROTO_TCP; + iph->saddr = c->ip4.addr.s_addr; + iph->daddr = c->ip4.guest_gw.s_addr; + iph->tot_len = htons(pkt_len - sizeof(*eh)); + + /* Fix TCP Header */ + th = (struct tcphdr *)(pkt_buf + sizeof(*eh) + + sizeof(*iph)); + th->dest = htons(9999); + if (th->doff < 5) + th->doff = 5;
As Stefano also points out, this is constructing a fixed version of exactly the things we most want to fuzz.
+ data = IOV_TAIL_FROM_BUF(pkt_buf, pkt_len, 0); + tap_add_packet(c, &data, &now); + tap_handler(c, &now); + injected = 1; + } + + /* Turn exchange -- only if data was sent */ + if (injected && fuzz_turn_ptr) { + __atomic_store_n(&fuzz_turn_ptr->turn, 1, + __ATOMIC_RELEASE); + while (__atomic_load_n(&fuzz_turn_ptr->turn, + __ATOMIC_ACQUIRE) != 0); + }
I don't really understand what this 'turn' thing is doing.
"turn" flag is being used to synchronized the frame exchange between passt and fuzz-server over the UNIX socket.
+ + passt_worker(c, 1, &ev); + + /* Process host-side TCP events */ + for (round = 0; round < 4; round++) { + nfds = epoll_wait(c->epollfd, events, + NUM_EPOLL_EVENTS, 0); + if (nfds <= 0) + break; + passt_worker(c, nfds, events); + } + + post_handler(c, &now);
post_handler() is already called from passt_worker(), why do we need another call?
+ } + } + return 0; +#else loop: /* NOLINTBEGIN(bugprone-branch-clone): intervals can be the same */ /* cppcheck-suppress [duplicateValueTernary, unmatchedSuppression] */ @@ -461,4 +649,5 @@ loop: passt_worker(c, nfds, events);
goto loop; +#endif /* FUZZING */ } -- 2.55.0
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
-- Anshu
On Fri, 14 Aug 2026 20:02:46 +1000
David Gibson
On Fri, Aug 14, 2026 at 09:35:59AM +0200, Stefano Brivio wrote:
On Fri, 14 Aug 2026 15:40:35 +1000 David Gibson
wrote: On Thu, Aug 13, 2026 at 09:53:24AM +0200, Stefano Brivio wrote:
On Wed, 12 Aug 2026 12:56:28 +0530 Anshu Kumari
wrote: Add fuzz-server that acts as passt's network peer during fuzzing.
To me, this part makes sense. But this one:
It connects to passt's UNIX socket
much less, while:
and listens on 127.0.0.1:9999 for TCP connections.
this is the part that I expected instead. Otherwise it's not just passt's network peer, it's the guest as well.
UNIX socket path: responds to ARP requests and TCP SYNs with stateless replies (swapped addresses, fixed ISN). Responses are XOR'd with AFL++ shared memory data so the fuzzer can mutate server behavior.
This looks rather complicated to me.
It does.
The approach I was suggesting with a test server is the following:
,- exchanges guest-side data with ------------. | ,---------|---------. | ,--| passt | | / '-.---------------^-' ,---|---. / | connect(), | accept(), | AFL++ |-- shares memory with ---| | send data, | reply with '---|---' \ | etc. | data, etc. | \ ,-v---------------'-. | '--| test server | | '---------|---------' '- exchanges host-side data with -------------'
...at least in its basic form. Eventually, the test server should be able to connect to passt itself (and we could call it "test peer" at that point).
So, I agree that to meaningfully fuzz things, we want the fuzzer to be able to control data on both the guest and host side. I can see two basic approaches:
A) Alter passt/pasta so that instead of directly communicating with external entities (either guest or host side) we use mocked versions which retrieve data from AFL. We can do that either at the system call level, or at a higher helper function level, the lower level we go, the more of the "normal" passt code we're exercising, but doing at a slightly higher level might be easier to implement
B) Run passt/pasta in an environment where we can intercept the external transfers to a test server / test peer / test guest which in turn responds based on data from AFL.
Both the current draft and the sketch diagram Stefano has provided are a hybrid of both approaches, so far I'm not seeing a clear advantage to that over going all one way or the other.
Of course, B) would be cleaner (and not that complicated, see below), but we don't want to do that guest-side (sending data from a test guest) because we would lose the speed advantage of having shared memory on the path that _really_ matters for fuzzing (the guest is untrusted, the kernel isn't).
I agree we lose the speed advantage, but I don't really see why that matters more on the guest side than the host.
Because AFL++ tries to vary the input to discover new code paths, but if it's not necessary (and in general it's not for host-side payload), the input might remain relatively constant, and memory content that isn't changed it's cache hot. I have only profiled the "guest" side of things so far, though. In any case, this is minor. If we can have shared memory and no further transport on one path, it's better than having it on zero paths.
Yes, fuzzing the guest side matters more, but most guest side operations will induce passt to perform a host side operation, so the speed of the host side handling matters even if the guest side is what we care about fuzzing.
That's something we already established a while ago when AbdAlRahman was working on it. We hadn't really looked into the host side yet, back then.
So, host side: we can't do it (and it's much less important) because we need to use those sockets in the same way passt uses them.
I'm not sure what you mean. I outlined a way to do this below.
Sure, strictly speaking, we can, but we can't if we want to obtain a realistic approximation of what we would be normally doing on sockets. We would wrap all the host-side socket operations, in that case, which is really not ideal, to the point of questioning the whole effectiveness.
The guest side interface is a trivial recv(), the host side is something complicated with iovecs and everything.
I'm now not sure if you're saying this in relation to approach B, or approach A.
That's independent from the chosen approach: it's like that without fuzzing. If we wrap / skip recv() on the guest side, that's not a big loss. If we wrap host-side socket operations, that's a significant deviation and we'll miss bugs.
(B) is quite easy to do guest side - we just connect a "test guest" to passt's socket. Approach B is much harder for host side. The current draft has a test server listening on a single address.
This is just to get something up and running though, it obviously needs to be changed later.
I don't see how we are "up and running" if fuzzed packets from the guest induce passt to forward them to random host side addresses that we're not controlling - we won't generate reproducible results.
If you look at patch 4/5, it hardcodes the destination port and uses a known destination address (again, for the moment). It's not random. It will need to be random of course.
But that means the fuzzing is fundamentally incapable of finding bugs involving talking to multiple peers at once. Worse, we have to constrain the construction of guest side data so that we talk to the test server not something else, and that's one of the things we most want to fuzz.
To really take approach B for the host side we'd need to intercept *all* host side network traffic regardless of address. Probably easiest way to do that would be put the whole thing inside another netns. That outside netns would have a default route to a tap device, and on the other side of the tap device would be a test server serving up frames built from the fuzzer output.
As I was mentioning, this could be done in a network namespace without any interface, by making the test server listen to all ports and all addresses, with a non-local bind and a so-called AnyIP route. Tested:
$ pasta -- sh -c 'ip route add local default dev lo; nc -l 1 & { sleep 1; echo x | nc -N 1.2.3.4 1; }' x
True, but having the fuzzer synthesize L2 frames
Slightly easier, perhaps, but that's beyond the scope of fuzzing we need (AFL++ will start trying to build malformed frames which we won't see anyway and effectively fuzz the kernel instead).
seems easier to me than having it directly synthesize the various socket operations the host side peer might perform.
I don't see this as complicated. There just needs to be a way for AFL++ to say how much payload the test server might need to send in a given round, and that can be used as argument to send() or sendmsg(). That's what I meant by "play script."
It's probably easier to co-ordinate if the host side and guest side test server is the same, so we'd have:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ | | | /- test netns ----------^-------------------|-------------\ | | | | ,--------. | | | | passt >-----------<unix socket>-----/ | | '--------' | \---------------------------------------------------------/
The test peer generates host side frames via the tap device, and guest side frames via the Unix socket.
The UNIX socket is something we want to avoid, it's really much slower compared to shared memory (we tried something like that) on the path where AFL++ is trying to mutate data fast (because it can hit a lot of different code paths with small changes, compared to changing socket-side payload).
Right. That's why I conclude approch A is probably better further down. But even if we only care about fuzzing guest side, we'll usually incur the cost of operations on both sides, so I don't see that using shared memory is more important for guest side than host side interposition.
It could also be done with pasta, like this:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ packet | socket /- test netns ----------^-------------------|-------------\ | | | | ,--------. ,----------------|---. | | | pasta >-tap device-< guest netns * | | | '--------' '--------------------' | \---------------------------------------------------------/
The order it generates host vs. guest frames should also come from the fuzzer, not be fixed. At least theoretically, this is non-invasive: it could run with an unmodified passt/pasta. Except that - AFL would still need coverage feedback from passt/pasta - It wouldn't allow us to simulate odd timings (except by actually expending real time) - The various interposing layers will probably slow down fuzzing.
So, I rather suspect it will work better to go fully to approach A: no test peer at all, instead passt itself is modified to use mocked versions of all the external syscalls to slurp data from AFL. The draft series already does this for epoll_wait() and recv*(), but we'd need to also do that for recv*() on the tap socket, connect(), accept(), TCP_INFO and probably others. We'd also need to mock "sending" calls, send(), write() and shutdown() at least - but those could probably be no-ops.
...except that by mocking all those we lose a lot of complexity where historically we had a ton of bugs. If we just mock recv() it's much less (well yes we had bugs there as well but it was like 3 or 4 over the entire project history).
If we mock as close as possible to the syscall level, I don't see that bypass much of our complexity. To be clear, I'm suggesting mocks where both returned data and error codes are derived from the fuzzer, not just no-op stubs.
Doing that is not a realistic test though, because the kernel won't return random error codes. Indeed it would be nice to be robust to kernel issues, but I don't see it as a priority (and I guess we would waste the whole time on those "issues" if we do that).
This is more invasive, of course. It also means we need to deal with the case where AFL generates a syscall results that should be impossible - that should move onto the next case ASAP, but not be flagged as a passt bug. On the other hand, this approach should be fast, and since we can also mock clock_gettime(), the fuzzer can potentially find timer logic bugs that would only occur after hours or days in real time.
As far as I understood, it's not trivial to make the same instance of AFL++ share memory with two processes at the same time, so the memory-sharing path might need to take a more complicated turn, for example there could be a wrapper starting both passt and the test server and sharing memory with them, or passt could _additionally_ (using a special out-of-band fuzzing channel) share data from AFL++ with the test server.
An example of communication below (but events don't necessarily need to be in this order, this is just an example). For simplicity, let's ignore the fact that AFL++ might not directly share memory with passt and test server, and assume there are three areas of memory that AFL++ directly controls:
a. shared with passt: an array of struct epoll_event, 'ev'
b. shared with passt: the kind of tap-side buffer you implemented in 4/5, 'buf'
c. shared with the test server: a separate buffer, 'test_buf'
Example:
1. AFL++ writes an EPOLLIN event in 'ev' with type EPOLL_TYPE_TAP_PASST, of some data in 'buf', and some data in 'test_buf'
2. AFL++ starts passt and the test server
3. passt reads the EPOLL_TYPE_TAP_PASST event from 'ev', reads data from 'buf' and hands it to passt_tap_handler()
4. this happens to be have Ethernet, IP, and TCP headers, with the SYN flag set, and destination address set to the address of the test server (we might want to force all this, at least initially, or give it as a hint to AFL++ somehow), so passt connects to the test server
5. the test server accepts the connection, and sends the contents of 'test_buf' on it (for the test server, this is directly payload, without headers, as they don't make sense there). I'm not sure if we should have a different set of events (maybe we need a "play script" for the server, in case?)
6. this generates an EPOLLOUT event for passt. It's not in 'ev', it's a regular epoll_wait() (I think we could have an epoll_wait() loop where we additionally read one event from 'ev' for every iteration, or something like that)
7. passt marks the connection as established and inserts it in the flow table
8. passt reads the data sent from the test server and generates whatever TCP data packet to the "guest" (it might simply be a sink)
...and this attempt ends here because AFL++ generated a single event for passt, but there could be more (this should also be decided by AFL++).
Would something like this make sense?
-- Stefano
On Fri, Aug 14, 2026 at 04:02:58PM +0530, Anshu Kumari wrote:
On Fri, Aug 14, 2026 at 7:27 AM David Gibson
wrote: On Wed, Aug 12, 2026 at 12:56:27PM +0530, Anshu Kumari wrote:
Add the AFL++ persistent mode fuzz loop to passt.c main(). The loop uses __AFL_LOOP() for in-process iteration and __AFL_FUZZ_TESTCASE_BUF for shared memory fuzzing.
Each iteration: - Resets deterministic clock, flow table, and epoll instance. - Drains stale data from the TAP socket. - Reads an epoll event from the AFL++ buffer. - For TAP events: constructs a packet with fixed L2/L3/L4 headers and injects it via tap_add_packet() + tap_handler(). - Exchanges a turn flag with the test server for bidirectional flow over the UNIX socket. - Calls passt_worker() to process the event. - Polls for host-side TCP events via epoll_wait(). - Runs post_handler() for deferred work.
Added the 'make fuzz' target which builds passt with afl-clang-fast, -DFUZZING, -DNDEBUG, and AddressSanitizer.
Stefano's concerns generally seconded (although I haven't really got my head around the role of the test server in either yours or his mind - I'll address that once I've read 5/5).
The big concerns here are that to do interesting fuzzing we'll need a) sequences of multiple packets/packets and b) to fuzz-generate the headers, including malformed ones.
AIUI, logically each fuzzer generated case could be run in a separate instance of passt: the __AFL_LOOP() stuff is an optimization to avoid the delay of a fresh startup on each cycle. Is that correct?
yes, without __AFL_LOOP(), AFL++ forks a fresh passt for each input.
Understood.
Signed-off-by: Anshu Kumari
--- Makefile | 8 +++ passt.c | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/Makefile b/Makefile index fe1df58..8e4121e 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,14 @@ valgrind: BASE_CPPFLAGS += -DVALGRIND valgrind: BASE_CFLAGS += -g valgrind: all
+FUZZ_CC ?= afl-clang-fast + +.PHONY: fuzz + +fuzz: + $(MAKE) clean + $(MAKE) CC="$(FUZZ_CC)" CPPFLAGS="-DFUZZING -DNDEBUG" CFLAGS="-g -fsanitize=address" passt
I'd recommend building the fuzzing binary under a different name, to make accidentally using the wrong one a bit less likely.
.PHONY: clean clean: $(RM) $(BIN) *~ *.o seccomp.h seccomp_repair.h seccomp_pesto.h pasta.1 \ diff --git a/passt.c b/passt.c index 5054551..e026eb2 100644 --- a/passt.c +++ b/passt.c @@ -35,6 +35,7 @@ #include
#include #include +#include #include "util.h" #include "passt.h" @@ -54,12 +55,56 @@ #include "repair.h" #include "netlink.h" #include "epoll_ctl.h" +#include "flow_table.h" +#include "fuzz.h"
#define NUM_EPOLL_EVENTS 8
#define TIMER_INTERVAL_ MIN(TCP_TIMER_INTERVAL, FWD_PORT_SCAN_INTERVAL) #define TIMER_INTERVAL MIN(TIMER_INTERVAL_, FLOW_TIMER_INTERVAL)
+#ifdef FUZZING + +/* AFL++ persistent mode / shared memory fuzzing compatibility macros. */ +#ifndef __AFL_FUZZ_TESTCASE_LEN + ssize_t fuzz_len; + unsigned char fuzz_buf[1024 * 1024]; +# define __AFL_FUZZ_TESTCASE_LEN fuzz_len +# define __AFL_FUZZ_TESTCASE_BUF fuzz_buf +# define __AFL_FUZZ_INIT() void sync(void) +# define __AFL_LOOP(x) \ + ((fuzz_len = read(0, fuzz_buf, sizeof(fuzz_buf))) > 0 ? 1 : 0)
This macro ignores its parameter. Is that intentional?
Yes, this is intentional as it helps compile afl++ without afl-clang-fast. more about this: https://github.com/AFLplusplus/AFLplusplus/blob/stable/instrumentation/READM...
Weird, ok.
+# define __AFL_INIT() sync() +#endif + +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_FUZZ_INIT(); +#endif + +static struct fuzz_turn *fuzz_turn_ptr; + +/** + * fuzz_turn_connect() - Map the turn flag shared memory + * + * Return: pointer to mapped turn flag, or NULL on failure + */ +static struct fuzz_turn *fuzz_turn_connect(void) +{ + struct fuzz_turn *t; + int fd; + + fd = open(FUZZ_TURN_PATH, O_RDWR);
FUZZ_TURN_PATH was defined in 1/5 but only used here, which makes review harder. I'd suggest moving the definition to this patch.
Noted !!
+ if (fd < 0) + return NULL; + + t = mmap(NULL, sizeof(*t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + close(fd); + + return (t == MAP_FAILED) ? NULL : t; +} + +#endif + char pkt_buf[PKT_BUF_BYTES] __attribute__ ((aligned(PAGE_SIZE)));
struct ctx passt_ctx = { @@ -282,9 +327,17 @@ static void passt_worker(void *opaque, int nfds, struct epoll_event *events) icmp_sock_handler(c, ref, &now); break; case EPOLL_TYPE_VHOST_CMD: +#ifdef FUZZING + if (!c->vdev) + break; +#endif
This serves a very similar purpose to the checks in 2/5, and the comments I had there apply here as well. If we ignore an event here, it means we're now on a path that's not really interesting to fuzz. So instead of ignoring and carrying on, it would be better to mark this as "program died correctly" and proceed to the next case.
Noted.
vu_control_handler(c->vdev, c->fd_tap, eventmask); break; case EPOLL_TYPE_VHOST_KICK: +#ifdef FUZZING + if (!c->vdev) + break; +#endif vu_kick_cb(c->vdev, ref, &now); break; case EPOLL_TYPE_REPAIR_LISTEN: @@ -450,6 +503,141 @@ int main(int argc, char **argv)
timer_init(c, &now);
+#ifdef FUZZING + fuzz_turn_ptr = fuzz_turn_connect(); + +#define FUZZ_LOOP_ITERATIONS 10000
AFAICT, this has no effect, since __AFL_LOOP() ignores its parameter.
+#define FUZZ_DRAIN_BUF_SIZE 1600 + +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_INIT();
Both the definition and use of __AFL_INIT() are conditional on __AFL_HAVE_MANUAL_CONTROL. Would it make more sense to define __AFL_INIT() as a no-op if !__AFL_HAVE_MANUAL_CONTROL to avoid a second #ifdef?
I guess yes. Noted !!
+#endif + { + unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF; + + while (__AFL_LOOP(FUZZ_LOOP_ITERATIONS)) { + int len = __AFL_FUZZ_TESTCASE_LEN; + int injected = 0; + int pkt_len, round; + struct epoll_event ev; + union epoll_ref ref; + int min_pkt = sizeof(struct ethhdr) + + sizeof(struct iphdr) + + sizeof(struct tcphdr); + + if (len < (int)sizeof(ev)) + continue; + + /* Reset clock, flow table and epoll for each + * AFL++ iteration. + */ + fuzz_clock_reset(); + clock_gettime(CLOCK_MONOTONIC, &now); + timer_init(c, &now); + + flow_init();
flow_init() wipes the table itself, but doesn't clean up any existing flows. If you're creating real external sockets, that means those will be leaked, which means you could well hit the file descriptor limit during a long fuzzing session.
Also, it looks like flow_init() doesn't reset flow_first_free.
Seeing the structure of the afl loop, I now have further thoughts on the assert()s you were suppressing earlier in the series. As I said, if we hit those we want to stop this fuzzing path - it's no longer interesting - but we don't want to mark it as a bug. A die() might accomplish that, but of course would mean restarting passt, bypassing the acceleration that __AFL_LOOP() is supposed to provide.
Essentially what you want in those cases is to abort whatever you're doing and continue on to the next iteration of the AFL loop. This might make it one of the rare cases where setjmp() / longjmp() is a good idea.
+ /* Recreate epoll instance */ + close(c->epollfd); + c->epollfd = epoll_create1(EPOLL_CLOEXEC); + flow_epollid_register(EPOLLFD_ID_DEFAULT, c->epollfd); + + if (c->fd_tap >= 0) { + union epoll_ref tref = { + .type = EPOLL_TYPE_TAP_PASST, + .fd = c->fd_tap + }; + epoll_add(c->epollfd, + EPOLLIN | EPOLLRDHUP, tref); + + /* Drain stale socket data */ + char drain[FUZZ_DRAIN_BUF_SIZE]; + while (recv(c->fd_tap, drain, sizeof(drain), + MSG_DONTWAIT) > 0);
You could use MSG_TRUNC here to avoid the need for a drain buffer.
+ } + + /* Read epoll event from AFL++ buffer */ + memcpy(&ev, buf, sizeof(ev)); + ref = *((union epoll_ref *)&ev.data.u64); + + /* Set recv payload in AFL++ shared memory */ + fuzz_recv_data = buf + FUZZ_RECV_OFF; + fuzz_recv_data_len = + (len > FUZZ_RECV_OFF + FUZZ_RECV_MAX) + ? FUZZ_RECV_MAX + : ((len > FUZZ_RECV_OFF) + ? len - FUZZ_RECV_OFF : 0); + + /* Inject fuzz packet for TAP events */ + if (ref.type == EPOLL_TYPE_TAP_PASST || + ref.type == EPOLL_TYPE_TAP_PASTA) { + struct iov_tail data; + struct ethhdr *eh; + struct iphdr *iph; + struct tcphdr *th; + + tap_flush_pools(); + memset(pkt_buf, 0, min_pkt); + + pkt_len = len - (int)sizeof(ev);
How does this differ from fuzz_recv_data_len?
pkt_len has the size of the TAP packet injected.
fuzz_recv_data_len contains the size of the recv payload available to the determinstic fuzz_recv()/fuzz_recvmsg() wrappers. It starts at offset 12 and can go upto 64KB.
Ok, but they both have the same value of (len - sizeof(ev)). The fuzz_recv_data_len case checks some more edge cases and uses different defines, but it will mostly work out to the same thing. That seems odd.
+ if (pkt_len > 0) + memcpy(pkt_buf, buf + sizeof(ev), + pkt_len); + if (pkt_len < min_pkt) + pkt_len = min_pkt; + + /* construct ethernet header */ + eh = (struct ethhdr *)pkt_buf; + memcpy(eh->h_dest, c->our_tap_mac, ETH_ALEN); + memcpy(eh->h_source, c->guest_mac, ETH_ALEN); + eh->h_proto = htons(ETH_P_IP); + + /* construct IPv4 header */ + iph = (struct iphdr *)(pkt_buf + sizeof(*eh)); + iph->version = 4; + iph->ihl = 5; + iph->protocol = IPPROTO_TCP; + iph->saddr = c->ip4.addr.s_addr; + iph->daddr = c->ip4.guest_gw.s_addr; + iph->tot_len = htons(pkt_len - sizeof(*eh)); + + /* Fix TCP Header */ + th = (struct tcphdr *)(pkt_buf + sizeof(*eh) + + sizeof(*iph)); + th->dest = htons(9999); + if (th->doff < 5) + th->doff = 5;
As Stefano also points out, this is constructing a fixed version of exactly the things we most want to fuzz.
+ data = IOV_TAIL_FROM_BUF(pkt_buf, pkt_len, 0); + tap_add_packet(c, &data, &now); + tap_handler(c, &now); + injected = 1; + } + + /* Turn exchange -- only if data was sent */ + if (injected && fuzz_turn_ptr) { + __atomic_store_n(&fuzz_turn_ptr->turn, 1, + __ATOMIC_RELEASE); + while (__atomic_load_n(&fuzz_turn_ptr->turn, + __ATOMIC_ACQUIRE) != 0); + }
I don't really understand what this 'turn' thing is doing.
"turn" flag is being used to synchronized the frame exchange between passt and fuzz-server over the UNIX socket.
I figured, but can you elaborate on how exactly it does that.
+ + passt_worker(c, 1, &ev); + + /* Process host-side TCP events */ + for (round = 0; round < 4; round++) { + nfds = epoll_wait(c->epollfd, events, + NUM_EPOLL_EVENTS, 0); + if (nfds <= 0) + break; + passt_worker(c, nfds, events); + } + + post_handler(c, &now);
post_handler() is already called from passt_worker(), why do we need another call?
+ } + } + return 0; +#else loop: /* NOLINTBEGIN(bugprone-branch-clone): intervals can be the same */ /* cppcheck-suppress [duplicateValueTernary, unmatchedSuppression] */ @@ -461,4 +649,5 @@ loop: passt_worker(c, nfds, events);
goto loop; +#endif /* FUZZING */ } -- 2.55.0
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
-- Anshu
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
On Fri, Aug 14, 2026 at 01:55:24PM +0200, Stefano Brivio wrote:
On Fri, 14 Aug 2026 20:02:46 +1000 David Gibson
wrote: On Fri, Aug 14, 2026 at 09:35:59AM +0200, Stefano Brivio wrote:
On Fri, 14 Aug 2026 15:40:35 +1000 David Gibson
wrote: On Thu, Aug 13, 2026 at 09:53:24AM +0200, Stefano Brivio wrote:
On Wed, 12 Aug 2026 12:56:28 +0530 Anshu Kumari
wrote: Add fuzz-server that acts as passt's network peer during fuzzing.
To me, this part makes sense. But this one:
It connects to passt's UNIX socket
much less, while:
and listens on 127.0.0.1:9999 for TCP connections.
this is the part that I expected instead. Otherwise it's not just passt's network peer, it's the guest as well.
UNIX socket path: responds to ARP requests and TCP SYNs with stateless replies (swapped addresses, fixed ISN). Responses are XOR'd with AFL++ shared memory data so the fuzzer can mutate server behavior.
This looks rather complicated to me.
It does.
The approach I was suggesting with a test server is the following:
,- exchanges guest-side data with ------------. | ,---------|---------. | ,--| passt | | / '-.---------------^-' ,---|---. / | connect(), | accept(), | AFL++ |-- shares memory with ---| | send data, | reply with '---|---' \ | etc. | data, etc. | \ ,-v---------------'-. | '--| test server | | '---------|---------' '- exchanges host-side data with -------------'
...at least in its basic form. Eventually, the test server should be able to connect to passt itself (and we could call it "test peer" at that point).
So, I agree that to meaningfully fuzz things, we want the fuzzer to be able to control data on both the guest and host side. I can see two basic approaches:
A) Alter passt/pasta so that instead of directly communicating with external entities (either guest or host side) we use mocked versions which retrieve data from AFL. We can do that either at the system call level, or at a higher helper function level, the lower level we go, the more of the "normal" passt code we're exercising, but doing at a slightly higher level might be easier to implement
B) Run passt/pasta in an environment where we can intercept the external transfers to a test server / test peer / test guest which in turn responds based on data from AFL.
Both the current draft and the sketch diagram Stefano has provided are a hybrid of both approaches, so far I'm not seeing a clear advantage to that over going all one way or the other.
Of course, B) would be cleaner (and not that complicated, see below), but we don't want to do that guest-side (sending data from a test guest) because we would lose the speed advantage of having shared memory on the path that _really_ matters for fuzzing (the guest is untrusted, the kernel isn't).
I agree we lose the speed advantage, but I don't really see why that matters more on the guest side than the host.
Because AFL++ tries to vary the input to discover new code paths, but if it's not necessary (and in general it's not for host-side payload), the input might remain relatively constant, and memory content that isn't changed it's cache hot.
Ok... but I'm still not seeing the connection. AFAIK the important thing for fuzzing speed is how long each fuzz test case takes to run - the duration of the AFL_LOOP() body. That includes getting a new case via shared memory, but also whatever else we do in that loop, which will typically involve at least one socket side and at least one tap side event. If the socket side events involve real socket calls and synchronization with another process that will cost us on that loop duration, even if the socket peer was informed what to do via cache-hot shared memory. Or am I missing something super obvious.
I have only profiled the "guest" side of things so far, though.
In any case, this is minor. If we can have shared memory and no further transport on one path, it's better than having it on zero paths.
Well, true. But doing some of the fuzz directly via interposed syscalls and some via an external test peer adds the complication of having to synchronize eahc of those things operating from the same source of fuzz data.
Yes, fuzzing the guest side matters more, but most guest side operations will induce passt to perform a host side operation, so the speed of the host side handling matters even if the guest side is what we care about fuzzing.
That's something we already established a while ago when AbdAlRahman was working on it. We hadn't really looked into the host side yet, back then.
So, host side: we can't do it (and it's much less important) because we need to use those sockets in the same way passt uses them.
I'm not sure what you mean. I outlined a way to do this below.
Sure, strictly speaking, we can, but we can't if we want to obtain a realistic approximation of what we would be normally doing on sockets.
So sorry, I was unclear, in that I outlined both an approach A (mocking the socket syscalls) and an approach B (fuzzed frames in an containing namespace) way of handling the socket side. I'm not sure which one you're addressing here.
We would wrap all the host-side socket operations, in that case, which is really not ideal, to the point of questioning the whole effectiveness.
Why? I mean mocking all the socket syscalls is a moderate amount of work, but I'm not actually sure it's any more than writing a separate test peer. Plus it allows the fuzzer to find bugs related to weird timing and/or weird TCP_INFO results.
The guest side interface is a trivial recv(), the host side is something complicated with iovecs and everything.
I'm now not sure if you're saying this in relation to approach B, or approach A.
That's independent from the chosen approach: it's like that without fuzzing.
Um.. I don't follow what you're saying here at all.
If we wrap / skip recv() on the guest side, that's not a big loss. If we wrap host-side socket operations, that's a significant deviation and we'll miss bugs.
I don't see why that would be a significant deviation.
(B) is quite easy to do guest side - we just connect a "test guest" to passt's socket. Approach B is much harder for host side. The current draft has a test server listening on a single address.
This is just to get something up and running though, it obviously needs to be changed later.
I don't see how we are "up and running" if fuzzed packets from the guest induce passt to forward them to random host side addresses that we're not controlling - we won't generate reproducible results.
If you look at patch 4/5, it hardcodes the destination port and uses a known destination address (again, for the moment). It's not random. It will need to be random of course.
Right, that's my point. If we hardcode the destination, we're not fuzzing one of the things we most want to fuzz. If we don't hardcode the destination then this simple approach for a test peer doesn't work any more.
But that means the fuzzing is fundamentally incapable of finding bugs involving talking to multiple peers at once. Worse, we have to constrain the construction of guest side data so that we talk to the test server not something else, and that's one of the things we most want to fuzz.
To really take approach B for the host side we'd need to intercept *all* host side network traffic regardless of address. Probably easiest way to do that would be put the whole thing inside another netns. That outside netns would have a default route to a tap device, and on the other side of the tap device would be a test server serving up frames built from the fuzzer output.
As I was mentioning, this could be done in a network namespace without any interface, by making the test server listen to all ports and all addresses, with a non-local bind and a so-called AnyIP route. Tested:
$ pasta -- sh -c 'ip route add local default dev lo; nc -l 1 & { sleep 1; echo x | nc -N 1.2.3.4 1; }' x
True, but having the fuzzer synthesize L2 frames
Slightly easier, perhaps, but that's beyond the scope of fuzzing we need (AFL++ will start trying to build malformed frames which we won't see anyway and effectively fuzz the kernel instead).
True. But once you're doing all this anyip magic in the test server and fuzzing the various addresses, I can no longer see that it's any easier to implement than mocking the syscalls on the passt side.
seems easier to me than having it directly synthesize the various socket operations the host side peer might perform.
I don't see this as complicated. There just needs to be a way for AFL++ to say how much payload the test server might need to send in a given round, and that can be used as argument to send() or sendmsg(). That's what I meant by "play script."
If the test server is just sending back a fixed payload, which isn't particularly interesting. We're much more likely to find bugs with odd mixtures of sending payloads, resets and new connections.
It's probably easier to co-ordinate if the host side and guest side test server is the same, so we'd have:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ | | | /- test netns ----------^-------------------|-------------\ | | | | ,--------. | | | | passt >-----------<unix socket>-----/ | | '--------' | \---------------------------------------------------------/
The test peer generates host side frames via the tap device, and guest side frames via the Unix socket.
The UNIX socket is something we want to avoid, it's really much slower compared to shared memory (we tried something like that) on the path where AFL++ is trying to mutate data fast (because it can hit a lot of different code paths with small changes, compared to changing socket-side payload).
Right. That's why I conclude approch A is probably better further down. But even if we only care about fuzzing guest side, we'll usually incur the cost of operations on both sides, so I don't see that using shared memory is more important for guest side than host side interposition.
It could also be done with pasta, like this:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ packet | socket /- test netns ----------^-------------------|-------------\ | | | | ,--------. ,----------------|---. | | | pasta >-tap device-< guest netns * | | | '--------' '--------------------' | \---------------------------------------------------------/
The order it generates host vs. guest frames should also come from the fuzzer, not be fixed. At least theoretically, this is non-invasive: it could run with an unmodified passt/pasta. Except that - AFL would still need coverage feedback from passt/pasta - It wouldn't allow us to simulate odd timings (except by actually expending real time) - The various interposing layers will probably slow down fuzzing.
So, I rather suspect it will work better to go fully to approach A: no test peer at all, instead passt itself is modified to use mocked versions of all the external syscalls to slurp data from AFL. The draft series already does this for epoll_wait() and recv*(), but we'd need to also do that for recv*() on the tap socket, connect(), accept(), TCP_INFO and probably others. We'd also need to mock "sending" calls, send(), write() and shutdown() at least - but those could probably be no-ops.
...except that by mocking all those we lose a lot of complexity where historically we had a ton of bugs. If we just mock recv() it's much less (well yes we had bugs there as well but it was like 3 or 4 over the entire project history).
If we mock as close as possible to the syscall level, I don't see that bypass much of our complexity. To be clear, I'm suggesting mocks where both returned data and error codes are derived from the fuzzer, not just no-op stubs.
Doing that is not a realistic test though, because the kernel won't return random error codes.
Sure, so AFL learns those inputs send it into a boring exit-with-error path, and looks for inputs (i.e. return codes) that send it down more interesting paths.
Indeed it would be nice to be robust to kernel issues, but I don't see it as a priority (and I guess we would waste the whole time on those "issues" if we do that).
This is more invasive, of course. It also means we need to deal with the case where AFL generates a syscall results that should be impossible - that should move onto the next case ASAP, but not be flagged as a passt bug. On the other hand, this approach should be fast, and since we can also mock clock_gettime(), the fuzzer can potentially find timer logic bugs that would only occur after hours or days in real time.
As far as I understood, it's not trivial to make the same instance of AFL++ share memory with two processes at the same time, so the memory-sharing path might need to take a more complicated turn, for example there could be a wrapper starting both passt and the test server and sharing memory with them, or passt could _additionally_ (using a special out-of-band fuzzing channel) share data from AFL++ with the test server.
An example of communication below (but events don't necessarily need to be in this order, this is just an example). For simplicity, let's ignore the fact that AFL++ might not directly share memory with passt and test server, and assume there are three areas of memory that AFL++ directly controls:
a. shared with passt: an array of struct epoll_event, 'ev'
b. shared with passt: the kind of tap-side buffer you implemented in 4/5, 'buf'
c. shared with the test server: a separate buffer, 'test_buf'
Example:
1. AFL++ writes an EPOLLIN event in 'ev' with type EPOLL_TYPE_TAP_PASST, of some data in 'buf', and some data in 'test_buf'
2. AFL++ starts passt and the test server
3. passt reads the EPOLL_TYPE_TAP_PASST event from 'ev', reads data from 'buf' and hands it to passt_tap_handler()
4. this happens to be have Ethernet, IP, and TCP headers, with the SYN flag set, and destination address set to the address of the test server (we might want to force all this, at least initially, or give it as a hint to AFL++ somehow), so passt connects to the test server
5. the test server accepts the connection, and sends the contents of 'test_buf' on it (for the test server, this is directly payload, without headers, as they don't make sense there). I'm not sure if we should have a different set of events (maybe we need a "play script" for the server, in case?)
6. this generates an EPOLLOUT event for passt. It's not in 'ev', it's a regular epoll_wait() (I think we could have an epoll_wait() loop where we additionally read one event from 'ev' for every iteration, or something like that)
7. passt marks the connection as established and inserts it in the flow table
8. passt reads the data sent from the test server and generates whatever TCP data packet to the "guest" (it might simply be a sink)
...and this attempt ends here because AFL++ generated a single event for passt, but there could be more (this should also be decided by AFL++).
Would something like this make sense?
-- Stefano
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
On Mon, Aug 17, 2026 at 9:16 AM David Gibson
On Fri, Aug 14, 2026 at 04:02:58PM +0530, Anshu Kumari wrote:
On Fri, Aug 14, 2026 at 7:27 AM David Gibson < david@gibson.dropbear.id.au> wrote:
On Wed, Aug 12, 2026 at 12:56:27PM +0530, Anshu Kumari wrote:
Add the AFL++ persistent mode fuzz loop to passt.c main(). The loop uses __AFL_LOOP() for in-process iteration and __AFL_FUZZ_TESTCASE_BUF for shared memory fuzzing.
Each iteration: - Resets deterministic clock, flow table, and epoll instance. - Drains stale data from the TAP socket. - Reads an epoll event from the AFL++ buffer. - For TAP events: constructs a packet with fixed L2/L3/L4 headers and injects it via tap_add_packet() + tap_handler(). - Exchanges a turn flag with the test server for bidirectional flow over the UNIX socket. - Calls passt_worker() to process the event. - Polls for host-side TCP events via epoll_wait(). - Runs post_handler() for deferred work.
Added the 'make fuzz' target which builds passt with afl-clang-fast, -DFUZZING, -DNDEBUG, and AddressSanitizer.
Stefano's concerns generally seconded (although I haven't really got my head around the role of the test server in either yours or his mind - I'll address that once I've read 5/5).
The big concerns here are that to do interesting fuzzing we'll need a) sequences of multiple packets/packets and b) to fuzz-generate the headers, including malformed ones.
AIUI, logically each fuzzer generated case could be run in a separate instance of passt: the __AFL_LOOP() stuff is an optimization to avoid the delay of a fresh startup on each cycle. Is that correct?
yes, without __AFL_LOOP(), AFL++ forks a fresh passt for each input.
Understood.
Signed-off-by: Anshu Kumari
--- Makefile | 8 +++ passt.c | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/Makefile b/Makefile index fe1df58..8e4121e 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,14 @@ valgrind: BASE_CPPFLAGS += -DVALGRIND valgrind: BASE_CFLAGS += -g valgrind: all
+FUZZ_CC ?= afl-clang-fast + +.PHONY: fuzz + +fuzz: + $(MAKE) clean + $(MAKE) CC="$(FUZZ_CC)" CPPFLAGS="-DFUZZING -DNDEBUG" CFLAGS="-g -fsanitize=address" passt
I'd recommend building the fuzzing binary under a different name, to make accidentally using the wrong one a bit less likely.
.PHONY: clean clean: $(RM) $(BIN) *~ *.o seccomp.h seccomp_repair.h seccomp_pesto.h pasta.1 \ diff --git a/passt.c b/passt.c index 5054551..e026eb2 100644 --- a/passt.c +++ b/passt.c @@ -35,6 +35,7 @@ #include
#include #include +#include #include "util.h" #include "passt.h" @@ -54,12 +55,56 @@ #include "repair.h" #include "netlink.h" #include "epoll_ctl.h" +#include "flow_table.h" +#include "fuzz.h"
#define NUM_EPOLL_EVENTS 8
#define TIMER_INTERVAL_ MIN(TCP_TIMER_INTERVAL, FWD_PORT_SCAN_INTERVAL) #define TIMER_INTERVAL MIN(TIMER_INTERVAL_, FLOW_TIMER_INTERVAL)
+#ifdef FUZZING + +/* AFL++ persistent mode / shared memory fuzzing compatibility macros. */ +#ifndef __AFL_FUZZ_TESTCASE_LEN + ssize_t fuzz_len; + unsigned char fuzz_buf[1024 * 1024]; +# define __AFL_FUZZ_TESTCASE_LEN fuzz_len +# define __AFL_FUZZ_TESTCASE_BUF fuzz_buf +# define __AFL_FUZZ_INIT() void sync(void) +# define __AFL_LOOP(x) \ + ((fuzz_len = read(0, fuzz_buf, sizeof(fuzz_buf))) > 0 ? 1 : 0)
This macro ignores its parameter. Is that intentional?
Yes, this is intentional as it helps compile afl++ without afl-clang-fast. more about this:
https://github.com/AFLplusplus/AFLplusplus/blob/stable/instrumentation/READM...
Weird, ok.
+# define __AFL_INIT() sync() +#endif + +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_FUZZ_INIT(); +#endif + +static struct fuzz_turn *fuzz_turn_ptr; + +/** + * fuzz_turn_connect() - Map the turn flag shared memory + * + * Return: pointer to mapped turn flag, or NULL on failure + */ +static struct fuzz_turn *fuzz_turn_connect(void) +{ + struct fuzz_turn *t; + int fd; + + fd = open(FUZZ_TURN_PATH, O_RDWR);
FUZZ_TURN_PATH was defined in 1/5 but only used here, which makes review harder. I'd suggest moving the definition to this patch.
Noted !!
+ if (fd < 0) + return NULL; + + t = mmap(NULL, sizeof(*t), PROT_READ | PROT_WRITE, MAP_SHARED,
fd,
0);
+ close(fd); + + return (t == MAP_FAILED) ? NULL : t; +} + +#endif + char pkt_buf[PKT_BUF_BYTES] __attribute__ ((aligned(PAGE_SIZE)));
struct ctx passt_ctx = { @@ -282,9 +327,17 @@ static void passt_worker(void *opaque, int nfds, struct epoll_event *events) icmp_sock_handler(c, ref, &now); break; case EPOLL_TYPE_VHOST_CMD: +#ifdef FUZZING + if (!c->vdev) + break; +#endif
This serves a very similar purpose to the checks in 2/5, and the comments I had there apply here as well. If we ignore an event here, it means we're now on a path that's not really interesting to fuzz. So instead of ignoring and carrying on, it would be better to mark this as "program died correctly" and proceed to the next case.
Noted.
vu_control_handler(c->vdev, c->fd_tap,
eventmask);
break; case EPOLL_TYPE_VHOST_KICK: +#ifdef FUZZING + if (!c->vdev) + break; +#endif vu_kick_cb(c->vdev, ref, &now); break; case EPOLL_TYPE_REPAIR_LISTEN: @@ -450,6 +503,141 @@ int main(int argc, char **argv)
timer_init(c, &now);
+#ifdef FUZZING + fuzz_turn_ptr = fuzz_turn_connect(); + +#define FUZZ_LOOP_ITERATIONS 10000
AFAICT, this has no effect, since __AFL_LOOP() ignores its parameter.
+#define FUZZ_DRAIN_BUF_SIZE 1600 + +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_INIT();
Both the definition and use of __AFL_INIT() are conditional on __AFL_HAVE_MANUAL_CONTROL. Would it make more sense to define __AFL_INIT() as a no-op if !__AFL_HAVE_MANUAL_CONTROL to avoid a second #ifdef?
I guess yes. Noted !!
+#endif + { + unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF; + + while (__AFL_LOOP(FUZZ_LOOP_ITERATIONS)) { + int len = __AFL_FUZZ_TESTCASE_LEN; + int injected = 0; + int pkt_len, round; + struct epoll_event ev; + union epoll_ref ref; + int min_pkt = sizeof(struct ethhdr) + + sizeof(struct iphdr) + + sizeof(struct tcphdr); + + if (len < (int)sizeof(ev)) + continue; + + /* Reset clock, flow table and epoll for each + * AFL++ iteration. + */ + fuzz_clock_reset(); + clock_gettime(CLOCK_MONOTONIC, &now); + timer_init(c, &now); + + flow_init();
flow_init() wipes the table itself, but doesn't clean up any existing flows. If you're creating real external sockets, that means those will be leaked, which means you could well hit the file descriptor limit during a long fuzzing session.
Also, it looks like flow_init() doesn't reset flow_first_free.
Seeing the structure of the afl loop, I now have further thoughts on the assert()s you were suppressing earlier in the series. As I said, if we hit those we want to stop this fuzzing path - it's no longer interesting - but we don't want to mark it as a bug. A die() might accomplish that, but of course would mean restarting passt, bypassing the acceleration that __AFL_LOOP() is supposed to provide.
Essentially what you want in those cases is to abort whatever you're doing and continue on to the next iteration of the AFL loop. This might make it one of the rare cases where setjmp() / longjmp() is a good idea.
+ /* Recreate epoll instance */ + close(c->epollfd); + c->epollfd = epoll_create1(EPOLL_CLOEXEC); + flow_epollid_register(EPOLLFD_ID_DEFAULT, c->epollfd); + + if (c->fd_tap >= 0) { + union epoll_ref tref = { + .type = EPOLL_TYPE_TAP_PASST, + .fd = c->fd_tap + }; + epoll_add(c->epollfd, + EPOLLIN | EPOLLRDHUP, tref); + + /* Drain stale socket data */ + char drain[FUZZ_DRAIN_BUF_SIZE]; + while (recv(c->fd_tap, drain, sizeof(drain), + MSG_DONTWAIT) > 0);
You could use MSG_TRUNC here to avoid the need for a drain buffer.
+ } + + /* Read epoll event from AFL++ buffer */ + memcpy(&ev, buf, sizeof(ev)); + ref = *((union epoll_ref *)&ev.data.u64); + + /* Set recv payload in AFL++ shared memory */ + fuzz_recv_data = buf + FUZZ_RECV_OFF; + fuzz_recv_data_len = + (len > FUZZ_RECV_OFF + FUZZ_RECV_MAX) + ? FUZZ_RECV_MAX + : ((len > FUZZ_RECV_OFF) + ? len - FUZZ_RECV_OFF : 0); + + /* Inject fuzz packet for TAP events */ + if (ref.type == EPOLL_TYPE_TAP_PASST || + ref.type == EPOLL_TYPE_TAP_PASTA) { + struct iov_tail data; + struct ethhdr *eh; + struct iphdr *iph; + struct tcphdr *th; + + tap_flush_pools(); + memset(pkt_buf, 0, min_pkt); + + pkt_len = len - (int)sizeof(ev);
How does this differ from fuzz_recv_data_len?
pkt_len has the size of the TAP packet injected.
fuzz_recv_data_len contains the size of the recv payload available to the determinstic fuzz_recv()/fuzz_recvmsg() wrappers. It starts at offset 12 and can go upto 64KB.
Ok, but they both have the same value of (len - sizeof(ev)). The fuzz_recv_data_len case checks some more edge cases and uses different defines, but it will mostly work out to the same thing. That seems odd.
+ if (pkt_len > 0) + memcpy(pkt_buf, buf +
+ pkt_len); + if (pkt_len < min_pkt) + pkt_len = min_pkt; + + /* construct ethernet header */ + eh = (struct ethhdr *)pkt_buf; + memcpy(eh->h_dest, c->our_tap_mac, ETH_ALEN); + memcpy(eh->h_source, c->guest_mac, ETH_ALEN); + eh->h_proto = htons(ETH_P_IP); + + /* construct IPv4 header */ + iph = (struct iphdr *)(pkt_buf + sizeof(*eh)); + iph->version = 4; + iph->ihl = 5; + iph->protocol = IPPROTO_TCP; + iph->saddr = c->ip4.addr.s_addr; + iph->daddr = c->ip4.guest_gw.s_addr; + iph->tot_len = htons(pkt_len - sizeof(*eh)); + + /* Fix TCP Header */ + th = (struct tcphdr *)(pkt_buf + sizeof(*eh) + + sizeof(*iph)); + th->dest = htons(9999); + if (th->doff < 5) + th->doff = 5;
As Stefano also points out, this is constructing a fixed version of exactly the things we most want to fuzz.
+ data = IOV_TAIL_FROM_BUF(pkt_buf,
sizeof(ev), pkt_len,
0);
+ tap_add_packet(c, &data, &now); + tap_handler(c, &now); + injected = 1; + } + + /* Turn exchange -- only if data was sent */ + if (injected && fuzz_turn_ptr) { + __atomic_store_n(&fuzz_turn_ptr->turn, 1, + __ATOMIC_RELEASE); + while (__atomic_load_n(&fuzz_turn_ptr->turn, + __ATOMIC_ACQUIRE) != 0); + }
I don't really understand what this 'turn' thing is doing.
"turn" flag is being used to synchronized the frame exchange between passt and fuzz-server over the UNIX socket.
I figured, but can you elaborate on how exactly it does that.
turn = 0 (passt's turn) turn = 1 (test-server's turn) -> Once passt inject the TAP packet, it sets "turn = 1" using atomic store which tells the test-server that passt has sent something and now it can perform the read opr (as turn flag is mmap'd by both passt and fuzz-server). -> passt then does spin-wait until it's turn != 0. -> similary test-server is spinning on it's own, checking if (turn == 1). When it sees 1, it does recv() on the UNIX socket to read the frame passt sent, generates a protocol response (e.g., ARP reply, SYN-ACK), sends it back via send() on the same socket, then sets turn = 0. -> Once passt sees turn = 0, it exit its spin-wait and continue processing. Without this turn flag, there was a timing problem: passt could call passt_worker() before test-server had a chance to read the outbound frame and send it's response back. The turn flag guarantees the response is available before passt tries to process it.
+ + passt_worker(c, 1, &ev); + + /* Process host-side TCP events */ + for (round = 0; round < 4; round++) { + nfds = epoll_wait(c->epollfd, events, + NUM_EPOLL_EVENTS, 0); + if (nfds <= 0) + break; + passt_worker(c, nfds, events); + } + + post_handler(c, &now);
post_handler() is already called from passt_worker(), why do we need another call?
+ } + } + return 0; +#else loop: /* NOLINTBEGIN(bugprone-branch-clone): intervals can be the same */ /* cppcheck-suppress [duplicateValueTernary, unmatchedSuppression] */ @@ -461,4 +649,5 @@ loop: passt_worker(c, nfds, events);
goto loop; +#endif /* FUZZING */ } -- 2.55.0
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
-- Anshu
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
-- Anshu
On Mon, 17 Aug 2026 16:58:18 +1000
David Gibson
On Fri, Aug 14, 2026 at 01:55:24PM +0200, Stefano Brivio wrote:
On Fri, 14 Aug 2026 20:02:46 +1000 David Gibson
wrote: On Fri, Aug 14, 2026 at 09:35:59AM +0200, Stefano Brivio wrote:
On Fri, 14 Aug 2026 15:40:35 +1000 David Gibson
wrote: On Thu, Aug 13, 2026 at 09:53:24AM +0200, Stefano Brivio wrote:
On Wed, 12 Aug 2026 12:56:28 +0530 Anshu Kumari
wrote: > Add fuzz-server that acts as passt's network peer > during fuzzing.
To me, this part makes sense. But this one:
> It connects to passt's UNIX socket
much less, while:
> and listens on 127.0.0.1:9999 for TCP connections.
this is the part that I expected instead. Otherwise it's not just passt's network peer, it's the guest as well.
> UNIX socket path: responds to ARP requests and TCP SYNs with > stateless replies (swapped addresses, fixed ISN). Responses > are XOR'd with AFL++ shared memory data so the fuzzer can > mutate server behavior.
This looks rather complicated to me.
It does.
The approach I was suggesting with a test server is the following:
,- exchanges guest-side data with ------------. | ,---------|---------. | ,--| passt | | / '-.---------------^-' ,---|---. / | connect(), | accept(), | AFL++ |-- shares memory with ---| | send data, | reply with '---|---' \ | etc. | data, etc. | \ ,-v---------------'-. | '--| test server | | '---------|---------' '- exchanges host-side data with -------------'
...at least in its basic form. Eventually, the test server should be able to connect to passt itself (and we could call it "test peer" at that point).
So, I agree that to meaningfully fuzz things, we want the fuzzer to be able to control data on both the guest and host side. I can see two basic approaches:
A) Alter passt/pasta so that instead of directly communicating with external entities (either guest or host side) we use mocked versions which retrieve data from AFL. We can do that either at the system call level, or at a higher helper function level, the lower level we go, the more of the "normal" passt code we're exercising, but doing at a slightly higher level might be easier to implement
B) Run passt/pasta in an environment where we can intercept the external transfers to a test server / test peer / test guest which in turn responds based on data from AFL.
Both the current draft and the sketch diagram Stefano has provided are a hybrid of both approaches, so far I'm not seeing a clear advantage to that over going all one way or the other.
Of course, B) would be cleaner (and not that complicated, see below), but we don't want to do that guest-side (sending data from a test guest) because we would lose the speed advantage of having shared memory on the path that _really_ matters for fuzzing (the guest is untrusted, the kernel isn't).
I agree we lose the speed advantage, but I don't really see why that matters more on the guest side than the host.
Because AFL++ tries to vary the input to discover new code paths, but if it's not necessary (and in general it's not for host-side payload), the input might remain relatively constant, and memory content that isn't changed it's cache hot.
Ok... but I'm still not seeing the connection. AFAIK the important thing for fuzzing speed is how long each fuzz test case takes to run - the duration of the AFL_LOOP() body. That includes getting a new case via shared memory, but also whatever else we do in that loop, which will typically involve at least one socket side and at least one tap side event. If the socket side events involve real socket calls and synchronization with another process that will cost us on that loop duration, even if the socket peer was informed what to do via cache-hot shared memory.
Those "real socket calls" are faster if copy_to_user() and copy_from_user() (AFL++ to kernel, kernel to passt, passt to kernel, AFL++ to passt) use cache-hot data, that's all. For sufficiently big amounts of data (megabytes) the system call overhead is negligible compared to data copies.
Or am I missing something super obvious.
I have only profiled the "guest" side of things so far, though.
In any case, this is minor. If we can have shared memory and no further transport on one path, it's better than having it on zero paths.
Well, true. But doing some of the fuzz directly via interposed syscalls and some via an external test peer adds the complication of having to synchronize eahc of those things operating from the same source of fuzz data.
True, that part could be a bit simpler if the test peer could take care of both (I really think we should avoid mocking all the host-side system calls, that is, approach A, also because we already tried to do that, see below). On the other hand, note that there's no need for explicit synchronisation with the approach I described. AFL++ could send data through passt using a buffer, the test peer could send data back using another buffer (all part of the same memory region from the perspective of AFL++). Those memory regions don't need to overlap, and AFL++ doesn't mutate output after it started passt (or the wrapper running passt and test server). The test peer doesn't really need to synchronise with passt. It could send data after it receives some, or it could send data a number of times in a row without receiving anything. There would need to be some kind of "playbook" generated by AFL++ with discrete quantities of time. I think you would need this regardless of the approach (A, B, or a mix).
Yes, fuzzing the guest side matters more, but most guest side operations will induce passt to perform a host side operation, so the speed of the host side handling matters even if the guest side is what we care about fuzzing.
That's something we already established a while ago when AbdAlRahman was working on it. We hadn't really looked into the host side yet, back then.
So, host side: we can't do it (and it's much less important) because we need to use those sockets in the same way passt uses them.
I'm not sure what you mean. I outlined a way to do this below.
Sure, strictly speaking, we can, but we can't if we want to obtain a realistic approximation of what we would be normally doing on sockets.
So sorry, I was unclear, in that I outlined both an approach A (mocking the socket syscalls) and an approach B (fuzzed frames in an containing namespace) way of handling the socket side. I'm not sure which one you're addressing here.
Approach A. The mocking we would do of host-side system calls won't be realistic.
We would wrap all the host-side socket operations, in that case, which is really not ideal, to the point of questioning the whole effectiveness.
Why? I mean mocking all the socket syscalls is a moderate amount of work, but I'm not actually sure it's any more than writing a separate test peer.
But the test peer would just call send() on sockets it accepts. It's not much writing.
Plus it allows the fuzzer to find bugs related to weird timing and/or weird TCP_INFO results.
...the weird timing conditions would anyway be maintained with approach B for the host-side. The weird TCP_INFO results is exactly what I'd like to avoid testing (at least to start with).
The guest side interface is a trivial recv(), the host side is something complicated with iovecs and everything.
I'm now not sure if you're saying this in relation to approach B, or approach A.
That's independent from the chosen approach: it's like that without fuzzing.
Um.. I don't follow what you're saying here at all.
Leaving fuzzing alone: 1. guest-side operations are a simple recv() (again, my main concern is the guest *writing* data in a problematic way) 2. host-side operations are a complicated mix of a number of system calls with I/O vectoring ...therefore 1. is okay to mock in my opinion because we are unlikely to discover bugs there, but 2. isn't because our host-side interface is much more complicated.
If we wrap / skip recv() on the guest side, that's not a big loss. If we wrap host-side socket operations, that's a significant deviation and we'll miss bugs.
I don't see why that would be a significant deviation.
Because AFL++ won't behave like a real kernel?
(B) is quite easy to do guest side - we just connect a "test guest" to passt's socket. Approach B is much harder for host side. The current draft has a test server listening on a single address.
This is just to get something up and running though, it obviously needs to be changed later.
I don't see how we are "up and running" if fuzzed packets from the guest induce passt to forward them to random host side addresses that we're not controlling - we won't generate reproducible results.
If you look at patch 4/5, it hardcodes the destination port and uses a known destination address (again, for the moment). It's not random. It will need to be random of course.
Right, that's my point. If we hardcode the destination, we're not fuzzing one of the things we most want to fuzz. If we don't hardcode the destination then this simple approach for a test peer doesn't work any more.
...yes, of course, until you set up a route that redirects connections to any address to the test peer. Then it works without hardcoding any address.
But that means the fuzzing is fundamentally incapable of finding bugs involving talking to multiple peers at once. Worse, we have to constrain the construction of guest side data so that we talk to the test server not something else, and that's one of the things we most want to fuzz.
To really take approach B for the host side we'd need to intercept *all* host side network traffic regardless of address. Probably easiest way to do that would be put the whole thing inside another netns. That outside netns would have a default route to a tap device, and on the other side of the tap device would be a test server serving up frames built from the fuzzer output.
As I was mentioning, this could be done in a network namespace without any interface, by making the test server listen to all ports and all addresses, with a non-local bind and a so-called AnyIP route. Tested:
$ pasta -- sh -c 'ip route add local default dev lo; nc -l 1 & { sleep 1; echo x | nc -N 1.2.3.4 1; }' x
True, but having the fuzzer synthesize L2 frames
Slightly easier, perhaps, but that's beyond the scope of fuzzing we need (AFL++ will start trying to build malformed frames which we won't see anyway and effectively fuzz the kernel instead).
True. But once you're doing all this anyip magic in the test server and fuzzing the various addresses, I can no longer see that it's any easier to implement than mocking the syscalls on the passt side.
There's no need to do any AnyIP magic in the test server, it's one single setup command (the one I showed above). The test server could just bind to all ports on 0.0.0.0 and ::, and receive everything.
seems easier to me than having it directly synthesize the various socket operations the host side peer might perform.
I don't see this as complicated. There just needs to be a way for AFL++ to say how much payload the test server might need to send in a given round, and that can be used as argument to send() or sendmsg(). That's what I meant by "play script."
If the test server is just sending back a fixed payload, which isn't particularly interesting. We're much more likely to find bugs with odd mixtures of sending payloads, resets and new connections.
Well, of course the test server / peer would eventually need to do that. That would be part of its play script, eventually (I would leave it out at the beginning and have just variable-sized payloads). But it's just calling connect() or close() depending on some bytecode sequence rather than having to implement something that behaves like connnect().
It's probably easier to co-ordinate if the host side and guest side test server is the same, so we'd have:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ | | | /- test netns ----------^-------------------|-------------\ | | | | ,--------. | | | | passt >-----------<unix socket>-----/ | | '--------' | \---------------------------------------------------------/
The test peer generates host side frames via the tap device, and guest side frames via the Unix socket.
The UNIX socket is something we want to avoid, it's really much slower compared to shared memory (we tried something like that) on the path where AFL++ is trying to mutate data fast (because it can hit a lot of different code paths with small changes, compared to changing socket-side payload).
Right. That's why I conclude approch A is probably better further down. But even if we only care about fuzzing guest side, we'll usually incur the cost of operations on both sides, so I don't see that using shared memory is more important for guest side than host side interposition.
It could also be done with pasta, like this:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ packet | socket /- test netns ----------^-------------------|-------------\ | | | | ,--------. ,----------------|---. | | | pasta >-tap device-< guest netns * | | | '--------' '--------------------' | \---------------------------------------------------------/
The order it generates host vs. guest frames should also come from the fuzzer, not be fixed. At least theoretically, this is non-invasive: it could run with an unmodified passt/pasta. Except that - AFL would still need coverage feedback from passt/pasta - It wouldn't allow us to simulate odd timings (except by actually expending real time) - The various interposing layers will probably slow down fuzzing.
So, I rather suspect it will work better to go fully to approach A: no test peer at all, instead passt itself is modified to use mocked versions of all the external syscalls to slurp data from AFL. The draft series already does this for epoll_wait() and recv*(), but we'd need to also do that for recv*() on the tap socket, connect(), accept(), TCP_INFO and probably others. We'd also need to mock "sending" calls, send(), write() and shutdown() at least - but those could probably be no-ops.
...except that by mocking all those we lose a lot of complexity where historically we had a ton of bugs. If we just mock recv() it's much less (well yes we had bugs there as well but it was like 3 or 4 over the entire project history).
If we mock as close as possible to the syscall level, I don't see that bypass much of our complexity. To be clear, I'm suggesting mocks where both returned data and error codes are derived from the fuzzer, not just no-op stubs.
Doing that is not a realistic test though, because the kernel won't return random error codes.
Sure, so AFL learns those inputs send it into a boring exit-with-error path, and looks for inputs (i.e. return codes) that send it down more interesting paths.
One thing is exit-with-error, another whole thing is EAGAIN returns that have no reason to be there. Or recvmsg() with random iovec structs populated by AFL++. Of course we'll want to be robust to all that but I guess we'll spend months finding those "bugs" and fixing them before we get to do anything useful.
Indeed it would be nice to be robust to kernel issues, but I don't see it as a priority (and I guess we would waste the whole time on those "issues" if we do that).
This is more invasive, of course. It also means we need to deal with the case where AFL generates a syscall results that should be impossible - that should move onto the next case ASAP, but not be flagged as a passt bug. On the other hand, this approach should be fast, and since we can also mock clock_gettime(), the fuzzer can potentially find timer logic bugs that would only occur after hours or days in real time.
As far as I understood, it's not trivial to make the same instance of AFL++ share memory with two processes at the same time, so the memory-sharing path might need to take a more complicated turn, for example there could be a wrapper starting both passt and the test server and sharing memory with them, or passt could _additionally_ (using a special out-of-band fuzzing channel) share data from AFL++ with the test server.
An example of communication below (but events don't necessarily need to be in this order, this is just an example). For simplicity, let's ignore the fact that AFL++ might not directly share memory with passt and test server, and assume there are three areas of memory that AFL++ directly controls:
a. shared with passt: an array of struct epoll_event, 'ev'
b. shared with passt: the kind of tap-side buffer you implemented in 4/5, 'buf'
c. shared with the test server: a separate buffer, 'test_buf'
Example:
1. AFL++ writes an EPOLLIN event in 'ev' with type EPOLL_TYPE_TAP_PASST, of some data in 'buf', and some data in 'test_buf'
2. AFL++ starts passt and the test server
3. passt reads the EPOLL_TYPE_TAP_PASST event from 'ev', reads data from 'buf' and hands it to passt_tap_handler()
4. this happens to be have Ethernet, IP, and TCP headers, with the SYN flag set, and destination address set to the address of the test server (we might want to force all this, at least initially, or give it as a hint to AFL++ somehow), so passt connects to the test server
5. the test server accepts the connection, and sends the contents of 'test_buf' on it (for the test server, this is directly payload, without headers, as they don't make sense there). I'm not sure if we should have a different set of events (maybe we need a "play script" for the server, in case?)
6. this generates an EPOLLOUT event for passt. It's not in 'ev', it's a regular epoll_wait() (I think we could have an epoll_wait() loop where we additionally read one event from 'ev' for every iteration, or something like that)
7. passt marks the connection as established and inserts it in the flow table
8. passt reads the data sent from the test server and generates whatever TCP data packet to the "guest" (it might simply be a sink)
...and this attempt ends here because AFL++ generated a single event for passt, but there could be more (this should also be decided by AFL++).
Would something like this make sense?
-- Stefano
On Mon, Aug 17, 2026 at 10:09:16PM +0200, Stefano Brivio wrote:
On Mon, 17 Aug 2026 16:58:18 +1000 David Gibson
wrote: On Fri, Aug 14, 2026 at 01:55:24PM +0200, Stefano Brivio wrote:
On Fri, 14 Aug 2026 20:02:46 +1000 David Gibson
wrote: On Fri, Aug 14, 2026 at 09:35:59AM +0200, Stefano Brivio wrote:
On Fri, 14 Aug 2026 15:40:35 +1000 David Gibson
wrote: On Thu, Aug 13, 2026 at 09:53:24AM +0200, Stefano Brivio wrote: > On Wed, 12 Aug 2026 12:56:28 +0530 > Anshu Kumari
wrote: > > > Add fuzz-server that acts as passt's network peer > > during fuzzing. > > To me, this part makes sense. But this one: > > > It connects to passt's UNIX socket > > much less, while: > > > and listens on 127.0.0.1:9999 for TCP connections. > > this is the part that I expected instead. Otherwise it's not just > passt's network peer, it's the guest as well. > > > UNIX socket path: responds to ARP requests and TCP SYNs with > > stateless replies (swapped addresses, fixed ISN). Responses > > are XOR'd with AFL++ shared memory data so the fuzzer can > > mutate server behavior. > > This looks rather complicated to me. It does.
> The approach I was suggesting with a test server is the following: > > > ,- exchanges guest-side data with ------------. > | ,---------|---------. > | ,--| passt | > | / '-.---------------^-' > ,---|---. / | connect(), | accept(), > | AFL++ |-- shares memory with ---| | send data, | reply with > '---|---' \ | etc. | data, etc. > | \ ,-v---------------'-. > | '--| test server | > | '---------|---------' > '- exchanges host-side data with -------------' > > ...at least in its basic form. Eventually, the test server should be > able to connect to passt itself (and we could call it "test peer" at > that point).
So, I agree that to meaningfully fuzz things, we want the fuzzer to be able to control data on both the guest and host side. I can see two basic approaches:
A) Alter passt/pasta so that instead of directly communicating with external entities (either guest or host side) we use mocked versions which retrieve data from AFL. We can do that either at the system call level, or at a higher helper function level, the lower level we go, the more of the "normal" passt code we're exercising, but doing at a slightly higher level might be easier to implement
B) Run passt/pasta in an environment where we can intercept the external transfers to a test server / test peer / test guest which in turn responds based on data from AFL.
Both the current draft and the sketch diagram Stefano has provided are a hybrid of both approaches, so far I'm not seeing a clear advantage to that over going all one way or the other.
Of course, B) would be cleaner (and not that complicated, see below), but we don't want to do that guest-side (sending data from a test guest) because we would lose the speed advantage of having shared memory on the path that _really_ matters for fuzzing (the guest is untrusted, the kernel isn't).
I agree we lose the speed advantage, but I don't really see why that matters more on the guest side than the host.
Because AFL++ tries to vary the input to discover new code paths, but if it's not necessary (and in general it's not for host-side payload), the input might remain relatively constant, and memory content that isn't changed it's cache hot.
Ok... but I'm still not seeing the connection. AFAIK the important thing for fuzzing speed is how long each fuzz test case takes to run - the duration of the AFL_LOOP() body. That includes getting a new case via shared memory, but also whatever else we do in that loop, which will typically involve at least one socket side and at least one tap side event. If the socket side events involve real socket calls and synchronization with another process that will cost us on that loop duration, even if the socket peer was informed what to do via cache-hot shared memory.
Those "real socket calls" are faster if copy_to_user() and copy_from_user() (AFL++ to kernel, kernel to passt, passt to kernel, AFL++ to passt) use cache-hot data, that's all.
Ok.
For sufficiently big amounts of data (megabytes) the system call overhead is negligible compared to data copies.
Sure, but I think the more interesting fuzzing cases are going to be short but malformed packets.
Or am I missing something super obvious.
I have only profiled the "guest" side of things so far, though.
In any case, this is minor. If we can have shared memory and no further transport on one path, it's better than having it on zero paths.
Well, true. But doing some of the fuzz directly via interposed syscalls and some via an external test peer adds the complication of having to synchronize eahc of those things operating from the same source of fuzz data.
True, that part could be a bit simpler if the test peer could take care of both (I really think we should avoid mocking all the host-side system calls, that is, approach A, also because we already tried to do that, see below).
On the other hand, note that there's no need for explicit synchronisation with the approach I described. AFL++ could send data through passt using a buffer, the test peer could send data back using another buffer (all part of the same memory region from the perspective of AFL++). Those memory regions don't need to overlap, and AFL++ doesn't mutate output after it started passt (or the wrapper running passt and test server).
AIUI with __AFL_LOOP() it *will* mutate data after starting passt (that's kind of the whole point). But it will only do so at clearly defined points (between __AFL_LOOP() iterations).
The test peer doesn't really need to synchronise with passt. It could send data after it receives some, or it could send data a number of times in a row without receiving anything. There would need to be some kind of "playbook" generated by AFL++ with discrete quantities of time.
So.. I can see a case for doing stuff with a "dumb" test server, that *doesn't* use an AFL generated playbook: it either just echoes back, or transmits a fixed payload. Obviously that won't exercise bugs triggered by host side weirdness, but as you've said malformed guest side packets are probably the more interesting place to start fuzzing, so I could see this as an interesting interim step. Once the test server is drive by an AFL generated playbook, I'm not seeing why it's any easier to build than mocking the host side syscalls in passt based on the AFL data.
I think you would need this regardless of the approach (A, B, or a mix).
I'm not sure what "this" refers to here.
Yes, fuzzing the guest side matters more, but most guest side operations will induce passt to perform a host side operation, so the speed of the host side handling matters even if the guest side is what we care about fuzzing.
That's something we already established a while ago when AbdAlRahman was working on it. We hadn't really looked into the host side yet, back then.
So, host side: we can't do it (and it's much less important) because we need to use those sockets in the same way passt uses them.
I'm not sure what you mean. I outlined a way to do this below.
Sure, strictly speaking, we can, but we can't if we want to obtain a realistic approximation of what we would be normally doing on sockets.
So sorry, I was unclear, in that I outlined both an approach A (mocking the socket syscalls) and an approach B (fuzzed frames in an containing namespace) way of handling the socket side. I'm not sure which one you're addressing here.
Approach A. The mocking we would do of host-side system calls won't be realistic.
Do you mean 1) "not realistic for us to implement", or 2) "won't behave sufficiently similarly to real syscalls to be a useful test". In either sense, I'm not sure why For 1), it's certainly non-trivial, but it seems to me it would be easier than a playbook driven test peer. For 2), yes, it would generate many non-realistic scenarios, but it will also generate realistic ones, in a much wider gamut than a test server would. The coverage drive feedback should let AFL find those cases.
We would wrap all the host-side socket operations, in that case, which is really not ideal, to the point of questioning the whole effectiveness.
Why? I mean mocking all the socket syscalls is a moderate amount of work, but I'm not actually sure it's any more than writing a separate test peer.
But the test peer would just call send() on sockets it accepts. It's not much writing.
Thus excluding multi-packet exchanges, inbound initiated connections, connections which are active in both directions concurrently, scenarios with concurrent connections, various combinations of retransmits, FINs and RSTs. i.e. lots of the most interesting ground to find bugs. And that's even assuming that the guest side fuzzed input causes passt to send a connection request to the test server at a particular address. To even get that far, we need to constrain the input to exclude most of the absolutely most interesting malformed packets.
Plus it allows the fuzzer to find bugs related to weird timing and/or weird TCP_INFO results.
...the weird timing conditions would anyway be maintained with approach B for the host-side. The weird TCP_INFO results is exactly what I'd like to avoid testing (at least to start with).
Well, it depends what you mean by "weird". The obvious way of mocking TCP_INFO would result in many straight-up illegal / impossible values, which I'll grant is not interesting. However, it would also result in rare but possible combinations, which is exactly the sort of thing it would be great to test. I'll grant you that as a place to start, it would be reasonable to look at fuzzing only guest side input, with a well behaved server on the host side. But I still don't see how you get around the problem of having to constrain the guest input a _lot_ to make it even be something that will connect to the test server in the way we expect.
The guest side interface is a trivial recv(), the host side is something complicated with iovecs and everything.
I'm now not sure if you're saying this in relation to approach B, or approach A.
That's independent from the chosen approach: it's like that without fuzzing.
Um.. I don't follow what you're saying here at all.
Leaving fuzzing alone:
1. guest-side operations are a simple recv() (again, my main concern is the guest *writing* data in a problematic way)
I'll allow that's probably the most interesting case. I certainly don't think it's the only one. I'm thinking something like a bug that only occurs if we get an RST from the host side at the same moment we were about to discard a connection. Mocking syscalls (including clock_gettime()) lets us find things like that in a short time period.
2. host-side operations are a complicated mix of a number of system calls with I/O vectoring
...therefore 1. is okay to mock in my opinion because we are unlikely to discover bugs there, but 2. isn't because our host-side interface is much more complicated.
Ah, I think I see. You're worried about bugs in the actual marshalling of buffers for the syscalls, that we might bypass. I can sort of see that, but I don't think it's actually that bad.
If we wrap / skip recv() on the guest side, that's not a big loss. If we wrap host-side socket operations, that's a significant deviation and we'll miss bugs.
I don't see why that would be a significant deviation.
Because AFL++ won't behave like a real kernel?
So we test ourselves against an even wider range of conditions than the real world. Oh no.
(B) is quite easy to do guest side - we just connect a "test guest" to passt's socket. Approach B is much harder for host side. The current draft has a test server listening on a single address.
This is just to get something up and running though, it obviously needs to be changed later.
I don't see how we are "up and running" if fuzzed packets from the guest induce passt to forward them to random host side addresses that we're not controlling - we won't generate reproducible results.
If you look at patch 4/5, it hardcodes the destination port and uses a known destination address (again, for the moment). It's not random. It will need to be random of course.
Right, that's my point. If we hardcode the destination, we're not fuzzing one of the things we most want to fuzz. If we don't hardcode the destination then this simple approach for a test peer doesn't work any more.
...yes, of course, until you set up a route that redirects connections to any address to the test peer. Then it works without hardcoding any address.
Ah, ok. I've been confusing a bit the single address test peer in this series with the any-route one you have in mind. Ok, fair enough, I now see an any-route test peer (without playbook) as a good place to start with guest side fuzzing. Once we want some sort of test peer playbook, I'm no longer convinced it's not as easy to do that via syscall mocking, which would also explore a wider range of possible bugs.
But that means the fuzzing is fundamentally incapable of finding bugs involving talking to multiple peers at once. Worse, we have to constrain the construction of guest side data so that we talk to the test server not something else, and that's one of the things we most want to fuzz.
To really take approach B for the host side we'd need to intercept *all* host side network traffic regardless of address. Probably easiest way to do that would be put the whole thing inside another netns. That outside netns would have a default route to a tap device, and on the other side of the tap device would be a test server serving up frames built from the fuzzer output.
As I was mentioning, this could be done in a network namespace without any interface, by making the test server listen to all ports and all addresses, with a non-local bind and a so-called AnyIP route. Tested:
$ pasta -- sh -c 'ip route add local default dev lo; nc -l 1 & { sleep 1; echo x | nc -N 1.2.3.4 1; }' x
True, but having the fuzzer synthesize L2 frames
Slightly easier, perhaps, but that's beyond the scope of fuzzing we need (AFL++ will start trying to build malformed frames which we won't see anyway and effectively fuzz the kernel instead).
True. But once you're doing all this anyip magic in the test server and fuzzing the various addresses, I can no longer see that it's any easier to implement than mocking the syscalls on the passt side.
There's no need to do any AnyIP magic in the test server, it's one single setup command (the one I showed above).
The test server could just bind to all ports on 0.0.0.0 and ::, and receive everything.
seems easier to me than having it directly synthesize the various socket operations the host side peer might perform.
I don't see this as complicated. There just needs to be a way for AFL++ to say how much payload the test server might need to send in a given round, and that can be used as argument to send() or sendmsg(). That's what I meant by "play script."
If the test server is just sending back a fixed payload, which isn't particularly interesting. We're much more likely to find bugs with odd mixtures of sending payloads, resets and new connections.
Well, of course the test server / peer would eventually need to do that. That would be part of its play script, eventually (I would leave it out at the beginning and have just variable-sized payloads).
But it's just calling connect() or close() depending on some bytecode sequence rather than having to implement something that behaves like connnect().
Right, but the thing with mocking syscalls is we don't actually have to implement what connect() (or, accept() from the passt side, I guess), actually does. We just need it to return things that connect() might return. Ideally anything the syscall can return, both error code and data. If it "overshoots" and also generates impossible stuff, that's not terrible - it just means we implement robustness against kernel weirdness along the way to finding other bugs.
It's probably easier to co-ordinate if the host side and guest side test server is the same, so we'd have:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ | | | /- test netns ----------^-------------------|-------------\ | | | | ,--------. | | | | passt >-----------<unix socket>-----/ | | '--------' | \---------------------------------------------------------/
The test peer generates host side frames via the tap device, and guest side frames via the Unix socket.
The UNIX socket is something we want to avoid, it's really much slower compared to shared memory (we tried something like that) on the path where AFL++ is trying to mutate data fast (because it can hit a lot of different code paths with small changes, compared to changing socket-side payload).
Right. That's why I conclude approch A is probably better further down. But even if we only care about fuzzing guest side, we'll usually incur the cost of operations on both sides, so I don't see that using shared memory is more important for guest side than host side interposition.
It could also be done with pasta, like this:
,-------. ,-------------. | AFL++ |-- shares memory with ---| test peer | '-------' '--v------v---' | | /-tap device-/ packet | socket /- test netns ----------^-------------------|-------------\ | | | | ,--------. ,----------------|---. | | | pasta >-tap device-< guest netns * | | | '--------' '--------------------' | \---------------------------------------------------------/
The order it generates host vs. guest frames should also come from the fuzzer, not be fixed. At least theoretically, this is non-invasive: it could run with an unmodified passt/pasta. Except that - AFL would still need coverage feedback from passt/pasta - It wouldn't allow us to simulate odd timings (except by actually expending real time) - The various interposing layers will probably slow down fuzzing.
So, I rather suspect it will work better to go fully to approach A: no test peer at all, instead passt itself is modified to use mocked versions of all the external syscalls to slurp data from AFL. The draft series already does this for epoll_wait() and recv*(), but we'd need to also do that for recv*() on the tap socket, connect(), accept(), TCP_INFO and probably others. We'd also need to mock "sending" calls, send(), write() and shutdown() at least - but those could probably be no-ops.
...except that by mocking all those we lose a lot of complexity where historically we had a ton of bugs. If we just mock recv() it's much less (well yes we had bugs there as well but it was like 3 or 4 over the entire project history).
If we mock as close as possible to the syscall level, I don't see that bypass much of our complexity. To be clear, I'm suggesting mocks where both returned data and error codes are derived from the fuzzer, not just no-op stubs.
Doing that is not a realistic test though, because the kernel won't return random error codes.
Sure, so AFL learns those inputs send it into a boring exit-with-error path, and looks for inputs (i.e. return codes) that send it down more interesting paths.
One thing is exit-with-error, another whole thing is EAGAIN returns that have no reason to be there.
That's effectively emulating a spurious event, which is something we generally _should_ be robust against.
Or recvmsg() with random iovec structs populated by AFL++.
We choose the iovecs, recvmsg() just populates the buffers, which is fairly straightforward (and since it will be payloads, fairly uninteresting).
Of course we'll want to be robust to all that but I guess we'll spend months finding those "bugs" and fixing them before we get to do anything useful.
Hm. Maybe.
Indeed it would be nice to be robust to kernel issues, but I don't see it as a priority (and I guess we would waste the whole time on those "issues" if we do that).
This is more invasive, of course. It also means we need to deal with the case where AFL generates a syscall results that should be impossible - that should move onto the next case ASAP, but not be flagged as a passt bug. On the other hand, this approach should be fast, and since we can also mock clock_gettime(), the fuzzer can potentially find timer logic bugs that would only occur after hours or days in real time.
> As far as I understood, it's not trivial to make the same instance > of AFL++ share memory with two processes at the same time, so the > memory-sharing path might need to take a more complicated turn, for > example there could be a wrapper starting both passt and the test > server and sharing memory with them, or passt could _additionally_ > (using a special out-of-band fuzzing channel) share data from AFL++ > with the test server. > > An example of communication below (but events don't necessarily need > to be in this order, this is just an example). For simplicity, let's > ignore the fact that AFL++ might not directly share memory with passt > and test server, and assume there are three areas of memory that > AFL++ directly controls: > > a. shared with passt: an array of struct epoll_event, 'ev' > > b. shared with passt: the kind of tap-side buffer you implemented in > 4/5, 'buf' > > c. shared with the test server: a separate buffer, 'test_buf' > > Example: > > 1. AFL++ writes an EPOLLIN event in 'ev' with type > EPOLL_TYPE_TAP_PASST, of some data in 'buf', and some data in > 'test_buf' > > 2. AFL++ starts passt and the test server > > 3. passt reads the EPOLL_TYPE_TAP_PASST event from 'ev', reads data > from 'buf' and hands it to passt_tap_handler() > > 4. this happens to be have Ethernet, IP, and TCP headers, with the > SYN flag set, and destination address set to the address of the > test server (we might want to force all this, at least initially, > or give it as a hint to AFL++ somehow), so passt connects to > the test server > > 5. the test server accepts the connection, and sends the contents > of 'test_buf' on it (for the test server, this is directly > payload, without headers, as they don't make sense there). I'm > not sure if we should have a different set of events (maybe we > need a "play script" for the server, in case?) > > 6. this generates an EPOLLOUT event for passt. It's not in 'ev', > it's a regular epoll_wait() (I think we could have an > epoll_wait() loop where we additionally read one event from > 'ev' for every iteration, or something like that) > > 7. passt marks the connection as established and inserts it in the > flow table > > 8. passt reads the data sent from the test server and generates > whatever TCP data packet to the "guest" (it might simply be > a sink) > > ...and this attempt ends here because AFL++ generated a single > event for passt, but there could be more (this should also be > decided by AFL++). > > Would something like this make sense?
-- Stefano
-- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
participants (3)
-
Anshu Kumari
-
David Gibson
-
Stefano Brivio