Се­год­ня раз­берем недав­но най­ден­ный баг в WordPress и напишем собс­твен­ный экс­пло­ит на Python. Уяз­вимость содер­жится в copypress-rest-api, поз­воля­ет обхо­дить зап­рет на ска­чива­ние пла­гина из катало­га WP и добивать­ся воз­можнос­ти исполне­ния команд. Она получи­ла номер CVE-2025-8625 и кри­тичес­кий ста­тус.

Пла­гин Copypress Rest API рас­ширя­ет воз­можнос­ти REST API WordPress фун­кци­ями управле­ния кон­тентом по HTTP. Удоб­но для авто­мати­чес­кого раз­мещения пос­тов. В сен­тябре 2025 года иссле­дова­тель kr0d нашел кри­тичес­кую уяз­вимость в пла­гине, которая поз­воля­ет получить RCE.

На момент написа­ния статьи сущес­тву­ет две вер­сии пла­гина: 1.1 и 1.2. Обе вер­сии уяз­вимы к CVE-2025-8625.

warning

Статья име­ет озна­коми­тель­ный харак­тер и пред­назна­чена для спе­циалис­тов по безопас­ности, про­водя­щих тес­тирова­ние в рам­ках кон­трак­та. Автор и редак­ция не несут ответс­твен­ности за любой вред, при­чинен­ный с при­мене­нием изло­жен­ной информа­ции. Рас­простра­нение вре­донос­ных прог­рамм, наруше­ние работы сис­тем, наруше­ние тай­ны перепис­ки, осу­щест­вле­ние прос­лушива­ния и чте­ние перепис­ки граж­дан без их сог­ласия прес­леду­ется по закону.

CVE-2025-8625 объ­еди­няет две проб­лемы:

  • в исходный код зашит сек­ретный ключ для генера­ции JWT;
  • заг­рузка фай­лов реали­зова­на небезо­пас­но.

Лю­бой жела­ющий может сге­нери­ровать валид­ный токен и заг­рузить любой файл на сер­вер веб‑при­ложе­ния.

Схема атаки
Схе­ма ата­ки
 

Скачиваем плагин

Прос­то ска­чать архив или уста­новить пла­гин через мас­тер уста­нов­ки не получит­ся. 26 сен­тября коман­да WP отклю­чила дос­туп к пла­гину в катало­ге. Ска­чать архив с дру­гих ресур­сов тоже не вый­дет, и даже Internet Archive не поможет.

Автор отключил плагин
Ав­тор отклю­чил пла­гин

Ла­зей­ка, через которую получит­ся дос­тать пла­гин, — это SVN. Под пла­гины WordPress раз­верну­та сис­тема кон­тро­ля вер­сий Apache Subversions. Она поз­воля­ет прос­матри­вать исходные фай­лы и отсле­живать, какие изме­нения были от вер­сии к вер­сии.

На стра­нице пла­гина на вклад­ке Development оста­лась ссыл­ка на репози­торий copypress-rest-api. Вот коман­да, которая ска­чает пос­леднюю вер­сию пла­гина в пап­ку plugin на твою машину:

svn export https://plugins.svn.wordpress.org/copypress-rest-api/trunk/ plugin

Ес­ли хочешь выкачать весь репози­торий, исполь­зуй такую коман­ду:

svn checkout https://plugins.svn.wordpress.org/copypress-rest-api/ plugin_full

В под­папку plugin_full попадут две пап­ки: trunk — акту­аль­ная вер­сия пла­гина, tags — все релизы пла­гина. Коман­да будет осо­бен­но акту­аль­на, если на момент чте­ния статьи автор выпус­тит новую вер­сию пла­гина.

Ес­ли у тебя не уста­нов­лен SVN, выпол­ни sudp apt install -y subversion.

Те­перь зай­ди в пап­ку plugin и упа­куй выб­ранную вер­сию в ZIP:

zip -r copypress-rest-api.zip .

Пла­гин готов к уста­нов­ке.

 

Изучаем исходники

Пер­вое проб­лемное мес­то ты най­дешь в фай­ле includes/class-copypress-jwt-token.php в конс­трук­торе:

public function __construct() {
// Use a secret key from wp-config.php if defined
$this->secret_key = defined('COPYREAP_JWT_SECRET_KEY') ? COPYREAP_JWT_SECRET_KEY : '826657a98e396172f8aed51d110d529d';
}

Ес­ли не опре­делен сек­ретный ключ, исполь­зует­ся жес­тко вши­тый 826657a98e396172f8aed51d110d529d. Спой­лер: что­бы зарегис­три­ровать собс­твен­ный сек­ретный ключ и обе­зопа­сить при­ложе­ние, нуж­но добавить объ­явле­ние COPYREAP_JWT_SECRET_KEY в wp-config.php. Но пла­гин ниг­де не сооб­щает об этом поль­зовате­лю.

Зная сек­ретный ключ, зло­умыш­ленник может под­делать JWT-токены и выпол­нить зап­рос к API пла­гина от име­ни любого поль­зовате­ля ресур­са.

info

Уяз­вимос­ти с жес­тко зашиты­ми клю­чами отно­сят­ся к CWE-321 — Use of Hard-coded Cryptographic Key (жес­тко зашитый крип­тогра­фичес­кий ключ).

Под­делка токена не была бы кри­тичес­кой без вто­рой проб­лемы — воз­можнос­ти заг­рузить любой файл на сер­вер. Магия про­исхо­дит в фун­кции copyreap_handle_image, которая совер­шенно не заботит­ся о том, что имен­но заг­ружа­ет. Нет про­вер­ки MIME-типа, не про­веря­ется рас­ширение фай­ла, пол­ностью отсутс­тву­ет филь­тра­ция. Класс про­веря­ет кор­рек­тность URL и дос­тупность для чте­ния фун­кци­ей file_get_contents:

