Intro

EXTerminated is a malware reversing challenge. You are handed an EXT4 partition that has been encrypted by a malware, your goal is to recover the original unencrypted files.

The description of the challenge tells us that the malware does not use any known cryptosystem to encrypt data, let’s find this out.

Challenge description

reverse | 472 pts 12 solves ⭐⭐

Un client a détecté un seveur compromis sur son parc. Ce serveur semble avoir
perdu l'ensemble de ses données suite à une infection. Il nous indique que les
attaquants exigent une rançon et affirment pouvoir récupérer les fichiers
disparus.

Une analyse rapide du virus indique que ce dernier ne disposerait à première
vue d'aucun algorithme cryptographique connu.

On vous demande d'analyser ce disque, et de récupérer les fichiers originaux.

SHA256(disk.img) = a9e7891224868af43e2aa134152beaa2a83f43cde21af8038d138001377157dc.

Author: Nofix

Given files

disk.img

Writeup

Overview

So we can see we are given an EXT4 partition.

$ file disk.img
disk.img: Linux rev 1.0 ext4 filesystem data,
UUID=c26167b3-e0b9-441d-ab4d-a5f4b5b1fcd0 (extents) (64bit) (large files)
(huge files)

Let’s try to mount it.

We can see that there are multiple pictures, a pdf and an executable called wannaweep, which is probably our malware.

$ sudo mount -o loop ./disk.img mnt/
$ tree mnt/
mnt/
├── Documents
│   └── anssi-guide-ransomware_attacks_all_concerned-v1.0.pdf
├── Images
│   ├── accident.png
│   ├── disk.jpg
│   ├── flag.jpg
│   ├── martine.jpg
│   ├── smile.png
│   ├── tintin.jpeg
│   └── valide.png
├── lost+found  [error opening dir]
└── wannaweep

3 directories, 9 files

By further investigating we can see that all data of the files have been erased except for wannaweep, which definitely is the malware, an x64 stripped and dinamically linked ELF.

$ file mnt/Images/flag.jpg 
mnt/Images/flag.jpg: data
$ xxd mnt/Images/flag.jpg | head
00000000: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000030: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000060: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000070: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000080: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000090: 0000 0000 0000 0000 0000 0000 0000 0000  ................
$ file mnt/wannaweep
mnt/wannaweep: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=590a9f68cde37ffceb8e7441db343742e4032f47, for GNU/Linux
3.2.0, stripped

We can see that the binary links to libext2fs.so.2, a library for EXT file system parsing and manipulation. Since it is dinamically linked we will still have the corresponding symbols so I’m guessing I won’t have too much trouble understanding what the malware does even though I do not know the EXT specification.

$ ldd mnt/wannaweep
	linux-vdso.so.1 (0x00007ffccb2b3000)
	libext2fs.so.2 => /usr/lib/libext2fs.so.2 (0x00007f8059117000)
	libcom_err.so.2 => /usr/lib/libcom_err.so.2 (0x00007f8059111000)
	libc.so.6 => /usr/lib/libc.so.6 (0x00007f8058f07000)
	/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f80591ad000)

Main

It is now the perfect time for me to absolutely not launch this program, I could have setup a sandboxed environment but it turned out it wasn’t necessary, the code was really straight forward.

So I start my favorite decompiler and I download the source code of libext2fs.

A small fast forward in time as the main function is really straight forward,

The program takes the path to the device to encrypt as argument, calls a bunch of function that are mostly wrappers around libext2fs functions to initialize some global variables holding structures of the EXT filesystem. It also checks that some flags are set in the structures of libext2fs, I do not know if these flags are implementation specific or are standard EXT flags but I did not bother too much with this.

The interesting stuff is at the end, I can see that it calls a function that I called encrypt_folder after reversing it. It then flushes the filesystem to disk and write the inode bitmap.

