Advisory Details
Title: system.run allowlist approval bypass via nested /usr/bin/env dispatch wrappers in openclaw-cn
Description:
Summary
An approval-boundary bypass in the node-host system.run execution path allows an authenticated gateway operator to execute a shell payload that should be rejected by the configured allowlist. When /usr/bin/env is allowlisted, repeated transparent /usr/bin/env wrappers cause rawCommand analysis to approve the request even though the effective payload still reaches /bin/sh -c.
Details
The vulnerable flow starts at the documented operator-facing node.invoke RPC. Gateway-side dispatch accepts command: "system.run" and forwards the supplied parameters to the paired node host:
const res = await context.nodeRegistry.invoke({
nodeId,
command,
params: p.params,
timeoutMs: p.timeoutMs,
idempotencyKey: p.idempotencyKey,
});
On the node host, src/node-host/runner.ts reads attacker-controlled params.rawCommand and evaluates it with evaluateShellAllowlist(...) before deciding whether the configured allowlist requirement has been satisfied:
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : "";
const allowlistEval = evaluateShellAllowlist({
command: rawCommand,
allowlist: approvals.allowlist,
safeBins,
cwd: params.cwd ?? undefined,
env,
skillBins: bins,
autoAllowSkills,
platform: process.platform,
});
allowlistSatisfied =
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
The root cause is in src/infra/exec-approvals.ts. evaluateShellAllowlist(...) analyzes the shell command as segments and returns success when every analyzed segment is satisfied by the configured allowlist. It does not fail closed when repeated transparent /usr/bin/env wrappers preserve an inner shell payload:
export function evaluateShellAllowlist(params: {
command: string;
allowlist: ExecAllowlistEntry[];
...
}): ExecAllowlistAnalysis {
const analysis = analyzeShellCommand({
command: params.command,
cwd: params.cwd,
env: params.env,
platform: params.platform,
});
...
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,
};
}
The dangerous sink remains shell execution through buildNodeShellCommand(...), which wraps the original rawCommand in /bin/sh -lc on Unix-like systems:
export function buildNodeShellCommand(command: string, platform?: string | null) {
...
return ["/bin/sh", "-lc", command];
}
This creates an approval mismatch:
- Direct shell payload:
/bin/sh -c 'printf blocked > control-canary.txt'
is denied with SYSTEM_RUN_DENIED: allowlist miss.
- Wrapped shell payload:
/usr/bin/env /usr/bin/env /usr/bin/env /usr/bin/env /usr/bin/env /bin/sh -c 'printf reached > verification-canary.txt'
is accepted when /usr/bin/env is allowlisted, and the inner /bin/sh -c executes successfully.
The included end-to-end reproduction confirms that verification-canary.txt is written with content reached, while the control case writes nothing.
PoC
Prerequisites
- A deployment using
openclaw-cn with a paired node that exposes system.run.
- Ability to act as an authenticated gateway operator or equivalent automation path that can call
node.invoke.
- Node exec approvals configured with:
security=allowlist
ask=off
askFallback=deny
- allowlist entry
/usr/bin/env
- Node.js 22+ and the project runtime dependencies installed.
Reproduction Steps
- Download the E2E harness from: e2e_harness.py
- Download the control script from: control-direct-shell.py
- Download the verification script from: verification_test.py
- Run the control script:
python3 control-direct-shell.py
- Confirm the request fails with
SYSTEM_RUN_DENIED: allowlist miss and no control canary file is created.
- Run the verification script:
python3 verification_test.py
- Confirm the request succeeds and
verification-canary.txt is created with content reached.
Log of Evidence
Control evidence from control_result.json:
result: [CONTROL-BLOCKED]
stderr: nodes invoke failed: Error: SYSTEM_RUN_DENIED: allowlist miss
canaryExists: false
Verification evidence from verification_result.json:
result: [DEFECT-CONFIRMED]
returncode: 0
payload.success: true
canaryExists: true
canaryContent: reached
Observation index from observation.log:
control-direct-shell.py -> [CONTROL-BLOCKED]
verification_test.py -> [DEFECT-CONFIRMED]
Impact
This is an authorization / approval-boundary bypass in the node-host command execution plane. It does not let an unauthenticated internet attacker compromise the gateway directly. Instead, it lets a caller who is already authorized to reach node system.run bypass the configured command-approval invariant and execute shell logic that the same configuration rejects in the direct-shell case.
The impact depends on the privileges of the node-host process and the assets reachable from that machine. A successful exploit can execute arbitrary shell commands in the node-host execution context, read or modify files available to that account, and abuse that host's network reachability or local credentials.
Affected products
- Ecosystem: npm
- Package name: openclaw-cn
- Affected versions: <= 0.2.0
- Patched versions:
Severity
- Severity: Medium
- Vector string: CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H
Weaknesses
- CWE: CWE-285: Improper Authorization
Occurrences
| Permalink |
Description |
|
"node.invoke": async ({ params, respond, context }) => { |
|
if (!validateNodeInvokeParams(params)) { |
|
respondInvalidParams({ |
|
respond, |
|
method: "node.invoke", |
|
validator: validateNodeInvokeParams, |
|
}); |
|
return; |
|
} |
|
const p = params as { |
|
nodeId: string; |
|
command: string; |
|
params?: unknown; |
|
timeoutMs?: number; |
|
idempotencyKey: string; |
|
}; |
|
const nodeId = String(p.nodeId ?? "").trim(); |
|
const command = String(p.command ?? "").trim(); |
|
if (!nodeId || !command) { |
|
respond( |
|
false, |
|
undefined, |
|
errorShape(ErrorCodes.INVALID_REQUEST, "nodeId and command required"), |
|
); |
|
return; |
|
} |
|
if (command === "system.execApprovals.get" || command === "system.execApprovals.set") { |
|
respond( |
|
false, |
|
undefined, |
|
errorShape( |
|
ErrorCodes.INVALID_REQUEST, |
|
"node.invoke does not allow system.execApprovals.*; use exec.approvals.node.*", |
|
{ details: { command } }, |
|
), |
|
); |
|
return; |
|
} |
|
|
|
await respondUnavailableOnThrow(respond, async () => { |
|
const nodeSession = context.nodeRegistry.get(nodeId); |
|
if (!nodeSession) { |
|
respond( |
|
false, |
|
undefined, |
|
errorShape(ErrorCodes.UNAVAILABLE, "node not connected", { |
|
details: { code: "NOT_CONNECTED" }, |
|
}), |
|
); |
|
return; |
|
} |
|
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, |
|
}); |
|
if (!res.ok) { |
|
respond( |
|
false, |
|
undefined, |
|
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", { |
|
details: { nodeError: res.error ?? null }, |
|
}), |
|
); |
|
return; |
|
} |
|
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload; |
|
respond( |
|
The public node.invoke RPC forwards operator-controlled system.run parameters to the paired node host. |
|
const argv = params.command.map((item) => String(item)); |
|
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : ""; |
|
const cmdText = rawCommand || formatCommand(argv); |
|
const agentId = params.agentId?.trim() || undefined; |
|
const cfg = loadConfig(); |
|
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined; |
|
const configuredSecurity = resolveExecSecurity(agentExec?.security ?? cfg.tools?.exec?.security); |
|
const configuredAsk = resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask); |
|
const approvals = resolveExecApprovals(agentId, { |
|
security: configuredSecurity, |
|
ask: configuredAsk, |
|
}); |
|
const security = approvals.agent.security; |
|
const ask = approvals.agent.ask; |
|
const autoAllowSkills = approvals.agent.autoAllowSkills; |
|
const sessionKey = params.sessionKey?.trim() || "node"; |
|
const runId = params.runId?.trim() || crypto.randomUUID(); |
|
const env = sanitizeEnv(params.env ?? undefined); |
|
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins); |
|
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>(); |
|
let analysisOk = false; |
|
let allowlistMatches: ExecAllowlistEntry[] = []; |
|
let allowlistSatisfied = false; |
|
let segments: ExecCommandSegment[] = []; |
|
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; |
|
segments = allowlistEval.segments; |
|
} else { |
|
const analysis = analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env }); |
|
const allowlistEval = evaluateExecAllowlist({ |
|
analysis, |
|
allowlist: approvals.allowlist, |
|
safeBins, |
|
cwd: params.cwd ?? undefined, |
|
skillBins: bins, |
|
autoAllowSkills, |
|
}); |
|
analysisOk = analysis.ok; |
|
allowlistMatches = allowlistEval.allowlistMatches; |
|
allowlistSatisfied = |
|
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false; |
|
segments = analysis.segments; |
|
} |
|
const isWindows = process.platform === "win32"; |
|
const cmdInvocation = rawCommand |
|
? isCmdExeInvocation(segments[0]?.argv ?? []) |
|
: isCmdExeInvocation(argv); |
|
if (security === "allowlist" && isWindows && cmdInvocation) { |
|
The node host reads attacker-controlled rawCommand, evaluates shell allowlist satisfaction, and uses that decision to gate execution. |
|
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, |
|
}; |
|
} |
|
|
|
const allowlistMatches: ExecAllowlistEntry[] = []; |
|
const segments: ExecCommandSegment[] = []; |
|
|
|
for (const part of chainParts) { |
|
const analysis = analyzeShellCommand({ |
|
command: part, |
|
cwd: params.cwd, |
|
env: params.env, |
|
platform: params.platform, |
|
}); |
|
if (!analysis.ok) { |
|
return { |
|
analysisOk: false, |
|
allowlistSatisfied: false, |
|
allowlistMatches: [], |
|
segments: [], |
|
}; |
|
} |
|
|
|
segments.push(...analysis.segments); |
|
const evaluation = evaluateExecAllowlist({ |
|
analysis, |
|
allowlist: params.allowlist, |
|
safeBins: params.safeBins, |
|
cwd: params.cwd, |
|
skillBins: params.skillBins, |
|
autoAllowSkills: params.autoAllowSkills, |
|
}); |
|
allowlistMatches.push(...evaluation.allowlistMatches); |
|
if (!evaluation.allowlistSatisfied) { |
|
return { |
|
analysisOk: true, |
|
allowlistSatisfied: false, |
|
allowlistMatches, |
|
segments, |
|
}; |
|
} |
|
} |
|
|
|
return { |
|
analysisOk: true, |
|
allowlistSatisfied: true, |
|
allowlistMatches, |
|
segments, |
|
}; |
|
} |
|
evaluateShellAllowlist(...) approves segmented shell commands without failing closed on repeated transparent /usr/bin/env wrappers that preserve an inner shell payload. |
|
export function buildNodeShellCommand(command: string, platform?: string | null) { |
|
const normalized = String(platform ?? "") |
|
.trim() |
|
.toLowerCase(); |
|
if (normalized.startsWith("win")) { |
|
return ["cmd.exe", "/d", "/s", "/c", command]; |
|
} |
|
return ["/bin/sh", "-lc", command]; |
|
The execution sink wraps the original command as /bin/sh -lc <rawCommand>, preserving the inner shell payload that the allowlist analysis failed to reject. |
Advisory Details
Title:
system.runallowlist approval bypass via nested/usr/bin/envdispatch wrappers inopenclaw-cnDescription:
Summary
An approval-boundary bypass in the node-host
system.runexecution path allows an authenticated gateway operator to execute a shell payload that should be rejected by the configured allowlist. When/usr/bin/envis allowlisted, repeated transparent/usr/bin/envwrappers causerawCommandanalysis to approve the request even though the effective payload still reaches/bin/sh -c.Details
The vulnerable flow starts at the documented operator-facing
node.invokeRPC. Gateway-side dispatch acceptscommand: "system.run"and forwards the supplied parameters to the paired node host:On the node host,
src/node-host/runner.tsreads attacker-controlledparams.rawCommandand evaluates it withevaluateShellAllowlist(...)before deciding whether the configuredallowlistrequirement has been satisfied:The root cause is in
src/infra/exec-approvals.ts.evaluateShellAllowlist(...)analyzes the shell command as segments and returns success when every analyzed segment is satisfied by the configured allowlist. It does not fail closed when repeated transparent/usr/bin/envwrappers preserve an inner shell payload:The dangerous sink remains shell execution through
buildNodeShellCommand(...), which wraps the originalrawCommandin/bin/sh -lcon Unix-like systems:This creates an approval mismatch:
/bin/sh -c 'printf blocked > control-canary.txt'is denied with
SYSTEM_RUN_DENIED: allowlist miss./usr/bin/env /usr/bin/env /usr/bin/env /usr/bin/env /usr/bin/env /bin/sh -c 'printf reached > verification-canary.txt'is accepted when
/usr/bin/envis allowlisted, and the inner/bin/sh -cexecutes successfully.The included end-to-end reproduction confirms that
verification-canary.txtis written with contentreached, while the control case writes nothing.PoC
Prerequisites
openclaw-cnwith a paired node that exposessystem.run.node.invoke.security=allowlistask=offaskFallback=deny/usr/bin/envReproduction Steps
python3 control-direct-shell.pySYSTEM_RUN_DENIED: allowlist missand no control canary file is created.python3 verification_test.pyverification-canary.txtis created with contentreached.Log of Evidence
Control evidence from
control_result.json:Verification evidence from
verification_result.json:Observation index from
observation.log:Impact
This is an authorization / approval-boundary bypass in the node-host command execution plane. It does not let an unauthenticated internet attacker compromise the gateway directly. Instead, it lets a caller who is already authorized to reach node
system.runbypass the configured command-approval invariant and execute shell logic that the same configuration rejects in the direct-shell case.The impact depends on the privileges of the node-host process and the assets reachable from that machine. A successful exploit can execute arbitrary shell commands in the node-host execution context, read or modify files available to that account, and abuse that host's network reachability or local credentials.
Affected products
Severity
Weaknesses
Occurrences
openclaw-cn/src/gateway/server-methods/nodes.ts
Lines 352 to 438 in 1b9f164
node.invokeRPC forwards operator-controlledsystem.runparameters to the paired node host.openclaw-cn/src/node-host/runner.ts
Lines 830 to 890 in 1b9f164
rawCommand, evaluates shell allowlist satisfaction, and uses that decision to gate execution.openclaw-cn/src/infra/exec-approvals.ts
Lines 1278 to 1365 in 1b9f164
evaluateShellAllowlist(...)approves segmented shell commands without failing closed on repeated transparent/usr/bin/envwrappers that preserve an inner shell payload.openclaw-cn/src/infra/node-shell.ts
Lines 1 to 8 in 1b9f164
/bin/sh -lc <rawCommand>, preserving the inner shell payload that the allowlist analysis failed to reject.