Lucid is an educational fuzzing project which aims to create a Bochs emulator based snapshot fuzzer capable of fuzzing traditionally hard to fuzz targets such as kernels and browsers. Lucid is based on a fuzzer originally conceived of and developed by Brandon Falk.
Lucid achieves deterministic fuzzing by creating a complete sandbox for the Bochs emulator. It loads a --static-pie Bochs ELF into its own process space and context switches between executing Lucid (for fuzzer tasks) and Bochs (for executing the fuzzing target) using inline assembly.
Lucid achieves this sandbox by building Bochs against a custom version of musl that does not emit syscall instructions. Instead, musl functions emit calls into Lucid's syscall emulation layer, denying Bochs access to the underlying operating system.
Below is a diagram demonstrating Lucid's architecture:
+------------------------------------------------------------------+
| Lucid (Process/Virtual Address Space) |
| |
| +------------------------------------------------------------+ |
| | Bochs x86-64 Emulator (--static-pie) | |
| | | |
| | +------------------+ | |
| | | Fuzzing Target | | |
| | | | | |
| | +------------------+ | |
| | | |
| +------------------------------------------------------------+ |
| | ^ |
| | | |
| | | Syscalls via custom musl |
| | | |
| v | |
| +---------------------------------+ |
| | Lucid Syscall Emulation Logic | |
| | | |
| +---------------------------------+ |
| |
+------------------------------------------------------------------+
Lucid supports Bochs SMP mode for true simulated CPU concurrency and interleaving. Multiple guest CPUs can execute within one fuzzing iteration, allowing targets to exercise concurrent state transitions and CPU interactions while retaining Lucid's deterministic execution and snapshot restoration.
I've made building the binaries that Lucid depends on extremely simple with Docker. This has been tested on Ubuntu 22.04 and Ubuntu 24.04.
Depending on what is stock on your distribution, you may also need to install libsdl2-dev and its dependencies to run the dynamically linked Bochs image we call gui-bochs in order to save Bochs snapshots to disk. See the Docker file Step 9 for more details. The build process may take a while since we have to build all of musl from scratch; however, with at least 8 cores, build time seems to be under 5 minutes on my machine.
- Update and install prerequisites:
sudo apt update
sudo apt install ca-certificates curl gnupg lsb-release- Add Docker's GPG key:
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg- Setup the repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null- Install Docker Engine
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin- Verify
sudo docker run hello-world- Add user to the docker group so you don't need
root
sudo usermod -aG docker $USER- Refresh the shell with the new docker group
newgrp docker- Install docker on your distribution, see "Docker Installation" above for Ubuntu
git clone https://github.com/h0mbre/Lucidcd Lucid && ./build-bins.sh
build-bins.sh should invoke Docker to build an image capable of building every binary we need to use Lucid. The script should output 7 files to a directory in the repository root called bins. Those files are:
lucid-fuzz: The Lucid fuzzer binary compiled from Rustgui-bochs: A dynamically linked single-CPU Bochs binary we use to take snapshots to disk for Lucid to resume from when fuzzinglucid-bochs: A single-CPU--static-pieBochs binary that we load into Lucid for fuzzing built against a custom muslgui-bochs-smp: A dynamically linked SMP Bochs binary we use to take SMP snapshots to disklucid-bochs-smp: An SMP--static-pieBochs binary that we load into Lucid for fuzzing built against a custom muslBIOS-bochs-latest: Bochs system BIOS referenced by the runtime configuration fileVGABIOS-lgpl-latest: Bochs VGA BIOS referenced by the runtime configuration file
lucid-fuzzb5939862c99f053331556e1d377dd795e8ddc183gui-bochs87ca1741e02f618e5ceb0e8ffb88d60c5eaa23fflucid-bochsd100f4bfdccf5fe422a85e6ee3302a757a00d4cfgui-bochs-smp1b6efa3fc6c97000e3bbacb60cf11f80d93ade68lucid-bochs-smp0509cbb102cc54192dd577c65b7c56f9a9cf3640BIOS-bochs-latestc654a401c6f4257324640b157a7e16bf334a263cVGABIOS-lgpl-latest35aa458948da1fcb747f70d3536c6de08e15f498
musl-cross-make depends on several third-party download endpoints. One of its pinned inputs, GNU config.sub, was previously fetched from an unreliable Savannah GitWeb endpoint. Lucid now includes an unmodified copy in vendor/config.sub, and the Docker build verifies it against the SHA-1 already pinned by musl-cross-make before continuing.
Other third-party downloads can still fail if an upstream server is unavailable or has a broken or expired TLS certificate.
Develop your environment, probably using something like QEMU system in order to do quick development cycles. For instance, if fuzzing a Linux kernel subsystem, you may develop a harness which sends user controlled input to a kernel API. Once you've confirmed your harness works in something like QEMU, you can create an .iso out of the kernel image (bzImage) which Bochs can then run.
Use the built gui-bochs Bochs binary in bins and run your harness. Use gui-bochs-smp when creating a snapshot for an SMP campaign. Before taking the snapshot, the harness must register its logical [u64 input_len][input bytes] layout with Bochs using the xchg r10w, r10w special NOP instruction. Pass the layout's guest virtual address in r8 and its total byte length in r9. Bochs saves the ordered physical spans backing that virtual range with the snapshot. Bochs will then save its state to disk when the harness reaches the xchg dx, dx special NOP instruction and exit.
// Pseudocode: this layout is contiguous in guest virtual memory, even when
// the guest pages are backed by non-contiguous physical memory.
struct {
u64 input_len;
u8 input[INPUT_MAX_SIZE];
} fuzzcase = {0};
asm volatile("movq %0, %%r8\n\t"
"movq %1, %%r9\n\t"
"xchgw %%r10w, %%r10w\n\t" // Register the layout
"xchgw %%dx, %%dx" // Take the snapshot
:
: "r" ((unsigned long)&fuzzcase), // R8: guest virtual address
"r" ((unsigned long)sizeof(fuzzcase)) // R9: total capacity
: "r8", "r9", "r10", "memory");
// After restoring the snapshot, Lucid writes each new logical fuzzcase across
// the registered spans in order, regardless of their physical addresses.Now with the saved-to-disk Bochs state, we are able to resume execution in the fuzzer. We do this by pointing Lucid at the matching lucid-bochs binary (lucid-bochs-smp for an SMP snapshot). Use the --bochs-snapshot-dir command line argument to tell lucid-fuzz where to find the snapshot saved on disk from Step 2.
The fuzzer should be able to resume the saved state of Bochs and continue execution from where it left off. This allows you to manipulate the user input and explore new code via fuzzing. You will need to adequately anticipate all possible code paths your input can cause as you will need to identify an appropriate choke-point to call back into the fuzzer to reset the snapshot via the special NOP instruction (xchg bx, bx). You will also need to implement your fuzzing target with crash oracles.
- Clone and build
git clone https://github.com/h0mbre/Lucid
cd Lucid && ./build-bins.sh- Run your target system in
gui-bochs, register[u64 input_len][input bytes]withxchg r10w, r10w(r8= guest virtual address,r9= total length), and reachxchg dx, dxto save Bochs state to disk - Point
lucid-fuzzat saved-to-disk Bochs snapshot solucid-bochscan begin emulating the target for fuzzing
The help menu for command line arguments is below:
h0mbre@pwn:~/Lucid/bins$ ./lucid-fuzz --help
lucid:: Parsing config options...
x86_64 Full-System Snapshot Fuzzer Powered by Bochs
Usage: lucid-fuzz [OPTIONS] --input-max-size <SIZE> --output-dir <OUTPUT_DIR> --bochs-image <IMAGE> --bochs-config <BOCHS_CONFIG> --bochs-snapshot-dir <BOCHS_SNAPSHOT_DIR>
Options:
--input-max-size <SIZE>
Sets the maximum input size for mutator to use (usize)
--coverage-map-size <SIZE>
Number of edge-pair coverage map slots (power of 2; 65536 default)
--seeds-dir <SEEDS_DIR>
Directory containing seed inputs (optional)
--output-dir <OUTPUT_DIR>
Directory to store fuzzer output (inputs, crashes, etc)
--verbose
Enables printing of Bochs stdout and stderr
--dryrun
Replay seed inputs before fuzzing to initialize coverage (slow)
--keep-timeouts
Save timed-out inputs to disk (disabled by default)
--mutator-seed <SEED>
Optional seed value provided to mutator pRNG (usize)
--output-limit <LIMIT>
Number of megabytes available for output (inputs, crashes, etc; 1000 default)
--fuzzers <COUNT>
Number of fuzzers we spawn (1 default)
--stat-interval <INTERVAL>
Number of seconds between stat reports (2 default)
--sync-interval <INTERVAL>
Number of seconds between corpus syncs (1800 default)
--icount-timeout <INSTRUCTION_COUNT>
Execution timeout in millions of guest instructions (250 default)
--bochs-image <IMAGE>
File path for the Bochs binary compatible with Lucid
--bochs-config <BOCHS_CONFIG>
File path for the Bochs runtime config file (bochsrc.txt)
--bochs-snapshot-dir <BOCHS_SNAPSHOT_DIR>
File path for the Bochs snapshot dir created with GUI Bochs
--mutator <MUTATOR>
Mutator to use: 'toy' (default) or 'netlink'
--redqueen
Enable Redqueen comparison-guided input processing
--colorize
Enable slower Redqueen operand colorization (requires --redqueen)
-h, --help
Print help
-V, --version
Print version
./lucid-fuzz \
--input-max-size 65672 \
--coverage-map-size 65536 \
--bochs-image ~/Lucid/bins/lucid-bochs \
--output-dir /tmp/findings \
--output-limit 1000 \
--icount-timeout 500 \
--fuzzers 8 \
--stat-interval 5 \
--sync-interval 1800 \
--seeds-dir ~/seeds/ \
--dryrun \
--mutator toy \
--mutator-seed 4919 \
--redqueen \
--bochs-config /tmp/bochsrc_nogui.txt \
--bochs-snapshot-dir /tmp/lucid_snapshot/
Longer-form design and implementation notes are available in the project blog series: https://h0mbre.github.io/New_Fuzzer_Project/
[lucid stats (start time: 2025-10-08 10:30:21)]
globals: uptime: 0d 0h 0m 5s | fuzzers: 8 | crashes: 0 | timeouts: 0
campaign: execs: 1.09K | execs/s: 218.00 | execs/s/f: 27.25
batch: execs: 211 | execs/s: 211.36 | execs/s/f: 26.42
cpu: target: 83.5% | reset: 10.3% | mutator: 0.0% | coverage: 0.2% | redqueen: 6.0% | misc: 0.1%
coverage: edges: 17487 | last: 0h 0m 0s, 0 execs | drought: 0h 0m 2s, 438 execs | map: 26.68%
snapshot: dirty pages: 7392 | dirty / total: 0.00131% | reset memcpys: 672
corpus: total 291 (180 perm, 80 sample, 9 private, 22 gen) | size: 0.100 (MB) | max: 0x10088
These are stats about the entire fuzzing campaign:
uptime: The duration thus far of this fuzzing sessionfuzzers: The configured number of fuzzer worker processescrashes: Total crashes across the campaigntimeouts: Total timeouts across the campaign
Timed-out inputs are counted and processed for coverage regardless of whether
they are written to disk. Pass --keep-timeouts to retain .timeout artifacts;
without the flag, timeout artifact saving is disabled.
These are lifetime stats measured from the start of the fuzzing campaign:
execs: Total fuzzcase executions thus farexecs/s: Average executions per second achieved globally over the entire campaignexecs/s/f: Average executions per second achieved per fuzzer over the entire campaign
These are stats measured over the latest reporting batch:
execs: Fuzzcase executions completed during the batchexecs/s: Executions per second achieved globally during the batchexecs/s/f: Executions per second achieved per fuzzer during the batch
These are stats about how we are spending our CPU time:
target: CPU time spent executing the target (Bochs)reset: CPU time spent performing snapshot resetsmutator: CPU time spent in the mutatorcoverage: CPU time spent checking the coverage map for new coverageredqueen: CPU time spent processing inputs through Redqueenmisc: Remainder of CPU time
edges: Occupied edge-pair coverage-map slots. In a multi-fuzzer report this is the largest count reported by any worker, not a union across workerslast: Wall-clock time and executions sinceedgeslast increased in a multi-fuzzer report. In single-fuzzer mode, positive feedback can reset it even without a new edge, including retained hit-count inputs and IJON feedbackdrought: Longest wall-clock interval and corresponding execution count observed between resets oflast. The current interval becomes the reported drought as soon as it exceeds every completed intervalmap: The reportededgescount as a percentage of the configured coverage-map size
These are disk artifacts the fuzzers produce related to coverage. Each individual fuzzer tracks its own novel edge-transition PCs and the campaign managing parent process will merge those files into a global.coverage file.
coverage/fuzzer-N.coverage: PCs newly observed by fuzzerNcoverage/global.coverage: the manager's consolidated campaign PCs
dirty pages: The number of pages we've marked dirty for differential resets; a multi-fuzzer report shows the largest count reported by any workerdirty / total: Ratio between dirtied pages and writable pages in Bochsreset memcpys: The number ofmemcpyinvocations needed to reset the dirty pages (after merging neighboring page ranges); a multi-fuzzer report shows the largest count reported by any worker
total: Aggregate number of in-memory inputs across all fuzzers, followed by theperm,sample,private, andgenpool sizesperm: Initial seeds and permanent findings owned by each fuzzer. Permanent findings are written to disk and are eligible for corpus sharingsample: A bounded view of permanent inputs discovered by other fuzzers, refreshed from disk at corpus sync (up to 1,000 per fuzzer)private: Bounded fuzzer-local feedback inputs that are never written to disk or shared (up to 1,000 per fuzzer)gen: Bounded fuzzer-local inputs created without a corpus parent (up to 64 per fuzzer)size: Aggregate bytes held in the permanent, private, and generated pools, reported in MB; sampled inputs are excludedmax: Size limit for an input
When both classes are available for mutation, Lucid chooses from the permanent and sampled pools 15/16 of the time and from the private and generated pools 1/16 of the time. Selection within the chosen class is uniform.
Redqueen processing is disabled by default. Pass --redqueen to enqueue
retained positive-feedback inputs for comparison-guided processing. Mutators that do
not expose Redqueen fields receive no benefit from enabling it and should
normally leave it disabled.
The optional --colorize pass is substantially slower and requires
--redqueen:
./lucid-fuzz ... --redqueen --colorize
Lucid exposes six generic, target-defined IJON operations. Instrumented guest
code places the operation in R8, a tag in R9, a value in R10, and executes
xchg r11w, r11w. The self-exchange remains an ordinary NOP outside Lucid's
patched Bochs. For scalar operations, the instruction RIP identifies the
instrumentation site:
0— SET records a previously unseen(site, tag, value)tuple.1— MAX records a new maximum value for(site, tag).2— INC records a new per-input execution-count maximum for(site, tag).3— STATE records a previously unseen terminal value for(site, tag). Intermediate values are deliberately ignored so feedback represents a discrete end-state difference rather than every prefix used to reach it.4— EVENT records a previously unseen adjacent value transition for(site, tag). Repeated identical events are ignored; this rewards semantic ordering differences without hashing an ever-growing event prefix.5— TEMPORAL associates a logical site ID (tag) with a per-input object ID (value). Bochs attaches the vCPU and the event's position in a machine-wide instruction timeline before reporting the events to Lucid.
Temporal events are paired with the immediately preceding event for the same object during that fuzzcase. Same-vCPU events update the object's history but do not earn temporal novelty. A transition between vCPUs is canonicalized as one site pair with independent direction and distance state, producing one or more of these feedback reasons:
- TEMPORAL — the first observation of a directed cross-vCPU site pair.
- ORDER_FLIP — the first observation of that pair in the opposite order.
- DISTANCE — the first observation of a distance bucket for that direction.
- PROXIMITY — a new nearest-distance frontier for that direction.
- SEPARATION — a new farthest-distance frontier for that direction.
Instruction distances one through 64 have exact buckets; larger distances use power-of-two buckets. Thus close race windows retain single-instruction resolution while widely separated events do not create one corpus distinction per instruction. Initial temporal discovery initializes both frontiers but does not count as a proximity or separation improvement. One execution may report several IJON reasons at once.
IJON and ordinary edge coverage are tracked independently. Lucid passes the
complete InputFeedback and its suggested CorpusInputType to the mutator,
which may retain, reclassify, or decline the input and may use frontier feedback
for its own scheduling policy. New edge coverage is always forced into the
permanent corpus. By default, IJON findings are permanent; campaign mutators can
instead keep high-volume feedback such as distance-only discoveries in the
bounded, fuzzer-local private corpus. Any retained input is eligible for
Redqueen when Redqueen is enabled.
Permanent IJON inputs are written and shared with an empty .ijon sidecar so
peers can recognize their provenance. The sidecar is currently a marker, not a
serialization of the semantic or temporal maps; those novelty histories remain
in worker memory and are not yet reconstructed by a resumed process. All IJON
bookkeeping remains target-agnostic: only the instrumented target assigns
meaning to site IDs, tags, values, and object identities.
People who have had a hand in the project one way or another thus far:
- Brandon Falk
- WorksButNotTested
- epi
- Kharos
- netspooky
- richinseattle
- eqv
- Axel Souchet
- domenuk
- Addison
- thc
- alxndr
Lucid is licensed under the MIT License
- Bochs Patches: Licensed under the GNU Lesser General Public License v2.1 (LGPL-2.1) due to Bochs’ licensing
- musl Patches: Licensed under the MIT License due to musl’s licensing
You must comply with the license terms of Bochs and musl when applying these patches. Lucid does not redistribute either project; please obtain original source code from:
I added a copyright claim for my contributions included in the patches as I'm under the impression this is best practice. Please let me know if I'm mistaken. Thank you!
diff -x 'Makefile' -x'bochs' -x '*.txt' -x 'bochs-dlx' -x '*.plist' -x'*.nsi' -x'bxhub' -x'bximage' -x'*.o' -x'bxversion.h' -x'*.rc' -x'config.h' -x'*.log' -x'*.status' -x'*.a' -x'libtool' -x'ltdlconf.h' -x'*.conf' -ruN
diff -x'*.o' -x'*.a' -x'config.mak' -x'*.so' -x'*.specs' -x'alltypes.h' -x'syscall.h' -x'*.lo' -x'version.h' -x'musl-gcc' -ruN
commit a9d2e8f41990c05db4a1a2f52383fc7ae51d819b (HEAD -> master, origin/master, origin/HEAD)
Author: Volker Ruppert <Volker.Ruppert@t-online.de>
Date: Wed Mar 20 18:19:40 2024 +0100
Some more work on the MSVC workspace files.
Don't generate debug information for release builds (plugin DLLs).
commit 26bb55104559325b5e840911742220268f556d7a (HEAD -> master, origin/master, origin/HEAD)
Author: Viktor Szakats <commit@vsz.me>
Date: Wed Aug 30 08:57:42 2023 +0000
use HTTPS when retrieving code from the internet