Safeguard
Vulnerability Analysis•

Wasmtime CVE-2026-58494: How a Hard Link Beat Read-Only WASI Preopens

Wasmtime CVE-2026-58494: a WASI guest could hard-link a read-only file into a writable preopen and overwrite it. Who's exposed, the fix, and a checker.

Safeguard Security Team
Security Research
12 min read

Wasmtime lets a host hand a WebAssembly guest a directory with limits attached: the guest may create and delete entries there, say, but may not write to the files inside. CVE-2026-58494 showed those limits could be stepped around with a hard link. A guest that also had a writable directory could link a read-only file into it, open the link for writing, and change the original. It was the second bug in five weeks from the same corner of the permission model, and the Wasmtime maintainers' answer was to delete that corner. Since Wasmtime 48, a preopened directory is either read-only or read-write, nothing in between.

GitHub's reviewed advisory database added CVE-2026-58494 on 16 October, almost four months after the fix shipped and long after Rust's own advisory database listed it. This teardown is for teams that embed Wasmtime in Rust, as a plugin host, a serverless runtime or a sandbox for untrusted code. It covers how the bug worked, why hard links defeat path-based permissions, who was actually exposed, and a script that checks your lockfiles and preopen code.

The short answer

CVE-2026-58494 lets a WASI guest overwrite a host file exposed as read-only, by hard-linking or renaming it into a second preopened directory that allows writes. It affects wasmtime-wasi before 24.0.11, 36.0.12, 45.0.3 and 46.0.1 on their branches. 47.0.0 and later are fixed. It only matters if your embedding gives a preopen DirPerms::MUTATE with read-only FilePerms and also has a writable preopen. The Wasmtime CLI doesn't, and the Wasmtime team found no production embedder that does. Upgrade anyway, and if you set mixed permissions, plan for Wasmtime 48, which replaced them with FsPerms::ReadOnly and FsPerms::ReadWrite.

How preopen permissions worked

A WASI guest has no access to the host file system until the host grants it one directory at a time, called a preopen. Before version 48, wasmtime-wasi attached two sets of flags to each one:

builder.preopened_dir(
    "/srv/app/config",
    "/config",
    DirPerms::READ | DirPerms::MUTATE, // may create, rename and delete entries
    FilePerms::READ,                   // may not write file contents
)?;

DirPerms governs the directory: listing it, and mutating it by creating, renaming or removing entries. FilePerms governs the files in it: reading and writing their contents. The combination above reads like "the guest can reorganize this directory but not change what's in the files". That combination is where both 2026 bugs lived.

Two bugs in the same corner

The first, CVE-2026-47261, was published by Wasmtime on 21 May and added to GitHub's reviewed database on 5 June. Opening a file with only the TRUNCATE flag emptied it, because that code path forgot to count truncation as a write when it checked FilePerms. The fix was one line.

The second is CVE-2026-58494. The advisory describes two routes. In the first, the guest calls link_at on its read-only preopen, naming a file there as the source and a path in a writable preopen as the destination. In the second, it creates the link inside the read-only preopen, which DirPerms::MUTATE allows, then renames the link into the writable one. Either way it ends up with a path in a writable preopen pointing at the read-only file's data. Opening that path for writing passes the permission check, because the check looks at the preopen the path is in.

Before the fix, link_at and rename_at checked only that both directories allowed mutation. The fix adds one condition to each: the two preopens must have identical directory and file permissions, or the call fails with NotPermitted. Symbolic links were never a route, because WASI already requires a symlink's target to stay inside the same preopen.

Why hard links break path-based permissions

A hard link isn't a pointer to a path. It's a second name for the same file, so writing through either name changes the same data. Any policy that decides "can this be written?" by looking at the path is only as good as its control over which names exist. This model reproduces Wasmtime's old and new checks in Python on a temporary directory. It's not Wasmtime code, just the same logic:

"""A model of per-preopen file permissions, and why hard links break them.

This is not Wasmtime. It mimics its policy in a few lines of Python and runs
on the host file system inside a temporary directory.
"""
import os
import tempfile

