You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
kilnd's WASI filesystem containment check is_within_sandbox (kiln-wasi/src/dispatcher.rs:2819)
is purely lexical — it rejects .. components and checks starts_with(base), but never resolves
symlinks. Every FS op then hits the real host filesystem via std::fs::* on the joined path, which follows symlinks. So a symlink that already exists inside the preopen (e.g. sandbox/escape -> ..)
lets a guest read/write/create/delete outside the sandbox: the requested path escape/secret.txt
has no .. component and starts_with(base), so the lexical check admits it, and the OS resolves the
symlink to a location outside the preopen.
This is a containment failure in the --wasi-fs sandbox. It is distinct from #392/#396: #392 was
the --wasi-fs no-op, and #396 added exactly this is_within_sandbox lexical check as the fix — the
symlink blind spot is a new gap in that fix. The reference WASI runtime (wasmtime, via
cap-std/openat2(RESOLVE_BENEATH)) blocks the identical layout.
Verified on kiln v0.4.1 (7a039320).
Root cause (source)
kiln-wasi/src/dispatcher.rs:
// :2819 — the ONLY containment gate; lexical, never touches the filesystemfnis_within_sandbox(base:&Path,full:&Path) -> bool{
!full.components().any(|c| matches!(c,Component::ParentDir))// rejects ".." only
&& full.starts_with(base)// lexical prefix}
Callers join the guest path and gate the real op on this check, then follow symlinks:
:741 full_path = base_path.join(&path); :747 if !is_within_sandbox(..) {..}; :2415 std::fs::read(&path)
No canonicalize, no read_link, no O_NOFOLLOW/openat2 anywhere in the open/read flow (the is_symlink() at :973/:2310 is a stat-type report and does not gate containment). The existing tests
(:2841-2847) cover .. and absolute-path escapes but not symlink traversal.
The lexical choice was deliberate (the doc comment cites #392: canonicalize fails on a
not-yet-created file, so it can't gate writes to new paths). The fix must resolve symlinks without
requiring the target to exist — see below.
Reproduction
(a) kiln's exact containment code admits the escape. Running kilnd's verbatimis_within_sandbox
plus the dispatcher's exact join → check → std::fs::read sequence, with a symlink pre-existing in
the preopen (sandbox/escape -> ../outside, outside/secret.txt = "TOP-SECRET-HOST-DATA"):
Read, write, create-dir, and unlink all share the same lexical gate → full containment failure,
not read-only. A guest can exfiltrate or corrupt host files outside the granted directory.
Precondition (stated honestly): the symlink must already exist in the preopen — kiln exposes no create-symlink-at WASI method, so the guest cannot plant it itself. This is common in real
deployments: extracted archives, shared/working directories, mounted volumes, /tmp, and OS
base-image symlinks. Write/delete outside the sandbox is a containment failure regardless of who
created the link.
Verification level (brutally honest)
CONFIRMED at source + host-logic level: kiln's verbatimis_within_sandbox admits the escape
and std::fs::read returns the outside file; wasmtime denies the identical layout.
PLAUSIBLE, not demonstrated end-to-end through a WASM guest under kilnd: kilnd rejects raw
components (RFC build(deps): bump wast from 228.0.0 to 229.0.0 #46, wants a meld-lowered core module) and the meld→kiln FS path did not surface a
file read in testing, so the full guest→dispatcher→escape chain was not executed. The vulnerable
function is exactly the one gating all FS ops, so the source-level confirmation is strong.
Suggested fix
Resolve symlinks during containment without requiring the target to exist: canonicalize the longest
existing ancestor of full_path (or read_link-walk each component) and verify the result still starts_with(base_canonical), or perform the open with openat2(RESOLVE_BENEATH) / O_NOFOLLOW per
component (the approach cap-std/wasmtime use). Add a symlink-escape test alongside the existing ../absolute-path tests at :2841.
Reported by the pulseengine-challenge harness (research-agent lead; source-confirmed and reproduced by
running kiln's verbatim containment code against a real symlink, contrasted with wasmtime).
Summary
kilnd's WASI filesystem containment check
is_within_sandbox(kiln-wasi/src/dispatcher.rs:2819)is purely lexical — it rejects
..components and checksstarts_with(base), but never resolvessymlinks. Every FS op then hits the real host filesystem via
std::fs::*on the joined path, whichfollows symlinks. So a symlink that already exists inside the preopen (e.g.
sandbox/escape -> ..)lets a guest read/write/create/delete outside the sandbox: the requested path
escape/secret.txthas no
..component andstarts_with(base), so the lexical check admits it, and the OS resolves thesymlink to a location outside the preopen.
This is a containment failure in the
--wasi-fssandbox. It is distinct from #392/#396: #392 wasthe
--wasi-fsno-op, and #396 added exactly thisis_within_sandboxlexical check as the fix — thesymlink blind spot is a new gap in that fix. The reference WASI runtime (wasmtime, via
cap-std/
openat2(RESOLVE_BENEATH)) blocks the identical layout.Verified on kiln v0.4.1 (
7a039320).Root cause (source)
kiln-wasi/src/dispatcher.rs:Callers join the guest path and gate the real op on this check, then follow symlinks:
:741 full_path = base_path.join(&path); :747 if !is_within_sandbox(..) {..}; :2415 std::fs::read(&path):950/957 create-directory-at → std::fs::create_dir:989/996 unlink-file-at → std::fs::remove_file:902 / :2302 read-directory → std::fs::read_dirNo
canonicalize, noread_link, noO_NOFOLLOW/openat2anywhere in the open/read flow (theis_symlink()at :973/:2310 is a stat-type report and does not gate containment). The existing tests(:2841-2847) cover
..and absolute-path escapes but not symlink traversal.The lexical choice was deliberate (the doc comment cites #392:
canonicalizefails on anot-yet-created file, so it can't gate writes to new paths). The fix must resolve symlinks without
requiring the target to exist — see below.
Reproduction
(a) kiln's exact containment code admits the escape. Running kilnd's verbatim
is_within_sandboxplus the dispatcher's exact
join → check → std::fs::readsequence, with a symlink pre-existing inthe preopen (
sandbox/escape -> ../outside,outside/secret.txt= "TOP-SECRET-HOST-DATA"):inside.txt(control)"public-canary"(ok)../outside/secret.txt(classic ..)escape/secret.txt(symlink)"TOP-SECRET-HOST-DATA"← read outside the sandbox(b) wasmtime (reference WASI) blocks the identical layout. Same
sandbox/dir, same symlink:Impact & scope
not read-only. A guest can exfiltrate or corrupt host files outside the granted directory.
create-symlink-atWASI method, so the guest cannot plant it itself. This is common in realdeployments: extracted archives, shared/working directories, mounted volumes,
/tmp, and OSbase-image symlinks. Write/delete outside the sandbox is a containment failure regardless of who
created the link.
Verification level (brutally honest)
is_within_sandboxadmits the escapeand
std::fs::readreturns the outside file; wasmtime denies the identical layout.components (RFC build(deps): bump wast from 228.0.0 to 229.0.0 #46, wants a meld-lowered core module) and the meld→kiln FS path did not surface a
file read in testing, so the full guest→dispatcher→escape chain was not executed. The vulnerable
function is exactly the one gating all FS ops, so the source-level confirmation is strong.
Suggested fix
Resolve symlinks during containment without requiring the target to exist: canonicalize the longest
existing ancestor of
full_path(orread_link-walk each component) and verify the result stillstarts_with(base_canonical), or perform the open withopenat2(RESOLVE_BENEATH)/O_NOFOLLOWpercomponent (the approach cap-std/wasmtime use). Add a symlink-escape test alongside the existing
../absolute-path tests at :2841.Reported by the pulseengine-challenge harness (research-agent lead; source-confirmed and reproduced by
running kiln's verbatim containment code against a real symlink, contrasted with wasmtime).