int32_t main(int32_t argc, char** argv, char** envp)
{
    int32_t res;
    if (argc != 2)
    {
        printf("Usage %s <device> \n", *(int64_t*)argv);
        res = 1;
    }
    else if (check_fs_opened() != 0)
    {
        res = 1;
    }
    else
    {
        open_and_read(argv[1]);
        if (fs == 0)
        {
            perror("Could not get handle on FS");
            exit(1);
            /* no return */
        }
        if (check_flag_clear() != 0)
        {
            res = 1;
        }
        else
        {
            if (fs->blocksize != 0x1000)
            {
                perror("FS blocksize is invalid");
                exit(1);
                /* no return */
            }
            encrypt_folder(".", encrypt_file);
            ext2fs_flush(fs);
            write_inode_bitmap();
            res = 0;
        }
    }
    return res;
}

Encrypt folder

Alright so let’s take a look at the encrypt_folder to understand why I called it this way.

I could clearly see from main that this function was called with the string . to reference the current directory, and a function pointer that was still unkown to me at the time but that I renamed encrypt_file.

How I knew that the function was encrypting folders and that the parameter was a callback to encrypt files is really simple, you can clearly see the libc symbols calling opendir on the path given as argument (.), reading all the entries of the directory and calling the callback if the entry is a file.

If it is not a file, it will chdir in the said directory, before recursively calling itself with the same arguments.

DIR* encrypt_folder(char* path, void* callback)
{
    DIR* dir = opendir(path);
    if (dir != 0)
    {
        while (true)
        {
            struct dirent64* dirent = readdir(dir);
            if (dirent == 0)
            {
                break;
            }
            if (((uint32_t)dirent->d_type) != 4)
            {
                callback(&dirent->d_name);
            }
            else if ((strcmp(&dirent->d_name, ".") != 0 && strcmp(&dirent->d_name, "..") != 0))
            {
                chdir(&dirent->d_name);
                encrypt_folder(".", callback);
            }
        }
        chdir("..");
        dir = closedir(dir);
    }
    return dir;
}

Encrypt file

Let’s now look at the encrypt_file we guessed. Again, mostly wrapper functions so I did not bother to show you why I named them this way, the code basically reads the content of the file to encrypt, encrypts it in a dedicated block, puts it in the file system before deleting the original content of the file.

int64_t encrypt_file(char* name)
{
    return encrypt_file2(name);
}

int64_t encrypt_file2(char* name)
{
    int32_t inode = 0;
    int32_t len_read = 0;
    uint64_t blocksize = ((uint64_t)fs->blocksize);
    void* block = xcalloc(1, blocksize); // Allocate data for encrypted block
    int64_t fd = get_size(name); // Get the file size
    if (fd != 0)
    {
        fd = open(name, 2); // Open the file
        int32_t fd = fd;
        if (fd != 0xffffffff)
        {
            if (inode == 0)
                // Create an inode for the encrypted file
                ext2fs_new_inode(fs, 0, 0x41ed, 0, &inode);

            ext2fs_inode_alloc_stats2(fs, ((uint64_t)inode), 1, 1);
            char stop = 0;
            int64_t nb_blocks = 1;
            uint64_t* blocks = xcalloc(1, 0x10);
            do
            {
                // Fill the block with file data
                if (fill_block(fd, block, blocksize, &len_read) == 0)
                    break;

                // Determine if another block is needed to encrypt the file
                int32_t n_blocks = len_read / 0x1000;
                if (len_read % 0x1000 != 0)
                    stop = 1;

                // Encrypt the block
                encrypt_block(block, ((int64_t)len_read));

                // Write encrypted block to the filesystem
                int64_t new_block = write_block(block, ((uint64_t)fs->blocksize), inode);

                // Some stuff I did not bother to understand
                // probably setting up recursive blocks for big files
                // or preparing new empty blocks for the file that must be
                // emptied
                // but I do not know nor does it really matter for now
                if (blocks[((nb_blocks - 1) * 2)] == 0)
                {
                    blocks[((nb_blocks - 1) * 2)] = new_block;
                    blocks[(((nb_blocks - 1) * 2) + 1)] = new_block;
                }
                else if ((blocks[(((nb_blocks - 1) * 2) + 1)] + 1) != new_block)
                {
                    nb_blocks = (nb_blocks + 1);
                    realloc(blocks, (nb_blocks << 4));
                    blocks[((nb_blocks - 1) * 2)] = new_block;
                    blocks[(((nb_blocks - 1) * 2) + 1)] = new_block;
                }
                else
                    blocks[(((nb_blocks - 1) * 2) + 1)] = new_block;

                memset(block, 0, ((uint64_t)fs->blocksize));
            } while ((stop & 1) == 0);
            // Remove data from the original file
            fd = delete_data(name, nb_blocks, blocks);
        }
    }
    return fd;
}

