https://habrahabr.ru/company/yandex/blog/327590/- Информационная безопасность
- Open source
- Блог компании Яндекс
Nginx, однозначно, один из крутейших веб-серверов. Однако, будучи в меру простым, довольно расширяемым и производительным, он требует уважительного отношения к себе. Впрочем, это относится к почти любому ПО, от которого зависит безопасность и работоспособность сервиса. Признаюсь, нам нравится Nginx. В Яндексе он представлен огромным количеством инсталляций с разнообразной конфигурацией: от простых reverse proxy до полноценных приложений. Благодаря такому разнообразию у нас накопился некий опыт его [не]безопасного конфигурирования, которым мы хотим поделиться.
Но обо всем по порядку. Нас давно терзал вопрос безопасного конфигурирования Nginx, ведь он — полноправный кубик веб-приложения, а значит, и его конфигурация требует не меньшего контроля с нашей стороны, чем код самого приложения. В прошлом году нам стало очевидно, что этот процесс требует серьезной автоматизации. Так начался in-house проект
Gixy, требования к которому мы обозначили следующим образом:
— быть простым;
— но расширяемым;
— с возможностью удобного встраивания в процессы тестирования;
— неплохо бы уметь резолвить инклюды;
— и работать с переменными;
— и про регулярные выражения не забыть.
Признаться, мы до последнего колебались с выбором языка (между Golang и Python). В итоге был выбран Python с надеждой на то, что он более распространен, а значит, будет чуть проще с развитием.
О проблемах
На этом покончим со вступлением и перейдем к примерам распространенных проблем :) Чтобы избежать путаницы в будущем, во всех примерах использовалась текущая mainline версия Nginx — 1.13.0.
Server-Side-Request-Forgery
Server Side Request Forgery — уязвимость, позволяющая выполнять различного рода запросы от имени веб-приложения (в нашем случае от имени Nginx). Возникает, когда злоумышленник может контролировать адрес проксируемого сервера — например, в случае некорректной настройки
XSendfile.
По своему опыту могу сказать, что зачастую уязвимость связана с несколькими ошибками:
— отсутствие директивы
internal. Ее смысл заключается в указании того, что определенный location может использоваться только для внутренних запросов;
— небезопасное внутреннее перенаправление.
Если с первым случаем все понятно, то с внутренним перенаправлением дела обстоят не так просто. Полагаю, многие из вас видели/писали подобную конфигурацию:
location ~* ^/internal-proxy/(?<proxy_proto>https?)/(?<proxy_host>.*?)/(?<proxy_path>.*)$ {
internal;
proxy_pass $proxy_proto://$proxy_host/$proxy_path ;
proxy_set_header Host $proxy_host;
}
К сожалению, в такой конфигурации вам необходимо проверить как минимум все директивы rewrite и try_files, так как согласно документации:
Внутренними запросами являются:
– запросы, перенаправленные директивами error_page, index, random_index и try_files;
– запросы, перенаправленные с помощью поля “X-Accel-Redirect” заголовка ответа вышестоящего сервера;
– подзапросы, формируемые командой “include virtual” модуля ngx_http_ssi_module и директивами модуля ngx_http_addition_module;
– запросы, изменённые директивой rewrite.
Получается, любой неосторожный реврайт позволит сделать запрос в internal location. В этом довольно легко убедиться:
– конфигурация:
location ~* ^/internal-proxy/(?<proxy_proto>https?)/(?<proxy_host>.*?)/(?<proxy_path>.*)$ {
internal;
return 200 "proto: $proxy_proto\nhost: $proxy_host\npath: $proxy_path";
}
rewrite ^/(?!_api)(.*)/\.files/(.*)$ /$1/.download?file=$2 last;
– эксплуатация:
GET /internal-proxy/http/evil.com/.files/some HTTP/1.0
Host: localhost
HTTP/1.1 200 OK
Content-Length: 42
Content-Type: application/octet-stream
Date: Fri, 28 Apr 2017 13:55:51 GMT
Server: nginx/1.13.0
proto: http
host: evil.com
path: .download
В данной ситуации мы обычно рекомендуем несколько практик:
— использовать только internal location для проксирования;
— по возможности запретить передачу пользовательских данных;
— обезопасить адрес проксируемого сервера:
• если количество проксируемых хостов ограничено (например, у вас S3), то лучше их захардкодить и выбирать при помощи map или иным удобным для вас образом;
• если по какой-то причине нет возможности перечислить все возможные хосты для проксирования, его стоит подписать.
Плохие регулярные выражения для валидации реферера или ориджинаУ вас есть проблема. Вы решили использовать регулярные выражения, чтобы её решить.
– Теперь у вас две проблемы.
Нередко валидация заголовка запроса «Referer» или «Origin» делается при помощи регулярного выражения. Зачастую это необходимо для условного выставления заголовка X-Frame-Options (защита от ClickJacking) или реализации Cross-Origin Resource Sharing (
CORS). И если с валидацией «Referer» все немного проще и, с некоторыми условиями, можно отказаться от регулярного выражения в пользу модуля
ngx_http_referer_module, то с «Origin» не все так однозначно.
Мы выделяем два основных класса проблем:
— ошибки в составлении регулярного выражения;
— разрешение недоверенных third-party доменов.
Проблемная конфигурация выглядит следующим образом:
if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)/)) {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Allow-Credentials' 'true';
}
На самом деле я очень упростил регулярное выражение, но даже в этом примере увидеть проблему с первого раза не столь просто. Людям проще писать регулярные выражения, нежели читать их.
К счастью, машине несвойственна эта проблема, поэтому Gixy умеет самостоятельно определять, что это регулярное выражение сматчит
www.yandex.ru.evil.com как валидный origin и сообщит вам об этом:
$ gixy --origins-domains yandex.ru,ya.ru /etc/nginx/nginx.conf
==================== Results ===================
Problem: [origins] Validation regex for "origin" or "referrer" matches untrusted domain.
Description: Improve the regular expression to match only trusted referrers.
Additional info: https://github.com/yandex/gixy/blob/master/docs/ru/plugins/origins.md
Reason: Regex matches "https://www.yandex.ru.evil.com" as a valid origin.
Pseudo config:
include /etc/nginx/sites/default.conf;
server {
server_name _;
if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)/)) {
}
}
Или, если считать ya.ru недостаточно доверенным, сообщит о ориджинах
ya.ru и
www.yandex.ru.evil.com:
$ gixy --origins-domains yandex.ru /etc/nginx/nginx.conf
==================== Results ===================
Problem: [origins] Validation regex for "origin" or "referrer" matches untrusted domain.
Description: Improve the regular expression to match only trusted referrers.
Additional info: https://github.com/yandex/gixy/blob/master/docs/ru/plugins/origins.md
Reason: Regex matches "https://www.yandex.ru.evil.com", "https://ya.ru/" as a valid origin.
Pseudo config:
include /etc/nginx/sites/default.conf;
server {
server_name _;
if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)/)) {
}
}
HTTP Splitting
HTTP Splitting используется для атак на приложение, стоящее за Nginx (HTTP Request Splitting), или на клиентов приложения (HTTP Response Splitting). Уязвимость возникает в случае, когда атакующий может внедрить символ перевода строки \n в запрос или ответ, формируемый Nginx.
Безотказного совета (кроме как быть внимательными) у меня нет, но всегда следует обращать внимание на несколько вещей:
— какие переменные используются в директивах, отвечающих за формирование запросов (могут ли они содержать CRLF), например: rewrite, return, add_header, proxy_set_header и proxy_pass;
— используются ли переменные $uri и $document_uri, и если да, то в каких директивах, так как они гарантированно содержат урлдекодированное значение;
— уделить особое внимание переменным, полученным из групп с исключающим диапазоном: (?P[^.]+).
Пример с исключающим диапазоном:
— конфигурация:
server {
listen 80 default;
location ~ /v1/((?<action>[^.]*)\.json)?$ {
add_header X-Action $action;
return 200 "OK";
}
}
— эксплуатация:
GET /v1/see%20below%0d%0ax-crlf-header:injected.json HTTP/1.0
Host: localhost
HTTP/1.1 200 OK
Content-Length: 2
Content-Type: application/octet-stream
Date: Fri, 28 Apr 2017 13:57:28 GMT
Server: nginx/1.13.0
X-Action: see below
x-crlf-header: injected
OK
Как вы видите, мы смогли добавить заголовок ответа x-crlf-header: injected. Это случилось благодаря стечению нескольких обстоятельств:
— add_header не кодирует/валидирует переданные ему значения, считая, что автор знает о последствиях;
— значение пути нормализуется перед обработкой локейшена;
— переменная $action была выделена из группы регулярного выражения с исключающим диапазоном: [^.]*;
— таким образом, значение переменной $action стало равно see below\r\nx-crlf-header:injected и попало в HTTP-ответ.
К счастью, Gixy с немалым успехом справляется с этой задачей:
— он знает об «опасных» переменных — точнее, он знает о допустимом множестве символов в большинстве встроенных переменных. Таким образом, отличие $request_uri от $uri для него очевидно;
— умеет выделять переменные из групп регулярного выражения;
— умеет определять, может ли какой-либо символ (в нашем случае \n) сматчиться регулярным выражением (или отдельно взятой группой).
Другой интересный пример — реврайт с помощью try_files:
— конфигурация:
server {
listen 80 default;
location / {
try_files $uri $uri/ /index.php?q=$uri;
}
location ~ \.php {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:9000;
}
}
— эксплуатация (на 127.0.0.1:9000 слушает отладочный echo-сервер):
GET /request%20HTTP/1.0%0aInjection: HTTP/1.0
Host: localhost
HTTP/1.1 200 Ok
Content-Length: 244
Content-Type: text/plain
Date: Fri, 28 Apr 2017 13:59:18 GMT
Server: nginx/1.13.0
GET /index.php?q=/request HTTP/1.0\n
Injection: HTTP/1.0\r\n
X-Real-IP: 127.0.0.1\r\n
X-Forwarded-For: 127.0.0.1\r\n
Host: localhost\r\n
Connection: close\r\n
User-Agent: HTTPie/0.9.8\r\n
Accept-Encoding: gzip, deflate\r\n
Accept: */*\r\n
\r\n
Что делать?
— Старайтесь использовать более безопасные переменные, например $request_uri вместо $uri.
— Запретите перевод строки в исключающем диапазоне, например /some/(?[^/\s]+) вместо /some/(?[^/]+.
— Возможно, хорошей идеей будет добавить валидацию $uri (только если вы знаете, что делаете).
Переопределение «вышестоящих» заголовков ответа директивой add_header
Это известная особенность Nginx, о которую спотыкались и будут продолжать спотыкаться многие из нас. Суть крайне проста — если у вас устанавливаются заголовки на одном уровне (например, в серверной секции), а уровнем ниже (например, в локейшене) устанавливаются какие-либо еще, то первый не будет применен.
Наиболее простой пример выглядит следующим образом:
server {
listen 80 default;
server_name _;
add_header X-Content-Type-Options nosniff;
location / {
add_header X-Frame-Options DENY;
}
}
В данном случае заголовок ответа X-Content-Type-Options не будет установлен при обработке локейшена /.
Gixy с успехом расскажет вам об этом:
$ gixy /etc/nginx/nginx.conf
==================== Results ===================
Problem: [add_header_redefinition] Nested "add_header" drops parent headers.
Description: "add_header" replaces ALL parent headers. See documentation: http://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header
Additional info: https://github.com/yandex/gixy/blob/master/docs/ru/plugins/addheaderredefinition.md
Reason: Parent headers "x-content-type-options" was dropped in current level
Pseudo config:
include /etc/nginx/sites/default.conf;
server {
server_name _;
add_header X-Content-Type-Options nosniff;
location / {
add_header X-Frame-Options DENY;
}
}
Мне известно несколько способов решить эту проблему:
— продублировать важные заголовки;
— устанавливать заголовки на одном уровне, например в серверной секции;
— рассмотреть вариант с использованием модуля ngx_headers_more.
Каждый из них имеет свои преимущества и недостатки. Какой предпочесть, зависит от вас.
О Gixy
Надеюсь, я вас убедил в том, что конфигурация Nginx требует более пристального внимания. Я также верю в то, что статический анализ конфигураций Nginx может работать (это также подтверждает опыт
Nginx Amplify). К сожалению, не всегда есть возможность автоматически определить все пограничные случаи или специфичные особенности приложения, стоящего за Nginx. Так, к примеру, я не стал включать в стандартный набор проверку переопределения заголовков запроса X-Forwarded-*, так как реакция на них зависит от приложения, а в некоторых случаях к ним и вовсе нельзя притрагиваться (например, при множественном проксировании). Но у себя вы можете сделать нужные вам проверки, основываясь на более глубоком понимании работы приложения. Да, сейчас Gixy не умеет определять весь спектр известных нам проблем, но учится и, возможно, с вашей помощью начнет делать это лучше и полнее.
Если же говорить о сценариях использования, то для себя мы выделили несколько типовых случаев:
— запуск в тестовой среде, где установлен nginx;
— веб-приложение для проверки отдельно взятого блока. Это бывает полезно, когда вам встретился подозрительный участок конфига;
— HTTP API для интеграции с CI или тонкими клиентами.
Нам кажется, что наиболее интересен вариант с использованием HTTP API для тонких клиентов. Ведь в таком случае мы можем централизованно управлять нужными нам проверками, обновлять их и так далее. К счастью, современные версии nginx обладают ключом -T для тестирования конфигурации и дампа оной, а Gixy умеет парсить этот формат.
Сами посудите, насколько это удобно$ nginx -T | http -v https://gixy/api/check Content-Type:'application/nginx'
POST /api/check HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 959
Content-Type: application/nginx
Host: gixy
User-Agent: HTTPie/0.9.8
# configuration file /etc/nginx/nginx.conf:
user http;
worker_processes 1;
#daemon on;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
access_log /var/log/nginx/access.log combined;
error_log /var/log/nginx/error.log debug;
include sites/*.conf;
}
# configuration file /etc/nginx/mime.types:
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
}
# configuration file /etc/nginx/sites/default.conf:
server {
listen 80;
return 301 https://some$uri;
}
HTTP/1.1 200 OK
Connection: keep-alive
Content-Encoding: gzip
Content-Type: application/json
Date: Tue, 24 Apr 2017 19:45:57 GMT
Keep-Alive: timeout=120
Server: nginx
Transfer-Encoding: chunked
{
"result": [
{
"auditor": "http_splitting",
"config": "\ninclude /etc/nginx/sites/default.conf;\n\n\tserver {\n\t\treturn 301 https://some$uri;\n\t}",
"description": "Текущая конфигурация позволяет злоумышленнику внедрить символ перевода строки (\"\\n\") в запрос или ответ формируемый nginx. В первую очередь это касается директив: rewrite, return и proxy_pass.",
"help_url": "https://wiki/product-security/gixy/httpsplitting/",
"reason": "At least variable \"$uri\" can contain \"\\n\"",
"recommendation": "ограничьте допустимый набор символов, зачастую достаточно использовать более безопасное значение (e.g. \"$request_uri\" вместо \"$uri\").",
"severity": "HIGH",
"summary": "Обнаружена уязвимость типа HTTP Splitting"
}
],
"status": "ok",
"warnings": []
}
Напоследок хотелось бы подчеркнуть тот факт, что это первая публичная alpha-версия Gixy, поэтому API может изменяться без сохранения обратной совместимости. В связи с этим, если у вас есть необходимость в реализации собственного плагина, лучше написать Issue или прислать Pull Request — тогда мы вместе что-то придумаем.
Надеюсь, наш опыт был вам интересен и полезен, и, быть может, даже заставил пересмотреть свои конфигурации еще раз;)