root = tempfile.mkdtemp()
PREOPENS = {  # name: (directory may be mutated, files may be written)
    "config": (True, False),   # read-only files, like DirPerms::MUTATE + FilePerms::READ
    "scratch": (True, True),   # read-write
}
for name in PREOPENS:
    os.mkdir(os.path.join(root, name))
target = os.path.join(root, "config", "policy.json")
with open(target, "w") as f:
    f.write('{"allow_network": false}\n')


def preopen_of(path):
    return os.path.relpath(path, root).split(os.sep)[0]


def open_for_write(path):
    if not PREOPENS[preopen_of(path)][1]:
        raise PermissionError(f"{preopen_of(path)}: files are read-only")
    return open(path, "w")


def link(src, dst, fixed):
    if not (PREOPENS[preopen_of(src)][0] and PREOPENS[preopen_of(dst)][0]):
        raise PermissionError("directory mutation not allowed")
    if fixed and PREOPENS[preopen_of(src)] != PREOPENS[preopen_of(dst)]:
        raise PermissionError("link crosses permission domains")  # the 2026 fix
    os.link(src, dst)


try:
    open_for_write(target)
except PermissionError as e:
    print("direct write refused:", e)

for fixed in (False, True):
    alias = os.path.join(root, "scratch", "alias.json")
    try:
        link(target, alias, fixed)
        with open_for_write(alias) as f:
            f.write('{"allow_network": true}\n')
        print(f"fixed={fixed}: wrote through scratch/alias.json")
    except PermissionError as e:
        print(f"fixed={fixed}: link refused: {e}")
    print("  config/policy.json now:", open(target).read().strip(),
          "| same inode:", os.path.exists(alias) and os.stat(alias).st_ino == os.stat(target).st_ino)
    if os.path.exists(alias):
        os.unlink(alias)
    with open(target, "w") as f:
        f.write('{"allow_network": false}\n')
$ python3 preopen_model.py
direct write refused: config: files are read-only
fixed=False: wrote through scratch/alias.json
  config/policy.json now: {"allow_network": true} | same inode: True
fixed=True: link refused: link crosses permission domains
  config/policy.json now: {"allow_network": false} | same inode: False

The direct write is refused. With the old check, the link is allowed, the write through scratch/ succeeds, and config/policy.json changes, because both names share one inode. With the fixed check, the link is refused before it exists.

Timeline

Date (2026)Event
21 MayCVE-2026-47261 (open with TRUNCATE) published by Wasmtime, fixed in 24.0.9, 36.0.10 and 44.0.2. GitHub's reviewed database added it on 5 June
24 JuneCVE-2026-58494 fix merged (pull request #13725) and released in 46.0.1, 45.0.3, 36.0.12 and 24.0.11. Repository advisory published
29 JuneRustSec assigns RUSTSEC-2026-0188, so cargo audit and cargo deny can flag it
20 JulyWasmtime 47.0.0, the first new major version with the fix
30 JulyPull request #14010 replaces DirPerms and FilePerms with FsPerms
20 AugustWasmtime 48.0.0 ships FsPerms
16 OctoberGitHub's reviewed advisory database adds CVE-2026-58494

The advisories credit sdjasj with reporting CVE-2026-58494 and shumbo with CVE-2026-47261. Pat Hickey wrote the hard-link fix and the redesign.

Which date your tooling saw depends on its source. cargo audit and cargo deny read RustSec, which had the advisory five days after the fix. Dependabot and other tools that read GitHub's reviewed database couldn't flag it until 16 October, 16 weeks later, by which time Wasmtime had moved two major versions past it. For Rust code, run a RustSec-based check in CI as well as Dependabot.

Who was actually exposed

Fewer embedders than the CVSS score of 6.5 suggests. The advisory names the condition exactly: a preopen with DirPerms::MUTATE and FilePerms::READ, plus a second preopen that allows file writes. The Wasmtime CLI always grants FilePerms::all(), so it was never affected. And in the pull request that removed the old permission model, Pat Hickey wrote that an informal survey found no production user relying on that combination.

We checked the main branches of five well-known Wasmtime embedders on 17 October:

Projectwasmtime-wasi in Cargo.lockHow it sets preopens
Spin49.0.3FsPerms::ReadOnly or ReadWrite
wasmCloud (wash-runtime)48.0.6FsPerms::ReadOnly or ReadWrite
Zed extension host48.0.1FsPerms::ReadWrite
Extism^49 in Cargo.toml, no lockfileFsPerms, chosen by an ro: path prefix
Golem46.0.1, fixedNo preopened_dir call found

None can express the vulnerable combination on its current branch. Older Extism releases used wasi-common, a separate crate these advisories don't cover.

Those versions are as of 17 October, and they aren't the whole story. Wasmtime published a separate, high-severity filesystem sandbox escape on 14 October (GHSA-m9f7-9w4p-gv38, macOS and FreeBSD hosts), fixed in 49.0.3, 48.0.6 and 36.0.18. RustSec listed it the same day as RUSTSEC-2026-0352. On 17 October it wasn't in GitHub's advisory database, so Dependabot won't report it. Check your lockfile against that one too.

Check your project

This script reads Cargo.lock files for wasmtime-wasi versions affected by either advisory and Rust files for preopened_dir calls that grant directory mutation with read-only files. It reports a project as affected only when it finds both. Calls whose permissions come from a variable are listed for review if the file uses FilePerms. It reads files only and uses the Python standard library.

#!/usr/bin/env python3
"""Check Rust projects for the wasmtime-wasi preopen permission advisories.

Reads Cargo.lock files for wasmtime-wasi versions affected by CVE-2026-58494
(hard link / rename across preopens) and CVE-2026-47261 (open with TRUNCATE),
and Rust sources for preopens that allow directory mutation but not file
writes, the only configuration either bug affects. Reads files only.
Usage: wasi_perms_check.py DIR
"""
import re
import sys
from pathlib import Path

ADVISORIES = {
    "CVE-2026-58494": [((0, 0, 0), (24, 0, 11)), ((25, 0, 0), (36, 0, 12)),
                       ((37, 0, 0), (45, 0, 3)), ((46, 0, 0), (46, 0, 1))],
    "CVE-2026-47261": [((0, 0, 0), (24, 0, 9)), ((25, 0, 0), (36, 0, 10)),
                       ((37, 0, 0), (44, 0, 2))],
}
LOCK_ENTRY = re.compile(r'name = "wasmtime-wasi"\nversion = "(\d+)\.(\d+)\.(\d+)"')
PREOPEN = re.compile(r"(?<!fn )preopened_dir\s*\(", re.S)


def advisories_for(ver):
    return [cve for cve, ranges in ADVISORIES.items() if any(lo <= ver < hi for lo, hi in ranges)]


def check_lock(path, out):
    for m in LOCK_ENTRY.finditer(path.read_text()):
        ver = tuple(int(x) for x in m.groups())
        hits = advisories_for(ver)
        label = ".".join(map(str, ver))
        if hits:
            out.append(("UPGRADE", path, f"wasmtime-wasi {label}: {', '.join(hits)}"))
        else:
            api = "FsPerms" if ver >= (48, 0, 0) else "DirPerms/FilePerms"
            out.append(("ok", path, f"wasmtime-wasi {label} (fixed; preopen API: {api})"))


def call_args(text, start):
    depth, i = 0, start
    while i < len(text):
        if text[i] == "(":
            depth += 1
        elif text[i] == ")":
            depth -= 1
            if depth == 0:
                return text[start:i + 1]
        i += 1
    return text[start:]


def check_rust(path, out):
    text = path.read_text(errors="ignore")
    for m in PREOPEN.finditer(text):
        args = call_args(text, m.end() - 1)
        line = text.count("\n", 0, m.start()) + 1
        if "FsPerms" in args:
            continue
        mutate = re.search(r"DirPerms::(MUTATE|all\(\))", args)
        file_write = re.search(r"FilePerms::(WRITE|all\(\))", args)
        if mutate and not file_write and "FilePerms" in args:
            out.append(("EXPOSED", path, f"line {line}: directory mutation allowed, files read-only. "
                        "This is the configuration both bugs bypass"))
        elif "DirPerms" not in args and "FilePerms" not in args and "FilePerms" in text:
            out.append(("REVIEW", path, f"line {line}: permissions come from a variable in a file that uses "
                        "FilePerms; check whether it can hold MUTATE with read-only files"))


def main(root):
    out = []
    for p in sorted(Path(root).rglob("*")):
        if "target" in p.parts or ".git" in p.parts or not p.is_file():
            continue
        if p.name == "Cargo.lock":
            check_lock(p, out)
        elif p.suffix == ".rs":
            check_rust(p, out)
    order = {"EXPOSED": 0, "UPGRADE": 1, "REVIEW": 2, "ok": 3}
    for level, path, msg in sorted(out, key=lambda r: (order[r[0]], str(r[1]))):
        print(f"{level:7} {Path(path).relative_to(root)}: {msg}")
    exposed = any(r[0] == "EXPOSED" for r in out)
    upgrade = any(r[0] == "UPGRADE" for r in out)
    if exposed and upgrade:
        print("=> affected: vulnerable version and the vulnerable configuration")
    return 1 if exposed and upgrade else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "."))

