Skip to content
GHSA 93f6 34w8 5g98

GHSA 93f6 34w8 5g98

github.com • October 1, 2026

Affected builds: This flaw is reachable only when ZeroClaw is built with the optional plugins-wasm feature, which exposes the plugin installation command. The lean official binary archives do not enable that feature. Source or custom feature-enabled builds through v0.8.4 are affected; v0.8.5 includes the shared manifest guard in every feature-enabled build.

zeroclaw plugin install copies a plugin's wasm_path into the plugins directory using dest_dir.join(wasm_path) without validating that wasm_path stays inside the plugin directory. A malicious plugin package (a local directory, or one fetched via zeroclaw plugin install --registry ) whose manifest.toml sets wasm_path to a .. traversal (or an absolute path) has its WASM bytes written to an attacker-chosen path outside the plugins directory — arbitrary file write with attacker-controlled content, i.e. remote/supply-chain code execution (e.g. overwrite a shell profile, drop a cron/systemd/autostart unit, or replace a binary). Plugin signature verification is disabled by default , so no signature gate stands between an untrusted package and this write. (CWE-22: Path Traversal; amplified by CWE-347: Improper Verification of Cryptographic Signature.)

wasm_path is copied into place without a containment check. PluginHost::install ( crates/zeroclaw-plugins/src/host.rs:218 ) reads the manifest, then: let wasm_source = manifest.wasm_path.as_deref().map(|p| source_dir.join(p)); ( host.rs:240 ) — the read side; let dest_dir = self.plugins_dir.join(&manifest.name); ( host.rs:260 ); let dest = dest_dir.join(rel); where rel = manifest.wasm_path ( host.rs:269 ), then std::fs::copy(&src, &dest) ( host.rs:273 ) — the write side. Path::join follows .. and (for an absolute value) discards the base, so wasm_path = "../../evil" (or "/abs/evil" ) makes dest land outside plugins_dir .

wasm_path is copied into place without a containment check. PluginHost::install ( crates/zeroclaw-plugins/src/host.rs:218 ) reads the manifest, then:

let wasm_source = manifest.wasm_path.as_deref().map(|p| source_dir.join(p)); ( host.rs:240 ) — the read side;

let dest_dir = self.plugins_dir.join(&manifest.name); ( host.rs:260 );

let dest = dest_dir.join(rel); where rel = manifest.wasm_path ( host.rs:269 ), then std::fs::copy(&src, &dest) ( host.rs:273 ) — the write side. Path::join follows .. and (for an absolute value) discards the base, so wasm_path = "../../evil" (or "/abs/evil" ) makes dest land outside plugins_dir .

The name is validated; the path is not. validate_manifest_shape ( host.rs:404 ) calls validate_package_name(&manifest.name) ( host.rs:408 ), which rejects .. , / , and absolute names (there is a test for name = "../escape" ). It performs no equivalent check on manifest.wasm_path — it only asserts the field is present for non-skill plugins ( host.rs:420 ). The same missing check affects discovery: discover computes path.join(manifest.wasm_path) ( host.rs:160 ) and loads WASM from there.

The name is validated; the path is not. validate_manifest_shape ( host.rs:404 ) calls validate_package_name(&manifest.name) ( host.rs:408 ), which rejects .. , / , and absolute names (there is a test for name = "../escape" ). It performs no equivalent check on manifest.wasm_path — it only asserts the field is present for non-skill plugins ( host.rs:420 ). The same missing check affects discovery: discover computes path.join(manifest.wasm_path) ( host.rs:160 ) and loads WASM from there.

No signature gate by default. verify_plugin_signature runs before the copy ( host.rs:257 ), but the default signature mode is disabled — PluginHost::from_plugins_dir passes SignatureMode::Disabled ( host.rs:54 ), and the config default is disabled ( crates/zeroclaw-config/src/schema.rs:8305-8319 : "In disabled mode (the default), no signature checking occurs"). In disabled mode an unsigned, untrusted package verifies OK, so nothing stops the traversal. Reachability (untrusted → sink), verified on master @ bf5627186b62 : Source (supply-chain / operator-install): a plugin package authored by the attacker — a local directory the victim installs, or a package fetched by zeroclaw plugin install --registry (remote registry support: PRs #7333 , #8264 ). The package's manifest.toml carries a crafted wasm_path . Trigger: the victim runs zeroclaw plugin install . (This is operator-initiated, not agent/prompt-injection reachable — there is no agent tool that calls install .) Sink: std::fs::copy(wasm_source, dest_dir.join(wasm_path)) ( host.rs:269-273 ) writes attacker bytes outside plugins_dir .

No signature gate by default. verify_plugin_signature runs before the copy ( host.rs:257 ), but the default signature mode is disabled — PluginHost::from_plugins_dir passes SignatureMode::Disabled ( host.rs:54 ), and the config default is disabled ( crates/zeroclaw-config/src/schema.rs:8305-8319 : "In disabled mode (the default), no signature checking occurs"). In disabled mode an unsigned, untrusted package verifies OK, so nothing stops the traversal.

Reachability (untrusted → sink), verified on master @ bf5627186b62 :

Source (supply-chain / operator-install): a plugin package authored by the attacker — a local directory the victim installs, or a package fetched by zeroclaw plugin install --registry (remote registry support: PRs #7333 , #8264 ). The package's manifest.toml carries a crafted wasm_path .

Trigger: the victim runs zeroclaw plugin install . (This is operator-initiated, not agent/prompt-injection reachable — there is no agent tool that calls install .)

Sink: std::fs::copy(wasm_source, dest_dir.join(wasm_path)) ( host.rs:269-273 ) writes attacker bytes outside plugins_dir .

Fired against the real PluginHost::install (added to host.rs 's test module; cargo test -p zeroclaw-plugins ):

Deterministic differential (benign marker only):

RESULT: a malicious plugin package's wasm_path writes attacker-controlled content to an arbitrary path on install; the fix rejects any absolute or .. -bearing wasm_path while legitimate relative paths still install.

CWE-22 (Path Traversal) leading to arbitrary file write with attacker-controlled content on plugin install → code execution in the installing user's context; amplified by CWE-347, since signature verification is disabled by default and therefore provides no gate against an untrusted package. Scope: a user/operator who installs an untrusted plugin (a shared package or one from a remote registry). It is not prompt-injection reachable (no agent tool installs plugins). Bounded by the installing user's privileges.

Suggested fix (verified: PoC flips from write-succeeds to rejected; a legitimate wasm_path still installs)

Validate every manifest-supplied relative path ( wasm_path ) in the shared validate_manifest_shape (which both install and discover call), rejecting absolute paths and any .. /root/prefix component:

Add a regression test that a .. /absolute wasm_path is rejected on install (nothing written outside plugins_dir ) while a legitimate relative wasm_path still installs. Separately consider defaulting signature_mode to permissive / strict (defence-in-depth; a distinct hardening change).