This blog post presents a LibAFL-Qemu based way to explore state machines and why we failed to find CVE-2026-9539.


Introduction

In this blogpost we present a LibAFL-Qemu based fuzzer we developed with the goal of targeting (complex) state machines using incremental snapshots. We targeted libslirp's TCP code during development and showcase the fuzzer on CVE-2026-9539 which we missed at that time due to a lack of detection.

First we will describe CVE-2026-9539, why it went unnoticed for years as well as when we fuzzed the code and what can be done about it. Then we will describe how the fuzzer works and show it running against libslirp to find CVE-2026-9539. The code is available on Quarkslab's github and we tried to isolate the target-specific parts so it is easy to adapt to a different target.

Description of CVE-2026-9539

libslirp is «a user-mode networking library used by virtual machines, containers or various tools.». Being Qemu's default network stack makes it an interesting target for a VM escape.

CVE-2026-9539 is described as an «heartbleed-like» Out-Of-Bound (OOB) Read vulnerability which exposes data from other connections. It was found by Bruce Chen and Shi Weiming from STAR Labs SG Pte. Ltd and was reported to libslirp in May 2026, around the same time we failed to see our fuzzer triggering it because of a lack of detection.
We won't explain the bug at length here and stick to the important bits as the ticket on libslirp's gitlab is very detailed:

  1. Libslirp allocates two large arrays (128KiB) that it uses to support the socket's send and receive buffers. It uses two pointers to walk these buffers like ring-buffers.
  2. Libslirp was testing the TCP urgent pointer against the size of an intermediate buffer, not against the amount of valid data in the socket.
  3. As it tries to read more data than available, an integer underflow happens leading to even more data being read.
  4. The data is sent with multiple packets and leaks the whole content of the receive buffer, including leftovers from a previous allocation if the memory chunk is a previously freed one that was re-used for this socket. The same 128KiB of data is sent over-and-over.

As the data is too big to be sent in a single packet and multiple checks try to prevent OOB accesses, the bug stays inside the ring-buffer area and never goes out-of-bound as far as Address Sanitizer (ASAN) is concerned, making this bug seemingly invisible.

Why CVE-2026-9539 went unnoticed and what can we do about it

As we said in the previous section, the bug is not a classical OOB read but an OOB read inside a custom allocator, so it will not crash the program and ASAN is blind even if a fuzzer is able to reach it.

Speaking about fuzzers, libslirp integrates a bunch of them. These are libfuzzer-based and use a custom mutator working with PCAP files. The main issue with the fuzzer targeting the TCP-related code is that it fails to open and connect to sockets. The fuzzers are not using libdesock or preeny and bypassing the socket part by modifying the source code can lead to surprising results and be tricky.

There are two ways to detect CVE-2026-9539 using sanitizers: the easiest one consists in detecting its side effect, while the other consists in working around ASAN's shortcomings when an internal allocator is used.

Detecting the side effect

The integer underflow in the sosendoob function is not the whole story but interestingly it makes the vulnerability easy to detect using ASAN's sibling UndefinedBehaviorSanitizer (UBSAN).

We recommend reading MaskRay's excellent blogpost All About UndefinedBehaviorSanitizer for a complete tour of UBSAN.

Contrary to ASAN, UBSAN only exists as an LLVM pass so it is only available when you are able to compile the project from source. Recompiling libslirp with integer overflow detection (-fsanitize=unsigned-integer-overflow,signed-integer-overflow) and reaching sosendoob's underflow, we are rewarded with multiple UBSAN runtime errors (which we can suppress with custom annotation after determining whether this is an expected behavior), including the relevant one:

../src/socket.c:394:15: runtime error: unsigned integer overflow: 8 - 2048 cannot be represented in type 'uint32_t' (aka 'unsigned int')
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ../src/socket.c:394:15

Customizing ASAN

Address Sanitizer is using a shadow memory to track allocation sizes and memory accesses. Basically, it handles a big array mimicking the memory of your program filled with values indicating if the program is allowed to read/write the associated byte.
A byte which should not be accessed is said to be poisoned and ASAN provides functions to (un)poison a memory region.

You can refer to TrailOfBits' Sanitize your C++ containers: ASan annotations step-by-step blogpost for a tutorial-like introduction to manual poisoning with ASAN.

Poisoning the whole ring-buffer and manually unpoisoning/poisoning memory when it is «allocated»/«freed» by the internal allocator should detect this bug. It requires more work and is prone to errors but it would be more reliable as it does not depend on side effects of the bug.

LibAFL-Qemu's ASAN implementation also exposes an API to poison/unpoison memory regions, so this can also be achieved when the source code is unavailable.

Fuzzing against the (state) machines

Fuzzing a state machine, such as the TCP one, generally requires sending multiple inputs in a given order. There are multiple ways to solve this problem, each having pros and cons. We are only listing some techniques which require low efforts and fuzzers built on modern fuzzers such as AFL++ or LibAFL.

Persistent mode and persistent record

The first and easiest option is to run your fuzzer in persistent mode, avoid resetting anything between iterations, opt-in for the low stability and hope AFL++'s persistent record option will catch the right input chain to have a reproducer.

Splitting the input

A common technique in order to easily find reproducible issues is to split the fuzzer's input into multiple inputs. This can be done by letting the fuzzer generate a sequence of bytes and then use FuzzedDataProvider, Arbitrary or similar.
Another way of doing it is by creating a custom mutator which uses a magic value to separate the parts of the input. This is what libslirp's fuzzers do.

This approach is subject to the havoc effect (a change to an early input part changes the interpretation of the next ones) and the fuzzer could theoretically generate the magic value (choose your separator wisely and avoid compiling the harness with cmplog).

Multipart inputs

Multipart inputs is a variation of the previous approach. It is built in LibAFL and removes the need for a custom mutator. It also removes the very unlikely risk that the fuzzer generates the magic value that separates the parts, but the havoc effect is still an issue.

Working from snapshots

Instead of working from the initial state and sending multiple inputs to the target, a security analyst tries to reach interesting states manually in order to take a snapshot. This snapshot is then fuzzed in isolation, one input at a time.

This setup removes most of the issues discussed previously but the ability to reach a complex state might depend on how the current state was reached.

Fuzzer in the Middle (FitM)

FitM is a fuzzer designed for client-server targets when both are available. The fuzzer impersonates one end of the protocol until the other end's response changes. When a new response (which is a proxy for the state) is found, the fuzzer takes a snapshot and switches to impersonating the other end of the communication channel.

It relies on Qemu for emulation and CRIU to take the snapshots. The setup might be a bit tedious.

Taking snapshots while fuzzing

The approach we have implemented is inspired by FitM, it is also close to NyxNet with a different backend and design choices, and to FTZ. We built our fuzzer on top of LibAFL-Qemu usermode, its SnapshotModule and SnapshotManager. We could have mostly copy-pasted the SnapshotModule into a MultiSnapshotModule but we chose another route which looked a bit easier back then.
Using the hook mecanism provided by LibAFL-Qemu, we are detecting changes to the state machine to trigger a new snapshot.
The SnapshotManager then saves the internal structures of the SnapshotModule and can restore it on demand. This way, we should be able to explore the different states and state transitions of the target.
Leveraging LibAFL's metadata concept, we are able to track which input generated a snapshot and the full chain of snapshots leading to a given state, allowing to easily try to reproduce any issue.
On libslirp, we are using a lain-based custom mutator to only generate TCP packets so the fuzzer does not explore code unrelated to the state machine we are targeting.

Lain is an input mutator/generator which works using annotations on Rust struct. The mutations it performs try to conform with the type and it offers some control over which parts of the struct can be mutated. Nowadays it could be replaced with Autarkie which shares the concept of using annotations on Rust struct but is considered a grammar mutator rather than a structure-aware one.

Our «incremental snapshot fuzzer»

LibAFL-Qemu's SnapshotModule

LibAFL-Qemu's usermode comes with a SnapshotModule which unlocks snapshot fuzzing for linux userland targets. It only snapshots the target's memory, not its registers. Taking a snapshot of the registers should be done manually in the harness.
In this section, we will briefly describe how it works.

The snapshot function is in charge of taking the snapshot and does the following:

  • It saves the values of brk (think of it as the limit of the memory region the heap can use) and mmap_start.
  • It walks the whole Qemu memory mapping and saves the bounds and permissions of the mappings.
  • While walking the memory map, it also walks the pages of each mapping, one at a time, and stores the content of the pages along their address and permissions in the SnapshotPageInfo structure.

In order to track when memory is written, the SnapshotModule sets up «write hooks» in Qemu tasked with adding the (now) dirty pages to the SnapshotModule's accesses field. It also registers pre_syscalls and post_syscalls hooks to track some syscalls which affect memory (mmap, munmap, mprotect, brk, read, ...).

