Stream: git-wasmtime

Topic: wasmtime / issue #14103 Fuzzing coverage gap: generative ...


view this post on Zulip Wasmtime GitHub notifications bot (Aug 10 2026 at 15:08):

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 no wasi* crate is among them. Grepping fuzz/fuzz_targets/ and crates/fuzzing/src/ for wasi also returns nothing.

Would a generative, stateful fuzz target for per-preopen FsPerms enforcement 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 rights spec-level, per-fd wasi-testsuite — fixed conformance cases (e.g. truncation_rights.rs)
wasmtime FsPerms embedder-level, per-preopen none found
cap-std containment per-Dir handle cap-std's fuzz_targets/cap-primitives.rs, with cfg(racy_asserts) checks

The three recent WASI advisories all sit in that middle layer:

Advisory Fix Cause
GHSA-2r75-cxrj-cmph (High) dc740c909 TRUNCATE didn't set open_mode \|= OpenMode::WRITE, so the perms check saw a read-only open
GHSA-4ch3-9j33-3pmj (Moderate) 2dc3f443d link_at/rename_at checked directory mutability but not permission parity
GHSA-3p27-qvp9-27qf (Low) f069f125c WASIp1 fd_renumber descriptor-table handling

In each the permission gate runs in crates/wasi/src/filesystem.rs before cap_primitives is called, so cap-std's fuzzer isn't positioned to catch them — and FsPerms has 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:

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-testsuite and crates/wasi/tests/. If it can't detect the three bugs already known, it isn't worth reviewing.

Alternatives

Extend the hand-written tests instead. FsPerms has 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 the link/rename issue 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 — FsPerms doesn'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.md so the next person doesn't retrace it.


Happy to take this to an RFC instead if that's a better fit.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 11 2026 at 15:04):

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 wasmtime crate 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 in wasmtime-wasi itself 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-wasi layer 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!

view this post on Zulip Wasmtime GitHub notifications bot (Aug 11 2026 at 15:04):

alexcrichton added the wasi:impl label to Issue #14103.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 11 2026 at 15:04):

alexcrichton added the fuzzing label to Issue #14103.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 11 2026 at 17:32):

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:

Any 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

view this post on Zulip Wasmtime GitHub notifications bot (Aug 11 2026 at 18:48):

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.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 11 2026 at 18:51):

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?

view this post on Zulip Wasmtime GitHub notifications bot (Aug 11 2026 at 18:54):

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?

view this post on Zulip Wasmtime GitHub notifications bot (Aug 11 2026 at 19:04):

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.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 12 2026 at 13:05):

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_how for openat2 rather than classifying on the syscall number. Carry the legacy non-at forms — cap-primitives uses *at throughout, so that was my detector being incomplete rather than a reachable escape. And get follow semantics per-syscall: unlinkat removes a directory entry and doesn't follow the final component, so resolving with realpath() 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 an O_CREAT target doesn't exist yet.

GHSA-3p27: sampled /proc/<pid>/fd externally at two markers and compared the descriptors resolving into the preopen tree, with no wasmtime instrumentation. With the fd_close removed from fd_renumber (p1.rs:1875) the descriptor that to previously 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 — --dir is hardcoded to FsPerms::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.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 12 2026 at 13:25):

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_how for openat2 rather than classifying on the syscall number. Carry the legacy non-at forms — cap-primitives uses *at throughout, so that was my detector being incomplete rather than a reachable escape. And get follow semantics per-syscall: unlinkat removes a directory entry and doesn't follow the final component, so resolving with realpath() 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 an O_CREAT target doesn't exist yet.

GHSA-3p27: sampled /proc/<pid>/fd externally at two markers and compared the descriptors resolving into the preopen tree, with no wasmtime instrumentation. With the fd_close removed from fd_renumber (p1.rs:1875) the descriptor that to previously 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 — --dir is hardcoded to FsPerms::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.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 13 2026 at 13:50):

4ktLuffy commented on issue #14103:

wasi-fsperms-fuzzer.zip

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.md first; DESIGN.md for why it is built this way.

./run.sh /path/to/your/wasmtime

Builds the host embedding against your checkout and fuzzes WASIp1 filesystem
operations against a read-only preopen. Your tree is mounted read-only for
campaign and replay, 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 a481961 and running selftest 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 into create_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.sh then 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:

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