Skip to content

[Security] POSIX exec-approvals allowlist overmatch lets unintended binaries execute via node-host system.run #608

Description

@YLChen-007

Advisory Details

Title: POSIX exec-approvals allowlist overmatch lets unintended binaries execute via node-host system.run

Description:

openclaw-cn's node-host exec approval matcher treats POSIX executable paths as case-insensitive and lets ? match /, which causes operator-authored allowlist entries to overmatch unintended executables. An authenticated caller that is already permitted to use the supported node.invoke(system.run) path can therefore execute binaries outside the intended exact-path allowlist on a paired POSIX node.

Summary

A logic flaw in the POSIX exec-allowlist matcher allows a paired node's system.run guardrail to be bypassed. When tools.exec.security=allowlist is used for a node host, the current matcher lowercases POSIX paths, compiles ? as ., and applies a case-insensitive regex, so an allowlist entry that was meant to authorize one exact executable can unintentionally authorize a differently cased path or a path separated by an extra /. This defeats the primary local containment mechanism documented for remote node execution.

Details

The vulnerable logic lives in src/infra/exec-approvals.ts and is consumed by the real node-host execution path in src/node-host/runner.ts.

The first problem is that non-Windows paths are normalized to lowercase, ? is compiled as ., and the resulting regex is created with the i flag:

function normalizeMatchTarget(value: string): string {
  if (process.platform === "win32") {
    const stripped = value.replace(/^\\\\[?.]\\/, "");
    return stripped.replace(/\\/g, "/").toLowerCase();
  }
  return value.replace(/\\\\/g, "/").toLowerCase();
}

function globToRegExp(pattern: string): RegExp {
  // ...
  if (ch === "?") {
    regex += ".";
  }
  // ...
  return new RegExp(regex, "i");
}

That matcher is then applied directly to the resolved executable path:

export function matchAllowlist(entries, resolution) {
  const resolvedPath = resolution.resolvedPath;
  for (const entry of entries) {
    const pattern = entry.pattern?.trim();
    if (matchesPattern(pattern, resolvedPath)) return entry;
  }
  return null;
}

On the real node-host path, handleInvoke() derives allowlistSatisfied from evaluateShellAllowlist(). If that value is true, the request falls through to runCommand():

const allowlistEval = evaluateShellAllowlist({ command: rawCommand, allowlist: approvals.allowlist, ... });
allowlistSatisfied =
  security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;

if (security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
  return "SYSTEM_RUN_DENIED: allowlist miss";
}

const result = await runCommand(execArgv, params.cwd?.trim() || undefined, env, params.timeoutMs ?? undefined);

This creates two concrete bypasses on POSIX systems:

  1. A stored allowlist entry for /.../Allowed-Tool also matches /.../allowed-tool.
  2. A stored allowlist entry for /.../a?b also matches the real path /.../a/b, because . crosses path separators.

I verified the issue on the current openclaw-cn package version 0.2.1, whose highest affected upstream tag is v0.2.1. The local Git remote still points to https://github.com/jiulingyun/openclaw-cn, but GitHub resolves that repository to the canonical path https://github.com/mf-yang/openclaw-cn; the occurrence permalinks below use the canonical repository path and the exact tag commit.

PoC

Prerequisites

  • Node.js 22+ and the project dependencies installed.
  • A POSIX environment (Linux/macOS-style path semantics).
  • The repository checked out and built with pnpm build.
  • Ability to run the existing localhost-only PoC harness that starts a temporary gateway and a temporary node host.

Reproduction Steps

  1. Download the main verification script from: verification_test.py
  2. Download the control script from: control-posix-glob-overmatch.py
  3. Download the helper files used by the harness:
  4. Build the repository so the current runtime artifacts match the checked-out source:
    • pnpm build
  5. Run the positive verification:
    • python3 llm-enhance/cve-finding/similar/exec-allowlist-bypass/Advisory-GHSA-f8r2-vg7x-gh8m-posix-path-glob-overmatch-exp/verification_test.py
  6. Observe that both bypass cases succeed:
    • the casefold sample allows .../allowed-tool when the allowlist contains .../Allowed-Tool
    • the wildcard sample allows .../a/b when the allowlist contains .../a?b
  7. Run the negative control:
    • python3 llm-enhance/cve-finding/similar/exec-allowlist-bypass/Advisory-GHSA-f8r2-vg7x-gh8m-posix-path-glob-overmatch-exp/control-posix-glob-overmatch.py
  8. Observe that both unrelated allowlist entries are rejected with SYSTEM_RUN_DENIED: allowlist miss, and no control canary file is created.

Log of Evidence