Thanks to these hooks, the reset function can restore the snapshot's state in a more efficient way than just restoring everything the way it was. It works as follows:

  • If the heap has shrunk or grown (the value of brk changed), it reverts it by mapping/unmapping memory.
  • It walks the dirty pages to restore their content.
  • If some mappings were mapped or unmapped, it reverts the changes and restores the content if needed.
  • If a mapping's permission changed, it calls mprotect to revert it.

The SnapshotManager

The SnapshotManager is a member of LibAFL-Qemu's Emulator structure. The default SnapshotManager for usermode is the NopSnapshotManager which does nothing as the name suggests. A SnapshotManager must implement the IsSnapshotManager trait, which exposes 3 functions:

  • save: which is responsible for taking snapshots;
  • restore: which is responsible for restoring snapshots;
  • do_check: which is in charge of verifying the snapshot's correctness. We are not using this function in our fuzzer.

Our implementation's save function is called when a change to the TCP state is detected. To limit the amount of duplicate snapshots, it first performs a hash of the state (augmented with the flags of the answer packets from the stack to have more diversity) to decide if we actually take a snapshot. It then saves most of the SnapshotModule's fields and uses the accesses as a diff between a snapshot and its parent.
The SnapshotManager keeps a HashMap tracking the chain of snapshots to restore in order to reach another snapshot. This HashMap is filled during the call to save, leveraging the fact that the first step to reach a given snapshot is to reach the new snapshot's parent, as well as the converse (reaching the new snapshots is done by first reaching its parent).
Last but not least, the SnapshotManager updates its internal state and a module we called ObserverModule, which is used to add snapshot-related metadata to the testcase.

A decision graph to show the save function's flow which is explained in the previous paragraph
Save function flow

The restore function is simpler, it restores the current snapshot before retrieving the path to the target snapshot. It then restores the chain of snapshots by copying the saved SnapshotModule state back into the SnapshotModule and calling the SnapshotModule's reset function.

A decision graph to show the restore function's flow which is explained in the previous paragraph
Restore function flow

Taking snapshots

As the goal is to explore a state machine, we want to take snapshots when the internal state changes. This part is target specific, some will use a single function that sets the state, others could have a function for each possible state, and some others will just write the state to a memory region. Libslirp falls into the latter category.

To target libslirp, our fuzzer is using a hook to retrieve the memory region allocated for the tcpcb structure (which contains the t_state field) to track the state of the connection. Then, a write_hooks tracks write-operations in the t_state field, signaling to the harness, through the OberserverModule, that it should ask the SnapshotManager to try taking a new snapshot when execution ends.
We also used a pre_syscall_hook to hook the socket and fcntl syscalls, and various InstructionHook to bypass some verifications such as the checksum.

The harness and metadata

With our design, part of the logic is handled in the harness. LibAFL-Qemu expects the harness to take the fuzzer's state as an argument, so it is possible to retrieve the current testcase and the metadata attached to it. Our implementation heavily relies on this to drive the SnapshotManager and to save to disk all the information we need to reproduce a given testcase.

When a snapshot is generated, its SnapshotId is added to the new testcase's metadata. When a testcase is used and mutated, the harness retrieves its metadata and restores the snapshot associated to the stored SnapshotId. If no new snapshot is generated but new coverage is found, the restored snapshot is kept as the associated snapshot.
The bytes that were sent are stored in the metadata under the name gen_bytes, along with the associated SnapshotId. This allows to easily retrieve the full chain of inputs leading to a given testcase (because it is not straightforward to differentiate between a testcase which generated new coverage while on a given snapshot, and the testcase which generated the snapshot).

A decision graph to show the harness decision making which is explained in the previous paragraph
Harness decision making

Drawbacks and limitations

There are a few drawbacks and limitations coming either from our design or LibAFL itself, that we will discuss a bit more later in the section:

  • QASAN does not work with (multiple) snapshots.
  • This fuzzer was built on LibAFL 0.15.4, which often crashes when the target/Qemu crashes, killing the fuzzer.
  • LibAFL-Qemu does not come with a full-restore mechanism, making it impossible to have two separate trees of snapshots.
  • A new snapshot is associated to the testcase that generated it, providing a low diversity in the subsequent inputs.
  • External tooling is required to reproduce a series of input.
  • The snapshots take a lot of memory so the deduplication criterion is very important.
  • It is not possible to share testcases between fuzzer instances.

The third limitation comes at the price of having only a single entry in the initial corpus, or taking a snapshot before starting the fuzzing loop and forcing it as the root of all snapshots.

