Advisory Details
Title: POSIX exec-approvals allowlist overmatch lets unintended binaries execute via node-host system.run
Description:
openclaw-cn's node-host exec approval matcher treats POSIX executable paths as case-insensitive and lets ? match /, which causes operator-authored allowlist entries to overmatch unintended executables. An authenticated caller that is already permitted to use the supported node.invoke(system.run) path can therefore execute binaries outside the intended exact-path allowlist on a paired POSIX node.
Summary
A logic flaw in the POSIX exec-allowlist matcher allows a paired node's system.run guardrail to be bypassed. When tools.exec.security=allowlist is used for a node host, the current matcher lowercases POSIX paths, compiles ? as ., and applies a case-insensitive regex, so an allowlist entry that was meant to authorize one exact executable can unintentionally authorize a differently cased path or a path separated by an extra /. This defeats the primary local containment mechanism documented for remote node execution.
Details
The vulnerable logic lives in src/infra/exec-approvals.ts and is consumed by the real node-host execution path in src/node-host/runner.ts.
The first problem is that non-Windows paths are normalized to lowercase, ? is compiled as ., and the resulting regex is created with the i flag:
function normalizeMatchTarget(value: string): string {
if (process.platform === "win32") {
const stripped = value.replace(/^\\\\[?.]\\/, "");
return stripped.replace(/\\/g, "/").toLowerCase();
}
return value.replace(/\\\\/g, "/").toLowerCase();
}
function globToRegExp(pattern: string): RegExp {
// ...
if (ch === "?") {
regex += ".";
}
// ...
return new RegExp(regex, "i");
}
That matcher is then applied directly to the resolved executable path:
export function matchAllowlist(entries, resolution) {
const resolvedPath = resolution.resolvedPath;
for (const entry of entries) {
const pattern = entry.pattern?.trim();
if (matchesPattern(pattern, resolvedPath)) return entry;
}
return null;
}
On the real node-host path, handleInvoke() derives allowlistSatisfied from evaluateShellAllowlist(). If that value is true, the request falls through to runCommand():
const allowlistEval = evaluateShellAllowlist({ command: rawCommand, allowlist: approvals.allowlist, ... });
allowlistSatisfied =
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
if (security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
return "SYSTEM_RUN_DENIED: allowlist miss";
}
const result = await runCommand(execArgv, params.cwd?.trim() || undefined, env, params.timeoutMs ?? undefined);
This creates two concrete bypasses on POSIX systems:
- A stored allowlist entry for
/.../Allowed-Tool also matches /.../allowed-tool.
- A stored allowlist entry for
/.../a?b also matches the real path /.../a/b, because . crosses path separators.
I verified the issue on the current openclaw-cn package version 0.2.1, whose highest affected upstream tag is v0.2.1. The local Git remote still points to https://github.com/jiulingyun/openclaw-cn, but GitHub resolves that repository to the canonical path https://github.com/mf-yang/openclaw-cn; the occurrence permalinks below use the canonical repository path and the exact tag commit.
PoC
Prerequisites
- Node.js 22+ and the project dependencies installed.
- A POSIX environment (Linux/macOS-style path semantics).
- The repository checked out and built with
pnpm build.
- Ability to run the existing localhost-only PoC harness that starts a temporary gateway and a temporary node host.
Reproduction Steps
- Download the main verification script from: verification_test.py
- Download the control script from: control-posix-glob-overmatch.py
- Download the helper files used by the harness:
- Build the repository so the current runtime artifacts match the checked-out source:
- Run the positive verification:
python3 llm-enhance/cve-finding/similar/exec-allowlist-bypass/Advisory-GHSA-f8r2-vg7x-gh8m-posix-path-glob-overmatch-exp/verification_test.py
- Observe that both bypass cases succeed:
- the casefold sample allows
.../allowed-tool when the allowlist contains .../Allowed-Tool
- the wildcard sample allows
.../a/b when the allowlist contains .../a?b
- Run the negative control:
python3 llm-enhance/cve-finding/similar/exec-allowlist-bypass/Advisory-GHSA-f8r2-vg7x-gh8m-posix-path-glob-overmatch-exp/control-posix-glob-overmatch.py
- Observe that both unrelated allowlist entries are rejected with
SYSTEM_RUN_DENIED: allowlist miss, and no control canary file is created.
Log of Evidence
The current end-to-end verification produced the following runtime evidence:
- vuln-posix-casefold: allowed; exit=0; allowlist=.../casefold/bin/Allowed-Tool
response={"ok": true, "command": "system.run", "payload": {"exitCode": 0, "success": true, ...}}
- vuln-qmark-cross-segment: allowed; exit=0; allowlist=.../qmark/a?b
response={"ok": true, "command": "system.run", "payload": {"exitCode": 0, "success": true, ...}}
[DEFECT CONFIRMED] Vulnerable allowlist patterns execute through the real gateway/node-host WebSocket path and write the expected canary files.
The matching negative control produced:
- control-unrelated-casefold: denied; response={"ok": false, "error": "SYSTEM_RUN_DENIED: allowlist miss"}
- control-unrelated-qmark: denied; response={"ok": false, "error": "SYSTEM_RUN_DENIED: allowlist miss"}
[CONTROL OK] Non-matching allowlist patterns are denied and do not write canaries.
The filesystem evidence matched the RPC results:
vuln-posix-casefold/canary.txt contained CASEFOLD-VULN
vuln-qmark-cross-segment/canary.txt contained QMARK-VULN
- control cases did not create canary files
Impact
This is an allowlist-bypass vulnerability on the node-host remote execution surface. The impacted asset is the node machine where system.run executes. A deployment that intentionally relies on tools.exec.security=allowlist to narrow remote command execution to a small set of exact binaries can be tricked into executing unintended programs on that node. The eventual blast radius depends on the node host's local OS permissions and installed tooling, but at minimum it breaks the documented containment boundary for remote execution and can lead to unauthorized command execution, file access, or further tool-chain abuse on the paired node.
Affected products
- Ecosystem: npm
- Package name: openclaw-cn
- Affected versions: <= 0.2.1
- Patched versions:
Severity
- Severity: Medium
- Vector string: CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:H/A:L
Weaknesses
- CWE: CWE-184: Incomplete List of Disallowed Inputs
Occurrences
| Permalink |
Description |
|
function normalizeMatchTarget(value: string): string { |
|
if (process.platform === "win32") { |
|
const stripped = value.replace(/^\\\\[?.]\\/, ""); |
|
return stripped.replace(/\\/g, "/").toLowerCase(); |
|
} |
|
return value.replace(/\\\\/g, "/").toLowerCase(); |
|
} |
|
|
|
function tryRealpath(value: string): string | null { |
|
try { |
|
return fs.realpathSync(value); |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
function globToRegExp(pattern: string): RegExp { |
|
let regex = "^"; |
|
let i = 0; |
|
while (i < pattern.length) { |
|
const ch = pattern[i]; |
|
if (ch === "*") { |
|
const next = pattern[i + 1]; |
|
if (next === "*") { |
|
regex += ".*"; |
|
i += 2; |
|
continue; |
|
} |
|
regex += "[^/]*"; |
|
i += 1; |
|
continue; |
|
} |
|
if (ch === "?") { |
|
regex += "."; |
|
i += 1; |
|
continue; |
|
} |
|
regex += ch.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&"); |
|
i += 1; |
|
} |
|
regex += "$"; |
|
return new RegExp(regex, "i"); |
|
} |
|
|
|
function matchesPattern(pattern: string, target: string): boolean { |
|
const trimmed = pattern.trim(); |
|
if (!trimmed) return false; |
|
const expanded = trimmed.startsWith("~") ? expandHome(trimmed) : trimmed; |
|
const hasWildcard = /[*?]/.test(expanded); |
|
let normalizedPattern = expanded; |
|
let normalizedTarget = target; |
|
if (process.platform === "win32" && !hasWildcard) { |
|
normalizedPattern = tryRealpath(expanded) ?? expanded; |
|
normalizedTarget = tryRealpath(target) ?? target; |
|
} |
|
normalizedPattern = normalizeMatchTarget(normalizedPattern); |
|
normalizedTarget = normalizeMatchTarget(normalizedTarget); |
|
const regex = globToRegExp(normalizedPattern); |
|
return regex.test(normalizedTarget); |
|
The POSIX matcher lowercases paths, compiles ? as ., and creates a case-insensitive regex, which causes casefold and slash-crossing overmatches. |
|
export function matchAllowlist( |
|
entries: ExecAllowlistEntry[], |
|
resolution: CommandResolution | null, |
|
): ExecAllowlistEntry | null { |
|
if (!entries.length || !resolution?.resolvedPath) return null; |
|
const resolvedPath = resolution.resolvedPath; |
|
for (const entry of entries) { |
|
const pattern = entry.pattern?.trim(); |
|
if (!pattern) continue; |
|
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~"); |
|
if (!hasPath) continue; |
|
if (matchesPattern(pattern, resolvedPath)) return entry; |
|
matchAllowlist() applies the flawed pattern matcher directly to the resolved executable path and returns a successful allowlist entry on the first overmatch. |
|
if (rawCommand) { |
|
const allowlistEval = evaluateShellAllowlist({ |
|
command: rawCommand, |
|
allowlist: approvals.allowlist, |
|
safeBins, |
|
cwd: params.cwd ?? undefined, |
|
env, |
|
skillBins: bins, |
|
autoAllowSkills, |
|
platform: process.platform, |
|
}); |
|
analysisOk = allowlistEval.analysisOk; |
|
allowlistMatches = allowlistEval.allowlistMatches; |
|
allowlistSatisfied = |
|
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false; |
|
The node-host system.run handler derives allowlistSatisfied from evaluateShellAllowlist() when a caller supplies rawCommand. |
|
if (security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) { |
|
await sendNodeEvent( |
|
client, |
|
"exec.denied", |
|
buildExecEventPayload({ |
|
sessionKey, |
|
runId, |
|
host: "node", |
|
command: cmdText, |
|
reason: "allowlist-miss", |
|
}), |
|
); |
|
await sendInvokeResult(client, frame, { |
|
ok: false, |
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" }, |
|
}); |
|
return; |
|
} |
|
|
|
if (allowlistMatches.length > 0) { |
|
const seen = new Set<string>(); |
|
for (const match of allowlistMatches) { |
|
if (!match?.pattern || seen.has(match.pattern)) continue; |
|
seen.add(match.pattern); |
|
recordAllowlistUse( |
|
approvals.file, |
|
agentId, |
|
match, |
|
cmdText, |
|
segments[0]?.resolution?.resolvedPath, |
|
); |
|
} |
|
} |
|
|
|
if (params.needsScreenRecording === true) { |
|
await sendNodeEvent( |
|
client, |
|
"exec.denied", |
|
buildExecEventPayload({ |
|
sessionKey, |
|
runId, |
|
host: "node", |
|
command: cmdText, |
|
reason: "permission:screenRecording", |
|
}), |
|
); |
|
await sendInvokeResult(client, frame, { |
|
ok: false, |
|
error: { code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording" }, |
|
}); |
|
return; |
|
} |
|
|
|
let execArgv = argv; |
|
if ( |
|
security === "allowlist" && |
|
isWindows && |
|
!approvedByAsk && |
|
rawCommand && |
|
analysisOk && |
|
allowlistSatisfied && |
|
segments.length === 1 && |
|
segments[0]?.argv.length > 0 |
|
) { |
|
// Avoid cmd.exe in allowlist mode on Windows; run the parsed argv directly. |
|
execArgv = segments[0].argv; |
|
} |
|
|
|
const result = await runCommand( |
|
execArgv, |
|
params.cwd?.trim() || undefined, |
|
env, |
|
params.timeoutMs ?? undefined, |
|
Requests are denied only on an allowlist miss; once the flawed matcher reports success, execution falls through to runCommand() on the node host. |
Advisory Details
Title: POSIX exec-approvals allowlist overmatch lets unintended binaries execute via node-host
system.runDescription:
openclaw-cn's node-host exec approval matcher treats POSIX executable paths as case-insensitive and lets?match/, which causes operator-authored allowlist entries to overmatch unintended executables. An authenticated caller that is already permitted to use the supportednode.invoke(system.run)path can therefore execute binaries outside the intended exact-path allowlist on a paired POSIX node.Summary
A logic flaw in the POSIX exec-allowlist matcher allows a paired node's
system.runguardrail to be bypassed. Whentools.exec.security=allowlistis used for a node host, the current matcher lowercases POSIX paths, compiles?as., and applies a case-insensitive regex, so an allowlist entry that was meant to authorize one exact executable can unintentionally authorize a differently cased path or a path separated by an extra/. This defeats the primary local containment mechanism documented for remote node execution.Details
The vulnerable logic lives in
src/infra/exec-approvals.tsand is consumed by the real node-host execution path insrc/node-host/runner.ts.The first problem is that non-Windows paths are normalized to lowercase,
?is compiled as., and the resulting regex is created with theiflag:That matcher is then applied directly to the resolved executable path:
On the real node-host path,
handleInvoke()derivesallowlistSatisfiedfromevaluateShellAllowlist(). If that value is true, the request falls through torunCommand():This creates two concrete bypasses on POSIX systems:
/.../Allowed-Toolalso matches/.../allowed-tool./.../a?balso matches the real path/.../a/b, because.crosses path separators.I verified the issue on the current
openclaw-cnpackage version0.2.1, whose highest affected upstream tag isv0.2.1. The local Git remote still points tohttps://github.com/jiulingyun/openclaw-cn, but GitHub resolves that repository to the canonical pathhttps://github.com/mf-yang/openclaw-cn; the occurrence permalinks below use the canonical repository path and the exact tag commit.PoC
Prerequisites
pnpm build.Reproduction Steps
pnpm buildpython3 llm-enhance/cve-finding/similar/exec-allowlist-bypass/Advisory-GHSA-f8r2-vg7x-gh8m-posix-path-glob-overmatch-exp/verification_test.py.../allowed-toolwhen the allowlist contains.../Allowed-Tool.../a/bwhen the allowlist contains.../a?bpython3 llm-enhance/cve-finding/similar/exec-allowlist-bypass/Advisory-GHSA-f8r2-vg7x-gh8m-posix-path-glob-overmatch-exp/control-posix-glob-overmatch.pySYSTEM_RUN_DENIED: allowlist miss, and no control canary file is created.Log of Evidence
The current end-to-end verification produced the following runtime evidence:
The matching negative control produced:
The filesystem evidence matched the RPC results:
vuln-posix-casefold/canary.txtcontainedCASEFOLD-VULNvuln-qmark-cross-segment/canary.txtcontainedQMARK-VULNImpact
This is an allowlist-bypass vulnerability on the node-host remote execution surface. The impacted asset is the node machine where
system.runexecutes. A deployment that intentionally relies ontools.exec.security=allowlistto narrow remote command execution to a small set of exact binaries can be tricked into executing unintended programs on that node. The eventual blast radius depends on the node host's local OS permissions and installed tooling, but at minimum it breaks the documented containment boundary for remote execution and can lead to unauthorized command execution, file access, or further tool-chain abuse on the paired node.Affected products
Severity
Weaknesses
Occurrences
openclaw-cn/src/infra/exec-approvals.ts
Lines 431 to 489 in 558f272
?as., and creates a case-insensitive regex, which causes casefold and slash-crossing overmatches.openclaw-cn/src/infra/exec-approvals.ts
Lines 507 to 518 in 558f272
matchAllowlist()applies the flawed pattern matcher directly to the resolved executable path and returns a successful allowlist entry on the first overmatch.openclaw-cn/src/node-host/runner.ts
Lines 854 to 868 in 558f272
system.runhandler derivesallowlistSatisfiedfromevaluateShellAllowlist()when a caller suppliesrawCommand.openclaw-cn/src/node-host/runner.ts
Lines 1041 to 1113 in 558f272
runCommand()on the node host.