Encrypt block

Well, I was scared of custom cryptography but this seems simple enough for me. To encrypt a plain text block, the malware starts at the last byte of the block and xor it with the previous one until it reaches the start. Actually this function does a heap buffer underflow when xoring the first byte of the block, since it will xor it with the byte right before the block in the heap.

Reversing the encryption is trivial, the only undefined behaviours is with the underflow because we cannot know the value of the byte preceding the block. However, I assumed it would really likely be 0.

int64_t encrypt_block(char* block, int64_t len)
{
    int64_t i;
    for (i = 0; i < len; i = (i + 1))
    {
        block[((len - i) - 1)] = (block[((len - i) - 1)] ^ block[((len - i) - 2)]);
    }
    return i;
}

Decrypting

So to decrypt a block, we simply need to know the value of the byte preceding the buffer. Let’s assume it is 0 since it is the most likely.

I will xor this byte with the first byte of cipher text and that will give me the first byte of plain text, I then repeat the operation, xoring the first byte of plain text with the second byte of cipher text and I do this for the whole block to recover the entire block.

Solve

So know I know how to decrypt a block, let’s decrypt the whole filesystem.

I could do something really smart and overengineered to recover a valid decrypted EXT filesystem that I could mount to recover the original files.

However I’m not familiar with the EXT specification and their are still small shadow zones in the malware code for me, so I try a naive solution.

I’m guessing that data blocks of files or all alligned on 0x1000, it would be kind of weird otherwise for me. So if I just cut the whole filesystem in 0x1000 sized blocks without any consideration of the semantic of those blocks in the EXT structure, I could just decrypt each of these blocks individually, and prey that I recover some files whose blocks were contiguous in the EXT structure.

Obviously that will corrupt all the EXT metadata but it doesn’t really matter as long as I can recover the files.

#!/usr/bin/env python3

import sys
import os