Optimizing the memory consumption of storing snapshots would require modifications to the SnapshotModule, so we did not do it (yet).
Sharing testcases does not carry metadata informations and even if it did, the SnashotId would not correlate between instances. It should be possible to work around it using the EventManager but this was not explored (yet) as the benefit might not be worth the time.

QASAN has an internal shadow memory which would need to be saved and restored by the SnapshotManager. It appears the AsanHostModule provides the snapshot and rollback functions that could be leveraged for this but it is not straightforward.

As new snapshots are associated to the testcase generating it, the mutation step will generate a variant of this testcase and restore the associated snapshot. So the fuzzer has a high chance to keep generating inputs which resemble the one which generated a given snapshot.
In the case of a TCP stack, the difference is not huge, especially when we have a mutator which ensures some fields are kept, but targeting a protocol such as TLS would require more diversity. Decoupling the testcase from the snapshot could be the answer in such cases.

External tooling is required to walk the input list and reconstruct the full chain required to run it. We can turn this limitation into a strength by having a separate tool which monitors new inputs, retrieves the chain leading to it and runs it under ASAN and dumps the coverage.

Offline reproducer, coverage collection and sanitization

As stated in the previous section, some instrumentation can interact with the snapshot part of the fuzzer in bad ways, so the easy path is to have external tooling. We chose to use Notify to react to new testcases being written to disk. When a new testcase is found its metadatas are parsed in order to retrieve the associated snapshot, the parent snapshot of the associated snapshot, the gen_bytes and a few other useful information.
Keeping a HashMap allows to retrieve the full chain of inputs leading to the new testcase, then the only thing left to do is spawn a new process which will set up a fuzzer with both AsanHostModule and DrCovModule to test the input chain against ASAN and collect the coverage in DrCov format, ready to load in bncov. The design of the two modules makes it mandatory to run a full fuzzer and call fuzzer.evaluate_input, rather then just call qemu.run on the instrumented Qemu instance because it does not trigger the pre_exec and post_exec functions.

Testing the fuzzer

The fuzzer targeting libslirp is available on Quarkslab's github. The instructions to compile libslirp on the correct commit, along with a patch to build the target binary and suppress false positive integer overflow (seq/ack numbers wrapping), and then run the fuzzer are in the readme.

The code has been split to highlight libslirp-related and tcp-related code parts. We kept the full naming (e.g. libslirp::utils::setup_target_arguments) to make it easier to locate which parts are the core of the fuzzer and which parts are target-specific.

├── Cargo.lock
├── Cargo.toml
└── src
    ├── libslirp
    │   ├── hooks.rs
    │   ├── misc.rs
    │   ├── mod.rs
    │   └── utils.rs
    ├── network_targets_utils
    │   ├── inputs.rs
    │   ├── mod.rs
    │   ├── mutator.rs
    │   ├── tcp.rs
    │   └── utils.rs
    ├── main.rs
    ├── feedback.rs
    ├── observer_module.rs
    ├── snapshot_manager.rs
    └── utils.rs

Our fuzzer against CVE-2026-9539

The video below shows the fuzzer running against an unpatched libslirp, compiled with UBSAN only looking for signed and unsigned integer overflows and ignoring the overflows on seq/ack numbers. The logs show both the TCP connection state before the next input is sent (call to libslirp::misc::log_target_state at main.rs:244) and the input we are sending.
In the video it takes seconds for Qemu to crash on a Illegal instruction, this is because UBSAN is in trap mode so it raises an UD instruction upon errors. As stated in the «Drawbacks and limitations» section, LibAFL-Qemu can have some issues with signal handling on the version we used.

A gif recording of the fuzzer running against libslirp, finding CVE-2026-9539
Our fuzzer against CVE-2026-9539

As Qemu and the fuzzer are crashing on the UD instruction we have no information about what happens, so you should either trust us or try it yourself in minimal runtime mode, stop the fuzzer and scroll-up to find the error and confirm it did hit CVE-2026-9539.

Acknowledgement

This project was AI-free.

I'd like to thank my colleagues, in particular Laurent Laubin, Chris and Célian for their support and inputs on the project.

I'd also like to thank:

  • Addison Crump, his parking game fuzzer was a great and fun introduction to implementing custom feedbacks and metadatas in LibAFL;
  • Toka for his work on LibAFL-Qemu;
  • Romain Malmain for his work on LibAFL-Qemu and answering my questions when I first started experimenting with LibAFL-Qemu;
  • last but not least, I'd like to thank Samuel Thibault, who is a maintainer of libslirp and supervised my first try at fuzzing on a real target back in 2020.

If you would like to learn more about our security audits and explore how we can help you, get in touch with us!