Advisory Details
Title: Sandboxed write tool can overwrite files outside the workspace through a dangling symlink leaf
Description:
Summary
The embedded coding-tool surface in openclaw-cn exposes a sandboxed write tool that is expected to remain confined to the configured workspace. That guarantee can be bypassed when the attacker-controlled target path is a dangling symlink leaf located inside the workspace but pointing outside it. In that case, the lexical workspace check succeeds, the symlink escape walk fails open on the unresolved final symlink, and the subsequent file write follows the symlink target outside the workspace root. A lower-trust caller that can reach a normal writable embedded agent session can therefore create or overwrite host files outside the intended workspace boundary.
Details
The affected surface is the embedded coding runtime, not the public /tools/invoke HTTP endpoint. createOpenClawCodingTools(...) adds createSandboxedWriteTool(...) whenever a sandbox session has writable workspace access:
// src/agents/pi-tools.ts
...(sandboxRoot
? allowWorkspaceWrites
? [createSandboxedEditTool(sandboxRoot), createSandboxedWriteTool(sandboxRoot)]
: []
: []),
createSandboxedWriteTool(...) wraps the upstream write tool with a path guard:
// src/agents/pi-tools.read.ts
function wrapSandboxPathGuard(tool: AnyAgentTool, root: string): AnyAgentTool {
return {
...tool,
execute: async (toolCallId, args, signal, onUpdate) => {
const normalized = normalizeToolParams(args);
const record = normalized ?? ...;
const filePath = record?.path;
if (typeof filePath === "string" && filePath.trim()) {
await assertSandboxPath({ filePath, cwd: root, root });
}
return tool.execute(toolCallId, normalized ?? args, signal, onUpdate);
},
};
}
The defect is in assertNoSymlinkEscape(...) inside src/agents/sandbox-paths.ts. The function walks path components with lstat(), but when the final symlink leaf is dangling, tryRealpath(...) falls back to the unresolved lexical path and the walker can also return success on isNotFoundPathError(err):
// src/agents/sandbox-paths.ts
const target = await tryRealpath(current);
if (!isPathInside(rootReal, target)) {
throw new Error(...);
}
current = target;
...
} catch (err) {
if (isNotFoundPathError(err)) {
return;
}
throw err;
}
...
async function tryRealpath(value: string): Promise<string> {
try {
return await fs.realpath(value);
} catch {
return path.resolve(value);
}
}
This is enough to let a path such as jump appear safe because it is lexically inside the workspace, even though the OS will follow jump -> /tmp/.../owned.txt at write time. The PoC demonstrates the exact chain:
path=jump
createOpenClawCodingTools(...)
createSandboxedWriteTool(...)
wrapSandboxPathGuard(...)
assertSandboxPath(...)
assertNoSymlinkEscape(...)
- upstream
createWriteTool(...)
fs.writeFile(...)
- outside target file modified
The issue is not “plain ../ traversal is allowed.” The same interface rejects direct traversal correctly; the bypass is specific to the dangling symlink alias.
PoC
Prerequisites
openclaw-cn version 0.2.1 or earlier
- A writable sandbox session, meaning the embedded runtime reaches
createOpenClawCodingTools(...) with workspaceAccess: "rw"
- Bun and Python 3 installed locally
- A workspace state that contains a dangling symlink leaf pointing outside the workspace
Reproduction Steps
- Download the minimal driver from: tool_driver.ts
- Download the verification PoC from: verification_test.py
- Download the control PoC from: control-direct-traversal.py
- From the repository root, run the verification PoC:
python3 llm-enhance/cve-finding/similar/workspace-boundary-bypass/Advisory-GHSA-qcc4-p59m-p54m-dangling-symlink-sandboxed-write-exp/verification_test.py
- The script creates a temporary workspace, plants
workspace/jump -> /tmp/.../outside/owned.txt, invokes the real exported sandboxed write tool with path=jump, and checks the outside canary file directly.
- Run the control:
python3 llm-enhance/cve-finding/similar/workspace-boundary-bypass/Advisory-GHSA-qcc4-p59m-p54m-dangling-symlink-sandboxed-write-exp/control-direct-traversal.py
- Observe that the control blocks direct
../escape.txt traversal while the verification run modifies the outside file through the dangling symlink alias.
Log of Evidence
Verification run:
[Verification Mode] Integration-Test
[Data Flow] tool args.path -> createOpenClawCodingTools() -> createSandboxedWriteTool()/wrapSandboxPathGuard() -> assertSandboxPath()/assertNoSymlinkEscape() -> upstream createWriteTool() -> fs.writeFile()
[workspace symlink]
/tmp/ghsa-qcc4-sandboxed-write-11413h72/workspace/jump -> /tmp/ghsa-qcc4-sandboxed-write-11413h72/outside/owned.txt
[stdout]
[agents/auth-profiles] inherited auth-profiles from main agent
WRITE_OUTSIDE:"pwned-write"
[independent observation]
{"outside_file": "/tmp/ghsa-qcc4-sandboxed-write-11413h72/outside/owned.txt", "contents": "pwned-write"}
[DEFECT CONFIRMED] sandboxed write follows a dangling in-workspace symlink and writes outside the claimed workspace boundary.
Control run:
[Control Mode] Integration-Test
[Baseline] direct lexical traversal should be blocked by resolveSandboxPath()/assertSandboxPath().
[stdout]
[agents/auth-profiles] inherited auth-profiles from main agent
CONTROL_BLOCKED:Path escapes sandbox root (/tmp/ghsa-qcc4-sandboxed-write-control-6_qn6jvr/workspace): ../escape.txt
[independent observation]
{"outside_file": "/tmp/ghsa-qcc4-sandboxed-write-control-6_qn6jvr/outside/owned.txt", "exists": false, "contents": null}
[CONTROL OK] direct traversal was rejected and no outside file was created.
Impact
This is a workspace-boundary bypass on a mutating file tool. Any deployment that relies on the sandboxed write tool to stay inside the workspace can be tricked into creating or overwriting files outside that boundary, as long as the workspace contains a dangling symlink leaf chosen by the attacker-controlled path argument. The project’s trust model explicitly treats authenticated gateway operators as trusted, so the strongest practical impact is in lower-trust chat, prompt, or automation contexts that are intentionally allowed to use the embedded coding runtime while operators assume the workspace boundary still protects the host. Successful exploitation can corrupt adjacent project files, temporary scripts, configuration fragments, or other host-side content outside the workspace root.
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:L/UI:N/S:U/C:N/I:H/A:L
Weaknesses
- CWE: CWE-59: Improper Link Resolution Before File Access ('Link Following')
Occurrences
| Permalink |
Description |
|
export async function assertSandboxPath(params: { |
|
filePath: string; |
|
cwd: string; |
|
root: string; |
|
allowFinalSymlink?: boolean; |
|
}) { |
|
const resolved = resolveSandboxPath(params); |
|
await assertNoSymlinkEscape(resolved.relative, path.resolve(params.root), { |
|
allowFinalSymlink: params.allowFinalSymlink, |
|
}); |
|
return resolved; |
|
assertSandboxPath(...) is the enforcement entry point used before sandboxed file writes. It delegates safety entirely to the symlink-escape walker after only a lexical root check. |
|
async function assertNoSymlinkEscape( |
|
relative: string, |
|
root: string, |
|
options?: { allowFinalSymlink?: boolean }, |
|
) { |
|
if (!relative) { |
|
return; |
|
} |
|
const rootReal = await tryRealpath(root); |
|
const parts = relative.split(path.sep).filter(Boolean); |
|
let current = root; |
|
for (let idx = 0; idx < parts.length; idx += 1) { |
|
const part = parts[idx]; |
|
const isLast = idx === parts.length - 1; |
|
current = path.join(current, part); |
|
try { |
|
const stat = await fs.lstat(current); |
|
if (stat.isSymbolicLink()) { |
|
// Unlinking a symlink itself is safe even if it points outside the root. What we |
|
// must prevent is traversing through a symlink to reach targets outside root. |
|
if (options?.allowFinalSymlink && isLast) { |
|
return; |
|
} |
|
const target = await tryRealpath(current); |
|
if (!isPathInside(rootReal, target)) { |
|
throw new Error( |
|
`Symlink escapes sandbox root (${shortPath(rootReal)}): ${shortPath(current)}`, |
|
); |
|
} |
|
current = target; |
|
} |
|
} catch (err) { |
|
if (isNotFoundPathError(err)) { |
|
return; |
|
} |
|
throw err; |
|
} |
|
} |
|
} |
|
|
|
async function tryRealpath(value: string): Promise<string> { |
|
try { |
|
return await fs.realpath(value); |
|
} catch { |
|
return path.resolve(value); |
|
assertNoSymlinkEscape(...) and tryRealpath(...) fail open for a dangling final symlink leaf, allowing an unresolved in-workspace alias to pass even though the eventual write follows a target outside the workspace root. |
|
function wrapSandboxPathGuard(tool: AnyAgentTool, root: string): AnyAgentTool { |
|
return { |
|
...tool, |
|
execute: async (toolCallId, args, signal, onUpdate) => { |
|
const normalized = normalizeToolParams(args); |
|
const record = |
|
normalized ?? |
|
(args && typeof args === "object" ? (args as Record<string, unknown>) : undefined); |
|
const filePath = record?.path; |
|
if (typeof filePath === "string" && filePath.trim()) { |
|
await assertSandboxPath({ filePath, cwd: root, root }); |
|
} |
|
return tool.execute(toolCallId, normalized ?? args, signal, onUpdate); |
|
}, |
|
}; |
|
} |
|
|
|
export function createSandboxedReadTool(root: string) { |
|
const base = createReadTool(root) as unknown as AnyAgentTool; |
|
return wrapSandboxPathGuard(createClawdbotReadTool(base), root); |
|
} |
|
|
|
export function createSandboxedWriteTool(root: string) { |
|
const base = createWriteTool(root) as unknown as AnyAgentTool; |
|
return wrapSandboxPathGuard(wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write), root); |
|
wrapSandboxPathGuard(...) applies the vulnerable assertSandboxPath(...) check to the upstream createWriteTool(...), making the bad path validation part of the real sandboxed write tool execution path. |
|
const tools: AnyAgentTool[] = [ |
|
...base, |
|
...(sandboxRoot |
|
? allowWorkspaceWrites |
|
? [createSandboxedEditTool(sandboxRoot), createSandboxedWriteTool(sandboxRoot)] |
|
: [] |
|
createOpenClawCodingTools(...) exposes createSandboxedWriteTool(...) in normal writable sandbox sessions, which is the supported runtime surface the PoC drives. |
Advisory Details
Title: Sandboxed
writetool can overwrite files outside the workspace through a dangling symlink leafDescription:
Summary
The embedded coding-tool surface in
openclaw-cnexposes a sandboxedwritetool that is expected to remain confined to the configured workspace. That guarantee can be bypassed when the attacker-controlled target path is a dangling symlink leaf located inside the workspace but pointing outside it. In that case, the lexical workspace check succeeds, the symlink escape walk fails open on the unresolved final symlink, and the subsequent file write follows the symlink target outside the workspace root. A lower-trust caller that can reach a normal writable embedded agent session can therefore create or overwrite host files outside the intended workspace boundary.Details
The affected surface is the embedded coding runtime, not the public
/tools/invokeHTTP endpoint.createOpenClawCodingTools(...)addscreateSandboxedWriteTool(...)whenever a sandbox session has writable workspace access:createSandboxedWriteTool(...)wraps the upstreamwritetool with a path guard:The defect is in
assertNoSymlinkEscape(...)insidesrc/agents/sandbox-paths.ts. The function walks path components withlstat(), but when the final symlink leaf is dangling,tryRealpath(...)falls back to the unresolved lexical path and the walker can also return success onisNotFoundPathError(err):This is enough to let a path such as
jumpappear safe because it is lexically inside the workspace, even though the OS will followjump -> /tmp/.../owned.txtat write time. The PoC demonstrates the exact chain:path=jumpcreateOpenClawCodingTools(...)createSandboxedWriteTool(...)wrapSandboxPathGuard(...)assertSandboxPath(...)assertNoSymlinkEscape(...)createWriteTool(...)fs.writeFile(...)The issue is not “plain
../traversal is allowed.” The same interface rejects direct traversal correctly; the bypass is specific to the dangling symlink alias.PoC
Prerequisites
openclaw-cnversion0.2.1or earliercreateOpenClawCodingTools(...)withworkspaceAccess: "rw"Reproduction Steps
python3 llm-enhance/cve-finding/similar/workspace-boundary-bypass/Advisory-GHSA-qcc4-p59m-p54m-dangling-symlink-sandboxed-write-exp/verification_test.pyworkspace/jump -> /tmp/.../outside/owned.txt, invokes the real exported sandboxedwritetool withpath=jump, and checks the outside canary file directly.python3 llm-enhance/cve-finding/similar/workspace-boundary-bypass/Advisory-GHSA-qcc4-p59m-p54m-dangling-symlink-sandboxed-write-exp/control-direct-traversal.py../escape.txttraversal while the verification run modifies the outside file through the dangling symlink alias.Log of Evidence
Verification run:
Control run:
Impact
This is a workspace-boundary bypass on a mutating file tool. Any deployment that relies on the sandboxed
writetool to stay inside the workspace can be tricked into creating or overwriting files outside that boundary, as long as the workspace contains a dangling symlink leaf chosen by the attacker-controlled path argument. The project’s trust model explicitly treats authenticated gateway operators as trusted, so the strongest practical impact is in lower-trust chat, prompt, or automation contexts that are intentionally allowed to use the embedded coding runtime while operators assume the workspace boundary still protects the host. Successful exploitation can corrupt adjacent project files, temporary scripts, configuration fragments, or other host-side content outside the workspace root.Affected products
Severity
Weaknesses
Occurrences
openclaw-cn/src/agents/sandbox-paths.ts
Lines 54 to 64 in 558f272
assertSandboxPath(...)is the enforcement entry point used before sandboxed file writes. It delegates safety entirely to the symlink-escape walker after only a lexical root check.openclaw-cn/src/agents/sandbox-paths.ts
Lines 101 to 145 in 558f272
assertNoSymlinkEscape(...)andtryRealpath(...)fail open for a dangling final symlink leaf, allowing an unresolved in-workspace alias to pass even though the eventual write follows a target outside the workspace root.openclaw-cn/src/agents/pi-tools.read.ts
Lines 233 to 257 in 558f272
wrapSandboxPathGuard(...)applies the vulnerableassertSandboxPath(...)check to the upstreamcreateWriteTool(...), making the bad path validation part of the real sandboxedwritetool execution path.openclaw-cn/src/agents/pi-tools.ts
Lines 318 to 323 in 558f272
createOpenClawCodingTools(...)exposescreateSandboxedWriteTool(...)in normal writable sandbox sessions, which is the supported runtime surface the PoC drives.