От XSS до RCE одним движением мыши. Эксплуатируем новую уязвимость в WordPress

Не прошло и месяца с последнего раза, как ребята из RIPS снова обнаружили уязвимость в WordPress. На этот раз уязвимость — в комментариях. Проблему усугубляет отсутствие токенов CSRF, в итоге уязвимость можно эксплуатировать, просто посетив сайт злоумышленника.

Корень проблемы в том, что текст комментария недостаточно фильтруется, если его оставляет администратор, а излишнее экранирование некоторых функций позволяет провести атаку типа межсайтовый скриптинг. Из-за особенностей администрирования 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
Инсталляция WordPress

После настройки основных параметров можно отключить автоматическое обновление, добавив в конфигурационный файл такую строку:

$ 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_comment

Больше всего нас интересует 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:   }
Фильтрация текста комментария при помощи kses

По умолчанию список разрешенных тегов включает в себя: a, abbr, acronym, b, blockquote, cite, code, del, em, i, q, s, strike, strong.

Список разрешенных в комментарии HTML-тегов

Наш комментарий состоит из одного лишь img, и, как видишь, в списке он отсутствует. Поэтому, после того как функция отработает, весь текст комментария будет удален.

Текст комментария после прохождения фильтрации

Теперь ты понимаешь, через что приходится пройти комментарию прежде, чем он попадет в базу данных.

Сейчас авторизуемся от имени администратора и оставим комментарий с тегом a, который разрешен.

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

Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте

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

Вариант 2. Открой один материал

Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


aLLy: Специалист по информационной безопасности в ONsec. Research, ethical hacking and Photoshop.

Комментарии (1)