Skip to content

[Security] OpenClaw CN gateway exec safeBins validation leaks workdir file existence through state-dependent allowlist decisions #606

Description

@YLChen-007

Advisory Details

Title: OpenClaw CN gateway exec safeBins validation leaks workdir file existence through state-dependent allowlist decisions

Description:

OpenClaw CN treats sort as a stdin-only safe binary in gateway exec allowlist mode, but the active validator still consults the host filesystem when deciding whether a bare filename operand is safe. This makes the allow/deny result depend on whether the referenced file already exists in the agent workdir, creating a filesystem-existence oracle on the supported agent -> exec path.

Summary

The gateway-host exec allowlist path in OpenClaw CN is supposed to let a small safe-bin set run only in stdin-only shapes. Instead, isSafeBinUsage(...) rejects bare operands such as existing.txt only when path.resolve(cwd, token) already exists on disk. A lower-trust caller that can influence an agent session configured with exec.host=gateway, security=allowlist, and default safe bins can distinguish existing from missing files in the workdir by comparing the tool result classes for sort -o existing.txt vs sort -o missing.txt. In the local unfixed branch verified here, the missing-file branch continues into a separate consumer mismatch and throws a distinct segmentSatisfiedBy TypeError, which makes the oracle especially obvious.

Details

I verified the runtime behavior against the current local unfixed tree using the real loopback Gateway and the public WebSocket agent interface, then resolved the highest published npm version that still contains the same root cause for permalink-safe occurrences.

The supported source-to-sink path is:

WS agent request
  -> agent session
  -> model-issued exec tool call
  -> src/agents/bash-tools.exec.ts
  -> evaluateShellAllowlist(...)
  -> src/infra/exec-approvals.ts:isSafeBinUsage(...)
  -> observable allow/deny/error result

The vulnerable validator is the bare-filename handling inside isSafeBinUsage(...):

for (let i = 0; i < argv.length; i += 1) {
  const token = argv[i];
  if (!token) continue;
  if (token === "-") continue;
  if (token.startsWith("-")) {
    const eqIndex = token.indexOf("=");
    if (eqIndex > 0) {
      const value = token.slice(eqIndex + 1);
      if (value && (isPathLikeToken(value) || exists(path.resolve(cwd, value)))) {
        return false;
      }
    }
    continue;
  }
  if (isPathLikeToken(token)) return false;
  if (exists(path.resolve(cwd, token))) return false;
}

That logic does two different things depending on argv shape:

  • Explicit paths such as ./existing.txt are blocked immediately by isPathLikeToken(...).
  • Bare names such as existing.txt or missing.txt are not blocked immediately. They are resolved against cwd, and the validator asks the real filesystem whether they exist right now.

That is the root bug. A stdin-only allowlist decision should be deterministic from argv shape; it should not depend on host state.

On the gateway execution path, the attacker-controlled command string is evaluated directly through evaluateShellAllowlist(...):

const allowlistEval = evaluateShellAllowlist({
  command: params.command,
  allowlist: approvals.allowlist,
  safeBins,
  cwd: workdir,
  env,
});

If allowlist analysis fails, the caller sees a direct denial:

if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied)) {
  throw new Error("exec denied: allowlist miss");
}

That gives a clean oracle:

  • sort -o ./existing.txt -> denied
  • sort -o ./missing.txt -> denied
  • sort -o existing.txt -> denied only because the file exists
  • sort -o missing.txt -> continues down the safe-bin success path instead of taking the same denial branch

In the current local verification tree, that non-denied branch then hits a second bug: the consumer expects allowlistEval.segmentSatisfiedBy, but the active evaluator object does not provide it. The result is a distinct TypeError for the missing-file probe. That crash is not required for the vulnerability; it only makes the oracle easier to observe.

Version resolution used for this report:

  • npm view openclaw-cn version --userconfig "$(mktemp)" returned 2026.2.5
  • the canonical GitHub repository resolves to https://github.com/mf-yang/openclaw-cn
  • upstream tag v2026.2.5 resolves to commit 253435917c161937021a14f82462a60b55056984
  • package.json at that commit reports version 2026.2.5
  • the same exists(path.resolve(cwd, token)) root cause is still present at that commit

I therefore constrained the affected range to <= 2026.2.5, which is the highest published npm version currently carrying the vulnerable validator logic. No public patched release was found.

PoC

Prerequisites

  • Node.js 22+
  • Bun
  • A local checkout of openclaw-cn
  • GitHub-authenticated gh is not required to run the exploit itself; it was only used to publish the PoC gists for this report
  • The target execution mode must allow the gateway-host exec tool path with:
    • tools.exec.host = gateway
    • tools.exec.security = allowlist
    • tools.exec.ask = off for stable no-prompt repro output
    • tools.exec.safeBins containing sort (this is still the documented default safe-bin set)

The provided verification harness creates an isolated temporary HOME, writes an isolated openclaw.json, starts a loopback Gateway, pairs a temporary device identity, and drives the public WS agent method end-to-end through the real allowlist path.

Reproduction Steps

  1. Download the shared harness from: gateway_exec_oracle_harness.ts
  2. Download the verification script from: verification_test.ts
  3. Download the control script from: control-pathlike.ts
  4. Put those three files in the same directory.
  5. From the repository root, run the verification PoC:
    bun llm-enhance/cve-finding/similar/Info_Leak/CVE-2026-4040-safeBins-file-existence-oracle-exp/verification_test.ts
  6. Confirm the output includes:
    • Verification mode: Integration-Test
    • [DEFECT-CONFIRMED-WITH-LIMITATIONS]
  7. Confirm the three key results differ exactly as follows:
    • sort -> undefined is not an object (evaluating 'allowlistEval.segmentSatisfiedBy.some')
    • sort -o existing.txt -> exec denied: allowlist miss
    • sort -o missing.txt -> undefined is not an object (evaluating 'allowlistEval.segmentSatisfiedBy.some')
  8. Run the control PoC:
    bun llm-enhance/cve-finding/similar/Info_Leak/CVE-2026-4040-safeBins-file-existence-oracle-exp/control-pathlike.ts
  9. Confirm the control output includes:
    • Control mode: Integration-Test
    • [CONTROL-PASSED]
  10. Confirm both control commands are uniformly denied:
  • sort -o ./existing.txt -> exec denied: allowlist miss
  • sort -o ./missing.txt -> exec denied: allowlist miss
  1. Optional legacy supporting artifact, if you want a smaller direct internal-path confirmation rather than the public WS path:

Log of Evidence

Fresh verification rerun:

Verification mode: Integration-Test
[DEFECT-CONFIRMED-WITH-LIMITATIONS]

sort -> undefined is not an object (evaluating 'allowlistEval.segmentSatisfiedBy.some')
sort -o existing.txt -> exec denied: allowlist miss
sort -o missing.txt -> undefined is not an object (evaluating 'allowlistEval.segmentSatisfiedBy.some')

Structured evidence from verification_result.json:

baseline.workspaceObservation.existingBefore = true
baseline.workspaceObservation.missingBefore = false
existing.toolError = exec denied: allowlist miss
missing.toolError = undefined is not an object (evaluating 'allowlistEval.segmentSatisfiedBy.some')
existing.workspaceObservation.existingAfter = true
missing.workspaceObservation.missingAfter = false

Fresh control rerun:

Control mode: Integration-Test
[CONTROL-PASSED]

sort -o ./existing.txt -> exec denied: allowlist miss
sort -o ./missing.txt -> exec denied: allowlist miss

Attempted full live-model E2E evidence:

LLM request rejected: Third-party apps now draw from your extra usage, not your plan limits. Add more at claude.ai/settings/usage and keep going.

That quota failure is why the final proof uses the real loopback Gateway plus the public WS agent interface with a deterministic protocol-level mock provider to force the exec tool call. The vulnerability evidence itself is still runtime evidence, not static source matching.

Impact

This is an information disclosure issue, specifically a filesystem-existence oracle on the gateway-host exec allowlist path.

Impacted deployments are the ones that intentionally expose agent sessions or message sources capable of reaching exec in host=gateway + security=allowlist mode and rely on safeBins as a reduced-risk subset. In those environments, a lower-trust caller can probe whether specific files already exist in the agent workdir by observing whether the request is denied immediately or takes a different result path.

The direct impact is narrower than arbitrary file read or code execution, but it is still useful to an attacker:

  • it leaks project/workdir state
  • it reveals whether generated artifacts, copied secrets, or config files are present
  • it can guide follow-on exploitation by narrowing target filenames before attempting a second bug

This is not an unauthenticated public-internet issue by itself. SECURITY.md explicitly treats authenticated Gateway operators as trusted. The practical exposure is the lower-trust prompt/channel/hook/user input case where that input can indirectly drive an agent session that has the gateway exec path enabled.

Affected products

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

Severity

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

Weaknesses

  • CWE: CWE-203: Observable Discrepancy

Occurrences

Permalink Description
export const DEFAULT_SAFE_BINS = ["jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc"];
The default safe-bin set still includes sort, so the vulnerable fast-path is enabled in standard safe-bin deployments unless operators override it.
export function isSafeBinUsage(params: {
argv: string[];
resolution: CommandResolution | null;
safeBins: Set<string>;
cwd?: string;
fileExists?: (filePath: string) => boolean;
}): boolean {
if (params.safeBins.size === 0) return false;
const resolution = params.resolution;
const execName = resolution?.executableName?.toLowerCase();
if (!execName) return false;
const matchesSafeBin =
params.safeBins.has(execName) ||
(process.platform === "win32" && params.safeBins.has(path.parse(execName).name));
if (!matchesSafeBin) return false;
if (!resolution?.resolvedPath) return false;
const cwd = params.cwd ?? process.cwd();
const exists = params.fileExists ?? defaultFileExists;
const argv = params.argv.slice(1);
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token) continue;
if (token === "-") continue;
if (token.startsWith("-")) {
const eqIndex = token.indexOf("=");
if (eqIndex > 0) {
const value = token.slice(eqIndex + 1);
if (value && (isPathLikeToken(value) || exists(path.resolve(cwd, value)))) {
return false;
}
}
continue;
}
if (isPathLikeToken(token)) return false;
if (exists(path.resolve(cwd, token))) return false;
isSafeBinUsage(...) rejects explicit paths immediately, but for bare operands it calls exists(path.resolve(cwd, ...)). That makes allowlist approval depend on host filesystem state instead of argv shape alone.
const approvals = resolveExecApprovals(agentId, { security, ask });
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
if (hostSecurity === "deny") {
throw new Error("exec denied: host=gateway security=deny");
}
const allowlistEval = evaluateShellAllowlist({
command: params.command,
allowlist: approvals.allowlist,
safeBins,
cwd: workdir,
env,
});
const allowlistMatches = allowlistEval.allowlistMatches;
const analysisOk = allowlistEval.analysisOk;
const allowlistSatisfied =
hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
The gateway-host exec path feeds the attacker-controlled params.command string into evaluateShellAllowlist(...) with the configured safeBins set and the real working directory.
if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied)) {
throw new Error("exec denied: allowlist miss");
This is the observable deny branch for existing-file probes: when allowlistSatisfied is false, the caller receives exec denied: allowlist miss directly. Missing-file probes do not take the same branch because the safe-bin validator has already treated the bare operand as acceptable.

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