
CVE-2026-63030 + CVE-2026-60137 - «wp2shell»: RCE без аутентификации в ядре WordPress
REST API путаница маршрутов (batch route confusion) (CVE-2026-63030) в связке с SQL-инъекцией
author__not_inвWP_Query(CVE-2026-60137) → удалённое выполнение кода без аутентификации на стандартной установке WordPress.Обнаружено Адамом Кьюсом (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 для batch confusion; 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 без учётных данных создаёт поддельный WP_Post через UNION-путаницу одиночной записи, использует кастомайзер для создания свежего администратора (POST /wp/v2/users), выполняет вход и размещает веб-шелл, защищённый токеном. SQLi-дамп хэша администратора (read --preset users) сохранён как второй проверенный путь.block_cannot_read), используемый как основной неразрушающий check.sqli), которого нет в остальных PoC.$wp$2y$ (-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)
Шесть публичных PoC, на которые опирается этот инструмент, не включены в репозиторий; на них есть ссылки в разделе Благодарности.
Всё нижеописанное проверено в локальной Docker-лаборатории (см. §4); утверждения, которые не запускались в лаборатории, помечены как таковые.
Цепочка связывает две независимые ошибки. Номера строк — по реальному исходному коду WordPress 6.9.4 (извлечённому из wordpress:6.9.4-apache).
author__not_in (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) повторно применяет
array_map('absint', …) внутри implode и безопасен — один отсутствующий
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 сама по себе — лишь «опосредованная» (facilitated, требует вспомогательного плагина/темы). Ошибка 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[]
(continue на строке 1753 пропускает 1757), поэтому $matches оказывается короче,
и $matches[$i] (1841) содержит обработчик следующего запроса. Запрос i
диспетчеризуется с обработчиком запроса i+1, сохраняя собственные параметры и
собственный (пройденный) вердикт валидации.
Происхождение регрессии (проверено по диффу 6.8.3 → 6.9.4): в 6.8.3 цикл
добавляет $matches[] = $match для каждого запроса, а битые пути отбрасываются
в первом цикле — массивы остаются выровненными, рассинхронизации нет. Рефакторинг
в 6.9.0 ввёл сдвиг. Именно поэтому 6.8.x — это «только SQLi», а цепочка RCE
начинается с 6.9.0.
Патч добавляет $matches[] и для ошибочных записей, ужесточает защиту от
повторного входа (re-entrancy) и разбирает author__not_in с помощью хелпера
для списка идентификаторов. (6.9.5 не было на Docker Hub на момент тестирования,
поэтому это взято из бюллетеней, а не из лабораторного диффа.)
Схема batch допускает только подзапросы POST/PUT/PATCH/DELETE, но
get_items записей (приёмник author_exclude) доступен только через GET,
поэтому путаница вкладывается дважды: