High severityNVD Advisory· Published Mar 18, 2026· Updated Mar 18, 2026
OpenClaw < 2026.2.24 - Sandbox Bind Validation Bypass via Symlink-Parent Missing-Leaf Paths
CVE-2026-27523
Description
OpenClaw versions prior to 2026.2.24 contain a sandbox bind validation vulnerability allowing attackers to bypass allowed-root and blocked-path checks via symlinked parent directories with non-existent leaf paths. Attackers can craft bind source paths that appear within allowed roots but resolve outside sandbox boundaries once missing leaf components are created, weakening bind-source isolation enforcement.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.24 | 2026.2.24 |
Affected products
1Patches
1b5787e4abba0fix(sandbox): harden bind validation for symlink missing-leaf paths
3 files changed · +81 −23
CHANGELOG.md+1 −0 modified@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. ## 2026.2.23
src/agents/sandbox/validate-sandbox-security.test.ts+39 −1 modified@@ -1,4 +1,4 @@ -import { mkdtempSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -117,6 +117,44 @@ describe("validateBindMounts", () => { expect(run).toThrow(/blocked path/); }); + it("blocks symlink-parent escapes with non-existent leaf outside allowed roots", () => { + if (process.platform === "win32") { + // Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX. + return; + } + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const workspace = join(dir, "workspace"); + const outside = join(dir, "outside"); + mkdirSync(workspace, { recursive: true }); + mkdirSync(outside, { recursive: true }); + const link = join(workspace, "alias-out"); + symlinkSync(outside, link); + const missingLeaf = join(link, "not-yet-created"); + expect(() => + validateBindMounts([`${missingLeaf}:/mnt/data:ro`], { + allowedSourceRoots: [workspace], + }), + ).toThrow(/outside allowed roots/); + }); + + it("blocks symlink-parent escapes into blocked paths when leaf does not exist", () => { + if (process.platform === "win32") { + // Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX. + return; + } + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const workspace = join(dir, "workspace"); + mkdirSync(workspace, { recursive: true }); + const link = join(workspace, "run-link"); + symlinkSync("/var/run", link); + const missingLeaf = join(link, "openclaw-not-created"); + expect(() => + validateBindMounts([`${missingLeaf}:/mnt/run:ro`], { + allowedSourceRoots: [workspace], + }), + ).toThrow(/blocked path/); + }); + it("rejects non-absolute source paths (relative or named volumes)", () => { const cases = ["../etc/passwd:/mnt/passwd", "etc/passwd:/mnt/passwd", "myvol:/mnt"] as const; for (const source of cases) {
src/agents/sandbox/validate-sandbox-security.ts+41 −22 modified@@ -119,18 +119,38 @@ export function getBlockedReasonForSourcePath(sourceNormalized: string): Blocked return null; } -function tryRealpathAbsolute(path: string): string { - if (!path.startsWith("/")) { - return path; +function resolvePathViaExistingAncestor(sourcePath: string): string { + if (!sourcePath.startsWith("/")) { + return sourcePath; } - if (!existsSync(path)) { - return path; + + const normalized = normalizeHostPath(sourcePath); + let current = normalized; + const missingSegments: string[] = []; + + // Resolve through the deepest existing ancestor so symlink parents are honored + // even when the final source leaf does not exist yet. + while (current !== "/" && !existsSync(current)) { + missingSegments.unshift(posix.basename(current)); + const parent = posix.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + if (!existsSync(current)) { + return normalized; } + try { - // Use native when available (keeps platform semantics); normalize for prefix checks. - return normalizeHostPath(realpathSync.native(path)); + const resolvedAncestor = normalizeHostPath(realpathSync.native(current)); + if (missingSegments.length === 0) { + return resolvedAncestor; + } + return normalizeHostPath(posix.join(resolvedAncestor, ...missingSegments)); } catch { - return path; + return normalized; } } @@ -145,7 +165,7 @@ function normalizeAllowedRoots(roots: string[] | undefined): string[] { const expanded = new Set<string>(); for (const root of normalized) { expanded.add(root); - const real = tryRealpathAbsolute(root); + const real = resolvePathViaExistingAncestor(root); if (real !== root) { expanded.add(real); } @@ -227,7 +247,8 @@ function formatBindBlockedError(params: { bind: string; reason: BlockedBindReaso /** * Validate bind mounts — throws if any source path is dangerous. - * Includes a symlink/realpath pass when the source path exists. + * Includes a symlink/realpath pass via existing ancestors so non-existent leaf + * paths cannot bypass source-root and blocked-path checks. */ export function validateBindMounts( binds: string[] | undefined, @@ -268,18 +289,16 @@ export function validateBindMounts( } } - // Symlink escape hardening: resolve existing absolute paths and re-check. - const sourceReal = tryRealpathAbsolute(sourceNormalized); - if (sourceReal !== sourceNormalized) { - const reason = getBlockedReasonForSourcePath(sourceReal); - if (reason) { - throw formatBindBlockedError({ bind, reason }); - } - if (!options?.allowSourcesOutsideAllowedRoots) { - const allowedReason = getOutsideAllowedRootsReason(sourceReal, allowedRoots); - if (allowedReason) { - throw formatBindBlockedError({ bind, reason: allowedReason }); - } + // Symlink escape hardening: resolve through existing ancestors and re-check. + const sourceCanonical = resolvePathViaExistingAncestor(sourceNormalized); + const reason = getBlockedReasonForSourcePath(sourceCanonical); + if (reason) { + throw formatBindBlockedError({ bind, reason }); + } + if (!options?.allowSourcesOutsideAllowedRoots) { + const allowedReason = getOutsideAllowedRootsReason(sourceCanonical, allowedRoots); + if (allowedReason) { + throw formatBindBlockedError({ bind, reason: allowedReason }); } } }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/b5787e4abba0dcc6baf09051099f6773c1679ec1ghsapatchWEB
- github.com/advisories/GHSA-m8v2-6wwh-r4gcghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-m8v2-6wwh-r4gcghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-27523ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-sandbox-bind-validation-bypass-via-symlink-parent-missing-leaf-pathsghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.