「ちゃんと確認ダイアログを出しているし、CSRFトークンも入れている。このフォームはもう安全だ」と思ったことがある人は多いと思います。実際、その2つを押さえていれば大抵の攻撃は防げます。ところが、ユーザー自身が画面を見て、自分の意思でボタンをクリックしているのに、押した結果がその画面に映っているものと違う——という攻撃が成立してしまうケースがあります。クリックジャッキングです。
この記事では、クリックジャッキングがどういう仕組みで成立するのか、なぜCSRF対策だけでは防げないのか、そしてどう防御するのかを、実際のHTML・CSSのコードを追いながら解説します。読み終える頃には、「ユーザーの意思」と「サーバーが受け取るリクエスト」の間に、ブラウザの表示という薄い皮一枚しか信頼の根拠がないことに気づくはずです。
iframeは「重ねられる」
まず、攻撃の材料になっている仕組み自体はごく普通の機能だという点から確認します。ブラウザには iframe という、あるページの中に別のページを埋め込む要素があります。ニュースサイトに埋め込まれたYouTube動画やGoogleマップは、たいていこの仕組みです。
そして、CSSにはある要素を別の要素の上に透明な状態で重ねる機能があります。opacity: 0 と position: absolute を組み合わせれば、見た目には存在しないのに、クリックだけはちゃんと受け付ける要素を作れます。
<div style="position: relative; width: 300px; height: 100px;">
<!-- 見た目のボタン(おとり) -->
<button style="position: absolute; top: 0; left: 0; width: 300px; height: 100px;">
無料プレゼントを受け取る
</button>
<!-- 本当にクリックされる標的(透明化した他サイトのiframe) -->
<iframe
src="https://sns.example.com/follow?user=victim"
style="position: absolute; top: 0; left: 0; width: 300px; height: 100px; opacity: 0;">
</iframe>
</div>
ここまでは、単に「透明な要素を重ねられる」という事実の確認です。危険はまだどこにもありません。ボタンもiframeも、それぞれ単体では正当な部品です。問題は、この2つを重ね合わせたときに何が起きるかにあります。
クリックは「座標」にしか反応しない
ブラウザがクリックを処理するとき、見ているのは基本的に「その座標に、その時点で一番手前にある要素は何か」だけです。ユーザーの意図や、ユーザーが目で見ている画像の内容までは考慮しません。
先ほどのコードでは、見た目のボタンより iframe を後に配置し、かつ opacity: 0 にしています。DOM上での重なり順(スタッキングコンテキスト)次第では、視覚的には「無料プレゼントを受け取る」ボタンが見えているのに、実際にクリック座標を受け取るのは透明化されたiframe側になります。ユーザーは自分の意思でボタンを押したつもりでも、ブラウザが実際に発火させたのは、透明化されていた sns.example.com へのクリックイベントです。
このとき厄介なのは、ユーザーがすでに sns.example.com にログイン済みであれば、そのセッションCookieがiframe内のリクエストにも自動で付与されるという点です。SNS側のサーバーから見れば、正規のドメインから、正規のCookieを持ったユーザーが、実際にクリックして送ってきたリクエストにしか見えません。
CSRF対策だけでは防げない理由
ここで「CSRFトークンを入れていれば防げるのでは」と思うかもしれません。実際、CSRF対策の多くはこの攻撃にもある程度効きます。フォーム送信の要求元を検証する仕組みだからです。
ですが、防げないケースがあります。iframeで読み込まれているのは、CSRFトークンを検証する「フォーム送信」ではなく、単純な「ボタンクリックによるGETリクエスト」や「SNSの1クリックフォローボタン」のような、そもそもトークンを介さないUIであることが少なくありません。フォロー・いいね・チャンネル登録・退会・権限変更といった、ワンクリックで完結する操作は、CSRFトークンの検証ロジックが組み込まれていない場合、クリックジャッキングにそのまま流用されてしまいます。
つまり、CSRF対策とクリックジャッキング対策は、守っている層が違います。CSRFは「リクエストの送信元が正しいか」を検証する仕組みであるのに対し、クリックジャッキングは「そのリクエストを送る操作を、ユーザーが本当に意図して行ったか」という、もっと手前の層の問題です。前者をいくら固めても、後者の穴はふさがりません。
防御その1: フレーム内での表示自体を拒否する
対策の基本方針は明快です。「自分のページが、意図しない他サイトのiframe内に埋め込まれること自体を拒否する」ことです。HTTPレスポンスヘッダで指定します。
X-Frame-Options: DENY
このヘッダを付けたページは、そもそもどんなサイトのiframeにも埋め込めなくなります。埋め込みを許可したい相手が自分自身のドメインだけなら、次のようにします。
X-Frame-Options: SAMEORIGIN
X-Frame-Options は歴史のあるヘッダですが、現在は後継である Content-Security-Policy の frame-ancestors ディレクティブを使うのが標準的です。
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.example.com
frame-ancestors は埋め込みを許可するオリジンを複数指定できる点で X-Frame-Options より柔軟です。両方を送っておき、対応していないブラウザ向けの保険として X-Frame-Options を残す構成もよく見られます。
# FastAPIでの設定例
from fastapi import FastAPI, Response
app = FastAPI()
@app.middleware("http")
async def add_frame_protection_headers(request, call_next):
response: Response = await call_next(request)
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
return response
防御その2: フレームバスティング(JavaScriptによる対策)
サーバー側のヘッダ設定ができない、または古いブラウザも考慮したい場合に、JavaScriptで自分自身が最上位のウィンドウかどうかをチェックする方法もあります。俗に「フレームバスティング」と呼ばれます。
if (window.top !== window.self) {
window.top.location = window.self.location;
}
ただし、この方法は攻撃側が sandbox 属性や Content-Security-Policy の script-src 制限をかけたiframeで埋め込むことで無効化できてしまう場合があり、あくまで補助的な手段です。実務では、HTTPヘッダによる対策を主軸に据え、JavaScriptでの対策は保険として重ねる、という位置づけで考えるのが妥当です。
筆者の考え・所感
個人的に、クリックジャッキングという攻撃を最初に知ったとき、少し不思議な感覚を覚えました。SQLインジェクションやXSSは「入力値の検証を怠った」という、比較的わかりやすい落ち度に対応する攻撃です。ところがクリックジャッキングは、フォームの実装もCookieの扱いもすべて正しく書いた上で、それでも成立してしまいます。攻撃者は自分のコードを1行も脆弱にしていないのに、ただ「重ね方」を工夫しただけで穴が開く。これは、脆弱性というものが必ずしも「間違ったコード」から生まれるとは限らず、「正しいコード同士の組み合わせ」から生まれることもあるのだと実感させられる例だと思っています。
もう一つ興味深いのは、この攻撃が突いているのがサーバーでもDBでもなく、ブラウザの「見たものを信じる」というUIの前提そのものだという点です。セキュリティの議論はどうしてもバックエンド側の実装に偏りがちですが、クリックジャッキングを知ってからは、フロントエンドのレビューでも「このボタンは他サイトに埋め込まれても安全か」という視点を持つようになりました。実際、自社サービスのiframe埋め込み可否を検討する場面では、「便利だから許可する」ではなく「許可した先で何が悪用され得るか」を先に洗い出すべきだと考えるようになりました。frame-ancestors を安易に * にしないのは、この経験則から来ています。
まとめ
- クリックジャッキングは、透明化したiframeを正規のUIの下に重ね、ユーザーの意図しないクリックを狙いのサイトへ届ける攻撃である
- CSRFトークンでは防げないケースがある。守っている層が違うため、
X-Frame-OptionsやContent-Security-Policy: frame-ancestorsによるフレーム埋め込み自体の制限が本質的な対策になる - JavaScriptによるフレームバスティングは補助手段にとどめ、HTTPヘッダでの対策を主軸に据えるのが安全である