Содержание статьи
- Стенд
- Анализ уязвимости
- Демонстрация уязвимости (видео)
- Выводы
Не прошло и месяца с последнего раза, как ребята из 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
После настройки основных параметров можно отключить автоматическое обновление, добавив в конфигурационный файл такую строку:
$ 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_entitieswp_targeted_link_relwp_filter_kseswp_rel_nofollowbalanceTags

Больше всего нас интересует 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 год7490 р. Экономия 1400 рублей! |
1 месяц720 р. 25-30 статей в месяц |
Уже подписан?