Skip to content

Fuzz the syscall emulation layer; fix 10 guest-reachable bugs - #90

Closed
perbu wants to merge 12 commits into
masterfrom
syscall-fuzzing
Closed

Fuzz the syscall emulation layer; fix 10 guest-reachable bugs#90
perbu wants to merge 12 commits into
masterfrom
syscall-fuzzing

Conversation

@perbu

@perbu perbu commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Fuzzes the host-side Linux syscall emulation

fuzz/syscall_fuzz.cpp drives Machine::system_call() directly on a CoW fork: no VM entry, ~8k exec/s under ASan+UBSan. Inputs are record streams — POKE writes fuzzer-chosen bytes into guest memory, CALL sets 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 off copy_from_guest().

MachineException escaping a handler is treated as correct. Sanitizer reports are findings. Other std::exception types 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 in tests/unit/syscalls.cpp, each verified to fail before its fix.

Memory safety

  • sendmsgmsg_namelen unchecked into a 128-byte stack sockaddr_storage. Guest-controlled length and bytes; segfaults a plain release build.
  • sendmmsgvlen only checked > 0 before use as a copy length into a 64KB stack array. Same, segfaults release.
  • getdents64 — host -1 stored in __u64 sysret() then tested > 0, so it became a ~2^64 copy_to_guest length. Host stack streamed into guest memory, then a wild copy. getdents64 on any regular fd (ENOTDIR) triggers it.
  • zero-length I/Obuffers[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 .rodata to 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

  • ppolltv_sec * 1000 on a raw guest value: signed-overflow UB, then truncation to an arbitrary timeout.
  • recvmsg/recvfromsocklen_t& bound to an arbitrarily-aligned guest pointer.
  • timerfd_create/eventfd2/inotify_init1 — failed host fd passed to manage(), which throws on a negative fd, so the if (vfd < 0) below each was dead code and a std::runtime_error escaped. clockid and flags are guest-controlled. epoll_create1 already had the right shape.
  • openat — non-zero open_how.mode without O_CREAT; openat2 requires 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.

  1. SIGPIPE kills the VMM. pipe2(fds); close(fds[0]); write(fds[1], buf, 16) — three legal syscalls, process dies. MSG_NOSIGNAL is passed on the sendmsg/sendto paths, so the hazard is known, but plain write/writev/pwritev64 have no equivalent and pipes cannot be protected that way. Options: signal(SIGPIPE, SIG_IGN) in Machine::init() (what most servers want, but imposes a side effect on the host — the src/ CLIs would stop dying on EPIPE into head); send(..., MSG_NOSIGNAL) for sockets (needs FileDescriptors to keep the is_socket flag it currently discards, and still leaves pipes); or block SIGPIPE per write (a syscall pair per I/O). Which?

  2. Guest-triggerable std::runtime_error. The contract is that guest misbehaviour arrives as MachineException, but futex() with an unimplemented op (FUTEX_REQUEUE suffices), manage() at max_files, and translate_writable_vfd() on a read-only fd all throw bare std::runtime_error. Retyping to MachineException matches the rest of the layer — is anything relying on the current types?

  3. clock_nanosleep. Passes the guest timespec straight through, so a guest can pin a host thread indefinitely; same blind spot as the madvise hang. 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.)

  4. Remote-VM madvise. The memzero clamp widens its bound to include a connected remote's range, preserving existing behaviour. But should a guest be able to MADV_DONTNEED a storage VM's pages at all? I'd expect not, and narrowing it would be the isolation-correct change.

  5. Callbacks returning without sysret(). poll, ppoll, epoll_wait and accept4 all return early when their callback declines, leaving the return register untouched — at syscall entry that is the syscall number, so a declined accept4 looks 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, while writev is implemented. glibc uses readv in some configurations.
  • poll/ppoll still bind writable_memarray<pollfd> to an arbitrarily-aligned guest pointer (same UB as the recvmsg fix). Fixing it means copying in/out or rejecting misaligned arrays with -EFAULT; the latter changes guest-visible semantics.
  • Harness only ever uses a plain fork. Remote-connected VMs are untouched — the obvious next extension.
  • ARM64 untested; the harness is arch-neutral but has only run on AMD64.

test_reset and test_tegridy fail on master too, unrelated to this branch.

🤖 Generated with Claude Code

perbu and others added 12 commits July 29, 2026 21:51
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>
@perbu
perbu requested a review from fwsGonzo July 29, 2026 20:00
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

Copy link
Copy Markdown
Member

Thanks! Merged manually.

@fwsGonzo fwsGonzo closed this Jul 31, 2026
@perbu
perbu deleted the syscall-fuzzing branch August 1, 2026 06:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants