Skip to content

[Security] Sandboxed write tool can overwrite files outside the workspace through a dangling symlink leaf #603

Description

@YLChen-007

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

  1. Download the minimal driver from: tool_driver.ts
  2. Download the verification PoC from: verification_test.py
  3. Download the control PoC from: control-direct-traversal.py
  4. 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
  5. 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.
  6. 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
  7. 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.

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