4ktLuffy opened issue #14103:
Feature
WASI doesn't appear to be exercised by any fuzz target. The OSS-Fuzz
crates/coverage report for 2026-08-09 lists 15 instrumented crates —cache,cli-flags,component-util,core,cranelift,environ,fiber,fuzzing,jit-debug,jit-icache-coherence,test-util,unwinder,wasmtime,wast,winch— and nowasi*crate is among them. Greppingfuzz/fuzz_targets/andcrates/fuzzing/src/forwasialso returns nothing.Would a generative, stateful fuzz target for per-preopen
FsPermsenforcement be useful? I'd rather ask than build, partly because there may be good reasons this hasn't been done that aren't visible from outside.Benefit
Filesystem permissions look like they're enforced at three levels, and generative coverage seems to exist for the outer two but not the middle one:
Layer Mechanism Generative coverage WASIp1 rightsspec-level, per-fd wasi-testsuite— fixed conformance cases (e.g.truncation_rights.rs)wasmtime FsPermsembedder-level, per-preopen none found cap-std containment per- Dirhandlecap-std's fuzz_targets/cap-primitives.rs, withcfg(racy_asserts)checksThe three recent WASI advisories all sit in that middle layer:
Advisory Fix Cause GHSA-2r75-cxrj-cmph (High) dc740c909TRUNCATEdidn't setopen_mode \|= OpenMode::WRITE, so the perms check saw a read-only openGHSA-4ch3-9j33-3pmj (Moderate) 2dc3f443dlink_at/rename_atchecked directory mutability but not permission parityGHSA-3p27-qvp9-27qf (Low) f069f125cWASIp1 fd_renumberdescriptor-table handlingIn each the permission gate runs in
crates/wasi/src/filesystem.rsbeforecap_primitivesis called, so cap-std's fuzzer isn't positioned to catch them — andFsPermshas no cap-std analogue to test against. The regression tests added with those fixes (p{1,2,3}_file_truncation_readonly.rs,p{1,2,3}_file_{hardlink,rename}_across_perms.rs) are targeted cases rather than generated sequences.Implementation
Sketch, open to redirection:
- Two or more preopens with differing
FsPerms— thelink/renamecase needs at least two.- Random sequences of filesystem operations across them, spanning the preopen boundary.
- Oracle: with a
ReadOnlypreopen, snapshot the tree before and after and assert it's unchanged. That follows the documented contract onFsPermsdirectly and needs no model of individual operation semantics — it wouldn't need to knowopen_at(TRUNCATE)should fail, only that a file got shorter.atimeexcluded, since reading metadata is permitted.- A second oracle asserting host file descriptors return to baseline would cover the
fd_renumberclass.- p1, p2 and p3 separately, since the regression tests suggest fixes don't automatically carry across surfaces.
Before proposing any of it as a PR I'd calibrate: check out the commit before each of the three fixes and confirm the oracle fires, then confirm it's silent across
wasi-testsuiteandcrates/wasi/tests/. If it can't detect the three bugs already known, it isn't worth reviewing.Alternatives
Extend the hand-written tests instead.
FsPermshas two states, so the space is arguably small enough to enumerate — which is roughly what the current regression tests do. My hesitation is that the real space is operations × 2 permission states × 2+ preopens × path shapes × symlink/hard-link states × 3 API surfaces, and thelink/renameissue was a relational property across two guest-chosen descriptors. But it's a fair objection and I'd rather hear it now than after writing the harness.Fuzz at the cap-std layer. Doesn't reach it —
FsPermsdoesn't exist there.Do nothing. Entirely possible this is deliberate; filesystem fuzzing is slow and awkward under OSS-Fuzz. If so, knowing that is worth the issue, and it may be worth a line in
fuzz/README.mdso the next person doesn't retrace it.
Happy to take this to an RFC instead if that's a better fit.
alexcrichton commented on issue #14103:
We generally try to avoid I/O in the fuzzers because it can greatly slow down execution time, and historically as you've found we generally haven't been too proactive about fuzzing WASI implementations. We've mostly been relying on the foundations of the
wasmtimecrate to more-or-less avoid the need to fuzz things. Nevertheless as you point out I/O is the whole purpose of this fuzzer, it's relatively clearly warranted, and it's clear that the logic inwasmtime-wasiitself is nontrivial enough to justify its own fuzzer. In that sense I'd agree that it'd be great to have a fuzzer in Wasmtime!Your constraints/implementation here all sound reasonable to me. One additional thing I might throw out there though would be to use OS-level primitives where possible. For example we may want to have a tmpfs on Linux to ensure everything stays in RAM and avoids the disk. There might be something interesting to do with read-only filesystems as well, but what we sort of want is to abort-the-process on an attempt to write rather than generate an error since the writability should be enforced at the
wasmtime-wasilayer rather than the OS layer.Regardless though we also don't have to necessarily run this on OSS-Fuzz immediately. We've always got the option of running it locally for awhile and periodically and such. If you're willing to work on this that'd be much appreciated!
alexcrichton added the wasi:impl label to Issue #14103.
alexcrichton added the fuzzing label to Issue #14103.
4ktLuffy commented on issue #14103:
Yessss, happy to take this on thanks for the detail
Checking I've read the read only point right: a write reaching the OS at all means wasmtime-wasi has already failed, so it should abort rather than return an error the guest can't distinguish from a correct denial. That's a better signal than the before/after tree diff I described, and it points at the bug directly rather than requiring you to work backwards from a modified file.
Two options for the mechanism, no strong view between them:
- seccomp
SECCOMP_RET_KILL_PROCESSon writes to the read-only preopen's fds — real enforcement, Linux-only- an in-process abort at the
cap_primitivesboundary — portable, but it's the same layer under test, so a bug there is a blind spotAny preference? Otherwise I'll start with tmpfs plus the in-process abort to get something running, and add seccomp after.
Building locally on Linux first and ignoring OSS-Fuzz for now. I'll do the calibration described above before anything else, check it fires on the three advisories at their pre-fix commits so there's evidence it detects known bugs before it's worth your review time
pchickey commented on issue #14103:
Thanks for offering to take on this work. Alex has outlined all of the difficult bits. Even if this isn't continuously fuzzed, I would still be grateful if there was a fuzzer I could set to work on a VM for a few days when there are significant changes to wasmtime-wasi.
I'd prefer to see seccomp used to detect out-of-spec behavior to cap_primitives: cap_primitives is still part of our trusted computing base our project is responsible for and the kernel is not, and it would be nice if the approach keeps working if cap_primitives changes shape as we work on the design of the filesystem interfaces and implementation in the future.
Enumerating the full set of disallowed behaviors is one of the hard parts here, but thats also something we can do iteratively and detail in the docs.
pchickey edited a comment on issue #14103:
Thanks for offering to take on this work. Alex has outlined all of the difficult bits. Even if this isn't continuously fuzzed, I would still be grateful if there was a fuzzer I could set to work on a VM for a few days when there are significant changes to wasmtime-wasi.
I'd prefer to see seccomp used to detect out-of-spec behavior to cap_primitives: cap_primitives is still part of our trusted computing base our project is responsible for and the kernel is not, and it would be nice if the approach keeps working if cap_primitives changes shape as we work on the design of the filesystem interfaces and implementation in the future.
Enumerating the full set of disallowed behaviors is one of the hard parts here, but thats also something we can do iteratively and detail in the docs. GHSA-4ch3-9j33-3pmj should longer possible to express with the simplified FsPerms system, but my guess is if you carefully back out the fix, the other two should be findable?
pchickey edited a comment on issue #14103:
Thanks for offering to take on this work. Alex has outlined all of the difficult bits. Even if this isn't continuously fuzzed, I would still be grateful if there was a fuzzer I could set to work on a VM for a few days when there are significant changes to wasmtime-wasi.
I'd prefer to see seccomp used to detect out-of-spec behavior to cap_primitives: cap_primitives is still part of our trusted computing base our project is responsible for and the kernel is not, and it would be nice if the approach keeps working if cap_primitives changes shape as we work on the design of the filesystem interfaces and implementation in the future.
Enumerating the full set of disallowed behaviors is one of the hard parts here, but thats also something we can do iteratively and detail in the docs. GHSA-4ch3-9j33-3pmj should longer possible to express with the simplified FsPerms system, but my guess is if you carefully back out their fix, the other two should be findable?
4ktLuffy commented on issue #14103:
Makes sense, seccomp it is. The TCB argument is the convincing one, instrumenting at cap_primitives would have excluded cap_primitives itself from what's being tested.
Agreed on GHSA-4ch3 too. #14010 collapsed DirPerms × FilePerms into a single bit, so the differentiated-file-perms shape that bug needed can't be constructed any more. For the other two I'll back the fixes out on main rather than checking out the pre-fix commits, which avoids fighting the old API.
Will report back when there's something to look at.
4ktLuffy commented on issue #14103:
Calibration results — both reproducible CVEs are detectable by backing out their fixes on current main (a481961), x86_64 Linux 6.8.
GHSA-2r75: removing
open_mode |= OpenMode::WRITE(filesystem.rs:959) makes the detector fire on the truncating open, with the file hash unchanged, so it fires before the kernel truncates. Silent with the fix in place, and on a plain read-only open.Enumerating the disallowed behaviours really is the hard part, as you said, and the shape of it surprised me. TRUNCATE mutates via the open itself, so watching the write family sees nothing: the unit isn't "writes", it's any syscall that can mutate, and the mutating argument is sometimes a flag rather than the operation. Three corrections followed. Decode
struct open_howfor openat2 rather than classifying on the syscall number. Carry the legacy non-atforms — cap-primitives uses*atthroughout, so that was my detector being incomplete rather than a reachable escape. And get follow semantics per-syscall:unlinkatremoves a directory entry and doesn't follow the final component, so resolving withrealpath()both missed an unlink inside the guarded tree and fired on one outside it. Containment is now canonicalised-parent-plus-unfollowed-basename for namespace operations, full resolution for the ones that follow, and a hand-walked symlink chain when anO_CREATtarget doesn't exist yet.GHSA-3p27: sampled
/proc/<pid>/fdexternally at two markers and compared the descriptors resolving into the preopen tree, with no wasmtime instrumentation. With thefd_closeremoved fromfd_renumber(p1.rs:1875) the descriptor thattopreviously referred to is never released; with it, released. One entry per descriptor, so two handles on one path stay distinguishable — checked with same-file as well as distinct-file cases. Concurrent runtime fd activity during a sample isn't calibrated yet.GHSA-4ch3 not attempted, per your note that #14010 makes it inexpressible.
Two things shape v1. The CLI can't construct the case under test —
--diris hardcoded toFsPerms::ReadWrite(common.rs:353) — so the target has to be an embedding. And a blind spot I went looking for and confirmed: with a ReadOnly and a ReadWrite preopen, I injected a misroute so an operation intended for the read-only preopen resolved against the writable one. The guest was allowed to create and write a file it shouldn't have and the detector recorded nothing, because every syscall involved is legal at the kernel layer and the intended preopen isn't inferable from the emitted syscall alone. So v1 detects "forbidden syscall through a protected descriptor", not "operation routed onto the wrong capability."Smoke controls only so far: a hand-written workload against the read-only preopen runs silent, with a liveness check confirming the same binary still fires. Nothing has run longer than seconds, and the harness currently needs a privileged container, so neither multi-day false-alarm rate nor handoff readiness is established.
4ktLuffy edited a comment on issue #14103:
Calibration results — both reproducible CVEs are detectable by backing out their fixes on current main (a481961), x86_64 Linux 6.8.
GHSA-2r75: removing
open_mode |= OpenMode::WRITE(filesystem.rs:959) makes the detector fire on the truncating open, with the file hash unchanged, so it fires before the kernel truncates. Silent with the fix in place, and on a plain read-only open.Enumerating the disallowed behaviours really is the hard part, as you said, and the shape of it surprised me. TRUNCATE mutates via the open itself, so watching the write family sees nothing: the unit isn't "writes", it's any syscall that can mutate, and the mutating argument is sometimes a flag rather than the operation. Three corrections followed. Decode
struct open_howfor openat2 rather than classifying on the syscall number. Carry the legacy non-atforms — cap-primitives uses*atthroughout, so that was my detector being incomplete rather than a reachable escape. And get follow semantics per-syscall:unlinkatremoves a directory entry and doesn't follow the final component, so resolving withrealpath()both missed an unlink inside the guarded tree and fired on one outside it. Containment is now canonicalised-parent-plus-unfollowed-basename for namespace operations, full resolution for the ones that follow, and a hand-walked symlink chain when anO_CREATtarget doesn't exist yet.GHSA-3p27: sampled
/proc/<pid>/fdexternally at two markers and compared the descriptors resolving into the preopen tree, with no wasmtime instrumentation. With thefd_closeremoved fromfd_renumber(p1.rs:1875) the descriptor thattopreviously referred to is never released; with it, released. One entry per descriptor, so two handles on one path stay distinguishable — checked with same-file as well as distinct-file cases. Concurrent runtime fd activity during a sample isn't calibrated yet.GHSA-4ch3 not attempted, per your note that #14010 makes it inexpressible.
Two things shape v1. The CLI can't construct the case under test —
--diris hardcoded toFsPerms::ReadWrite(common.rs:353) — so the target has to be an embedding. And a blind spot I went looking for and confirmed: with a ReadOnly and a ReadWrite preopen, I injected a misroute so an operation intended for the read-only preopen resolved against the writable one. The guest was allowed to create and write a file it shouldn't have and the detector recorded nothing, because every syscall involved is legal at the kernel layer and the intended preopen isn't inferable from the emitted syscall alone. So v1 detects "forbidden syscall through a protected descriptor", not "operation routed onto the wrong capability."Smoke controls only so far: a hand-written workload against the read-only preopen runs silent, with a liveness check confirming the same binary still fires. Nothing has run longer than seconds, and the harness currently needs a privileged container, so neither multi-day false-alarm rate nor handoff readiness is established and
Working on the generator now, I'll follow up when there's something you could actually set running on a VM. If there's an operation set or a shape you'd rather see first, say so and I'll build that instead.
4ktLuffy commented on issue #14103:
Follow-up to the calibration results: here's the fuzzer itself, attached.
wasi-fsperms-fuzzer.zip— 45 files, Apache-2.0 WITH LLVM-exception, same terms
as wasmtime.README.mdfirst;DESIGN.mdfor why it is built this way../run.sh /path/to/your/wasmtimeBuilds the host embedding against your checkout and fuzzes WASIp1 filesystem
operations against a read-only preopen. Your tree is mounted read-only for
campaignandreplay, so it cannot be modified.The oracle sits below wasmtime rather than inside it: a
SECCOMP_RET_USER_NOTIF
supervisor holds each mutating syscall stopped before the kernel acts, resolves
the target through/proc/<pid>/fd, and fires if it lands inside the guarded
preopen — regardless of what WASI returned. On a firing the tree is verifiably
unchanged, because the tracee is killed while stopped.Every operation runs twice, against a read-only and a read-write preopen. A
denial only counts as evidence if the identical operation demonstrably succeeds
where it is permitted, so the verdict reports witnessed cells rather than
execution count:VERDICT: no violation observed in 40 executions. SCOPE: write-permission enforcement, WASIp1, in the 65 witnessed cell(s) of 148 reached. This is NOT evidence about read confinement, WASIp2, or the 83 unwitnessed cell(s).Calibration, reproducible by checking out
a481961and runningselftest MODE=full— it refuses to run against any other revision, because it patches
fixed line numbers and would otherwise patch the wrong ones. GHSA-2r75 and
GHSA-3p27 are both detected when their fixes are backed out and silent when
restored, plus a fresh bug injected intocreate_directory_at— a mechanism it
was never tuned for — with a four-way test that requires the injected bug to
demonstrably work when the detector is absent.A firing is an event, not a finding, until
replay.sh <seed>reproduces it.
minimize.shthen reduces it to the smallest program that still fires (16
operations to 1 on the GHSA-2r75 reproduction).Limits worth knowing before you spend time on it:
Needs
--privileged(seccomp user-notification listener + tmpfs). Linux
x86_64 only. Intended for a disposable VM.~1000 lines of C in a Rust project, with no maintenance story.
WASIp1 and write permission only. Read confinement and WASIp2 are untested,
and cross-preopen misrouting is a verified blind spot of the syscall oracle —
the A/B errno oracle is what covers it.I have not run it for days myself. Disk growth, descriptor leaks and the
heartbeat are measured, but the longest campaign I have run is short, so the
unattended behaviour is inferred rather than observed.Happy to change the shape of it — in-tree, external, or something narrower — if
that is more useful than what I guessed at, and Thank you for your time!!
Last updated: Aug 30 2026 at 09:07 UTC