ぶるーたるごぶりん

UI, UX, セキュリティとか😘

Content-Disposition の filename という地雷。 (1個の観点で17個の脆弱性を見つけた話)

English ver:
https://gist.github.com/motoyasu-saburi/1b19ef18e96776fe90ba1b9f910fa714#file-lack_escape_content-disposition_filename-md

TL;DR

  • 1つのブラウザ、1つのプログラミング言語、15個の { Web Framework, HTTP Client ライブラリ, Email ライブラリ / Web Service 等} で脆弱性を見つけました。
  • 見つけた脆弱性は、全て 1つの観点で発見した (多分 50-80 くらいのプロダクトの調査をした)。
  • RFC の記載では、(かなりわかりにくく)この問題に対する要件が記載されており、WHATWG > HTML Spec の方はしっかりと書かれているといった状況にある。
  • この問題は、 Content-Dispositionfilename, filename* というフィールドを明確なターゲットとしている。
  • この問題は、 HTTP Request / Response / Email の箇所で影響がそれぞれ変わる。
    • HTTP Request : リクエスト改竄(特にファイルコンテンツの改竄、他のフィールドの汚染等)
    • HTTP Response : Reflect File Download の脆弱性
    • Email : 添付ファイルの改竄(拡張子やファイル名の改竄と、潜在的なファイルコンテンツの改竄等)
  • これらの攻撃ベクトルの明確な攻撃対象として、 Content-Disposition (の filename, filename*) を見ている人は現在はあまりいない。
  • OWASP の刊行物でこのあたりをちゃんとまとめているのはパッと見ない。 ASVS ではこれに関する Issue が立てられている。
  • Content-Dispositionfilename, filename* はちゃんとエスケープしましょう。
    • filename:
      • " --> \" or %22
      • \r --> \\r or %0D
      • \n --> \\n or %0A
    • filename*:
      • ちゃんとフォーマットを守って URL Encode する

はじめに

どうも、涼宮カルビの牛角です。

この記事では、2018-2022 のリサーチ結果で発見した 脆弱性に関して書いていきます。 リサーチ期間が非常に長くなっていますが、単純にやる気のアップダウン等が入り混じって調査していない期間が長くなっただけです。実際の調査期間は 3ヶ月-半年程度です。

リサーチの結果、以下のプロダクト(?)で脆弱性を発見しました。

  • 1個の ブラウザ
  • 1個の プログラミング言語
  • 15個の {Web Framework / Library / Web Service } (あえてぼかすために混ぜてます)

見つけた問題は大きく以下の3つに分かれます。

  1. HTTP Request における multipart/form-data > Content-Disposition 上の filenameエスケープ不足による、ペイロード生成時のコンテンツ改竄の脆弱性
  2. HTTP Response における Content-Disposition ヘッダ上の filename, filename*エスケープ不足による、Reflect File Download の脆弱性
  3. Email の multipart > Content-Disposition における filenameエスケープ不足によるコンテンツ改竄の脆弱性

それぞれ発生場所が違うだけで、この問題の原因は共通しています。

問題の痕跡を発見した当初は RFC に準拠していない Web Framework 側の実装ミスだと判断していましたが、 いくつかの Web Framework に対して報告を行った結果「ブラウザ側の実装の問題ではないか?」というコメントをいただきました。 そのため、 ブラウザ(Firefox)に対して本件の脆弱性を報告し、無事に(2年寝かした後で)修正されました。

これにて終了かと思われましたが、とある人の新しい視点と、数年越しにやる気が湧いてきたことで、追加での調査を行いました。

既に内容が公開されている脆弱性は以下になります。

また、この問題自体は氷山の一角で、本件が取り扱う脆弱な実装方法について、多くのブログの記事では言及・・・というより RFC ですら若干曖昧な記載になっており、対策に関するアドバイスは世間一般にはあまり認知されていません。このことから、本問題は一部の(おそらく有名フレームワークのメンテナや、一部のセキュリティリサーチャー以外)把握をしていないと思われます。

実際、この問題に関する議論を OWASP ASVS v5 で現在議論中です。

github.com

本記事では、これらの一連の研究結果についてまとめていきたいと思います。

注意事項

結構時間が経ってしまったことで記録を遡れない部分があります。そのため、曖昧な箇所には(曖昧)と書いておきます。

発見した問題全ての修正を待ってから本記事を公開するか悩みましたが、以下の理由から公開することに決めました。

  • 思いつく限りの努力を全てしたが、連絡が返ってこなかったケースがあった (Patch / Unit Test / 詳細なレポート / 公表する際の Security Advisory の参考等を送ったが連絡がなかったり、マージされなかったり。)
  • メンテナンスされてないライブラリで脆弱性があった(連絡したが返信がない)
  • そもそも Content-Disposition のこの問題について、世間一般に認識がなさそうなので、早めに問題の周知をした方が良いと判断した

実際、ブログ等で紹介される Content-Disposition に関する実装の 99% はこの問題に関する注意書きがない状況にあります。 (もちろん、 Framework / Library 等が暗黙的にエスケープなどをおこなってくれているケースもあります。逆に、何もしないケースもあります。)

そのため、この問題の根本原因について、詳細なレポートを記載します。 もちろん、修正がされていないライブラリについては名前や問題の箇所等は一切触れずに記事を記載します。

脆弱性の発見と報告

さて、脆弱性の兆候を見つけたのはだいぶ前になります。当時 (Spring Framework 製)Webアプリケーションの脆弱性を探しており、 簡単な Fuzzing 用ファイルを用いてテストを行っていました。

テストでは以下のようなファイルを用いて、ファイルアップロード等をテストしていました。

example";';.jpg

このようなデータを利用し、ファイルアップロードなどの機能をテストしていると、一部の機能で 500 Error が返ってくる箇所がありました。

「何かしらの Injection が可能な脆弱性があるかもしれない」と期待に胸を膨らまし、挙動分析をおこなって見ましたが結果は惨敗。 最小での概念実証データを作成したところ、どうやら "; が入った場合にエラーが発生するようでした。

念のため「ただのバグ」だったのか、それとも「自分の Injection 力が足りないだけ」だったのかを調べるため、サーバ側のエラーログを見ていきました。

すると、以下の箇所のエラーログが出ていました

IllegalArgumentException("Invalid content disposition format");

https://github.com/spring-projects/spring-framework/blob/4f05da7fed7e55d0744a91e4ac384d8f5df6e665/spring-web/src/main/java/org/springframework/http/ContentDisposition.java#L316

どうやら、「ファイルアップロード時の Content-Disposition(後述) における、フォーマットエラーが起きている」らしいです。
Content-Dispositon は、リクエスト・レスポンスなどに使われるヘッダで、 リクエスト時には mutipart/form-data 内で利用され、ファイル名などの情報を持ちます。

当初想定していたような Injection といったベーシックな問題ではないことがわかりました。残念です。
しかし、見ただけでパッとわかる問題ではないだけで、調査を進めれば大きな脆弱性に繋がる可能性がありそうです!

では調べていきましょう。

Content-Disposition と multipart/form-data

・・・の前に、前提として Content-Disposition と、 multipart/form-data について、本記事で関係のある部分に触れておきます。

<form> と multipart/form-data

multipart/form-data は、 MIME タイプの一種で、 HTML内で利用できるフォーム (<form>) のエンコードタイプの一種です。

multipart/form-data の他にも <form> で送られるエンコードタイプには次のものがあります。

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

この中で、 ( <form> を使った方式で)バイナリデータを送信できるものは multipart/form-data のみになります。

例えば、以下のようなフォーム形式でバイナリデータを送る場合、自動で multipart/form-data で送られます。

<form action="file_upload">
    <input type="text" name="name">
    <input type="file" name="avatar">
    <input type="submit">
</form>

<!-- 以下のように書くことも可能 -->
<!-- <form enctype="multipart/form-data"> -->

multipart/form-data のフォーマット

multipart/form-data のフォーマットは以下のような形式で記載されます。

※HTTP Header / Body の一部を抜粋。省略箇所は ... と記載

HTTP Header

Content-Type: multipart/form-data; boundary=----RandomValue123

HTTP Body

------RandomValue123
Content-Disposition: form-data; name="name"

Taro
------RandomValue123
Content-Disposition: form-data; name="profile"; filename="profile.txt"
Content-Type: text/plain

Hello
World!
------RandomValue123
Content-Disposition: form-data; name="avator"; filename="avator.png"
Content-Type: image/png

{バイナリ}
------RandomValue123--

このように、 Header 部で Content-Typemultipart/form-data が指定されます。 合わせて Body 部の各パラメータを区切るための delimiter として、 boundary=----RandomValue123 のように値が設定されるようになっています。 また、最後の部分のみ ----RandomValue123 + -- という形式で、パラメータの終わりを表しています。

この boundary の値を用いて、 Body 部のパラメータを区切っています。 この区切ったパラメータ 1個を part と呼びます。 その part の中に、今回の主題である Content-Disposition を利用して、データを表現する場合があります。

Content-Dispotion について

回り道をしましたが、次は本記事の主題である Content-Disposition についてです。

正直 MDN を読むのが正確で一番わかりやすい気がするので、 URLを貼っておきます。
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Disposition

Content-Dispotion は HTTP Response / Request (multipart/form-data 内部) / Email で利用されるヘッダになります。

Response の場合ですと、対象のレスポンスファイルの MIME タイプをもとにどのようなアクションを起こすかを制御します。
例えば以下などです。

  • MIMEタイプが image/png の場合に、 Content-Disposition: inline だと、ブラウザに表示する
  • MIMEタイプが image/png の場合に、 Content-Disposition: attachment だと、ファイルをダウンロードする
  • MIMEタイプが image/png の場合に、 Content-Disposition: attachment; filename="abc.jpg" だと、ファイルを "abc.jpg" としてダウンロードする
  • MIMEタイプが image/png の場合に、 Content-Disposition: attachment; filename*=utf-8''{URLエンコードされたファイル名}.jpg だと、ファイルを "URLエンコードされた値" をデコードし、それをファイル名としてダウンロードする (非 ASCII 文字にも対応)

このように、 Content-Dispotion によってそのファイルをどう扱うかを制御できます。 また、 filename* のフィールドは、 HTTP Response の場合のみ登場します。

一方で、 Request の multipart/form-data 内部に記載する場合、 対象のパラメータの情報(フォームのフィールド名やファイル名)を表現するために利用されます。

例えば Content-Disposition: form-data; name="picture"; filename="filename.jpg" の場合ですと、以下のようになります。

  • パラメータの名前は picture (form で言うところの name)
  • ファイル名は filename.jpg

冒頭の Content-Disposition: form-data; における form-data 部分は決まり文句で、 リクエスト時の multipart/form-data では常に最初の引数はこの値になるそうです。

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Disposition#as_a_header_for_a_multipart_body

構文 ... マルチパート本文で使うヘッダーとして この用法では最初の引数は常に form-data です。

ちなみに、 multipart/form-data 内の Content-Disposition で利用できる(定義されている)フィールドには以下のものがあります。

  • disposition
  • disposition-type
  • disposition-param

これらのフィールド内部では、更に以下のパラメータが定義されています。

  • disposition

    • disposition-type (e.g. "form-data")
  • disposition-type

    • "inline"
    • "attachment"
    • extension-token
  • disposition-param

    • filename
    • creation-date
    • modification-date
    • read-date
    • size
    • parameter

上記のフィールドについては RFC 2183 [page 2] にて定義されています。
https://datatracker.ietf.org/doc/html/rfc2183

