Skip to content

[Security] Sandboxed image tool builder regression allows host-local image disclosure to the configured vision provider #605

Description

@YLChen-007

Advisory Details

Title: Sandboxed image tool builder regression allows host-local image disclosure to the configured vision provider

Description:

A runtime builder regression in openclaw-cn drops the sandboxRoot parameter before constructing the sandboxed image tool. An attacker who can get prompt content processed inside a sandboxed agent session can cause the model to issue an image tool call with an absolute host path, leading the gateway process to read a host-local image and forward the resulting bytes to the configured vision model.

Summary

The image tool in openclaw-cn is supposed to enforce a sandbox root when a session runs with agents.defaults.sandbox.* enabled. In the affected 0.2.x source line, the real runtime builder passes a sandbox object while the tool implementation checks options.sandboxRoot. That mismatch disables the intended containment check and lets an absolute host-local image path flow into loadWebMedia() and then fs.readFile(). I verified this with the real loopback gateway, the official agent CLI path, a mock OpenAI Responses provider, and a mock MiniMax VLM endpoint.

Details

I verified this against the local technical findings, the manual verification report, the TP exploit report, the reproduction harness under the -exp/ folder, and a fresh rerun on July 5, 2026.

The canonical upstream repository for permalink purposes resolves to https://github.com/mf-yang/openclaw-cn. The local origin remote is https://github.com/jiulingyun/openclaw-cn, but GitHub resolves commit and contents API requests to mf-yang/openclaw-cn, so that is the repository used for all occurrence URLs below.

The root cause is a straightforward runtime construction mismatch. At the highest affected upstream tag v0.2.1, which resolves to remote commit 558f272e6c90e7e0c37644e505e161b91ef738f0, the real builder in src/agents/openclaw-tools.ts constructs the image tool like this:

const imageTool = options?.agentDir?.trim()
  ? createImageTool({
      config: options?.config,
      agentDir: options.agentDir,
      // @ts-ignore -- cherry-pick upstream type mismatch
      // @ts-ignore -- cherry-pick upstream type mismatch
      workspaceDir,
      sandbox:
        options?.sandboxRoot && options?.sandboxFsBridge
          ? { root: options.sandboxRoot, bridge: options.sandboxFsBridge }
          : undefined,
      modelHasVision: options?.modelHasVision,
    })
  : null;

But the actual sandbox gate inside src/agents/tools/image-tool.ts is keyed off options?.sandboxRoot:

const sandboxRoot = options?.sandboxRoot?.trim();
...
const resolvedPathInfo = isDataUrl
  ? { resolved: "" }
  : sandboxRoot
    ? await resolveSandboxedImagePath({
        sandboxRoot,
        imagePath: resolvedImage,
      })
    : {
        resolved: resolvedImage.startsWith("file://")
          ? resolvedImage.slice("file://".length)
          : resolvedImage,
      };

const media = isDataUrl
  ? decodeDataUrl(resolvedImage)
  : await loadWebMedia(resolvedPath ?? resolvedImage, maxBytes);

The protective helper exists and works:

async function resolveSandboxedImagePath(params: {
  sandboxRoot: string;
  imagePath: string;
}) {
  const out = await assertSandboxPath({
    filePath,
    cwd: params.sandboxRoot,
    root: params.sandboxRoot,
  });
  ...
}

That is the important point: this is not “the image tool never had a sandbox check.” The check exists. It is simply bypassed because the real builder feeds the wrong option name into createImageTool().

The end-to-end source-to-sink chain I verified is:

attacker-controlled prompt content
  -> openclaw-cn agent / gateway run
  -> model-emitted image tool call with absolute host path
  -> createOpenClawTools()
  -> createImageTool().execute()
  -> loadWebMedia()
  -> fs.readFile(host-local image)
  -> POST /v1/coding_plan/vlm
  -> image contents disclosed to the configured vision provider

The release boundary is narrower than “all current versions.” I checked the remote tags and contents before writing this report:

  • v0.2.1 resolves to remote commit 558f272e6c90e7e0c37644e505e161b91ef738f0 and still contains the vulnerable mismatch.
  • v0.2.0 also contains the same bug pattern.
  • Later published release tags such as v2026.1.30, v2026.1.31, and v2026.2.2 already pass sandboxRoot directly and do not contain this regression.

This is why the affected range below is constrained to the 0.2.x line rather than a blanket “latest main” claim.

PoC

Prerequisites

  • openclaw-cn source at 0.2.0 or tagged 0.2.1.
  • Node.js / Bun environment capable of running the repository’s TypeScript entrypoints.
  • Python 3.
  • Docker available locally for the normal sandbox runtime assumptions.
  • No live model account or live external provider is required; the PoC uses loopback mock providers.
  • The exploit scripts must be run sequentially, not in parallel, because they reuse 127.0.0.1:18789.

Reproduction Steps

  1. Download the verification wrapper from: verification_test.py
  2. Download the shared helper from: lab_common.py
  3. Download the loopback provider harness from: mock_model_and_vision_server.ts
  4. Download the same-interface control from: control-inline-data-url.py
  5. Download the direct sandbox control from: control-direct-sandbox-root.py and direct_sandbox_control_driver.ts
  6. Place the files in the same directory, or use the copies already present in the local Advisory-GHSA-57GH-M6RQ-54CF-image-tool-sandbox-bypass-exp/ folder.
  7. From the repository root, run the main verification:
    python3 llm-enhance/cve-finding/similar/Info_Leak/Advisory-GHSA-57GH-M6RQ-54CF-image-tool-sandbox-bypass-exp/verification_test.py
  8. Confirm that verification-output.json shows a real gateway-path run with:
    • cli_exit_code = 0
    • fell_back_to_embedded = false
    • vision_request_count = 1
    • tool_argument_reference_seen = true
    • evidence_match = true
  9. Run the same-interface control:
    python3 llm-enhance/cve-finding/similar/Info_Leak/Advisory-GHSA-57GH-M6RQ-54CF-image-tool-sandbox-bypass-exp/control-inline-data-url.py
  10. Confirm that only the inline image is processed and that observed_bytes_match_expected = true.
  11. Run the direct sandbox-root control:
    python3 llm-enhance/cve-finding/similar/Info_Leak/Advisory-GHSA-57GH-M6RQ-54CF-image-tool-sandbox-bypass-exp/control-direct-sandbox-root.py
  12. Confirm that the same host-local path is rejected with CONTROL_REJECTED / Path escapes sandbox root.

Log of Evidence

Fresh rerun on July 5, 2026:

verification_test.py
  cli_exit_code = 0
  fell_back_to_embedded = false
  vision_request_count = 1
  tool_argument_reference_seen = true
  evidence_match = true

control-inline-data-url.py
  cli_exit_code = 0
  fell_back_to_embedded = false
  vision_request_count = 1
  observed_bytes_match_expected = true
  evidence_match = true

control-direct-sandbox-root.py
  {"status":"CONTROL_REJECTED","message":"Path escapes sandbox root (...)"}

Independent runtime evidence collected during the verification includes:

provider-verification.jsonl
  - model emitted a function_call for image="/.../verification-runtime/host-secret.png"
  - the gateway then issued a real POST to /v1/coding_plan/vlm

observation.log
  - nc to 127.0.0.1:18789 succeeded
  - curl -I http://127.0.0.1:18789/ returned 200 OK

Impact

This is a sandbox boundary bypass that causes host-local image disclosure. It does not require direct source edits, local shell access, or disabling the sandbox by hand. Once attacker-controlled prompt content is processed inside a sandboxed agent session, the model can be induced to emit an image tool call for an absolute host path. The gateway then reads that image from the host filesystem and forwards its contents to the configured vision provider.

In practical terms, the exposed assets include:

  • local screenshots and screen recordings
  • photos stored on the host
  • QR codes, receipts, whiteboard photos, and camera images
  • any other image file readable by the OpenClaw process user

This matters because the deployment guidance explicitly positions sandboxing as the containment layer for lower-trust sessions and prompt-injection blast radius. The bug breaks that contract for the image tool path.

Affected products

  • Ecosystem: npm
  • Package name: openclaw-cn
  • Affected versions: = 0.2.0, = 0.2.1 (tagged source release v0.2.1 at commit 558f272e6c90e7e0c37644e505e161b91ef738f0)
  • Patched versions: for the 0.2.x line; published 2026.1.30+ release tags do not contain this regression

Severity

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

Weaknesses

  • CWE: CWE-668: Exposure of Resource to Wrong Sphere

Occurrences

Permalink Description
const imageTool = options?.agentDir?.trim()
? createImageTool({
config: options?.config,
agentDir: options.agentDir,
// @ts-ignore -- cherry-pick upstream type mismatch
// @ts-ignore -- cherry-pick upstream type mismatch
workspaceDir,
sandbox:
options?.sandboxRoot && options?.sandboxFsBridge
? { root: options.sandboxRoot, bridge: options.sandboxFsBridge }
: undefined,
modelHasVision: options?.modelHasVision,
The vulnerable runtime builder passes a sandbox object and never supplies sandboxRoot to createImageTool(), which prevents the sandboxed path check from activating.
async function resolveSandboxedImagePath(params: {
sandboxRoot: string;
imagePath: string;
}): Promise<{ resolved: string; rewrittenFrom?: string }> {
const normalize = (p: string) => (p.startsWith("file://") ? p.slice("file://".length) : p);
const filePath = normalize(params.imagePath);
try {
const out = await assertSandboxPath({
filePath,
cwd: params.sandboxRoot,
root: params.sandboxRoot,
});
return { resolved: out.resolved };
} catch (err) {
const name = path.basename(filePath);
const candidateRel = path.join("media", "inbound", name);
const candidateAbs = path.join(params.sandboxRoot, candidateRel);
try {
await fs.stat(candidateAbs);
} catch {
throw err;
}
const out = await assertSandboxPath({
filePath: candidateRel,
cwd: params.sandboxRoot,
root: params.sandboxRoot,
The intended protection already exists: resolveSandboxedImagePath() uses assertSandboxPath() to confine file reads to the sandbox root and reject escapes.
const sandboxRoot = options?.sandboxRoot?.trim();
const isUrl = isHttpUrl;
if (sandboxRoot && isUrl) {
throw new Error("Sandboxed image tool does not allow remote URLs.");
}
const resolvedImage = (() => {
if (sandboxRoot) {
return imageRaw;
}
if (imageRaw.startsWith("~")) {
return resolveUserPath(imageRaw);
}
return imageRaw;
})();
const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl
? { resolved: "" }
: sandboxRoot
? await resolveSandboxedImagePath({
sandboxRoot,
imagePath: resolvedImage,
})
: {
resolved: resolvedImage.startsWith("file://")
? resolvedImage.slice("file://".length)
: resolvedImage,
};
const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved;
const media = isDataUrl
? decodeDataUrl(resolvedImage)
: await loadWebMedia(resolvedPath ?? resolvedImage, maxBytes);
The execution path only enables containment when options.sandboxRoot is present. If that option is missing, the code falls back to directly loading the attacker-supplied path with loadWebMedia(resolvedPath ?? resolvedImage, maxBytes).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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