A locked room where the exit sign flips on if you knock with a slash at the end
The Go standard library ships os.Root, a directory-sandbox primitive introduced in Go 1.24 to safely open files under a fixed root even when paths contain .. or symlinks. On Unix, the implementation delegates to openat(fd, path, O_NOFOLLOW). The kernel quirk: O_NOFOLLOW does *not* suppress symlink resolution when the path ends in / — it only refuses to follow the final component when there is no trailing slash. os.Root didn't strip that trailing /, so root.Open("symlink/") happily returned a handle to whatever the symlink pointed at, even outside the sandbox. Affected: Go 1.25.0 through 1.25.11 and Go 1.26.0 through 1.26.4; fixed in 1.25.12 and 1.26.5. Unix targets only (Linux/BSD/macOS); Windows is not affected because it uses a different resolver.
Vendor scored this HIGH / 7.8 with vector AV:L/AC:L/PR:L/UI:N/C:H/I:H/A:H — a generic local-privesc-style framing that assumes CIA impact on the host. That framing is too generous. This is a sandbox-escape in a library primitive: to weaponize it, an application must (a) use os.Root in the first place — a young, niche API — (b) accept attacker-controlled path strings, and (c) grant the process access to interesting files outside that root. Absent all three, the bug is inert. The realistic blast radius is scoped to the process's own filesystem view, not host takeover.
4 steps from start to impact.
Locate a Go service using os.Root with attacker-influenced paths
os.Root and passes user-supplied path fragments into Root.Open, Root.Create, Root.Stat, or Root.OpenFile. Since os.Root is Go 1.24+ and marketed specifically as *the* untrusted-path handler, adopters are typically archive extractors, static file servers, and multi-tenant sandboxes.- Target compiled with Go 1.25.0–1.25.11 or 1.26.0–1.26.4
- Application accepts attacker-controlled path component
- Runs on Unix (Linux/BSD/macOS)
os.Rootwas only introduced in Go 1.24 (Feb 2025); adoption is thin- Most Go web frameworks still use
filepath.Clean+http.FileServer, notos.Root - Static analyzers (
govulncheck) flag both the CVE and unsafe usage patterns
govulncheck v1.1+ flags CVE-2026-39822 against binaries and modules; Semgrep rule go.lang.security.audit.os-root-trailing-slash (community).Plant or coerce a symlink inside the sandbox root
/etc/passwd, /proc/self/environ, another tenant's directory, secrets mount, etc.- Write access to a path inside the sandbox root, OR pre-existing attacker-controlled symlink
- Sandbox root sits on a filesystem where the process can also read the target
- Read-only sandboxes eliminate step 2a entirely
- AppArmor/SELinux profiles restrict which paths the process may read
- Container filesystem overlays often mask sensitive host paths
openat on symlink-through-root or file-integrity monitoring on tenant dirs; falco rule Read sensitive file untrusted fires on typical exfil targets.Trigger open via path with trailing slash
<symlinkname>/. The application calls root.Open("<symlinkname>/"). The underlying openat follows the symlink because the trailing slash forces symlink resolution regardless of O_NOFOLLOW. A file descriptor is returned pointing outside the root.- Application does not strip trailing slashes before calling Root
- Application returns file content / metadata to the attacker
- Many wrappers normalize paths (
strings.TrimRight(p,"/")) upstream, killing the bug - Directory-only endpoints won't return file bytes even if fd is obtained
/.Read/write data outside the sandbox
/proc for env vars), or writes if Root.Create/OpenFile was called. Impact is bounded by the OS uid the process runs under — not root, in most modern container deployments.- Process uid can read/write the target file
- Application surfaces file contents or accepts writes
- Non-root container uids see very little outside their own tenant dir
- Read-only rootfs blocks write path entirely
The supporting signals.
| In-the-wild exploitation | None observed. Not in CISA KEV. No public campaigns reference this CVE as of 2026-07-12. |
|---|---|
| Public PoC | Trivial one-liner in the Go issue tracker (go.dev/issue/79005); no weaponized toolkit. |
| EPSS | 0.00181 (roughly 45th percentile) — market signal says essentially no exploitation pressure. |
| KEV status | Not listed. |
| CVSS vector | AV:L/AC:L/PR:L/UI:N/C:H/I:H/A:H — local vector, low priv, no UI. Vendor treats it as local privesc-shaped, which overstates real reach. |
| Affected versions | Go 1.25.0–1.25.11 and 1.26.0–1.26.4 on Unix only. Windows unaffected. Anything pre-1.24 predates os.Root. |
| Fixed versions | Go 1.25.12, 1.26.5. Distro backports: RHEL via RHSA-2026:37435, Debian/Ubuntu golang-1.25 security updates, Alpine go 1.26.5-r0. |
| Exposure population | No Shodan/Censys fingerprint (library-level bug, not a service). Realistic exposed population is the intersection of {Go 1.25+/1.26 binaries} ∩ {uses os.Root} ∩ {Unix} ∩ {attacker-controlled paths}. Small. |
| Disclosure date | 2026-07-08, coordinated release with Go 1.26.5 / 1.25.12. |
| Reporter | Mundur (GitHub M0nd0R), via Go security team. |
noisgate verdict.
The single decisive factor is narrow reachable population: the bug only bites Go 1.25/1.26 Unix binaries that both use the young os.Root API and pipe attacker-controlled path strings into it — an intersection that is a rounding error across a 10,000-host fleet. Blast radius is bounded by the process uid and the sandbox's own tenant data, not host or domain compromise.
Why this verdict
- Library-level sandbox bug, not a service RCE: the vulnerability lives inside a Go stdlib primitive. There is no listening port, no network vector, no automated exploitation surface.
- Vector requires local + low priv + attacker-controlled path:
AV:L/PR:Lmeans the attacker already runs code or holds a session on the box, OR feeds path input into a specific vulnerable API. This is post-initial-access or narrowly application-scoped. os.Rootadoption is thin: the API shipped in Go 1.24 (Feb 2025). Sixteen months later, the overwhelming majority of Go code still usesfilepath.Join+ manual cleaning. The reachable code population is small.- Role multiplier — archive extractor / multi-tenant file server: on hosts where
os.Rootis used to sandbox untrusted archive extraction (e.g. artifact stores, CI runners, container image builders), a successful escape can reach adjacent tenant data or CI secrets. Blast radius = one process's fs view, not fleet. - Role multiplier — build server / CI runner: if the vulnerable code path is inside a self-hosted GitHub Actions runner or GitLab runner extracting attacker-supplied archives, escape ends at the runner's uid; not a supply-chain pivot unless the runner also has signing keys mounted — a config problem, not a CVE problem.
- EPSS 0.00181 + no KEV + no in-the-wild signal: market pricing agrees with practitioner intuition — no exploitation pressure.
Why not higher?
MEDIUM would require broader reachable population or a plausible network-reachable trigger. Neither exists: this is a Unix-only, library-level bug in an API most Go code doesn't call yet, requiring attacker-controlled path input plus the ability to plant or find a symlink inside the sandbox. HIGH would require a canonical high-value-role component (IdP, hypervisor, DC, PAM, edge appliance) — Go stdlib is horizontal infrastructure but this specific API's blast radius is bounded to one process's filesystem view.
Why not lower?
IGNORE is inappropriate because the bug is real and does allow sandbox escape in vulnerable applications; archive-extraction and multi-tenant file-serving are legitimate os.Root use cases and the escape is trivial once the preconditions are met. Any team running a Go-based artifact extractor, CI runner, or multi-tenant SaaS needs to rebuild against a fixed toolchain — this is not backlog-only material.
What to do — in priority order.
- Rebuild Go binaries against Go 1.25.12 or 1.26.5 — Only a recompile removes the flaw; there is no runtime toggle. Rebuild all first-party Go services and re-release. Target: within the noisgate remediation SLA of 365 days for LOW, but prioritize any binary that touches attacker-controlled paths within 90 days.
- Run
govulncheck ./...across your Go monorepo and CI —govulncheckreports both the CVE presence *and* whether your code actually calls the vulnerable symbols. Anything flagged as reachable jumps to the front of the remediation queue. Wire into CI as a blocking check within 30 days. - Strip trailing slashes before passing user input to
Root.Open/OpenFile/Stat— One-line application-layer mitigation:p = strings.TrimRight(p, "/")before the Root call. Ship as a defensive patch to any service you cannot recompile against fixed Go immediately. - Audit archive-extraction and multi-tenant file paths for
os.Rootusage — Grep foros.OpenRoot,.OpenInRoot,Root.Open,Root.Create. These are the code paths where escape has real consequence. Prioritize hosts running CI runners, artifact stores, and SaaS tenant isolators. - Enforce non-root uid and read-only rootfs on container workloads that use
os.Root— Even if the escape succeeds, kernel-level access controls limit what the escaped fd can read/write. Cheap defense-in-depth via existing Pod Security Standards or systemdProtectSystem=strict.
- WAF rules blocking trailing slashes — normal REST endpoints legitimately end in
/; you'll break the app before you stop the exploit. - Chroot / namespace isolation alone — the escape happens inside the process's own view; if the target file is reachable via the process's normal fs view, chroot doesn't help.
- SELinux/AppArmor without a tightly-scoped profile — default profiles for generic Go binaries won't distinguish sandbox-legal vs sandbox-escaping reads. Only a per-app profile pinned to the intended tenant directory helps.
Crowdsourced verification payload.
Run on any Linux/macOS host that ships Go binaries or has the Go toolchain installed. Invoke as ./check-cve-2026-39822.sh [optional-binary-or-dir]; defaults to scanning $PATH and /opt. No root required, but root gives broader filesystem coverage. Emits VULNERABLE / PATCHED / UNKNOWN per binary and sets exit code accordingly.
#!/usr/bin/env bash
# check-cve-2026-39822.sh
# Detects Go binaries built with a toolchain vulnerable to CVE-2026-39822.
# Vulnerable: Go 1.25.0-1.25.11, 1.26.0-1.26.4 on Unix.
# Patched: Go 1.25.12+, 1.26.5+, or anything <1.25 (predates os.Root).
set -u
SCAN_TARGETS=("${1:-}")
if [[ -z "${SCAN_TARGETS[0]}" ]]; then
SCAN_TARGETS=($(echo "$PATH" | tr ':' ' ') /opt)
fi
RC=0
SEEN=0
VULN=0
is_vuln_version() {
# $1 = version string like go1.25.4
local v="${1#go}"
local major minor patch
IFS='.' read -r major minor patch <<< "$v"
[[ "$major" != "1" ]] && return 1
case "$minor" in
25) [[ "${patch:-0}" -lt 12 ]] && return 0 ;;
26) [[ "${patch:-0}" -lt 5 ]] && return 0 ;;
esac
return 1
}
check_binary() {
local bin="$1"
local ver
ver=$(go version "$bin" 2>/dev/null | awk '{print $2}')
[[ -z "$ver" ]] && return
SEEN=$((SEEN+1))
if is_vuln_version "$ver"; then
echo "VULNERABLE $bin ($ver)"
VULN=$((VULN+1))
RC=1
else
echo "PATCHED $bin ($ver)"
fi
}
if ! command -v go >/dev/null 2>&1; then
echo "UNKNOWN: 'go' toolchain not on PATH; cannot introspect binaries." >&2
exit 2
fi
for tgt in "${SCAN_TARGETS[@]}"; do
[[ -z "$tgt" || ! -e "$tgt" ]] && continue
if [[ -f "$tgt" ]]; then
check_binary "$tgt"
else
while IFS= read -r -d '' f; do
[[ -x "$f" && -f "$f" ]] && check_binary "$f"
done < <(find "$tgt" -maxdepth 3 -type f -perm -u+x -print0 2>/dev/null)
fi
done
echo "---"
echo "Scanned Go binaries: $SEEN Vulnerable: $VULN"
[[ $SEEN -eq 0 ]] && { echo "UNKNOWN: no Go binaries found in scan targets."; exit 2; }
exit $RC
If you remember one thing.
govulncheck into CI within the next 30 days so any newly-reachable call site trips a build. Any service you own that (a) is a Go binary, (b) uses os.Root, and (c) accepts attacker-controlled path strings — CI runners, archive extractors, multi-tenant file APIs — is the priority-90-day bucket: rebuild those first and, as a bridging patch, strip trailing slashes at the app layer. Windows-only Go services and anything on Go ≤ 1.24 are safe by construction — deprioritize.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.