Содержание статьи
Корень проблемы в том, что текст комментария недостаточно фильтруется, если его оставляет администратор, а излишнее экранирование некоторых функций позволяет провести атаку типа межсайтовый скриптинг. Из-за особенностей администрирования WordPress XSS легко превращается в RCE.
Про баг снова сообщил Саймон Сканнелл (Simon Scannell) из RIPS Tech.
Стенд
Нам понадобится две машины: одна с WordPress, вторая же будет выступать в роли сайта злоумышленника. С него будет производиться атака «межсайтовая подделка запроса» (CSRF), результатом которой станет комментарий с полезной нагрузкой от имени администратора CMS.
Для этих целей используем пару контейнеров Docker. Начнем с WordPress. Сначала поднимаем базу данных MySQL.
$ docker run -d --rm -e MYSQL_USER="wpxss" -e MYSQL_PASSWORD="CdAT1pQ2lY" -e MYSQL_DATABASE="wpxss" --name=wpmysql --hostname=mysql mysql/mysql-server:5.7
Теперь веб-сервер и сопутствующие пакеты.
$ docker run -it --rm -p80:80 --name=wpxss --hostname=wpxss --link=wpmysql debian /bin/bash
$ apt-get update && apt-get install -y apache2 php php7.0-mysqli php-xdebug nano wget
Если будешь заниматься отладкой, то наряду с установкой расширения xdebug нужно указать необходимые настройки.
$ 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
Теперь скачиваем последнюю уязвимую версию WordPress — это 5.1.
$ cd /tmp && wget "https://wordpress.org/wordpress-5.1.tar.gz"
Затем распаковываем ее в веб-рут.
$ tar xzf wordpress-5.1.tar.gz
$ rm -rf /var/www/html/* && mv wordpress/* /var/www/html/
$ chown -R www-data:www-data /var/www/html/
После этого можно запускать сервер и приступать к установке CMS.
$ service apache2 start
После настройки основных параметров можно отключить автоматическое обновление, добавив в конфигурационный файл такую строку:
$ echo "define( 'WP_AUTO_UPDATE_CORE', false );" >> /var/www/html/wp-config.php
С первым стендом мы закончили, переходим ко второму. Назовем его машиной атакующего.
$ docker run -it --rm -p8080:80 --name=attacker --hostname=attacker debian /bin/bash
Устанавливаем веб-сервер и текстовый редактор.
$ apt-get update && apt-get install -y apache2 nano
И это все, что нам здесь понадобится. Запускаем Apache, и стенд готов.
$ service apache2 start
Анализ уязвимости
Баг у нас — в системе комментирования. Давай посмотрим на нее пристальнее. Вся логика находится в файле /wp-includes/comment.php
. Попробуем оставить коммент с тегом HTML в его тексте.
<img src="a" onerror=alert()>
Обработкой входящих комментариев занимается функция wp_handle_comment_submission
, в нее информация попадает после нажатия на кнопку Post Comment.
wp-includes/comment.php
3112: function wp_handle_comment_submission( $comment_data ) {
Вначале идет блок базовой фильтрации переданных пользователем данных, нужный, чтобы они соответствовали ожиданиям WordPress.
wp-includes/comment.php
3117: if ( isset( $comment_data['comment_post_ID'] ) ) {
3118: $comment_post_ID = (int) $comment_data['comment_post_ID'];
3119: }
3120: if ( isset( $comment_data['author'] ) && is_string( $comment_data['author'] ) ) {
3121: $comment_author = trim( strip_tags( $comment_data['author'] ) );
3122: }
3123: if ( isset( $comment_data['email'] ) && is_string( $comment_data['email'] ) ) {
3124: $comment_author_email = trim( $comment_data['email'] );
3125: }
3126: if ( isset( $comment_data['url'] ) && is_string( $comment_data['url'] ) ) {
3127: $comment_author_url = trim( $comment_data['url'] );
3128: }
3129: if ( isset( $comment_data['comment'] ) && is_string( $comment_data['comment'] ) ) {
3130: $comment_content = trim( $comment_data['comment'] );
3131: }
3132: if ( isset( $comment_data['comment_parent'] ) ) {
3133: $comment_parent = absint( $comment_data['comment_parent'] );
3134: }
После этого проверяется наличие авторизации в системе.
wp-includes/comment.php
3230: // If the user is logged in
3231: $user = wp_get_current_user();
3232: if ( $user->exists() ) {
...
3248: } else {
3249: if ( get_option( 'comment_registration' ) ) {
3250: return new WP_Error( 'not_logged_in', __( 'Sorry, you must be logged in to comment.' ), 403 );
3251: }
3252: }
Так как в данный момент я не залогинен в системе, тело условия игнорируется и выполнение кода продолжается.
Наконец, мы доходим до вызова функции wp_new_comment
. Она заносит информацию о новом комментарии в таблицу wp_comments
базы данных.
wp-includes/comment.php
3293: $comment_id = wp_new_comment(wp_slash($commentdata), true);
Пользовательские данные предварительно проходят санитизацию с помощью функции wp_slash
.
wp-includes/formatting.php
5301: function wp_slash( $value ) {
5302: if ( is_array( $value ) ) {
5303: foreach ( $value as $k => $v ) {
5304: if ( is_array( $v ) ) {
5305: $value[ $k ] = wp_slash( $v );
5306: } else {
5307: $value[ $k ] = addslashes( $v );
5308: }
5309: }
5310: } else {
5311: $value = addslashes( $value );
5312: }
5313:
5314: return $value;
5315: }
И текст комментария превращается в <img src=\"a\" onerror=alert()>
. Затем, уже внутри wp_new_comment
, выполняется фильтрация всех переданных данных вызовом wp_filter_comment
.
wp-includes/comment.php
2024: function wp_new_comment( $commentdata, $avoid_die = false ) {
...
2071: $commentdata = wp_filter_comment( $commentdata );
wp-includes/comment.php
1896: /**
1897: * Filters and sanitizes comment data.
...
1907: */
1908: function wp_filter_comment( $commentdata ) {
...
1936: /**
1937: * Filters the comment content before it is set.
1938: *
1939: * @since 1.5.0
1940: *
1941: * @param string $comment_content The comment content.
1942: */
1943: $commentdata['comment_content'] = apply_filters( 'pre_comment_content', $commentdata['comment_content'] );
Список фильтров состоит из нескольких функций:
convert_invalid_entities
wp_targeted_link_rel
wp_filter_kses
wp_rel_nofollow
balanceTags
Больше всего нас интересует wp_filter_kses. Эта функция удаляет все нежелательные элементы и атрибуты HTML, а также выполняет ряд проверок, чтобы избежать межсайтового скриптинга (XSS).
wp-includes/kses.php
1884: function wp_filter_kses( $data ) {
1885: return addslashes( wp_kses( stripslashes( $data ), current_filter() ) );
1886: }
wp-includes/kses.php
731: function wp_kses( $string, $allowed_html, $allowed_protocols = array() ) {
732: if ( empty( $allowed_protocols ) ) {
733: $allowed_protocols = wp_allowed_protocols();
734: }
735: $string = wp_kses_no_null( $string, array( 'slash_zero' => 'keep' ) );
736: $string = wp_kses_normalize_entities( $string );
737: $string = wp_kses_hook( $string, $allowed_html, $allowed_protocols );
738: return wp_kses_split( $string, $allowed_html, $allowed_protocols );
739: }
Здесь последний вызов wp_kses_split
убирает из текста комментария все HTML-теги, которые не разрешены разработчиками WordPress.
wp-includes/kses.php
943: function wp_kses_split( $string, $allowed_html, $allowed_protocols ) {
944: global $pass_allowed_html, $pass_allowed_protocols;
945: $pass_allowed_html = $allowed_html;
946: $pass_allowed_protocols = $allowed_protocols;
947: return preg_replace_callback( '%(<!--.*?(-->|$))|(<[^>]*(>|$)|>)%', '_wp_kses_split_callback', $string );
948: }
...
1012: function _wp_kses_split_callback( $match ) {
1013: global $pass_allowed_html, $pass_allowed_protocols;
1014: return wp_kses_split2( $match[0], $pass_allowed_html, $pass_allowed_protocols );
1015: }
...
1038: function wp_kses_split2( $string, $allowed_html, $allowed_protocols ) {
1039: $string = wp_kses_stripslashes( $string );
...
1071: if ( ! is_array( $allowed_html ) ) {
1072: $allowed_html = wp_kses_allowed_html( $allowed_html );
1073: }
1074:
1075: // They are using a not allowed HTML element.
1076: if ( ! isset( $allowed_html[ strtolower( $elem ) ] ) ) {
1077: return '';
1078: }
По умолчанию список разрешенных тегов включает в себя: a
, abbr
, acronym
, b
, blockquote
, cite
, code
, del
, em
, i
, q
, s
, strike
, strong
.
Наш комментарий состоит из одного лишь img
, и, как видишь, в списке он отсутствует. Поэтому, после того как функция отработает, весь текст комментария будет удален.
Теперь ты понимаешь, через что приходится пройти комментарию прежде, чем он попадет в базу данных.
Сейчас авторизуемся от имени администратора и оставим комментарий с тегом a
, который разрешен.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»