Skip to content

[Security] Authenticated system.run allowlist bypass via nested /usr/bin/env wrappers in openclaw-cn #568

Description

@YLChen-007

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

  1. Download the harness from: e2e_harness.py
  2. Download the exploit case from: verification_test.py
  3. Download the control case from: control-no-env-wrapper.py
  4. 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/
  5. 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
  6. 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
  7. 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
  8. 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.

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