Skip to content

kilnd --wasi-fs sandbox: is_within_sandbox is purely lexical -> symlink traversal escapes the preopen (read/write/create/delete outside); wasmtime blocks it #419

Description

@avrabe

Summary

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 filesystem
fn is_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)
  • :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_dir

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 verbatim is_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"):

guest path is_within_sandbox std::fs::read
inside.txt (control) true "public-canary" (ok)
../outside/secret.txt (classic ..) false rejected (the #396 lexical check works here)
escape/secret.txt (symlink) true "TOP-SECRET-HOST-DATA" ← read outside the sandbox

(b) wasmtime (reference WASI) blocks the identical layout. Same sandbox/ dir, same symlink:

$ (cd sandbox && wasmtime run --dir .::. guest.wasm)   # guest reads "escape/secret.txt"
ERR:Operation not permitted (os error 63)              # symlink escape DENIED
$ (cd sandbox && wasmtime run --dir .::. guest2.wasm)  # guest reads "inside.txt"
READ:public-canary                                     # legitimate access allowed

Impact & scope

  • 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 verbatim is_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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions