Advisory Details
Title: Authenticated system.run allowlist bypass via nested /usr/bin/env wrappers in openclaw-cn
Description:
Summary
openclaw-cn contains an approval-boundary bypass in the node-host system.run path. An authenticated gateway caller that can invoke node commands can execute a shell payload that should be blocked by the configured exec allowlist by wrapping the payload in repeated /usr/bin/env dispatchers. When /usr/bin/env is allowlisted, the node-host allowlist parser approves the wrapper chain, but the runtime still executes the original shell payload through /bin/sh -lc.
This is not an unauthenticated internet RCE under the trust model documented in SECURITY.md. It is a privilege-management flaw inside the trusted operator plane: a caller with operator.write and access to node.invoke can cross the intended system.run approval boundary and run a shell command on the paired node host despite a control configuration that rejects the same payload without the wrapper chain.
Details
The highest published npm package version for openclaw-cn is 2026.2.5. The matching upstream tag v2026.2.5 exists on mf-yang/openclaw-cn and resolves to commit 253435917c161937021a14f82462a60b55056984, which is used for all verified occurrence permalinks below.
The reachable execution path is the standard node command surface:
src/gateway/server-methods.ts places node.invoke in the operator.write scope.
src/gateway/server-methods/nodes.ts accepts node.invoke, checks the declared command allowlist, and forwards the request to the connected node.
src/node-host/runner.ts handles system.run, evaluates rawCommand against exec approvals, and then executes the original command array.
The bug is the mismatch between what gets approved and what gets executed.
In src/node-host/runner.ts, when params.rawCommand is present, allowlist evaluation is performed against the raw shell string:
if (rawCommand) {
const allowlistEval = evaluateShellAllowlist({
command: rawCommand,
allowlist: approvals.allowlist,
safeBins,
cwd: params.cwd ?? undefined,
env,
skillBins: bins,
autoAllowSkills,
});
analysisOk = allowlistEval.analysisOk;
allowlistSatisfied =
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
}
In the affected v2026.2.5 code, shell parsing tokenizes each segment and resolves only the first argv element as the executable identity for allowlist matching:
export function resolveCommandResolutionFromArgv(argv: string[], cwd?: string, env?: NodeJS.ProcessEnv) {
const rawExecutable = argv[0]?.trim();
if (!rawExecutable) return null;
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return { rawExecutable, resolvedPath, executableName };
}
Then evaluateSegments() treats the whole segment as approved if that first resolved executable matches the configured 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 like:
/usr/bin/env /usr/bin/env /usr/bin/env /usr/bin/env /usr/bin/env /bin/sh -c 'printf exploit > verification-canary.txt'
the parsed segment begins with /usr/bin/env, so an allowlist entry for /usr/bin/env satisfies the segment even though later argv entries still contain /bin/sh -c ....
Back in src/node-host/runner.ts, once allowlistSatisfied is true, the deny branch is skipped and the original command array is executed:
if (security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
...
message: "SYSTEM_RUN_DENIED: allowlist miss"
}
const result = await runCommand(
argv,
params.cwd?.trim() || undefined,
env,
params.timeoutMs ?? undefined,
);
On Unix-like hosts, argv is the original ["/bin/sh", "-lc", rawCommand]. That means the runtime still enters /bin/sh -lc and eventually reaches the inner /bin/sh -c ... payload even though the approval decision was satisfied only by the outer /usr/bin/env wrapper chain.
This was confirmed end-to-end using the real CLI, a real loopback gateway, a real paired node host, and isolated exec-approval state:
- Control case: direct
/bin/sh -c 'printf control > ...'
- Result:
SYSTEM_RUN_DENIED: allowlist miss
- Canary file: not created
- Verification case: nested
/usr/bin/env ... /bin/sh -c 'printf exploit > ...'
- Result:
system.run returned success
- Canary file: created with content
exploit
PoC
Prerequisites
- A checkout of the affected
openclaw-cn repository.
- Node.js 22+ and
python3.
- Linux or macOS with
/usr/bin/env, /bin/sh, and ss available.
- The PoC files saved under:
llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-ccg8-46r6-9qgj-node-system-run-env-depth-overflow-exp/
- No source patches or mocks. The harness starts a real loopback gateway and a real node-host process in isolated temporary state directories.
Reproduction Steps
- Download the harness from: e2e_harness.py
- Download the exploit case from: verification_test.py
- Download the control case from: control-no-env-wrapper.py
- Save all three files in the same directory:
llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-ccg8-46r6-9qgj-node-system-run-env-depth-overflow-exp/
- From the repository root, run the control case:
python3 llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-ccg8-46r6-9qgj-node-system-run-env-depth-overflow-exp/control-no-env-wrapper.py
- Confirm the baseline behavior:
system.which env resolves env to /usr/bin/env
- the node approvals are
security=allowlist, ask=off, and allowlist=[/usr/bin/env]
nodes invoke --command system.run fails with SYSTEM_RUN_DENIED: allowlist miss
- no control canary file is created
- Run the verification case:
python3 llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-ccg8-46r6-9qgj-node-system-run-env-depth-overflow-exp/verification_test.py
- Confirm the bypass:
- the same approval snapshot is still in effect
system.run returns ok: true
- the verification canary file is created
- the canary file content is
exploit
Log of Evidence
Verification evidence from the saved run:
verificationMode: End-to-End
standardInterface: CLI -> Gateway WebSocket -> node.invoke -> system.run
approvals: security=allowlist, ask=off, allowlist=[/usr/bin/env]
system.which env -> /usr/bin/env
nodes invoke -> ok: true, command: system.run, payload.success: true
verification-canary.txt -> exists, content: exploit
result -> [DEFECT-CONFIRMED]
Control evidence from the saved run:
verificationMode: End-to-End
standardInterface: CLI -> Gateway WebSocket -> node.invoke -> system.run
approvals: security=allowlist, ask=off, allowlist=[/usr/bin/env]
system.which env -> /usr/bin/env
nodes invoke failed: Error: SYSTEM_RUN_DENIED: allowlist miss
control-canary.txt -> not created
result -> [CONTROL-BLOCKED]
Artifacts recorded by the harness:
verification_result.json
control_result.json
observation.log
Impact
This is an improper privilege management vulnerability in the node-host command execution boundary. It impacts deployments that rely on system.run exec approvals in allowlist mode to constrain which commands a paired node may run. If /usr/bin/env is present in the allowlist, an authenticated caller with operator.write access to node.invoke can transform a shell payload that should be rejected into one that passes the allowlist check and still executes on the node host. In practice, this weakens the operator’s command-approval invariant and can lead to unauthorized filesystem modification, process execution, and follow-on access on the paired node.
Affected products
- Ecosystem: npm
- Package name: openclaw-cn
- Affected versions: <= 2026.2.5
- Patched versions:
Severity
- Severity: High
- Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Weaknesses
- CWE: CWE-269: Improper Privilege Management
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; |
|
} |
|
|
|
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, |
|
}); |
|
node.invoke is the standard reachable entry point. The gateway validates the requested node command and then forwards system.run to the connected node host. |
|
if (rawCommand) { |
|
const allowlistEval = evaluateShellAllowlist({ |
|
command: rawCommand, |
|
allowlist: approvals.allowlist, |
|
safeBins, |
|
cwd: params.cwd ?? undefined, |
|
env, |
|
skillBins: bins, |
|
autoAllowSkills, |
|
}); |
|
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 depends on shell parsing of the raw string rather than on the final command array that will be executed. |
|
export function resolveCommandResolutionFromArgv( |
|
argv: string[], |
|
cwd?: string, |
|
env?: NodeJS.ProcessEnv, |
|
): CommandResolution | null { |
|
const rawExecutable = argv[0]?.trim(); |
|
if (!rawExecutable) return null; |
|
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env); |
|
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; |
|
return { rawExecutable, resolvedPath, executableName }; |
|
resolveCommandResolutionFromArgv() derives the segment identity from argv[0] only. For a wrapped payload, that executable is /usr/bin/env, not the later /bin/sh -c ... shell that ultimately runs. |
|
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() marks the entire segment as approved when matchAllowlist() accepts the resolved executable path. A segment that starts with /usr/bin/env therefore satisfies the allowlist even when later argv elements still contain a shell payload. |
|
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; |
|
} |
|
|
|
const result = await runCommand( |
|
argv, |
|
params.cwd?.trim() || undefined, |
|
env, |
|
params.timeoutMs ?? undefined, |
|
); |
|
After the allowlist check passes, the deny branch is skipped and runCommand(argv, ...) executes the original ["/bin/sh", "-lc", rawCommand] array. This is the final sink that lets the inner /bin/sh -c ... payload run despite the wrapper-based approval. |
Advisory Details
Title: Authenticated
system.runallowlist bypass via nested/usr/bin/envwrappers inopenclaw-cnDescription:
Summary
openclaw-cncontains an approval-boundary bypass in the node-hostsystem.runpath. An authenticated gateway caller that can invoke node commands can execute a shell payload that should be blocked by the configured exec allowlist by wrapping the payload in repeated/usr/bin/envdispatchers. When/usr/bin/envis allowlisted, the node-host allowlist parser approves the wrapper chain, but the runtime still executes the original shell payload through/bin/sh -lc.This is not an unauthenticated internet RCE under the trust model documented in
SECURITY.md. It is a privilege-management flaw inside the trusted operator plane: a caller withoperator.writeand access tonode.invokecan cross the intendedsystem.runapproval boundary and run a shell command on the paired node host despite a control configuration that rejects the same payload without the wrapper chain.Details
The highest published
npmpackage version foropenclaw-cnis2026.2.5. The matching upstream tagv2026.2.5exists onmf-yang/openclaw-cnand resolves to commit253435917c161937021a14f82462a60b55056984, which is used for all verified occurrence permalinks below.The reachable execution path is the standard node command surface:
src/gateway/server-methods.tsplacesnode.invokein theoperator.writescope.src/gateway/server-methods/nodes.tsacceptsnode.invoke, checks the declared command allowlist, and forwards the request to the connected node.src/node-host/runner.tshandlessystem.run, evaluatesrawCommandagainst exec approvals, and then executes the originalcommandarray.The bug is the mismatch between what gets approved and what gets executed.
In
src/node-host/runner.ts, whenparams.rawCommandis present, allowlist evaluation is performed against the raw shell string:In the affected
v2026.2.5code, shell parsing tokenizes each segment and resolves only the first argv element as the executable identity for allowlist matching:Then
evaluateSegments()treats the whole segment as approved if that first resolved executable matches the configured allowlist:For a payload like:
/usr/bin/env /usr/bin/env /usr/bin/env /usr/bin/env /usr/bin/env /bin/sh -c 'printf exploit > verification-canary.txt'the parsed segment begins with
/usr/bin/env, so an allowlist entry for/usr/bin/envsatisfies the segment even though later argv entries still contain/bin/sh -c ....Back in
src/node-host/runner.ts, onceallowlistSatisfiedis true, the deny branch is skipped and the original command array is executed:On Unix-like hosts,
argvis the original["/bin/sh", "-lc", rawCommand]. That means the runtime still enters/bin/sh -lcand eventually reaches the inner/bin/sh -c ...payload even though the approval decision was satisfied only by the outer/usr/bin/envwrapper chain.This was confirmed end-to-end using the real CLI, a real loopback gateway, a real paired node host, and isolated exec-approval state:
/bin/sh -c 'printf control > ...'SYSTEM_RUN_DENIED: allowlist miss/usr/bin/env ... /bin/sh -c 'printf exploit > ...'system.runreturned successexploitPoC
Prerequisites
openclaw-cnrepository.python3./usr/bin/env,/bin/sh, andssavailable.llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-ccg8-46r6-9qgj-node-system-run-env-depth-overflow-exp/Reproduction Steps
llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-ccg8-46r6-9qgj-node-system-run-env-depth-overflow-exp/python3 llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-ccg8-46r6-9qgj-node-system-run-env-depth-overflow-exp/control-no-env-wrapper.pysystem.which envresolvesenvto/usr/bin/envsecurity=allowlist,ask=off, andallowlist=[/usr/bin/env]nodes invoke --command system.runfails withSYSTEM_RUN_DENIED: allowlist misspython3 llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-ccg8-46r6-9qgj-node-system-run-env-depth-overflow-exp/verification_test.pysystem.runreturnsok: trueexploitLog of Evidence
Verification evidence from the saved run:
Control evidence from the saved run:
Artifacts recorded by the harness:
verification_result.jsoncontrol_result.jsonobservation.logImpact
This is an improper privilege management vulnerability in the node-host command execution boundary. It impacts deployments that rely on
system.runexec approvals inallowlistmode to constrain which commands a paired node may run. If/usr/bin/envis present in the allowlist, an authenticated caller withoperator.writeaccess tonode.invokecan transform a shell payload that should be rejected into one that passes the allowlist check and still executes on the node host. In practice, this weakens the operator’s command-approval invariant and can lead to unauthorized filesystem modification, process execution, and follow-on access on the paired node.Affected products
Severity
Weaknesses
Occurrences
openclaw-cn/src/gateway/server-methods/nodes.ts
Lines 352 to 414 in 2534359
node.invokeis the standard reachable entry point. The gateway validates the requested node command and then forwardssystem.runto the connected node host.openclaw-cn/src/node-host/runner.ts
Lines 827 to 840 in 2534359
rawCommandpath,system.runcomputesallowlistSatisfiedfromevaluateShellAllowlist(rawCommand), so the approval decision depends on shell parsing of the raw string rather than on the final command array that will be executed.openclaw-cn/src/infra/exec-approvals.ts
Lines 419 to 428 in 2534359
resolveCommandResolutionFromArgv()derives the segment identity fromargv[0]only. For a wrapped payload, that executable is/usr/bin/env, not the later/bin/sh -c ...shell that ultimately runs.openclaw-cn/src/infra/exec-approvals.ts
Lines 893 to 912 in 2534359
evaluateSegments()marks the entire segment as approved whenmatchAllowlist()accepts the resolved executable path. A segment that starts with/usr/bin/envtherefore satisfies the allowlist even when later argv elements still contain a shell payload.openclaw-cn/src/node-host/runner.ts
Lines 1000 to 1058 in 2534359
runCommand(argv, ...)executes the original["/bin/sh", "-lc", rawCommand]array. This is the final sink that lets the inner/bin/sh -c ...payload run despite the wrapper-based approval.