エラーの原因調査

さて、ようやく前提が整いました。ここからは、問題のあったパースエラーがなぜ起きたのかを見ていきます。

冒頭で「 example";';.png というファイルをアップロード機能に対して送った」と記載しましたが、その際に送られる HTTP Request のペイロードについては記載していなかったので見て行きます。

以下が (当時の) "Firefox" を使って送ったリクエストした際の状況です。 (※現在は修正されたため、Firefox でこのようなリクエストは送れません)

送ったファイル:

example";';.jpg

送られた multipart/form-data リクエストの HTTP Header:

POST /form HTTP/1
...
Content-Type: multipart/form-data; boundary=---------------------------277224600214918072883416139191

送られた multipart/form-data リクエストの HTTP Body

-----------------------------277224600214918072883416139191
Content-Disposition: form-data; name="file"; filename="example";';.png"
Content-Type: image/png

{バイナリデータ}
-----------------------------277224600214918072883416139191--

発生したサーバサイド側のエラー (Spring)
https://github.com/spring-projects/spring-framework/blob/4f05da7fed7e55d0744a91e4ac384d8f5df6e665/spring-web/src/main/java/org/springframework/http/ContentDisposition.java#L316

既にお気づきの方もいらっしゃると思いますが、リクエスト HTTP Body の filename にて "エスケープされてません。

HTTP Header 部分:

Content-Disposition: form-data; name="file"; filename="example";';.png"



問題の箇所:

filename="example";';.png"
                 ^
                 | ここ

おそらく、 " によってサーバーサイドのアプリケーション上で、パラメータのパースが変になっているようです。
この " は、パラメータを区切る Delimiter ですので、エスケープがされないということは、パーサーを騙すことができるかもしれません。

つまり、(何かしらの攻撃シナリオの中で)細工したファイル名を送ることができれば、 パラメータやフィールドを改竄・偽装することができ、いずれかの被害者に対して悪影響を与えることが可能かもしれません。

そうとなれば、パーサーを騙せるようなペイロードの作成や、悪用可能なフィールドなどを知るためにパーサーのコードを見ていく必要があります。 まず Exception を発生させていたパーサーの関数部分を見てみましょう。

※最終的に、この Parser を狙ったケースは攻撃には悪用できないことがわかりますが、後述する内容にも関係があるので一応記載します。読み飛ばしていただいても問題ない内容だと思います。

パーサーの中身を見ていく

Exception を起こしていた parse() は、 RFC 2183 にて定義されたフィールドのパーサーにあたります。

Parse a {@literal Content-Disposition} header value as defined in RFC 2183.
https://github.com/spring-projects/spring-framework/blob/4f05da7fed7e55d0744a91e4ac384d8f5df6e665/spring-web/src/main/java/org/springframework/http/ContentDisposition.java#L252

この parse() は、パース結果として (パース済みのデータ) ContentDiposition 型を返します。

return new ContentDisposition(type, name, filename, charset, size, creationDate, modificationDate, readDate);

https://github.com/spring-projects/spring-framework/blob/4f05da7fed7e55d0744a91e4ac384d8f5df6e665/spring-web/src/main/java/org/springframework/http/ContentDisposition.java#L319

  • type
  • name
  • filename
  • charset
  • size
  • creationDate
  • modificationDate
  • readDate

つまり、パーサーを騙せる範囲(フィールド)は、これらのデータに限られるわけです。

詳細な実装を見ていきます。

まず parse()Content-Disposition ヘッダである String を受け取り、 tokenize() 関数にて parts という List<String> parts に分解します。

public static ContentDisposition parse(String contentDisposition) {
        List<String> parts = tokenize(contentDisposition);

https://github.com/spring-projects/spring-framework/blob/4f05da7fed7e55d0744a91e4ac384d8f5df6e665/spring-web/src/main/java/org/springframework/http/ContentDisposition.java#L257-L258

この tokenize() 関数では、 Delimiter [ ; " \\ ] を確認しながら、それぞれの Delimiter に対応した状態 (「現在は Quote の内部か」「現在の char はエスケープ文字であるか」) のフラグを管理しながらフィールドを分解して行きます。

https://github.com/spring-projects/spring-framework/blob/4f05da7fed7e55d0744a91e4ac384d8f5df6e665/spring-web/src/main/java/org/springframework/http/ContentDisposition.java#L322-L357

この tokenize() に対して、先ほどの(ブラウザが誤ったデータを送っている) Content-Disposition ヘッダの文字列を与えると、以下のようなパーツ群に分解されます。

Input:
Content-Disposition: form-data; name="file"; filename="example";';.png"

Output:
(ArrayList String)
  [0] = Content-Disposition: form-data
  [1] = name="file"
  [2] = filename="example"
  [3] = '
  [4] = .png"

Input で与えられた Content-Disposition は \\ によるエスケープがない " が混じってしまっているので、 見るからに歪なパース結果になっています。

次に適切にエスケープ ( "\\" に変換) されている場合の結果を見てみます。

(ArrayList String)
  [0] = Content-Disposition: form-data
  [1] = name="file"
  [2] = filename="example\";';.png"

適切にエスケープできている場合、直感と合っている結果が返ってきたのがわかります。

さて、次はこの壊れた形式でどこまで(エラーを出さずに) 定義ずみフィールドなどに値を偽装して処理させるかです。 現状だと Exception が発生してしまっているので、パーサーから見て(実際は壊れているが)問題がないものを作っていく必要があります。

parse() では、 tokenize() の結果を元に次のフィールドのペア(key,value)を抽出していきます。

  • type
  • name
  • filename
  • charset
  • size
  • creationDate
  • modificationDate
  • readDate

parse() の詳細な処理としては、以下を行います。 (定形部分 (Content-Disposition: form-data) は Skip )

https://github.com/spring-projects/spring-framework/blob/4f05da7fed7e55d0744a91e4ac384d8f5df6e665/spring-web/src/main/java/org/springframework/http/ContentDisposition.java#L268-L317

  1. フィールドを分解 (attribute / value のペアに) するために = を見つける ( = が1個もないと Exception)
    ( 値のペアには 2つの形式 ( key=value, key="value" ) があるため、そこを気にしながら抽出を行う )
  2. attribute が name なら、 name 変数に value を格納
  3. attribute が filename* なら、 (他のフォーマットと違って特殊なので) ' を気にしながら filename 変数に value を格納
  4. attribute が filename なら、 filename 変数に value を格納
  5. attribute が size なら、 size 変数に value を格納
  6. attribute が creationDate なら、 creationDate 変数に value を格納
  7. attribute が modificationDate なら、 modificationDate 変数に value を格納
  8. attribute が read-date なら、 readDate 変数に value を格納

ここまで来てようやく Exception が起きてた理由がわかりました。
先ほどの壊れた Content-Disposition では、 tokenize() によって = を1つも含まないパーツが出来上がっていました。

Output:
(ArrayList String)
  [0] = Content-Disposition: form-data
  [1] = name="file"
  [2] = filename="example"
  [3] = '
  [4] = .png"

そのため、 parse() 内部の = が1つもない場合に Exception を発生させる箇所に引っかかっていたようです。

つまり、

  1. ファイル名の " などを駆使して、 = を含んだパーツ群を生成し
  2. key=value (または key="value") 形式 にし、
  3. attribute に namefilename, creationDate 等を設定する

上記の全てが満たせるファイル名を作れれば、何かの攻撃に使える(かも)しれないわけです。

そこで作成したのが以下となります。

ファイル名:
a.txt"; dummy=".txt


Firefox が生成する Content-Disposition
Content-Disposition: form-data; name="file"; filename="a.txt"; dummy=".txt"


tokenize() がパースした結果のパーツ群:
[0] = Content-Disposition: form-data
[1] = name="file"
[2] = filename="a.txt"
[3] = dummy=".txt"

素晴らしい、うまくいきました。 少なくとも、 Exception は発生せず、 tokenize() を騙すことができました。

では次は生成した dummy を複数の別のフィールドにし、最終的な parse() の結果を見てみましょう。

ファイル名:
a.txt"; name="dummy"; filename="dummy"; size=1234; dummy=".txt


Firefox が生成する Content-Disposition:
Content-Disposition: form-data; name="file"; filename="a.txt"; name="dummy"; filename="dummy"; size=1234; dummy=".txt"


parse() の結果:

type = "Content-Disposition: form-data"
name = "dummy"
filename = "a.txt"
charset = null   ( filename* の際に設定される値)
size = {Long@438} 1234
creationDate = null
modificationDate = null
readDate = null

うまくいっています! フォームの name 属性で設定される値も、 file から dummy に上書きすることができましたし、ファイル名も a.txt にすることができました。
また、ファイルサイズも 1234 に設定できましたし、 dummy という RFC 上で未定義のフィールドも問題を発生させず無視されているので完璧です。

あとは、騙すことができたフィールドが Spring Framework 内部でどのように使われているかを見ていき、シナリオを考えるだけです。 ・・・が、内部コードを見ていった結果、殆どのフィールドは Spring Framework 内部では利用されていないことがわかりました。

内部で利用されていたフィールドは次の2つだけでした。

  • fileName
  • name

それでも諦めず、これらのフィールドを利用している箇所も追ってみましたが、 1つのリクエストで完結するような攻撃の糸口は見つかりませんでした。

仕方がないので、Spring Framework に対してここまでの脆弱性のレポートを送りつつ、水平展開で同様の問題が他のフレームワークにないかを探りました。また、同様の問題の再現を Ruby on Rails と Ktor で見つけ、レポートを送りました。 (この時点では Firefox の問題と気がついていないので...)

結果としては冒頭に書いた通り、「これは Framework 側の問題ではなく、ブラウザ側 (Firefox) の問題である」と返答されました。
今考えれば当たり前ですが、問題を発見してから検証を重ねているとこういった視点が抜け落ちます。

実際、各ブラウザで問題の再現をしたところ、Firefox でのみ問題が再現される結果となりました。そこで方向を変えて、 Firefox脆弱性として報告できないか考えて行きました。

Firefox脆弱性レポートをする

ただ、今の段階でブラウザに対して「脆弱性」として伝えるにはどうしても攻撃シナリオが弱い問題があります。 というのも、この段階では「潜在的な問題」「パラメータを上書きできる」等のシナリオしか構築していないためです。

そこで、今ある材料で以下のようなより明確なシナリオを考案しました。

1. 攻撃者が「(後続のリクエストで)ファイル名」として設定されるようなパラメータに、細工した値を送る
2. (1) で攻撃者が送った値が、フォームのフィールドに反射するようなページが存在する
3. (2) のフォームに被害者が Firefox で訪れる
4. 被害者が、フォームの submit を行う
5. 被害者の Firefox から「壊れた Content-Disposition」 が送られる
6. 壊れた Content-Disposition を、 Spring などのパーサーが誤った形式でパースする
7. (6) の結果、 フォームのフィールド名である `name` が上書きされ、本来のフィールドとは違うフィールドにペアとなる value が送られる

つまり、攻撃者の設定したフィールドによって、(それが反射するフィールド)の内容を汚染することができるというシナリオです。

具体的なシナリオを一つ挙げると、以下のようなシナリオです。 (若干無理のあるシナリオですが...)

  1. 被害者の送信するフォームのファイル名になる値を攻撃者が送る(フォーム age を上書きする様に細工する)
  2. (1) のデータが送られるプロフィールページのフォームで、被害者がフォームデータを送る
  3. 送られるフォームに age のフィールドが存在する
  4. ファイルの中身が 1000 などの数値になっている
  5. 被害者が送る multipart/form-data のリクエストで、 通常の age フィールド のデータと、 壊れた Content-Disposition によって、name が複数ある(片方は avator, もう片方は age ) フィールド のデータが送られる
  6. サーバ側は、フィールドが複数ある場合、後ろ側を優先する(上書きする)挙動をしている
  7. サーバ側は、ユーザの年齢を 1000 として保存した(完全性への影響)

上記のシナリオだと影響は非常に小さいく思われるかもしれませんが、 これらのインパクトは「どのようなシステムで」「どのパラメータへ影響を与えられるか」に大きく左右されるため、 一括りに「リスクが低い」と評価しきれない問題です。

仮に、 profile_html の様な パラメータがあり、そこに Self XSS 等があった場合、 そこにデータを入れ込めるとなれば、インパクトは高くなります。

とはいえ、この問題は multipart/form-data のフォームで、 攻撃者のデータが被害者のフィールドに反射するかなりレアなページでないと発生しません。 そのため、攻撃への条件は高く、リスクは低いと思っています。


以上の内容でFirefox に Issue を送りました。 結果は Security Issue のカテゴリと P3 のプライオリティが付きました。めでたしめでたし。

https://bugzilla.mozilla.org/show_bug.cgi?id=1556711

・・・とはならず、そこから色々とありました。

まず、報告してから2年ほど、一切音沙汰がありませんでした。発生条件があまりにも厳しいので、まあわからなくもありません。 (当時、Mozilla の大規模リストラなども起きていたので、内部でリソース不足とかもあったのかなと)

そして、報告してから2年経ったある日、「もしかして治ってるんじゃないか?」と思ってテストをしたところ案の定サイレントで治っていました。 そこから 「CVE は発行されるの?」と聞くも無視されたりと、あまり良い体験ではありませんでしたが、まあ世の中そんなもんです。

さて、余談ですが報告後の内容を見ていくと、個人的に面白い部分がありました。

Bugzilla のやりとりにもあるとおり、 whatwg の spec にエスケープに関する要件が記載されています。 https://html.spec.whatwg.org/#multipart-form-data

For field names and filenames for file fields, the result of the encoding in the previous bullet point must be escaped by replacing any 0x0A (LF) bytes with the byte sequence %0A, 0x0D (CR) with %0D and 0x22 (") with %22. The user agent must not perform any other escapes.

そして、(サイレント)修正された理由は、 対応者が HTML の仕様に準拠する形でコードを修正したためという理由でした。

This was fixed in bug 1686765, as part of changing the multipart/form-data encoding to follow the HTML specification and to be compatible with Chrome and Safari. When I was working on that bug, and on the specification change that enshrined Chrome/Safari's behavior, I did not know that this was an open security bug, nor did I realize that the fact that double quotes were not escaped in Firefox's previous implementation could be exploited. But this issue has now been fixed since Firefox 90.

ブラウザという大規模なシステムであれば 「規約上正しくなるように直したらこの問題が治っていた。その部分の修正が脆弱性に関わる部分だと知らなかった」という理由も頷けます。

また、(当たり前ではあるのですが)規約の中には潜在的問題を防ぐための要件があり、それ無視すると脆弱性となりえるというのも改めて実感できました。

更に脆弱性を探す

Firefox から CVE が発行されなさそうで少ししょんぼりしていましたが、 ここから更に Content-Disposition を調べてみようと思う出来事がありました。 切っ掛けは一つの CVE です。(確か以下の CVE だったはずです。同じような CVE が複数出てるので曖昧ですが。)

https://nvd.nist.gov/vuln/detail/CVE-2020-5398

In Spring Framework, versions 5.2.x prior to 5.2.3, versions 5.1.x prior to 5.1.13, and versions 5.0.x prior to 5.0.16, an application is vulnerable to a reflected file download (RFD) attack when it sets a “Content-Disposition” header in the response where the filename attribute is derived from user supplied input.

この CVE が公表された当時は、発行される CVE 全てにある程度目を通していたのですが、Spring から "Content-Disposition" 関連の新規 CVE が報告されたことに驚きがありました。

というのも、最初の問題がまだ「Web Framework 側の問題なのではないか?」と勘違いしていた時、 Spring へ Content-Disposition のパーサーの脆弱性としてこの問題を報告をしていたためです。 当時はこの問題が RFD(Reflect File Download) の脆弱性に繋がるかもしれないと考えもしていませんでした。

当時の自分からすると「これ、もしかして自分が報告した Content-Disposition の問題をもとに(自分が取り逃がした)脆弱性だった!?」と読めてしまいます。 RFD は当時 OWASP か何かの資料で名前くらいは知ってる というレベルでしたので、こういった時の知識不足や執念が足りなかったなと反省しました。

その後、しばらくたったある日に、やる気も戻ってきたことから、同様の問題を探すことを決めました。

調査を進める前に、改めて「どこを攻撃できるのか?」を考えてみます。
まず、調べた限りだと Content-Disposition は、以下の3つで利用されることがわかりました。

  • HTTP Request の HTTP Body 内にある multipart
  • HTTP Response の HTTP Header
  • Email の Body 内にある multipart

この中で、いくつかの攻撃ケースを考えた結果、攻撃に利用できるもの、利用できそうにないものが思い浮かびました。

攻撃に利用できるものとしては以下があります。

  • HTTP Request を送る際の filename による他のフィールドの汚染・ファイル内容の改竄など
  • HTTP Response を受け取る際に filename, filename* を利用して拡張子等を変更する( Reflect File Download の脆弱性
  • Email 送信する際の filename による他のフィールドの汚染・ファイル内容の改竄など

一方で、以下のケースは攻撃に使えないと判断し、調査をしませんでした。

  • mail(mbox) などで、細工された Content-Disposition ヘッダを含むメールをパースした際の問題

このケースでは、Mail Client 等のメールを受け取る側が、細工した Contnt-Disposition を Parse する際に発生する問題であり、その際に完全性が侵害されていても、メールを受け取る側の責任領域ではないと判断して調査を進めませんでした。仮に可用性等に影響があるのであれば取り上げられるかもしれませんが、本記事の調査スコープ外です。

調査は以下のように進めました

  1. 調査対象となるリポジトリを決定する
  2. dispositionリポジトリを調べる
  3. Content-Disposition の生成処理で、エスケープ処理が足りないように見える場合は追加調査をする
  4. Content-Disposition が生成されるコードサンプルの記入方法を調べる
  5. Web Framework の場合、「ファイルダウンロード」で調べる
  6. HTTP Client の場合、「multipart」で調べる
  7. Email ライブラリの場合、「Attach File」などで調べる
  8. サンプルを元にアプリケーションを構築し、いくつかのケースをテストする

(3) の部分は、例えば以下のように検索をします。

e.g Spring https://github.com/search?q=org%3Aspring-projects+Disposition&type=code

結果として、冒頭に記載した通り、(まだ非公開のものも含めて)17個のプロダクトで脆弱性を発見しました。 これらについて、特徴のあるものをピックアップして、詳細を記載していきます。

脆弱性毎の分析

ここからは、各カテゴリごとに問題を分析しつつ、発見した脆弱性の中でも特徴的だったものについて触れていきます。

HTTP Request の問題

最初に記載した Firefox の問題がこれに当たります。
他人の入力した値が、被害者の端末の(脆弱性の存在する箇所の) HTTP Request > Content-Disposition > filename に入った際に、コンテンツの改竄や、フィールドの汚染が起きると言うものです。

この「被害者の端末」には、以下が含まれます。

  • 被害者の端末で動く脆弱なブラウザ
  • Web App 等の機能に含まれる HTTP Client

この問題で可能なこととしては以下があります。

  • " が挿入できた場合、 filename の区切り文字を強制終了して、攻撃者の任意のフィールドを追加できる
  • \r, \n が挿入できた場合、multipart/form-data の中で CRLF を追加できる
    • multipart/form-data の part のヘッダ部で、新しい行を追加できる(e.g. Content-Disposition を追記できる)
    • multipart/form-data の part のヘッダ部を終了させ、Body 部に移行できる( head / body にコンテンツを挿入できる)

この問題の影響については、発生箇所 (ブラウザなどの Client か、Web App などのサービス内部)で発生しやすさが若干変わると思います。

例えばブラウザの場合ですと、被害者は常にブラウザの操作者で、攻撃者は外部の人間になります。つまり、攻撃者は "壊れた Content-Disposition" を被害者に送らせようとします。このようなフォーム設計は通常しないと思うので、発生率は高くないと思います。

一方で、Web App 内部の HTTP Client で問題が発生する場合、攻撃者は壊れた Content-Disposition を自身で(正確には、被害者となる HTTP Client で)送ろうとするケースがあります。こちらの方はまだ「あるかもなー」と感じます。Web Hookなどの機能が一般的になったこの世の中なので、多分探せば見つかるのではないでしょうか。

他の悪用方法としては、Validator のバイパスなどが挙げられます。 例えば Web Application Form Validator のような機能があった際に、Validation の段階では .txt にしておき、multipart/form-data のペイロード生成時に .sh に置き換わるようにすることで、 Form Validator のもつ拡張子のチェックをバイパスするといった悪用が可能です。

他にも、内部の隠しパラメータを上書きする形で改竄したりもできるため、使い方次第でインパクトのある攻撃につなげることが可能かもしれません。

だいぶ Penetration Test や CTF 向きのテクニックではありますが、使い方次第です。

httpparty のケース

httparty は ruby の HTTP Client です。 https://github.com/jnunemaker/httparty/

このケースは firefox と全く同じ問題でした。そのため、詳細については割愛させていただきます。
こちらにメンテナへ送ったレポートがありますので、適宜参照いただければと思います。 https://github.com/jnunemaker/httparty/security/advisories/GHSA-5pq7-52mg-hr42

httpcomponents-client のケース

apache/httpcomponents-client は、Java の HTTP Client です。 https://github.com/apache/httpcomponents-client

httpcomponents-client も原因は Firefox や httparty と同様のものでした。 しかし幾つかの差異があります。
それは、 v4 から v5 でリグレッションが発生していたと言うものです。

  • v4 は脆弱性の対応がされていた
  • v5 になって、大規模な変更があった際に(提供する API のポリシーなどの変更も含んでいた結果)この問題に対して脆弱になった

v4 では、こちらのコミットのように修正が行われています。 https://github.com/apache/httpcomponents-client/commit/6d583c7d8cc41a188a190218a6489541b79cf35a

HTTPCLIENT-1859: Encode header name, filename appropriately

この修正の元々の指摘としては以下の様にあります。

https://www.mail-archive.com/dev@hc.apache.org/msg18531.html

The ContentDisposition header, used in multi-part forms, has a name and filename subfield; these need to be escaped using unix-standard backslash character stuffing, but FormBodyPartBuilder does not currently do this. It should.

しかしながら、 v5 になるにあたり(おそらくですが)このライブラリで提供するのは、一番コアとなる http の部分のみに絞り、 multipart などの下位(?)の関心毎は取り扱わないようになっていました。 っと言うのも、 v4 まではあった multipart 関連のクラスがなくなっていたためです。つまり、見方によっては本問題はスコープ外の問題になってしまいます。

この辺りをどう扱うかはメンテナ側の意向があると思うので、一応本件を連絡したところ、修正をしていただけました。

HTTP Response の問題

HTTP Response の問題は、 Firefox の問題を報告した後に見つけた(この研究のきっかけとなった) Spring の問題 RFD (Reflect File Download) と同じです。

Reflect File Download は Blackhat Europe 2014 で初出の攻撃ベクタです。 https://www.blackhat.com/docs/eu-14/materials/eu-14-Hafif-Reflected-File-Download-A-New-Web-Attack-Vector.pdf

RFD は(語弊があるかもしれませんが)一言で言うと、以下のようの攻撃を行うものです。

攻撃者が入力した値が、(被害者の端末上で)ファイルとしてダウンロードされる問題です。 また、その際のファイルの拡張子等を攻撃者がコントロールできている場合にこの問題になります。

例えば以下のような(善良なサイトの) URL Path にアクセスしたとします。

/file_download?filename=abc.txt&contents=hello

この場合、攻撃者は filename=malicious.sh, contents=#!/bin/bash......... のようなURLを作ることで、 被害者が(問題のない)公式のサイトにアクセスしたにも関わらず、悪性なファイルをダウンロードしてしまいます。

ざっくりとした解説をすると RFD の名前にあるように入力したパラメータが "Reflect" し、そのファイルをダウンロードすると言うのがこの攻撃です。

今回のケースだと、アップロードしたコンテンツをダウンロードするタイミングでファイルの拡張子を変更したり、 (filename を起点とした)CRLF Injectionによる Content Injection などがあるため、(若干 RFD か?と思ってしまいますが) RFD の問題となります。

django, sinatra のケース

DjangoPythonの、そして SinatraRuby の Web Framework です。
これらは HTTP Response の Content-Disposition > filename"エスケープし忘れていたため、RFD が発生しておりました。

https://security.snyk.io/vuln/SNYK-PYTHON-DJANGO-2968205 https://github.com/advisories/GHSA-2x8x-jmrp-phxw

Iris のケース

Iris は Golang の Web Framework です。
https://github.com/kataras/iris

このケースでは、 Content-Disposition の filename="..." の形式を崩して(RFC違反)、 filename=... 形式で記載していました。

そのため、 ; の文字が入るだけで、別のフィールドを挿入が可能でした。

Ktor のケース

Ktor は Kotlin の Web Framework です。 Ktor で発生した RFD は、他のものと違って、 filename ではなく filename* で問題が発生しました。
https://security.snyk.io/vuln/SNYK-JAVA-IOKTOR-2980134

filename*Content-Disposition の中で利用できる filename のもう一つのフィールドで、以下の様なフォーマットになっており、ファイルダウンロード時に非 ASCII のファイル名も対応しています。

Content-Disposition: attachment; filename*=utf-8''{パラメータ}

( filename / filename* が混在している場合は、 filename* が優先されます)

Ktor の場合は、以下の様なファイル名にした場合、本来 URL Encode が必須である箇所で URL Encode を行っていませんでした。そのため一部のブラウザ(少なくとも Firefox) で RFD が可能でした。

ファイル名:
''malicious.sh%00'normal.txt

生成される Content-Disposition:
Content-Disposition: attachment; filename*=utf-8''malicious.sh%00'normal.txt

上記の Content-Disposition は通常のフォーマットではない (本来は URL Encode される)ため、ブラウザによってはこれを不正な Content-Disposition と判定し、読み込まない(ファイル名を無視する)といった挙動を行いました。

ちなみにこの PoC ファイルは以下の過程で作成されました。

''malicious.sh%00'normal.txt
  1. 最初に '' を挿入し、 filename*='' のフォーマットにする(RFC に準拠させるため... っと書いたものの、今更ながら UTF-8 などの指定がないのでこれ準拠してない)
  2. ' を途中で入れることで、(firefox の場合) なぜか変な形でファイル名を区切り始めた(ただ、うまく拡張子を置き換えられない。おそらく何かしらの char の Index がずれた)
  3. (2) で行ったずれたパース位置をより厳格にするために(つまり、パーサーに「ここが終わりだよ!」と伝えるために) %00 (NULL バイト文字) を挿入した

結果として、壊れた Content-Disposition を吐き出し、それをブラウザ側がどうにか解釈しようとして RFD を起こすことができました。
ChromeSafari でも問題が発生しないか 30分程度テストしましたが、うまくいかなかったです。

現在は先ほどのファイルを Input とすると、以下の様な Content-Disposition が生成されます。

Content-Disposition: attachment; filename*=utf-8''%27%27malicious.sh%2500%27normal.txt

Email の問題

email でも添付ファイルを利用すると multipart の中で Content-Disposition が挿入されます。

Python のケース

Python の Email モジュールにて、Content-Disposition > filename を起点とした CRLF Injection の問題がありました。
https://github.com/python/cpython/issues/100612

この問題では、他の問題とは違い、 "エスケープされていたことから、簡単なフィールドの挿入は難しそうでした。 しかし、 multipart 内部のパート(1パラメータの区切り)で CRLF Injection が可能であったため、それなりの問題はありそうでした(コンテンツの挿入が可能な可能性がある)。

しかし、Python におけるファイルオープン処理を書いた際に \r\n の文字を含んだファイルをロードしようとすると Exception が発生するため、影響度は低いと判断しました。

with open("abc\r\n.txt") as f:
  ...

見えてきた脆弱なパターン

このように複数の問題を発見したことで、影響のあるパターンはある程度見えてきました。

Content-Disposition: attachment; filename={パラメータ};  # 多分 RFC 違反
Content-Disposition: attachment; filename="{パラメータ}";
Content-Disposition: attachment; filename*=utf-8''{パラメータ}
(utf-8 のところはその他の Encode フォーマットなどに代わることもある)

ほとんどはこれらのパターンで実装されています。 (中には filenamefilename* が混在している書き方をしているケースもありますが、問題のない書き方です)

ただ、どの問題も filename の問題に関しては以下のエスケープが足らないことが原因でした。

filename の場合:

  • "
  • \r
  • \n

filename* の場合:

  • URL Encode をしていない

filename*エスケープを誤っていたケースについて細くしておくと、 50-80 くらいのサービスを見ましたが、 1つの Web Framework (Ktor) のみでしか発生していませんでした。そのためかなり遭遇率は低い問題だと思います。

修正

RFCWHATWG の HTML Spec(multipart/form-data) のどちらかに従ってエンコードすれば良いと思います。

RFC の場合だと、 \ によるエスケープを行います(ちょっと自信ないです。と言うのも、それに関する言及がされた最新版の RFC が見当たらないので)。

Golang の multipart モジュールはこの形式です。
https://github.com/golang/go/blob/1e7e160d070443147ee38d4de530ce904637a4f3/src/mime/multipart/writer.go#L132-L136

一方で、 WHATWG の方は、 URL Encode を行います。

  • " --> %22
  • \r --> %0D
  • \n --> %0A

https://html.spec.whatwg.org/#multipart-form-data

また、現在 OWASP ASVS v5 にこの問題を追加するかの Issue があるので、識者の方は是非コメントをお願いします。

github.com

おわりに

今回、50-80 程度のフレームワークやライブラリ等を調査し、大凡問題がない形になるようにレポートしまくったわけですが、「フレームワークを使っていれば安心」という訳ではないです。

と言うのも、言語や利用しているフレームワークによっては、自動でのエスケープをしていないものがあるためです。 例えば、 Web Framework の中には、生の HTTP Response Header を追加するメソッドなどが用意されていることがあります。 仮にそれを利用したファイルダウンロード機能を実装した場合、ファイル名のエスケープは、開発者側で実装しなければなりません。

そして、悲しいことに、 2023年 現在、ファイル名のエスケープに触れている記事はおそらく全くないです。 「Web Framework 名 + ファイルダウンロード」等で調べていただければ分かりますが、エスケープに関する言及は本当に見当たらないです。 なので、 Web Frameowork などが暗黙的に直してくれていない場合、おそらくあなたの実装した Content-Disposition は、脆弱である可能性が高いです。

また、Web セキュリティ診断等をやっていても絶対見つかるわけではありません。
自分自身がセキュリティ診断をやっている(やっていた)立場であり、多少は勉強しているつもりでしたが、今回の調査をするまで、この問題を認識すらしていなかったためです。

上記の背景もあるため、この問題は XSS, SQLI などの問題のように、モグラ叩きの様に発生し続けるのではないかと思っています。 そういったこともあり、まだ未修正のプロダクトが存在しているのは理解していますが、一旦本記事を公開しました。

修正が終わったものについては、自分の github のレポート用リポジトリに追記していきます。

github.com

まとめ

めちゃくちゃ長いリサーチになりましたが、とうとう終わりです。大変でした。 まだ、未修正のプロダクトが複数残っているため、自分の戦いは終わっていないですが、一旦これで区切りになりました。

TLDR のところに書きましたが、まとめとしては以下となります。

  • 1つのブラウザ、1つのプログラミング言語、15個の { Web Framework, HTTP Client ライブラリ, Email ライブラリ / Web サービス } で脆弱性を見つけました。
  • 見つけた脆弱性は、全て 1つの観点で発見した (多分 50-80 くらいのプロダクトの調査をした)。
  • RFC の記載では、(かなりわかりにくく)この問題に対する要件が記載されており、WhatsWG > HTML Spec の方はしっかりと書かれているといった状況にある。
  • この問題は、 Content-Dispositionfilename, filename* というフィールドを明確なターゲットとしている。
  • この問題は、 HTTP Request / Response / Email の箇所で影響がそれぞれ変わる。
    • HTTP Request : リクエスト改竄(特にファイルコンテンツの改竄、他のフィールドの汚染等)
    • HTTP Response : Reflect File Download の脆弱性
    • Email : 添付ファイルの改竄(拡張子やファイル名の改竄と、潜在的なファイルコンテンツの改竄等)
  • これらの攻撃ベクトルの明確な攻撃対象として、 Content-Disposition (の filename, filename*) を見ている人は現在はあまりいない。
  • Content-Dispositionfilename, filename* はちゃんとエスケープしましょう。
    • filename:
      • " --> \" or %22
      • \r --> \\r or %0D
      • \n --> \\n or %0A
    • filename*:
      • ちゃんとフォーマットを守って URL Encode する

Appendix

ちなみに、本記事を書いている途中で同様の視点で問題を探している人 (GitHub の社員の人) にたどり着きました。
https://securitylab.github.com/research/rfd-spring-mvc-CVE-2020-5398/

Reference

WHATWG HTML Spec - multipart/form-data
https://html.spec.whatwg.org/#multipart-form-data

RFC 6266 (Use of the Content-Disposition Header Field in the Hypertext Transfer Protocol (HTTP)):
https://tools.ietf.org/html/rfc6266#section-5

RFC 2183 (Communicating Presentation Information in Internet Messages: The Content-Disposition Header Field)
https://datatracker.ietf.org/doc/html/rfc2183

Golang におけるエスケープ実装: https://github.com/golang/go/blob/1e7e160d070443147ee38d4de530ce904637a4f3/src/mime/multipart/writer.go#L132-L136

Symfony におけるエスケープ実装: https://github.com/symfony/symfony/blob/123b1651c4a7e219ba59074441badfac65525efe/src/Symfony/Component/HttpFoundation/HeaderUtils.php#L187-L189

Spring におけるエスケープ実装: https://github.com/spring-projects/spring-framework/blob/4cc91e46b210b4e4e7ed182f93994511391b54ed/spring-web/src/main/java/org/springframework/http/ContentDisposition.java#L259-L267

https://github.com/spring-projects/spring-framework/blob/4cc91e46b210b4e4e7ed182f93994511391b54ed/spring-web/src/main/java/org/springframework/http/ContentDisposition.java#L605-L628