if ( ! filter_var( $image_url, FILTER_VALIDATE_URL ) ) {
return new WP_Error( 'invalid_image_url', 'Provided image URL is invalid.' );
}
$image_data = file_get_contents( $image_url );
if ( ! $image_data ) {
return new WP_Error( 'image_download_failed', 'Failed to download image.' );
}

Пла­гин сох­раня­ет файл под тем же име­нем, которое было в ссыл­ке. Зная струк­туру папок WP, путь к фай­лу лег­ко уга­дать: wp-content/uploads/YYYY/MM, где YYYY — это текущий год, а MM — текущий месяц с ведущим нолем.

$filename = basename( $image_url );
$upload_dir = wp_upload_dir();
$upload_path = $upload_dir['path'] . '/' . $filename;
file_put_contents( $upload_path, $image_data );
 

Собираем стенд

www

Все исходни­ки ты можешь ска­чать с моего GitHub ret0x2A.

Для тес­тов удоб­но исполь­зовать офи­циаль­ный образ WordPress для Docker. Соз­дай docker-compose.yml с таким содер­жимым:

version: '3.9'
services:
wordpress:
image: wordpress:latest
container_name: wp
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
volumes:
- ./wp_data:/var/www/html
db:
image: mysql:5.7
container_name: wp_db
restart: always
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
MYSQL_RANDOM_ROOT_PASSWORD: '1'
volumes:
- ./db_data:/var/lib/mysql

Что­бы запус­тить, выпол­ни коман­ду docker compose up -d.

В соб­ранном про­екте Docker нуж­но вклю­чить mod_rewrite. Выпол­ни docker exec wp a2enmod rewrite.

Про­верь содер­жимое фай­ла .htaccess, в базовом вари­анте он выг­лядит так:

$ sudo docker exec wp cat /var/www/html/.htaccess
# BEGIN WordPress
# The directives (lines) between "BEGIN WordPress" and "END WordPress" are
# dynamically generated, and should only be modified via WordPress filters.
# Any changes to the directives between these markers will be overwritten.
# END WordPress

Те­бе нуж­но про­писать пра­вила самос­тоятель­но:

docker exec wp bash -c 'echo -e "# BEGIN WordPress\nSetEnvIf Authorization "\(.*\)" HTTP_AUTHORIZATION=\$1\n<IfModule mod_rewrite.c>\nRewriteEngine On\nRewriteBase /\nRewriteRule ^index\.php$ - [L]\nRewriteCond %{REQUEST_FILENAME} !-f\nRewriteCond %{REQUEST_FILENAME} !-d\nRewriteRule . /index.php [L]\n</IfModule>\n# END WordPress" > /var/www/html/.htaccess'

Те­перь вывод дол­жен выг­лядеть так:

$ sudo docker exec wp cat /var/www/html/.htaccess
# BEGIN WordPress
SetEnvIf Authorization (.*) HTTP_AUTHORIZATION=$1
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
IfModule>
# END WordPress

Пе­реза­пус­ти кон­тей­нер коман­дой docker restart wp, что­бы изме­нения всту­пили в силу.

Пос­ле сбор­ки и запус­ка мас­тер уста­нов­ки WordPress дос­тупен по адре­су http://localhost:8080. Выпол­ни уста­нов­ку, ука­зав любые дан­ные. В кон­це вклю­чи пер­манен­тные ссыл­ки.

В админке WP перей­ди к раз­делу пла­гинов, добавь новый и выбери «Заг­рузить пла­гин». Ука­жи наш архив. Акти­вируй пла­гин.

 

Атакуем стенд

Для начала пос­мотри на код фун­кции copyreap_generate_token, что­бы понимать, как пра­виль­но сге­нери­ровать JWT:

public function copyreap_generate_token($user) {
$issued_at = time();
$expiration_time = $issued_at + 7200; // Token expires in 2 hour
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$payload = json_encode([
'iss' => get_bloginfo('url'),
'iat' => $issued_at,
'exp' => $expiration_time,
'data' => [
'user_id' => $user->ID,
'username' => $user->user_login,
'email' => $user->user_email
]
]);
$base64_url_header = $this->copyreap_base64UrlEncode($header);
$base64_url_payload = $this->copyreap_base64UrlEncode($payload);
$signature = hash_hmac('sha256', $base64_url_header . '.' . $base64_url_payload, $this->secret_key, true);
$base64_url_signature = $this->copyreap_base64UrlEncode($signature);
return $base64_url_header . '.' . $base64_url_payload . '.' . $base64_url_signature;
}

Те­бе пот­ребу­ется id адми­на. На это ука­зыва­ет стро­ка 46 в фай­ле пла­гина includes/class-copypress-rest-api-validation.php:

$jwt = new COPYREAP_JWT_Token();
$user_data = $jwt->copyreap_validate_token($token);
if (!$user_data) {
return new WP_Error('invalid_token', 'Invalid token or expired', ['status' => 403]);
}
wp_set_current_user($user_data['user_id']);

По­лучить дан­ные поль­зовате­ля мож­но, обра­тив­шись к сай­ту по пути /wp-json/wp/v2/users. В боль­шинс­тве слу­чаев при­ем сра­бота­ет. В ответ ты получишь JSON с основны­ми дан­ными по всем поль­зовате­лям сай­та.

Данные пользователей в JSON
Дан­ные поль­зовате­лей в JSON

Продолжение доступно только участникам

Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».

Присоединяйся к сообществу «Xakep.ru»!

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии