22
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

英語が苦手なエンジニアがセキュリティに挑んだ話〜クリックジャッキング攻撃編〜

Last updated at Posted at 2025-05-31

※前回の記事はこちらです。

ZooMにて

先輩:突然ですが、どこかの国の違法動画アップロードサービスとか見たことあります?

僕:あ、ありますね…大声では言えませんけど、過去のバラエティ動画を探していて。

有名な動画サイトに無かったんですけど、知らないサイトに上がってたのを見に行ったことがありまして…。

先輩:誘惑に負けたんですね。

僕:ええ(笑)でもそしたら、 動画の再生ボタンを押したはずなのに、なぜか変な出会い系サイトに飛ばされた んです。

先輩:ああ、よくありますよね。

僕:それがどうかしました?

先輩:「動画を再生しようとしたのに、変な出会い系サイトに飛ばされた」というのは、つまり 「目に見えている画面と違う動作をユーザーにさせる」 ということ。

それが 「クリックジャッキング攻撃」 の手口です。

僕:え?

先輩:もちろん得体の知れないサイトなら、最初から再生ボタンに出会い系サイトのリンクを仕込むことがあるでしょうけど、これが有名なサイトとかでも起きることがあるんですよ。

たとえばX(旧Twitter)やFacebookとかでも、実際に攻撃を受けたと言われています。

僕:僕も普段使ってるやつじゃないですか。

先輩:これ、攻撃の仕組みとしてはそこまで難しくないんですが、実際に攻撃を受けてしまうと非常に厄介なんです。

※2025/6/3追記
「動画を再生しようとしたのに、変な出会い系サイトに飛ばされた」という表現についてですが、あくまでクリックジャッキングのイメージのために引用しました。実際のクリックジャッキング攻撃とは少々異なるため、誤解を招く表現になってしまい申し訳ありません。

クリックジャッキング攻撃とは?

先輩:そもそもですが、攻撃者がクリックジャッキング攻撃を仕掛ける目的として主に以下が挙げられます。

  • 個人情報を奪う
  • サイバー犯罪の踏み台にする
  • 有料サービスの契約をさせる
  • 高額な商品を購入させる

上記の目的を実現させるため、攻撃者はユーザーがクリックしたくなるような見た目の画面を用意します。

そしてその上に、 透明な別の画面 を用意するんです。

僕:透明な別の画面?

先輩:たとえば見た目がこんな画面になってるとします。

スクリーンショット 2025-05-31 午後4.45.03.png

この画面が出てきたら、この「応募する」ボタンを押したくなりますよね?

でもいざ「応募する」ボタンをクリックしてみると…。

スクリーンショット 2025-05-31 午後4.46.50.png

僕:え!プレミアムサービスに登録なんかしてませんよ!

先輩:ではこのページの透明度を上げてみますね。

スクリーンショット 2025-05-31 午後4.45.33.png

僕:何だこれ!?

先輩:実はこれ、上にこんなページが被せてあったんですよ。

スクリーンショット 2025-05-31 午後4.46.26.png

つまり、ユーザーはただ豪華商品のプレゼントに応募しようとしただけなんです。

なのに、実際は 意図せず プレミアム月額サービスに登録してしまってるんですよね。

これが 「クリックジャッキング攻撃」 です。

僕:こんなの、一体どうやって…。

攻撃者がクリックジャッキング攻撃を仕込む主な方法

先輩:上に透明なページを被せるには、 iframeタグ を使用します。

僕:あの、別のリンク先に飛ばすときとかに入れるタグですか?

先輩:そうです。本来はそういう目的で使用するタグなのですが、クリックジャッキング攻撃ではこれを悪用します。

まずこれが、プレゼント応募ページのソースコードです。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>プレゼント応募キャンペーン</title>
  <style>
    body {
      margin: 0;
      padding: 0;
      font-family: sans-serif;
    }
    .container {
      position: relative;
      width: 800px;
      height: 700px;
      margin: 50px auto;
      border: 1px solid #ccc;
      padding: 40px;
      background-color: #f9f9f9;
    }
    label {
      display: block;
      margin-top: 20px;
    }
    input[type="text"] {
      width: 100%;
      padding: 10px;
      margin-top: 5px;
      font-size: 16px;
    }
    .fake-button {
      position: absolute;
      top: 431px;
      left: 305px;
      width: 220px;
      height: 55px;
      font-size: 16px;
      border: none;
      cursor: pointer;
    }
    iframe {
      width: 100%;
      height: 100%;
      border: none;
      position: absolute;
      top: 0;
      left: 0;
      z-index: 1;
      opacity: 0;
    }
  </style>
</head>
<body>
  <div class="container">
    <h2>🎁 プレゼント応募キャンペーン 🎁</h2>
    <p>今すぐ応募して豪華賞品をゲットしよう!</p>
    <p style="margin-top: 30px;">以下の「応募する」ボタンからご応募ください。</p>
    <button class="fake-button" onclick="alert('応募が完了しました!');">応募する</button>
    <iframe src="http://localhost:8080/static/subscribe.html"></iframe>
  </div>
</body>
</html>

注目して欲しいのが以下の部分です。

iframe {
 ...
 opacity: 0;
 }
<iframe src="http://localhost:8080/static/subscribe.html"></iframe>

iframeにリンクが貼られ、opacityが0に設定されています。

そして、subscribe.htmlの中身がこれです。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>プレミアムサービス登録</title>
  <style>
    body {
      margin: 0;
      padding: 0;
      font-family: sans-serif;
      background-color: #f5f5f5;
    }
    .container {
      position: relative;
      width: 800px;
      height: 650px;
      margin: 20px auto;
      padding: 30px;
      background-color: white;
      border: 1px solid #ccc;
      box-shadow: 0 0 10px rgba(0,0,0,0.1);
    }
    label {
      display: block;
      margin-top: 15px;
    }
    input[type="text"] {
      width: 100%;
      padding: 10px;
      margin-top: 5px;
      font-size: 16px;
    }
    .fake-button {
      position: absolute;
      top: 410px;
      left: 295px;
      width: 220px;
      height: 55px;
      z-index: 2;
      font-size: 16px;
      border: none;
      cursor: pointer;
      background-color: #28a745;
      color: white;
    }
    iframe {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      border: none;
      z-index: 1;
    }
  </style>
</head>
<body>
  <div class="container">
    <h2>プレミアム月額サービスへようこそ</h2>
    <p>月額 79,800円(自動更新)</p>
    <p style="margin-top: 20px;">以下の「今すぐ登録する」ボタンを押してください。</p>
    <button class="fake-button" onclick="alert('プレミアムサービスにご登録いただきありがとうございます!');">今すぐ登録する</button>
  </div>
</body>
</html>

透明度を調節するCSSプロパティであるopacityの効果をページ全体に効かせるため、わざとiframeタグで囲んでいます。

そうすることで、ユーザーの目に見えるページの上に別のページを重ねて表示することができるんです。

僕:うーん、怖いですけど、まあ大丈夫でしょう…だって住所もカードの番号も入力してないし、 向こうが勝手に「課金します」って言ってるだけ ですから。

先輩:本当にそうでしょうか?

僕:え?だって、向こうからはユーザーの情報が何もわからないはずですから、このままページを消しちゃえば…。

先輩:そのサービス、 すでにログインしている状態でアクセスしていたとしたらどうなりますか?

僕:…え?

先輩:たとえば、すでにクレジットカードが登録されているアカウントに、自動課金の「登録」操作をさせられたとしたら?

僕:でもそれって、ログイン状態でなければ…。

先輩:ログイン状態で仕込まれたら終わりですよ。

しかもログイン状態というのは、ブラウザがCookieを持っている限り継続されます。

X(旧Twitter)でもAmazonでも、ログインし直してないのに使えますよね?

僕:うっ…はい。

先輩:つまり、その企業の会員サイトやシステムに普段からログインしたまま使っているなら、クリック一発で高額課金も、情報変更も 「あなたの意思で行ったこと」 にされるんです。

「何もしてないのに勝手に」 ではなく 「したことになっている」 ことが問題なんです。

それがクリックジャッキングの本質です。

僕:そんな…本当は自分の意思でやったわけじゃないのに!

しかもこんなの、ページを見ただけじゃわからないじゃないですか!

先輩:そうです、だから厄介なんですよ。

なぜ自分のシステムが危険なのか?

僕:でも、X(旧Twitter)やFacebookとかのSNSは既存のサイトですよね?

攻撃者が自分で作ったサイトならわかりますけど、外部の人間がどうやって仕込むんですか?

もしかして 内部の人間の犯行とか…?

先輩:そういうクーデターとかではないです。

それに今回うちのシステムが脆弱性診断でクリックジャッキング攻撃の項目に引っかかったということは、攻撃される可能性があるってことですから。

僕:一体どうやって侵入するんですか?

先輩: 侵入なんかする必要はないんですよ。

僕:は?

先輩:では攻撃者に 「システム会社の社員のアカウントを乗っ取る目的」 があるとしましょう。

僕がもし攻撃するなら、まずお客様問い合わせページからバグの報告メールを送ります。

で、そこに 「バグが起きているのはこちらのページです」 と書いて 「こちら」の部分にリンクを設定 します。

ベタ貼りはしません。セキュリティ教育が行き届いている会社なら、リンクを見ただけで怪しまれますからね。

僕:…。(軽く引く)

先輩:そのリンクを開くと本物のシステムそっくりに作ったログイン画面が出ますが、

スクリーンショット 2025-05-30 午後8.50.30.png

実はその上に透明なページで 本物の「アカウント編集ページ」 を仕込んでおきます。

スクリーンショット 2025-05-30 午後8.51.29.png

僕:え、本物!?

先輩:そうです。 本物のアカウント編集ページのリンクをiframeタグで読み込んで、透明にして被せておく んですよ。

そこに僕のメールアドレスとパスワードをあらかじめ入力しておくんです。

そして、そのまま社員に自分のメールアドレスとパスワードを入力させてログインボタンを押させると…。

スクリーンショット 2025-05-30 午後8.52.46.png

アカウントが僕のメールアドレスとパスワードに変更されて、その社員のアカウントを乗っ取れますよね。

こんな通知、出たら怖いでしょ?

スクリーンショット 2025-05-30 午後8.55.21.png

僕:はい…。

先輩:でももうすでに遅いです。

僕は乗っ取った社員のアカウントを使い、データベースに入り込んでユーザーの情報を盗んだり、やりたい放題できてしまいます。

僕:めっちゃ怖いです…。

クリックジャッキング攻撃を防ぐには?

僕:でもこんなのどうやって防ぐんですか?

いくらセキュリティ教育が行き届いていたとしても、急いでたらリンクの確認せずに押しちゃうかもしれませんし…。

先輩:それはですね、 「システムのリンクを外部のiframeタグで読み込ませないようにする」 方法が有効です。

僕:外部のiframeタグで読み込ませないようにする?

X-Frame-Optionsレスポンスヘッダーを設定する

先輩:そもそもこの攻撃が成立するのは、外部の人間がシステムのリンクをiframeタグで埋め込める設定にシステム自体がなってるからなんです。

だからあらかじめ、システムのリンクを外部のiframeタグで読み込ませないようにする設定が必要です。

僕:それは一体…?

先輩:HTTPレスポンスヘッダーにX-Frame-Optionsレスポンスヘッダーを設定します。

僕:レスポンスヘッダー…?

先輩:Webサーバーがブラウザに返す情報の中には、HTML本文だけでなく、実はいろいろな“おまけ情報”がくっついています。

それが「HTTPレスポンスヘッダー」です。

たとえばこういうのです。

HTTP/1.1 200 OK  
Content-Type: text/html; charset=utf-8  
X-Frame-Options: DENY

ここに「X-Frame-Options」ってありますよね?

X-Frame-Options は 「このページをiframeタグに埋め込んで表示してもいいか?」という命令をブラウザに伝えるヘッダー です。

僕:なるほど…。じゃあ、これを禁止すれば、攻撃者がiframeタグに埋め込んでシステムのページを読み込もうとしても、読み込めなくなるんですね?

先輩:そうです。具体的には、次の3種類の指定ができます。

オプション 意味
DENY すべてのiframe埋め込みを拒否する
SAMEORIGIN 同じオリジン(ドメイン)からのiframe埋め込みのみ許可する
ALLOW-FROM URL 特定のURLからの埋め込みのみ許可する

今どきは「DENY」または「SAMEORIGIN」がほとんどですね。

僕:じゃあ、GoのWebアプリでこのヘッダーを返すにはどうすればいいですか?

先輩:たとえばこんなふうに書きます。

func secureHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("X-Frame-Options", "DENY") // ← iframe埋め込みを完全拒否
    fmt.Fprintln(w, "<h1>このページは iframe に埋め込めません</h1>")
}

もしくはシステム全体に適用するなら、ミドルウェアとして書くのも手です。

func withXFrameOptions(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-Frame-Options", "DENY")
        h.ServeHTTP(w, r)
    })
}
http.Handle("/secure", withXFrameOptions(http.HandlerFunc(secureHandler)))

僕:これだけで防げるんですね?

先輩:はい。もし診断で「クリックジャッキングの脆弱性あり」と出たら、まずはこれを疑いましょう。

Content-Security-Policy(CSP)を使う

先輩:次は「Content-Security-Policy」を使う方法です。

Content-Security-Policy、略して CSP は、Webブラウザに 「このページではどのようなコンテンツをどこから読み込んでいいか」 を細かく制御させる仕組みです。

そしてCSPの中には 「frame-ancestors」 というディレクティブがあります。

これは 「このページをどこからiframeタグに埋め込むことを許可するか」 を指定できます。

具体的には、次の3種類の指定ができますね。

設定例 意味
frame-ancestors 'none'; どのドメインからの埋め込みも許可しない(完全拒否)
frame-ancestors 'self'; 自分自身のドメインからのみ許可する
frame-ancestors https://example.com 特定のドメインのみ許可する

たとえばこのように設定すると、

Content-Security-Policy: frame-ancestors 'self';

「自分自身のドメインからのiframeタグ埋め込みだけを許可する」 という意味になります。

つまり、他サイトからのiframeタグ埋め込みはブロックされます。

僕:じゃあ、Goでこれを使うにはどうすれば?

先輩:X-Frame-Options のときと同じように、レスポンスヘッダーに追加するだけです。

func secureHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'")
    fmt.Fprintln(w, "<h1>このページは他ドメインから iframe に埋め込めません</h1>")
}

あるいはミドルウェアとして書くならこうですね。

func withCSP(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'")
        h.ServeHTTP(w, r)
    })
}

僕:でも、X-Frame-Optionsでもよくないですか?

先輩:たしかに簡単に使えるのはX-Frame-Optionsですが、以下のような理由があるので Content-Security-Policy(CSP)の方が推奨されやすい んですよね。

  • X-Frame-Options は 複数のドメイン指定ができない
  • X-Frame-Options は 非推奨扱いになりつつある

一方でCSPのframe-ancestorsは X-Frame-Optionsよりも柔軟で、複数のドメインを指定できたり、自己ホストと限定したりといった精密な制御が可能 なんです。

だから、最近の脆弱性診断では「X-Frame-Optionsではなくframe-ancestorsに移行するように」と指摘されることもあるんですよ。

僕:なるほど…。

ZooMにて

先輩:お疲れ様でした。大丈夫でした?

僕:いや、めっちゃ疲れました…しかも怖いし…。

先輩:そうですよね、でも大丈夫ですよ。

ただ怖がるんじゃなくて、正しく怖がればいいんですから。

さて、次はどうします?

僕:そうですね、次は…。

先輩:じゃあ、スクリプト埋め込み繋がりで「クロスサイトスクリプティング(XSS)」あたりにしましょうか?

僕:く、くろ…?

(次回「クロスサイトスクリプティング(XSS)編」へ続く)

22
11
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?