Skip to content

[Security] node-host system.run allowlist can be bypassed with env -S to reach arbitrary shell execution #607

Description

@YLChen-007

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:

  1. system.run is in the default Linux node command allowlist.
  2. node.invoke accepts a connected node command and forwards the request to node-host.
  3. node-host evaluates rawCommand with evaluateShellAllowlist(...).
  4. 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

  1. Download the verification script from: verification_test.py
  2. Download the shared harness from: shared_harness.py
  3. Download the Gateway RPC helper from: gateway_rpc_driver.mjs
  4. Download the baseline control from: control-baseline-sh-blocked.py
  5. Download the empty-allowlist control from: control-env-not-allowlisted.py
  6. 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.
  7. From that directory, run the main verification:
    python3 verification_test.py
  8. Confirm the exploit evidence:
    • cases/verification-vuln-env-allowlisted/canary.txt exists
    • cases/verification-vuln-env-allowlisted/node-invoke.log shows success: true
  9. Run the first control:
    python3 control-baseline-sh-blocked.py
  10. 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
  1. Run the second control:
    python3 control-env-not-allowlisted.py
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions