Fuzz the syscall emulation layer; fix 10 guest-reachable bugs - #90
Closed
perbu wants to merge 12 commits into
Closed
Fuzz the syscall emulation layer; fix 10 guest-reachable bugs#90perbu wants to merge 12 commits into
perbu wants to merge 12 commits into
Conversation
Drives Machine::system_call() directly on a CoW fork, so no VM entry is needed and the ~112 handlers in linux/system_calls.cpp run under ASan+UBSan at ~8k exec/s. Input is a record stream: POKE writes fuzzer-chosen bytes into guest memory, CALL sets the six argument registers and dispatches a syscall number. Both are needed -- most handlers read structs (iovec, sockaddr, msghdr, pollfd, timespec, paths) out of the guest, so the fuzzer has to control the pointer and the bytes it points at. Pointer arguments are drawn from a table of page boundaries, end-of-mapping addresses, unmapped holes and non-canonical values; uniformly random pointers just bounce off copy_from_guest() and coverage stalls. MachineException escaping a handler is expected and ignored -- that is how the layer rejects bad input. Sanitizer reports are findings. Any other std::exception is reported once per unique type+message, since an embedder catching MachineException would let it through. Sandboxed: guest paths are rewritten to two files in a private tmpdir, connect/bind/listen/accept are denied, guest-created fds are forced O_NONBLOCK, and clock_nanosleep's timespec is value-clamped so its marshalling is still covered without sleeping. blocked_syscall() refuses a handful that would block a worker or escape the sandbox; none of those has a handler today. Also makes -fuse-ld=lld conditional, and ignores .build-* dirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openat2 rejects a non-zero open_how.mode unless the open can create a file. The write path passed S_IWUSR|S_IRUSR unconditionally, so O_RDWR or O_WRONLY on a path that already exists always failed with EINVAL -- guests could never open an existing file for writing. Found while writing the regression test for the zero-length I/O fix, not by the fuzzer; there is no oracle for "returns EINVAL when it shouldn't". Adds tests/unit/syscalls.cpp for this series. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
read/write/pread64/pwrite64/mmap/sendmsg/recvmsg/sendto/recvfrom/sendmmsg gather the guest range into a vector of host buffers. A zero-length request gathers nothing, and the handlers then used buffers[0] or &buffers[0] on the empty vector. write() actually loaded through it, so write(fd, p, 0) -- a legal no-op -- null-dereferenced and killed the VMM. The rest bound a reference to a null pointer, which is UB even though the syscalls tolerate an iovec count of zero. Use buffers.data() throughout, and answer a zero-length write directly (the fd is still validated first, since write(badfd, _, 0) must fail). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The handler stored the host getdents64() return value straight into sysret(), which is __u64, and then tested it with `> 0`. On failure that turned -1 into ~2^64 and handed it to copy_to_guest() as a length, so the host stack was streamed into guest memory page after page until the copy walked off the end of the guest address space. getdents64 on any regular fd returns ENOTDIR, which is enough to trigger it: an unprivileged guest gets a host stack disclosure and then a crash. Also report -errno on failure, matching the rest of the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
msg_namelen comes from the guest's own msghdr and was used unchecked as the length of a copy into a 128-byte sockaddr_storage on the handler's stack. Both the length and the bytes are guest-controlled, so this was a straight stack smash of the VMM -- it segfaults a plain release build. Reject anything larger than the destination, as connect()/bind()/sendto() already do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vlen was only checked for being positive before being multiplied by sizeof(mmsghdr) and used as the length of a copy into a 1024-entry (64KB) stack array. A large vlen overflowed the VMM's stack with guest-supplied bytes; it segfaults a plain release build. iovlen was already bounded a few lines below; vlen was not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
memzero() walks a range one page-table lookup per 4K and deliberately ignores pages that are not present, so an out-of-range length is not an error -- it is an unbounded walk. madvise(MADV_DONTNEED) hands the guest's length straight in, so a single syscall pins the host thread: measured ~9M pages/s, i.e. minutes for a 16TB length and years for 2^64. It runs inside the syscall handler, so the execution timeout never fires. Clamp to memory.remote_end (which already accounts for extra virtual remappings) plus a connected remote's range, so address-space merging still works. Pages past the end can never be dirty, so nothing is lost: mmap_allocate() hands out virtual addresses below max_address -- MMAP_PHYS_BASE is the physical base of the mmap arena, not a virtual one -- so mmap'd memory stays inside the clamp and is still zeroed. Tests cover a full range, a partial (middle-pages-only) discard, and the has_remote() branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
buflen (up to 16) was used as the length of a copy straight out of the 8-byte literal "tinykvm", so the guest got up to 8 bytes of whatever .rodata followed it. Copy out of a zero-padded 16-byte buffer instead, which also matches Linux always writing 16 bytes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timerfd_create, eventfd2 and inotify_init1 passed a failed host fd straight to FileDescriptors::manage(), which throws on a negative fd. The `if (vfd < 0)` below each call was therefore dead code, and a std::runtime_error escaped the handler instead -- past every embedder that catches MachineException. timerfd_create's clockid and inotify_init1's flags are guest-controlled, so any guest can trigger it with a single call. epoll_create1 in the same file already had the right shape; follow it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ts.tv_sec * 1000 was computed on a raw guest value: signed overflow UB, and the int() truncation then produced an arbitrary timeout. Reject what Linux rejects (negative tv_sec/tv_nsec, tv_nsec >= 1e9) and compute in a range where the multiply cannot overflow, saturating at INT32_MAX ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guest chooses the address, so it has arbitrary alignment; binding a socklen_t reference to it via writable_memarray() is UB and the accesses are unaligned. Benign on x86-64, but it breaks under stricter codegen and on strict-alignment targets. Copy in and out instead. poll/ppoll still do the same thing with writable_memarray<pollfd>; fixing that changes guest-visible semantics, so it is left alone for now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the ten fixes in this series, the five things deliberately left alone (SIGPIPE, the std::runtime_error taxonomy, clock_nanosleep, remote madvise isolation, callbacks returning without setting sysret), and the notes worth knowing: readv has no handler, poll/ppoll pointer alignment, and the unsoaked corners. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fwsGonzo
added a commit
that referenced
this pull request
Jul 31, 2026
Fuzz the syscall emulation layer; fix 10 guest-reachable bugs Merged without the final docs commit (5940ea6).
fwsGonzo
approved these changes
Jul 31, 2026
Member
|
Thanks! Merged manually. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fuzzes the host-side Linux syscall emulation
fuzz/syscall_fuzz.cppdrivesMachine::system_call()directly on a CoW fork: no VM entry, ~8k exec/s under ASan+UBSan. Inputs are record streams —POKEwrites fuzzer-chosen bytes into guest memory,CALLsets the six argument registers and dispatches. Both matter, since most handlers read structs out of the guest. Pointer args come from a table of page boundaries, end-of-mapping addresses, unmapped holes and non-canonical values; uniformly random pointers just bounce offcopy_from_guest().MachineExceptionescaping a handler is treated as correct. Sanitizer reports are findings. Otherstd::exceptiontypes are logged once per unique type+message.~125M executions on the default table, ~47M with
TINYKVM_FUZZ_UNSAFE=1, 21k edges. Ten bugs, one commit each, all reachable from an unprivileged guest with legal syscalls. Regression tests intests/unit/syscalls.cpp, each verified to fail before its fix.Memory safety
sendmsg—msg_namelenunchecked into a 128-byte stacksockaddr_storage. Guest-controlled length and bytes; segfaults a plain release build.sendmmsg—vlenonly checked> 0before use as a copy length into a 64KB stack array. Same, segfaults release.getdents64— host-1stored in__u64 sysret()then tested> 0, so it became a ~2^64copy_to_guestlength. Host stack streamed into guest memory, then a wild copy.getdents64on any regular fd (ENOTDIR) triggers it.buffers[0]on an empty gather list.write(fd, p, 0), a legal no-op, null-dereferences and kills the VMM. UB in nine other handlers.prctl(PR_GET_NAME)— up to 16 bytes read out of the 8-byte literal"tinykvm"; adjacent.rodatato the guest.Availability
madvise(MADV_DONTNEED)→memzero()— guest length walked at one page-table lookup per 4K, missing pages skipped, unbounded. ~9M pages/s, so 2^64 pins the host thread for years. Inside the syscall handler, so the execution timeout never fires.Correctness / UB
ppoll—tv_sec * 1000on a raw guest value: signed-overflow UB, then truncation to an arbitrary timeout.recvmsg/recvfrom—socklen_t&bound to an arbitrarily-aligned guest pointer.timerfd_create/eventfd2/inotify_init1— failed host fd passed tomanage(), which throws on a negative fd, so theif (vfd < 0)below each was dead code and astd::runtime_errorescaped.clockidandflagsare guest-controlled.epoll_create1already had the right shape.openat— non-zeroopen_how.modewithoutO_CREAT;openat2requires 0, so guests could never open an existing file for writing. Found writing a test, not by the fuzzer.Questions for @fwsGonzo
Five things I deliberately did not touch, each changes behaviour outside the library's own scope. Details and repros in
docs/syscall-fuzzing.md.SIGPIPE kills the VMM.
pipe2(fds); close(fds[0]); write(fds[1], buf, 16)— three legal syscalls, process dies.MSG_NOSIGNALis passed on the sendmsg/sendto paths, so the hazard is known, but plainwrite/writev/pwritev64have no equivalent and pipes cannot be protected that way. Options:signal(SIGPIPE, SIG_IGN)inMachine::init()(what most servers want, but imposes a side effect on the host — thesrc/CLIs would stop dying on EPIPE intohead);send(..., MSG_NOSIGNAL)for sockets (needsFileDescriptorsto keep theis_socketflag it currently discards, and still leaves pipes); or block SIGPIPE per write (a syscall pair per I/O). Which?Guest-triggerable
std::runtime_error. The contract is that guest misbehaviour arrives asMachineException, butfutex()with an unimplemented op (FUTEX_REQUEUEsuffices),manage()atmax_files, andtranslate_writable_vfd()on a read-only fd all throw barestd::runtime_error. Retyping toMachineExceptionmatches the rest of the layer — is anything relying on the current types?clock_nanosleep. Passes the guesttimespecstraight through, so a guest can pin a host thread indefinitely; same blind spot as themadvisehang. Unlike that one, bounding it changes guest-visible semantics. Cap it, make it a policy hook, or leave it to embedders? (The fuzzer value-clamps it so the marshalling still gets covered.)Remote-VM
madvise. Thememzeroclamp widens its bound to include a connected remote's range, preserving existing behaviour. But should a guest be able toMADV_DONTNEEDa storage VM's pages at all? I'd expect not, and narrowing it would be the isolation-correct change.Callbacks returning without
sysret().poll,ppoll,epoll_waitandaccept4allreturnearly when their callback declines, leaving the return register untouched — at syscall entry that is the syscall number, so a declinedaccept4looks like it returned fd 288. Fine if the embedder also pauses the guest, but it is an undocumented part of the callback contract.Notes
readv(19) has no handler at all and returns-ENOSYS, whilewritevis implemented. glibc usesreadvin some configurations.poll/ppollstill bindwritable_memarray<pollfd>to an arbitrarily-aligned guest pointer (same UB as therecvmsgfix). Fixing it means copying in/out or rejecting misaligned arrays with-EFAULT; the latter changes guest-visible semantics.test_resetandtest_tegridyfail onmastertoo, unrelated to this branch.🤖 Generated with Claude Code