Skip to content

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

Description

@YLChen-007

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

  1. Download the E2E harness from: e2e_harness.py
  2. Download the control script from: control-direct-shell.py
  3. Download the verification script from: verification_test.py
  4. Run the control script:
    python3 control-direct-shell.py
  5. Confirm the request fails with SYSTEM_RUN_DENIED: allowlist miss and no control canary file is created.
  6. Run the verification script:
    python3 verification_test.py
  7. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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