with open('./disk.img', 'rb') as f:
    data = f.read()
    blocks = [data[i * 0x1000:(i * 0x1000) + 0x1000] for i in range(len(data) // 0x1000)]

clear = bytearray()

for block in blocks:
    x = 0
    for b in block:
        c = x ^ b
        clear.append(c)
        x = c

os.write(1, clear)

Now let’s try to output the result and see if we find any file signatures:

$ ./decrypt.py > decrypted.img
$ binwalk decrypted.img

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
9289728       0x8DC000        PNG image, 473 x 306, 8-bit/color RGB, non-interlaced
9531392       0x917000        JPEG image data, JFIF standard 1.01
9625600       0x92E000        PNG image, 500 x 564, 8-bit/color RGBA, non-interlaced
10125312      0x9A8000        JPEG image data, JFIF standard 1.01
10212833      0x9BD5E1        bix header, header size: 64 bytes, header CRC: 0x6003083D, created: 2004-01-10 13:39:36, image size: 2097152 bytes, Data Address: 0x22120000, Entry Point: 0x1E040003, data CRC: 0xD2100008, CPU: IA64, image name: ""
10231808      0x9C2000        JPEG image data, EXIF standard
10231820      0x9C200C        TIFF image data, big-endian, offset of first image directory: 8
10330112      0x9DA000        JPEG image data, EXIF standard
10330124      0x9DA00C        TIFF image data, big-endian, offset of first image directory: 8
10403840      0x9EC000        PNG image, 482 x 367, 8-bit/color RGB, non-interlaced
10403902      0x9EC03E        Zlib compressed data, default compression
34263365      0x20AD145       Cisco IOS experimental microcode, for ""
46640306      0x2C7ACB2       MySQL ISAM compressed data file Version 9
46691658      0x2C8754A       GIF image data 15531 x

We do! So let’s try to extract them

$ binwalk --dd='.*' decrypted.img 

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
9289728       0x8DC000        PNG image, 473 x 306, 8-bit/color RGB, non-interlaced
9531392       0x917000        JPEG image data, JFIF standard 1.01
9625600       0x92E000        PNG image, 500 x 564, 8-bit/color RGBA, non-interlaced
10125312      0x9A8000        JPEG image data, JFIF standard 1.01
10212833      0x9BD5E1        bix header, header size: 64 bytes, header CRC: 0x6003083D, created: 2004-01-10 13:39:36, image size: 2097152 bytes, Data Address: 0x22120000, Entry Point: 0x1E040003, data CRC: 0xD2100008, CPU: IA64, image name: ""
10231808      0x9C2000        JPEG image data, EXIF standard
10231820      0x9C200C        TIFF image data, big-endian, offset of first image directory: 8
10330112      0x9DA000        JPEG image data, EXIF standard
10330124      0x9DA00C        TIFF image data, big-endian, offset of first image directory: 8
10403840      0x9EC000        PNG image, 482 x 367, 8-bit/color RGB, non-interlaced
10403902      0x9EC03E        Zlib compressed data, default compression
34263365      0x20AD145       Cisco IOS experimental microcode, for ""
46640306      0x2C7ACB2       MySQL ISAM compressed data file Version 9
46691658      0x2C8754A       GIF image data 15531 x

$ file _decrypted.img.extracted/*
_decrypted.img.extracted/2C7ACB2:  MySQL ISAM compressed data file Version 9
_decrypted.img.extracted/2C8754A:  GIF image data 15531 x
_decrypted.img.extracted/8DC000:   PNG image data, 473 x 306, 8-bit/color RGB, non-interlaced
_decrypted.img.extracted/9A8000:   JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16, progressive, precision 8, 660x424, components 3
_decrypted.img.extracted/9BD5E1:   data
_decrypted.img.extracted/9C200C:   TIFF image data, big-endian, direntries=0
_decrypted.img.extracted/9C2000:   JPEG image data, Exif standard: [TIFF image data, big-endian, direntries=0], comment: "CREATOR: gd-jpeg v1.0 (using IJG JPEG v62), quality = 90", baseline, precision 8, 481x600, components 3
_decrypted.img.extracted/9DA000:   JPEG image data, Exif standard: [TIFF image data, big-endian, direntries=0], baseline, precision 8, 683x500, components 3
_decrypted.img.extracted/9DA00C:   TIFF image data, big-endian, direntries=0
_decrypted.img.extracted/9EC000:   PNG image data, 482 x 367, 8-bit/color RGB, non-interlaced
_decrypted.img.extracted/9EC03E:   empty
_decrypted.img.extracted/9EC03E-0: zlib compressed data
_decrypted.img.extracted/20AD145:  cisco IOS experimental microcode for ''
_decrypted.img.extracted/92E000:   PNG image data, 500 x 564, 8-bit/color RGBA, non-interlaced
_decrypted.img.extracted/917000:   JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16, baseline, precision 8, 495x669, components 3

Displaying the images we just extracted, we find the following one, giving us the flag.