fuzz: reach the poll/ppoll bodies, denial branches, and host fd leaks - #111
Open
perbu wants to merge 1 commit into
Open
fuzz: reach the poll/ppoll bodies, denial branches, and host fd leaks#111perbu wants to merge 1 commit into
perbu wants to merge 1 commit into
Conversation
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>
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.
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_callbackreturning 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'spollfdarray — never ran at all. This clamps the timeout instead, the same wayclock_nanosleepis already handled: poll's is a plain ms argument, ppoll's is a guest pointer to atimespec, and a null one is redirected to a private zeroedtimespeckept out of the address table. The bodies now run and still cannot block. This is where the fixed-256-entryarray::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
-EPERMand 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=1refuses 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/fdafter each input'sreset_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:
dup2(dup2 registers the stale fd instead of the dup() result, leaking a host fd per call #99) is one source, but not the only one — growth persists with dup2 blocked.pipe2/socketpair/epoll_create1/dupare worth investigating.Because of that it reports and re-baselines rather than aborting.
TINYKVM_FUZZ_FDLEAK_ABORT=1makes it fatal and should become the default insyscall_fuzzer.shonce the leaks are closed. Same story forTINYKVM_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 thanunset VAR.Verified: both modes complete a 4000-run campaign with no timeouts from the poll wrapper.
🤖 Generated with Claude Code