Skip to content

fuzz: reach the poll/ppoll bodies, denial branches, and host fd leaks - #111

Open
perbu wants to merge 1 commit into
masterfrom
fuzz-harness-gaps
Open

fuzz: reach the poll/ppoll bodies, denial branches, and host fd leaks#111
perbu wants to merge 1 commit into
masterfrom
fuzz-harness-gaps

Conversation

@perbu

@perbu perbu commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Three structural blind spots in the syscall fuzzer. Each one hid a confirmed bug from the week-long campaign — not because the fuzzer was unlucky, but because the code was unreachable or the symptom was invisible.

poll/ppoll bodies were never executed. The sandbox policy installed a poll_callback returning false to stop the handlers blocking on a forked VM. But that callback is consulted before the fd-translation loop, so both handler bodies — the part that parses the guest's pollfd array — never ran at all. This clamps the timeout instead, the same way clock_nanosleep is already handled: poll's is a plain ms argument, ppoll's is a guest pointer to a timespec, and a null one is redirected to a private zeroed timespec kept out of the address table. The bodies now run and still cannot block. This is where the fixed-256-entry array::at() overflow lived (#102).

Denial branches were unreachable. Contrary to what I initially assumed, the path callbacks are installed — but they rewrite every path into the sandbox directory and return true, so no policy denial ever happens. A handler that computes -EPERM and then runs the host call anyway is indistinguishable from a correct one under an always-approve policy, which is exactly #95. TINYKVM_FUZZ_DENY_PATHS=1 refuses everything instead.

Host fd leaks were invisible. They do not crash, and ASan/UBSan do not track fd counts, so an fd the emulation layer opens but never records shows up only as the VMM process slowly running out of descriptors. This counts /proc/self/fd after each input's reset_to() and reports growth past the baseline.

That last check reports on the current tree

fds grow monotonically, roughly linearly with inputs. Worth knowing before anyone turns it up to fatal:

Because of that it reports and re-baselines rather than aborting. TINYKVM_FUZZ_FDLEAK_ABORT=1 makes it fatal and should become the default in syscall_fuzzer.sh once the leaks are closed. Same story for TINYKVM_FUZZ_STRICT, which currently trips over the reachable futex throw (#101) — I did not make it the default for that reason, though the script now documents both.

Env flags now treat an empty value as unset, so a runner can default one on and still let the caller switch it off with VAR= rather than unset VAR.

Verified: both modes complete a 4000-run campaign with no timeouts from the poll wrapper.

🤖 Generated with Claude Code

Three structural blind spots in the syscall fuzzer, each of which hid a
confirmed bug from a week-long campaign.

poll/ppoll bodies were unreachable. The sandbox policy installed a
poll_callback returning false to stop the handlers blocking on a forked VM,
but that callback is consulted *before* the fd-translation loop, so both
handler bodies -- the part that parses the guest's pollfd array -- never
ran at all. Clamp the timeout instead, the same way clock_nanosleep is
already handled: poll's is a plain ms argument, ppoll's is a guest pointer
to a timespec, and a null one is redirected to a private zeroed timespec
kept out of the fuzzer's address table. The bodies now run and cannot
block. This is where the fixed-256-entry array::at() overflow lived (#102).

Denial branches were unreachable. The path callbacks are installed, but
they rewrite every path into the sandbox directory and return true, so no
policy denial ever happens. A handler that computes -EPERM and then runs
the host call anyway is indistinguishable from a correct one under an
always-approve policy -- which is exactly the statx bug (#95).
TINYKVM_FUZZ_DENY_PATHS=1 refuses everything instead. Worth alternating
with the default mode across runs.

Host fd leaks were invisible. They do not crash, and ASan and UBSan do not
track fd counts, so an fd the emulation layer opens but never records shows
up only as the VMM process slowly running out of descriptors. Count
/proc/self/fd after each input's reset_to() and report growth past the
baseline.

That check reports immediately on the current tree: fds grow monotonically,
roughly linearly with inputs. dup2 (#99) is one source but not the only one
-- the growth persists with dup2 blocked, and also in deny-paths mode where
no file can be opened at all, so pipe2/socketpair/epoll_create1/dup are
worth investigating too. Because of that it reports and re-baselines rather
than aborting; TINYKVM_FUZZ_FDLEAK_ABORT=1 makes it fatal, and should
become the default here once the leaks are closed. The same applies to
TINYKVM_FUZZ_STRICT, which trips over the reachable futex throw (#101).

Env flags now treat an empty value as unset, so a runner can default one on
and still let the caller switch it off with VAR= rather than unset VAR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant