Advisory Details
Title: node-host system.run allowlist can be bypassed with env -S to reach arbitrary shell execution
Description:
The node-host system.run approval path in openclaw-cn approves env -S wrapper commands based on the outer /usr/bin/env executable, but later executes the original shell string through /bin/sh -lc. When /usr/bin/env is present in the node exec allowlist, a caller can smuggle a broader sh -c ... payload past the intended allowlist boundary and execute arbitrary commands on the paired node host.
Summary
I verified a real system.run allowlist bypass in openclaw-cn. Through the normal Gateway WebSocket RPC surface (node.invoke), a caller can submit rawCommand = env -S 'sh -c "..."'. The allowlist logic treats the command as an approved /usr/bin/env wrapper, but the node-host runtime later executes the unchanged shell text and allows env -S to dispatch the inner sh -c ... payload.
This is not an unauthenticated internet RCE by default. It is still a genuine security issue because it breaks the product's own command-approval invariant: an operator can configure the node host to allow only specific commands, yet env -S allows a materially different shell payload to run on the node anyway.
The highest published GitHub release for the canonical upstream repository is v0.2.0, and the vulnerable logic is present there. I also reproduced the issue on the current unreleased local checkout (0.2.1), but the affected-version field below is intentionally scoped to the highest released vulnerable version.
Details
The canonical GitHub repository currently resolves to mf-yang/openclaw-cn, even though the local origin URL is https://github.com/jiulingyun/openclaw-cn. The latest GitHub release is v0.2.0, and that tag resolves to the remote commit:
1b9f16468d9841871cb15103693c3923424c9842
That same commit is used for every verified occurrence permalink in this report.
The reachable execution path is the standard node command surface:
system.run is in the default Linux node command allowlist.
node.invoke accepts a connected node command and forwards the request to node-host.
node-host evaluates rawCommand with evaluateShellAllowlist(...).
- Once allowlist approval succeeds, node-host executes the original command array, which still contains
["/bin/sh", "-lc", rawCommand].
The mismatch is between what gets approved and what actually runs.
At the node-host policy stage, the raw shell text is analyzed and converted into allowlistSatisfied:
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;
allowlistSatisfied =
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
}
Inside the shared exec-approval logic, each parsed command segment is treated as approved when the resolved executable identity matches the allowlist:
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
const candidateResolution =
candidatePath && segment.resolution
? { ...segment.resolution, resolvedPath: candidatePath }
: segment.resolution;
const match = matchAllowlist(params.allowlist, candidateResolution);
...
return Boolean(match || safe || skillAllow);
For a payload such as:
env -S 'sh -c "printf PWNED > /tmp/canary.txt"'
the parsed segment begins with env, so /usr/bin/env satisfies the allowlist even though the runtime later re-splits the payload and dispatches sh -c "printf PWNED > /tmp/canary.txt".
Back in src/node-host/runner.ts, once the deny branch is skipped, the node-host executes the original command array instead of a canonicalized plan derived from the allowlist analysis:
let execArgv = argv;
...
const result = await runCommand(
execArgv,
params.cwd?.trim() || undefined,
env,
params.timeoutMs ?? undefined,
);
And runCommand() directly spawns that original argv:
const child = spawn(argv[0], argv.slice(1), {
cwd,
env,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
That is why the same system.run surface behaves differently for the two controls:
sh -c 'printf ... > canary.txt' with only /usr/bin/env allowlisted:
blocked with SYSTEM_RUN_DENIED: allowlist miss
env -S 'sh -c "printf ... > canary.txt"' with /usr/bin/env allowlisted:
succeeds and writes the canary file
env -S ... with an empty allowlist:
blocked with SYSTEM_RUN_DENIED: allowlist miss
PoC
Prerequisites
- A checkout of
openclaw-cn from the released v0.2.0 line or a newer vulnerable local checkout.
- Node.js 22+ and repository dependencies installed.
- Python 3 available.
- Linux with
/usr/bin/env, /bin/sh, nc, and ss.
- The caller must be able to reach the normal Gateway WebSocket RPC surface and use
node.invoke.
- The paired node host must run with
security=allowlist, ask=off, and an allowlist entry for /usr/bin/env.
Reproduction Steps
- Download the verification script from: verification_test.py
- Download the shared harness from: shared_harness.py
- Download the Gateway RPC helper from: gateway_rpc_driver.mjs
- Download the baseline control from: control-baseline-sh-blocked.py
- Download the empty-allowlist control from: control-env-not-allowlisted.py
- Save all five files into the same directory. If you already have the supplied
Advisory-GHSA-48wf-g7cp-gr3m-node-host-env-s-exp/ directory, you can use it as-is.
- From that directory, run the main verification:
python3 verification_test.py
- Confirm the exploit evidence:
cases/verification-vuln-env-allowlisted/canary.txt exists
cases/verification-vuln-env-allowlisted/node-invoke.log shows success: true
- Run the first control:
python3 control-baseline-sh-blocked.py
- Confirm the control evidence:
cases/control-baseline-sh-blocked/canary.txt does not exist
cases/control-baseline-sh-blocked/node-invoke.log contains SYSTEM_RUN_DENIED: allowlist miss
- Run the second control:
python3 control-env-not-allowlisted.py
- Confirm the second control evidence:
cases/control-env-not-allowlisted/canary.txt does not exist
cases/control-env-not-allowlisted/node-invoke.log contains SYSTEM_RUN_DENIED: allowlist miss
Log of Evidence
The runtime evidence from the reproduced PoC was:
$ python3 verification_test.py
Verification Mode: End-to-End
[DEFECT-CONFIRMED]
verification node-invoke.log:
{"ok":true,"payload":{"ok":true,"nodeId":"82461bf3e489b71338748a85fef33131b10b1cd061a9f6d5abd713e8ab7f10f9","command":"system.run","payload":{"exitCode":0,"timedOut":false,"success":true,"stdout":"","stderr":"","error":null}}}
verification canary:
ghsa-48wf-node-host-env-s-1783250124
$ python3 control-baseline-sh-blocked.py
Verification Mode: End-to-End
[CONTROL-BLOCKED]
control-baseline node-invoke.log:
{"ok":false,"error":"SYSTEM_RUN_DENIED: allowlist miss"}
$ python3 control-env-not-allowlisted.py
Verification Mode: End-to-End
[CONTROL-BLOCKED]
control-env-not-allowlisted node-invoke.log:
{"ok":false,"error":"SYSTEM_RUN_DENIED: allowlist miss"}
Only the vulnerable case created a canary file. Both controls reached the same public interface and were denied before execution.
Impact
This is a command-execution guardrail bypass on the paired node machine.
In affected deployments, a caller that can reach the standard node.invoke(system.run) surface can execute arbitrary shell commands as the node-host service account when the operator relies on allowlist mode and allowlists /usr/bin/env. That can lead to:
- Modification of files reachable by the node-host user
- Theft of local credentials, tokens, or workspace data accessible to that account
- Tampering with automation outputs or persistence on the paired node
- Breakdown of the operator's assumption that the configured command allowlist meaningfully constrains node execution
This is most dangerous in deployments where system.run is exposed to automation or delegated operator workflows and the allowlist is treated as the last safety boundary.
Affected products
- Ecosystem: npm
- Package name: openclaw-cn
- Affected versions: <= 0.2.0
- Patched versions:
Severity
- Severity: High
- Vector string: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
Weaknesses
- CWE: CWE-88: Improper Neutralization of Argument Delimiters in a Command ('Argument Injection')
Occurrences
| Permalink |
Description |
|
const SYSTEM_COMMANDS = ["system.run", "system.which", "system.notify", "browser.proxy"]; |
|
|
|
const PLATFORM_DEFAULTS: Record<string, string[]> = { |
|
ios: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS], |
|
android: [ |
|
...CANVAS_COMMANDS, |
|
...CAMERA_COMMANDS, |
|
...SCREEN_COMMANDS, |
|
...LOCATION_COMMANDS, |
|
...SMS_COMMANDS, |
|
], |
|
macos: [ |
|
...CANVAS_COMMANDS, |
|
...CAMERA_COMMANDS, |
|
...SCREEN_COMMANDS, |
|
...LOCATION_COMMANDS, |
|
...SYSTEM_COMMANDS, |
|
], |
|
linux: [...SYSTEM_COMMANDS], |
|
windows: [...SYSTEM_COMMANDS], |
|
system.run is part of the default Linux node command surface, making the node-host execution path reachable through standard node operations. |
|
const cfg = loadConfig(); |
|
const allowlist = resolveNodeCommandAllowlist(cfg, nodeSession); |
|
const allowed = isNodeCommandAllowed({ |
|
command, |
|
declaredCommands: nodeSession.commands, |
|
allowlist, |
|
}); |
|
if (!allowed.ok) { |
|
respond( |
|
false, |
|
undefined, |
|
errorShape(ErrorCodes.INVALID_REQUEST, "node command not allowed", { |
|
details: { reason: allowed.reason, command }, |
|
}), |
|
); |
|
return; |
|
} |
|
const res = await context.nodeRegistry.invoke({ |
|
nodeId, |
|
command, |
|
params: p.params, |
|
timeoutMs: p.timeoutMs, |
|
idempotencyKey: p.idempotencyKey, |
|
}); |
|
node.invoke validates the requested node command and forwards system.run to the connected node host. This is the normal public entry point used in the reproduction. |
|
const satisfied = segments.every((segment) => { |
|
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd); |
|
const candidateResolution = |
|
candidatePath && segment.resolution |
|
? { ...segment.resolution, resolvedPath: candidatePath } |
|
: segment.resolution; |
|
const match = matchAllowlist(params.allowlist, candidateResolution); |
|
if (match) matches.push(match); |
|
const safe = isSafeBinUsage({ |
|
argv: segment.argv, |
|
resolution: segment.resolution, |
|
safeBins: params.safeBins, |
|
cwd: params.cwd, |
|
}); |
|
const skillAllow = |
|
allowSkills && segment.resolution?.executableName |
|
? params.skillBins?.has(segment.resolution.executableName) |
|
: false; |
|
return Boolean(match || safe || skillAllow); |
|
evaluateSegments() approves a parsed segment when the resolved executable path matches the allowlist. For env -S ..., that resolved identity is the wrapper /usr/bin/env, not the shell payload that later runs. |
|
export function evaluateShellAllowlist(params: { |
|
command: string; |
|
allowlist: ExecAllowlistEntry[]; |
|
safeBins: Set<string>; |
|
cwd?: string; |
|
env?: NodeJS.ProcessEnv; |
|
skillBins?: Set<string>; |
|
autoAllowSkills?: boolean; |
|
platform?: string | null; |
|
}): ExecAllowlistAnalysis { |
|
const chainParts = isWindowsPlatform(params.platform) ? null : splitCommandChain(params.command); |
|
if (!chainParts) { |
|
const analysis = analyzeShellCommand({ |
|
command: params.command, |
|
cwd: params.cwd, |
|
env: params.env, |
|
platform: params.platform, |
|
}); |
|
if (!analysis.ok) { |
|
return { |
|
analysisOk: false, |
|
allowlistSatisfied: false, |
|
allowlistMatches: [], |
|
segments: [], |
|
}; |
|
} |
|
const evaluation = evaluateExecAllowlist({ |
|
analysis, |
|
allowlist: params.allowlist, |
|
safeBins: params.safeBins, |
|
cwd: params.cwd, |
|
skillBins: params.skillBins, |
|
autoAllowSkills: params.autoAllowSkills, |
|
}); |
|
return { |
|
analysisOk: true, |
|
allowlistSatisfied: evaluation.allowlistSatisfied, |
|
allowlistMatches: evaluation.allowlistMatches, |
|
segments: analysis.segments, |
|
}; |
|
evaluateShellAllowlist() converts the raw shell string into analysisOk / allowlistSatisfied and returns success without forcing the runtime to use an equivalent canonical execution plan. |
|
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; |
|
In the rawCommand path, system.run computes allowlistSatisfied from evaluateShellAllowlist(rawCommand), so the approval decision is made against shell parsing of the raw string. |
|
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, |
|
); |
|
After approval, the node host executes the original argv through runCommand(...) instead of a normalized plan. This is the final sink that preserves /bin/sh -lc rawCommand and lets env -S dispatch the inner sh -c ... payload. |
Advisory Details
Title: node-host
system.runallowlist can be bypassed withenv -Sto reach arbitrary shell executionDescription:
The node-host
system.runapproval path inopenclaw-cnapprovesenv -Swrapper commands based on the outer/usr/bin/envexecutable, but later executes the original shell string through/bin/sh -lc. When/usr/bin/envis present in the node exec allowlist, a caller can smuggle a broadersh -c ...payload past the intended allowlist boundary and execute arbitrary commands on the paired node host.Summary
I verified a real
system.runallowlist bypass inopenclaw-cn. Through the normal Gateway WebSocket RPC surface (node.invoke), a caller can submitrawCommand = env -S 'sh -c "..."'. The allowlist logic treats the command as an approved/usr/bin/envwrapper, but the node-host runtime later executes the unchanged shell text and allowsenv -Sto dispatch the innersh -c ...payload.This is not an unauthenticated internet RCE by default. It is still a genuine security issue because it breaks the product's own command-approval invariant: an operator can configure the node host to allow only specific commands, yet
env -Sallows a materially different shell payload to run on the node anyway.The highest published GitHub release for the canonical upstream repository is
v0.2.0, and the vulnerable logic is present there. I also reproduced the issue on the current unreleased local checkout (0.2.1), but the affected-version field below is intentionally scoped to the highest released vulnerable version.Details
The canonical GitHub repository currently resolves to
mf-yang/openclaw-cn, even though the localoriginURL ishttps://github.com/jiulingyun/openclaw-cn. The latest GitHub release isv0.2.0, and that tag resolves to the remote commit:1b9f16468d9841871cb15103693c3923424c9842That same commit is used for every verified occurrence permalink in this report.
The reachable execution path is the standard node command surface:
system.runis in the default Linux node command allowlist.node.invokeaccepts a connected node command and forwards the request to node-host.node-hostevaluatesrawCommandwithevaluateShellAllowlist(...).["/bin/sh", "-lc", rawCommand].The mismatch is between what gets approved and what actually runs.
At the node-host policy stage, the raw shell text is analyzed and converted into
allowlistSatisfied:Inside the shared exec-approval logic, each parsed command segment is treated as approved when the resolved executable identity matches the allowlist:
For a payload such as:
env -S 'sh -c "printf PWNED > /tmp/canary.txt"'the parsed segment begins with
env, so/usr/bin/envsatisfies the allowlist even though the runtime later re-splits the payload and dispatchessh -c "printf PWNED > /tmp/canary.txt".Back in
src/node-host/runner.ts, once the deny branch is skipped, the node-host executes the original command array instead of a canonicalized plan derived from the allowlist analysis:And
runCommand()directly spawns that original argv:That is why the same
system.runsurface behaves differently for the two controls:sh -c 'printf ... > canary.txt'with only/usr/bin/envallowlisted:blocked with
SYSTEM_RUN_DENIED: allowlist missenv -S 'sh -c "printf ... > canary.txt"'with/usr/bin/envallowlisted:succeeds and writes the canary file
env -S ...with an empty allowlist:blocked with
SYSTEM_RUN_DENIED: allowlist missPoC
Prerequisites
openclaw-cnfrom the releasedv0.2.0line or a newer vulnerable local checkout./usr/bin/env,/bin/sh,nc, andss.node.invoke.security=allowlist,ask=off, and an allowlist entry for/usr/bin/env.Reproduction Steps
Advisory-GHSA-48wf-g7cp-gr3m-node-host-env-s-exp/directory, you can use it as-is.python3 verification_test.pycases/verification-vuln-env-allowlisted/canary.txtexistscases/verification-vuln-env-allowlisted/node-invoke.logshowssuccess: truepython3 control-baseline-sh-blocked.pycases/control-baseline-sh-blocked/canary.txtdoes not existcases/control-baseline-sh-blocked/node-invoke.logcontainsSYSTEM_RUN_DENIED: allowlist misspython3 control-env-not-allowlisted.pycases/control-env-not-allowlisted/canary.txtdoes not existcases/control-env-not-allowlisted/node-invoke.logcontainsSYSTEM_RUN_DENIED: allowlist missLog of Evidence
The runtime evidence from the reproduced PoC was:
Only the vulnerable case created a canary file. Both controls reached the same public interface and were denied before execution.
Impact
This is a command-execution guardrail bypass on the paired node machine.
In affected deployments, a caller that can reach the standard
node.invoke(system.run)surface can execute arbitrary shell commands as the node-host service account when the operator relies onallowlistmode and allowlists/usr/bin/env. That can lead to:This is most dangerous in deployments where
system.runis exposed to automation or delegated operator workflows and the allowlist is treated as the last safety boundary.Affected products
Severity
Weaknesses
Occurrences
openclaw-cn/src/gateway/node-command-policy.ts
Lines 23 to 42 in 1b9f164
system.runis part of the default Linux node command surface, making the node-host execution path reachable through standard node operations.openclaw-cn/src/gateway/server-methods/nodes.ts
Lines 403 to 426 in 1b9f164
node.invokevalidates the requested node command and forwardssystem.runto the connected node host. This is the normal public entry point used in the reproduction.openclaw-cn/src/infra/exec-approvals.ts
Lines 1109 to 1127 in 1b9f164
evaluateSegments()approves a parsed segment when the resolved executable path matches the allowlist. Forenv -S ..., that resolved identity is the wrapper/usr/bin/env, not the shell payload that later runs.openclaw-cn/src/infra/exec-approvals.ts
Lines 1278 to 1317 in 1b9f164
evaluateShellAllowlist()converts the raw shell string intoanalysisOk/allowlistSatisfiedand returns success without forcing the runtime to use an equivalent canonical execution plan.openclaw-cn/src/node-host/runner.ts
Lines 854 to 868 in 1b9f164
rawCommandpath,system.runcomputesallowlistSatisfiedfromevaluateShellAllowlist(rawCommand), so the approval decision is made against shell parsing of the raw string.openclaw-cn/src/node-host/runner.ts
Lines 1094 to 1114 in 1b9f164
argvthroughrunCommand(...)instead of a normalized plan. This is the final sink that preserves/bin/sh -lc rawCommandand letsenv -Sdispatch the innersh -c ...payload.