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
- Download the shared harness from: gateway_exec_oracle_harness.ts
- Download the verification script from: verification_test.ts
- Download the control script from: control-pathlike.ts
- Put those three files in the same directory.
- 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
- Confirm the output includes:
Verification mode: Integration-Test
[DEFECT-CONFIRMED-WITH-LIMITATIONS]
- 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')
- Run the control PoC:
bun llm-enhance/cve-finding/similar/Info_Leak/CVE-2026-4040-safeBins-file-existence-oracle-exp/control-pathlike.ts
- Confirm the control output includes:
Control mode: Integration-Test
[CONTROL-PASSED]
- Confirm both control commands are uniformly denied:
sort -o ./existing.txt -> exec denied: allowlist miss
sort -o ./missing.txt -> exec denied: allowlist miss
- 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. |
Advisory Details
Title: OpenClaw CN gateway
execsafeBins validation leaks workdir file existence through state-dependent allowlist decisionsDescription:
OpenClaw CN treats
sortas a stdin-only safe binary in gatewayexecallowlist 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 supportedagent->execpath.Summary
The gateway-host
execallowlist 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 asexisting.txtonly whenpath.resolve(cwd, token)already exists on disk. A lower-trust caller that can influence an agent session configured withexec.host=gateway,security=allowlist, and default safe bins can distinguish existing from missing files in the workdir by comparing the tool result classes forsort -o existing.txtvssort -o missing.txt. In the local unfixed branch verified here, the missing-file branch continues into a separate consumer mismatch and throws a distinctsegmentSatisfiedByTypeError, 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
agentinterface, 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:
The vulnerable validator is the bare-filename handling inside
isSafeBinUsage(...):That logic does two different things depending on argv shape:
./existing.txtare blocked immediately byisPathLikeToken(...).existing.txtormissing.txtare not blocked immediately. They are resolved againstcwd, 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(...):If allowlist analysis fails, the caller sees a direct denial:
That gives a clean oracle:
sort -o ./existing.txt-> deniedsort -o ./missing.txt-> deniedsort -o existing.txt-> denied only because the file existssort -o missing.txt-> continues down the safe-bin success path instead of taking the same denial branchIn 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 distinctTypeErrorfor 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)"returned2026.2.5https://github.com/mf-yang/openclaw-cnv2026.2.5resolves to commit253435917c161937021a14f82462a60b55056984package.jsonat that commit reports version2026.2.5exists(path.resolve(cwd, token))root cause is still present at that commitI 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
openclaw-cnghis not required to run the exploit itself; it was only used to publish the PoC gists for this reportexectool path with:tools.exec.host = gatewaytools.exec.security = allowlisttools.exec.ask = offfor stable no-prompt repro outputtools.exec.safeBinscontainingsort(this is still the documented default safe-bin set)The provided verification harness creates an isolated temporary
HOME, writes an isolatedopenclaw.json, starts a loopback Gateway, pairs a temporary device identity, and drives the public WSagentmethod end-to-end through the real allowlist path.Reproduction Steps
bun llm-enhance/cve-finding/similar/Info_Leak/CVE-2026-4040-safeBins-file-existence-oracle-exp/verification_test.tsVerification mode: Integration-Test[DEFECT-CONFIRMED-WITH-LIMITATIONS]sort->undefined is not an object (evaluating 'allowlistEval.segmentSatisfiedBy.some')sort -o existing.txt->exec denied: allowlist misssort -o missing.txt->undefined is not an object (evaluating 'allowlistEval.segmentSatisfiedBy.some')bun llm-enhance/cve-finding/similar/Info_Leak/CVE-2026-4040-safeBins-file-existence-oracle-exp/control-pathlike.tsControl mode: Integration-Test[CONTROL-PASSED]sort -o ./existing.txt->exec denied: allowlist misssort -o ./missing.txt->exec denied: allowlist missLog of Evidence
Fresh verification rerun:
Structured evidence from
verification_result.json:Fresh control rerun:
Attempted full live-model E2E evidence:
That quota failure is why the final proof uses the real loopback Gateway plus the public WS
agentinterface with a deterministic protocol-level mock provider to force theexectool 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
execallowlist path.Impacted deployments are the ones that intentionally expose agent sessions or message sources capable of reaching
execinhost=gateway+security=allowlistmode and rely onsafeBinsas 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:
This is not an unauthenticated public-internet issue by itself.
SECURITY.mdexplicitly 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 gatewayexecpath enabled.Affected products
Severity
Weaknesses
Occurrences
openclaw-cn/src/infra/exec-approvals.ts
Line 66 in 2534359
sort, so the vulnerable fast-path is enabled in standard safe-bin deployments unless operators override it.openclaw-cn/src/infra/exec-approvals.ts
Lines 836 to 870 in 2534359
isSafeBinUsage(...)rejects explicit paths immediately, but for bare operands it callsexists(path.resolve(cwd, ...)). That makes allowlist approval depend on host filesystem state instead of argv shape alone.openclaw-cn/src/agents/bash-tools.exec.ts
Lines 1160 to 1177 in 2534359
execpath feeds the attacker-controlledparams.commandstring intoevaluateShellAllowlist(...)with the configuredsafeBinsset and the real working directory.openclaw-cn/src/agents/bash-tools.exec.ts
Lines 1361 to 1362 in 2534359
allowlistSatisfiedis false, the caller receivesexec denied: allowlist missdirectly. Missing-file probes do not take the same branch because the safe-bin validator has already treated the bare operand as acceptable.