The current end-to-end verification produced the following runtime evidence:

- vuln-posix-casefold: allowed; exit=0; allowlist=.../casefold/bin/Allowed-Tool
  response={"ok": true, "command": "system.run", "payload": {"exitCode": 0, "success": true, ...}}
- vuln-qmark-cross-segment: allowed; exit=0; allowlist=.../qmark/a?b
  response={"ok": true, "command": "system.run", "payload": {"exitCode": 0, "success": true, ...}}
[DEFECT CONFIRMED] Vulnerable allowlist patterns execute through the real gateway/node-host WebSocket path and write the expected canary files.

The matching negative control produced:

- control-unrelated-casefold: denied; response={"ok": false, "error": "SYSTEM_RUN_DENIED: allowlist miss"}
- control-unrelated-qmark: denied; response={"ok": false, "error": "SYSTEM_RUN_DENIED: allowlist miss"}
[CONTROL OK] Non-matching allowlist patterns are denied and do not write canaries.

The filesystem evidence matched the RPC results:

  • vuln-posix-casefold/canary.txt contained CASEFOLD-VULN
  • vuln-qmark-cross-segment/canary.txt contained QMARK-VULN
  • control cases did not create canary files

Impact

This is an allowlist-bypass vulnerability on the node-host remote execution surface. The impacted asset is the node machine where system.run executes. A deployment that intentionally relies on tools.exec.security=allowlist to narrow remote command execution to a small set of exact binaries can be tricked into executing unintended programs on that node. The eventual blast radius depends on the node host's local OS permissions and installed tooling, but at minimum it breaks the documented containment boundary for remote execution and can lead to unauthorized command execution, file access, or further tool-chain abuse on the paired node.

Affected products

  • Ecosystem: npm
  • Package name: openclaw-cn
  • Affected versions: <= 0.2.1
  • Patched versions:

Severity

  • Severity: Medium
  • Vector string: CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:H/A:L

Weaknesses

  • CWE: CWE-184: Incomplete List of Disallowed Inputs

Occurrences

Permalink Description
function normalizeMatchTarget(value: string): string {
if (process.platform === "win32") {
const stripped = value.replace(/^\\\\[?.]\\/, "");
return stripped.replace(/\\/g, "/").toLowerCase();
}
return value.replace(/\\\\/g, "/").toLowerCase();
}
function tryRealpath(value: string): string | null {
try {
return fs.realpathSync(value);
} catch {
return null;
}
}
function globToRegExp(pattern: string): RegExp {
let regex = "^";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === "*") {
const next = pattern[i + 1];
if (next === "*") {
regex += ".*";
i += 2;
continue;
}
regex += "[^/]*";
i += 1;
continue;
}
if (ch === "?") {
regex += ".";
i += 1;
continue;
}
regex += ch.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&");
i += 1;
}
regex += "$";
return new RegExp(regex, "i");
}
function matchesPattern(pattern: string, target: string): boolean {
const trimmed = pattern.trim();
if (!trimmed) return false;
const expanded = trimmed.startsWith("~") ? expandHome(trimmed) : trimmed;
const hasWildcard = /[*?]/.test(expanded);
let normalizedPattern = expanded;
let normalizedTarget = target;
if (process.platform === "win32" && !hasWildcard) {
normalizedPattern = tryRealpath(expanded) ?? expanded;
normalizedTarget = tryRealpath(target) ?? target;
}
normalizedPattern = normalizeMatchTarget(normalizedPattern);
normalizedTarget = normalizeMatchTarget(normalizedTarget);
const regex = globToRegExp(normalizedPattern);
return regex.test(normalizedTarget);
The POSIX matcher lowercases paths, compiles ? as ., and creates a case-insensitive regex, which causes casefold and slash-crossing overmatches.
export function matchAllowlist(
entries: ExecAllowlistEntry[],
resolution: CommandResolution | null,
): ExecAllowlistEntry | null {
if (!entries.length || !resolution?.resolvedPath) return null;
const resolvedPath = resolution.resolvedPath;
for (const entry of entries) {
const pattern = entry.pattern?.trim();
if (!pattern) continue;
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
if (!hasPath) continue;
if (matchesPattern(pattern, resolvedPath)) return entry;
matchAllowlist() applies the flawed pattern matcher directly to the resolved executable path and returns a successful allowlist entry on the first overmatch.
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;
The node-host system.run handler derives allowlistSatisfied from evaluateShellAllowlist() when a caller supplies rawCommand.
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;
}
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,
Requests are denied only on an allowlist miss; once the flawed matcher reports success, execution falls through to runCommand() on the node host.

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