On three test projects: a plugin host on 45.0.2 with the vulnerable combination, a sandbox runner on a fixed 36.x release that passes permissions through a tuple, and an edge function on 49.0.3:

$ python3 wasi_perms_check.py fx
EXPOSED plugin-host/src/host.rs: line 6: directory mutation allowed, files read-only. This is the configuration both bugs bypass
UPGRADE plugin-host/Cargo.lock: wasmtime-wasi 45.0.2: CVE-2026-58494
REVIEW  sandbox-runner/src/main.rs: line 2: permissions come from a variable in a file that uses FilePerms; check whether it can hold MUTATE with read-only files
ok      edge-fn/Cargo.lock: wasmtime-wasi 49.0.3 (fixed; preopen API: FsPerms)
ok      sandbox-runner/Cargo.lock: wasmtime-wasi 36.0.12 (fixed; preopen API: DirPerms/FilePerms)
=> affected: vulnerable version and the vulnerable configuration

On the five embedder repositories above, it reports only fixed versions and nothing to review.

What to do

  1. Upgrade wasmtime-wasi to 46.0.1, 45.0.3, 36.0.12 or 24.0.11 on your branch, or to 47 or later. Check Cargo.lock, since another crate can pull in an older copy.
  2. Look for mixed permissions. If any preopen grants DirPerms::MUTATE without FilePerms::WRITE, decide what you meant. Both bugs this year bypassed exactly that.
  3. Plan the move to FsPerms. On Wasmtime 48 and later, each preopen is ReadOnly or ReadWrite. Keep read-only data in its own preopen, mounted read-only.
  4. Don't let a writable preopen and protected data share a file system if you can avoid it. A hard link can only join two names on the same file system, and the host can enforce that boundary even when a runtime check fails.

For the wider picture of what a WebAssembly sandbox does and doesn't contain, see our WebAssembly sandbox security analysis.

The fair view

This was a narrow bug in an option almost nobody used, found by outside reporters, fixed on four release branches the same day, and documented with the exact affected configuration. The maintainers then did the rarer thing. Instead of patching a third hole in the same model, they removed the model and replaced it with two states that are easy to enforce.

The lesson for embedders is about any permission system that checks paths. Hard links, renames and truncation are all ways to reach a file's data without opening it by its protected name. A rule like "the directory is mutable but its files aren't" has to hold against all of them, and Wasmtime's experience is that it's easier to stop offering that rule than to keep it true.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.