はじめに
Node.jsのExpressでテンプレートエンジンejsを使って実装するWebアプリを実例に、CSFR攻撃を受ける脆弱性がある状態と対策を講じた場合の実装を見ていく事で、CSRF攻撃について理解を深めてみようと思う。
CSRF(クロスサイト・リクエスト・フォージェリ)攻撃とは?
悪意のある人が用意した罠により、Webアプリのユーザ(利用者)に意図しないリクエストを送信させ、利用者の意図しない処理をWebアプリに実行させることが可能な状態になっている=脆弱性がある時に、それを利用されてユーザが意図していない処理が勝手に実行されてしまうような攻撃を CSRF(クロスサイト・リクエスト・フォージェリ)攻撃という。(問題は、ユーザ本人の意図したものではない悪意のあるリクエストをWebアプリが受け入れてしまう事)。
詳細は以下のサイトを参照。
※IPAのサイトを見ると、認証の仕組みを持つWebアプリ(ログインしているユーザ)に対する攻撃のみがCSRF攻撃のように見えるが、実際には認証の仕組みがないもの(例えばネット掲示板)もCSRF攻撃を受ける対象になり得る(認証の仕組みがないWebアプリでCSRF攻撃を受けた時の事を想像してみると、攻撃者ではない人(普通に掲示板を利用しているユーザ)に悪意のある書き込みを行わせることができてしまい、普通に利用していたユーザは知らぬ間に犯罪の加害者になるという事が起きたりするだろう)。
以下ではNode.jsのExpressでテンプレートエンジンejsを使った実装を例に、実際に脆弱性がある実装をやってみて、脆弱性がある時どのような事が起きるのか?またそれを防ぐためにどうするのか?をみていく。
見ていく内容としては、IPAのサイトに書かれている対策の一覧に書かれているものを順番に見ていくが、一部対策の実装例を示すのが難しいものについては取り上げていない(※全て同じ被害を防ぐための対策なので、未対策時の被害例は1度しか取り上げていない)。
取り上げる内容
- 【根本的解決】処理を実行するページをPOSTメソッドでアクセスするようにし、その「hiddenパラメータ」に秘密情報が挿入されるよう、前のページを自動生成して、実行ページではその値が正しい場合のみ処理を実行する
- 【根本的解決】処理を実行する直前のページで再度パスワードの入力を求め、実行ページでは、再度入力されたパスワードが正しい場合のみ処理を実行する
- 【根本的解決】Refererが正しいリンク元かを確認し、正しい場合のみ処理を実行する
今回は取り上げない内容
- 【保険的対応】重要な操作を行った際に、その旨を登録済みのメールアドレスに自動送信する
CSRF攻撃に対する対策ができていない場合、どうなるか?
まずは、仮にCSRF攻撃の対策をしなかった場合どうなるか?についてみていく。
前提として、登録→確認→完了という画面遷移で口コミを登録するアプリの機能があるとする。
登録 | 確認 | 完了 |
---|---|---|
通常のフローでは登録→確認→完了なので、確認→完了という遷移をするはずだが、もしCSRF攻撃を受けると、いきなり完了へ遷移する際のリクエスト(データ登録のPOSTリクエスト)がサーバに送信され、ユーザが意図しないデータが登録されてしまうという事が起きる。
具体的には、攻撃を仕掛けられるユーザが既に認証が必要なサイトにログインをしている=ブラウザのCookieにセッションIDが保存されている場合に、以下の動画のようにダミーのサイトのボタンをユーザがクリックすると、CookieがPOSTリクエストと一緒にサーバに送信され、ユーザが意図していないデータが登録されてしまう、という事が起きる。
動画に映っていたCookieは以下の表の画像の通り、ユーザがログイン状態で開いていた時のCookie=攻撃サイトのPOSTリクエスト時のリクエストヘッダのCookieになっている。
ユーザがログイン状態で開いていた時のCookie | 攻撃サイトからのPOST時のリクエストヘッダのCookie |
---|---|
動画のようになってしまうコードとしては以下。
import validateReq from '../lib/validate-req';
...
router.post('/regist/execute', validateReq(), async (req, res, next) => {
const {
body: { shopId, shopName },
app: {
locals: { createTransaction, fsSql }
}
} = req;
const error = validate(req);
const review = createReviewData(req);
// 省略
const tran = createTransaction();
try {
await tran.begin();
// DBへの保存処理
await tran.commit();
} catch (err) {
next(err);
}
res.render('./account/reviews/regist-complete.ejs', { shopId });
});
export default () => {
return (req, res, next) => {
const { session } = req;
if (!session.id || !session.login)
next(new Error('You should be logged in.'));
next();
};
};
ソースコード全体は以下から参照。
攻撃者のサイトの実装はこれ(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CSRF攻撃サイト</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.14.0/css/all.css"
/>
<style>
html,
body {
height: 100%;
}
</style>
</head>
<body>
<div class="d-flex flex-column h-100">
<header>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand">CSRF攻撃サイト</a>
</div>
</nav>
</header>
<main class="container">
<div class="row">
<div class="col-12 mt-5">
<h1>CSRF攻撃のリンク</h1>
<form
action="http://192.168.56.2:3000/account/reviews/regist/execute"
method="POST"
>
<input type="hidden" id="shopId" name="shopId" value="1" />
<input
type="hidden"
id="shopName"
name="shopName"
value="CSRF攻撃"
/>
<input type="hidden" id="visit" name="visit" value="1970-01-01" />
<input type="hidden" id="score" name="score" value="0.0" />
<input
type="hidden"
id="description"
name="description"
value="CSRF攻撃を受けた"
/>
<button type="submit" class="btn btn-danger">攻撃</button>
</form>
</div>
</div>
</main>
<footer class="footer bg-light text-muted" style="margin-top: auto">
<div class="container">
<div class="row pt-5 pb-3">
<div class="col-12 text-center">
<p>2022 dumy.</p>
</div>
</div>
</div>
</footer>
</div>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
crossorigin="anonymous"
></script>
</body>
</html>
実装を見て分かるように、ログイン状態の確認をきちんとしてもCSRF攻撃を防ぐことはできない。理由はブラウザのCookie(セッションID)をそのまま攻撃者も使っているので、サーバからするとログインユーザからのリクエストと判断してしまうため。
補足として
今回のCSRF攻撃を受けた時の動きを再現するために、わざと脆弱性を生む対応が必要だった。理由としては、Changes to the default behavior without SameSiteやSameSite cookiesに書かれている通り、最新版(84以降)のChromeではSameSiteを何も設定していな場合、デフォルトでSameSite=Lax
が適用され、POSTリクエスト時にはCookieが設定されないようになるため。Expressでexpress-sessionを利用してセッションを実現している場合、cookie.sameSite=false
にする事で、SameSiteの機能をOffにできるので、今回はこれを行い上記の動画のような脆弱性のある場合の再現をした。
※SameSiteの適用については、一時的にコロナの影響でSameSiteの適用が見送られていたようだが、最新のChromeにはSameSiteのデフォルト設定(Changes to the default behavior without SameSiteに書かれている)が適用されている(The Chromium Projects SameSite Updatesを参照)。以下、引用。
Chrome implements this default behavior as of version 84.(Chrome ではバージョン 84 よりこの動作がデフォルトとして実装されています)
対策でのポイント
対策をする上でのポイントとしては、登録処理を行うようなリクエストを受け取る際に、サーバ側でそのリクエストが正当なものであるか?を検証する事。そのため、例えば登録→確認→完了という遷移をする際に、登録→確認の遷移時(登録の内容をサーバにリクエストする時)にそのリクエストが正当なものか?を検証しても意味ない(あくまで、確認→完了の遷移の際(データ登録時)にそのリクエストが正当である事を確かめる必要がある)。
以降ではIPAに書かれている通りに、CSRF攻撃の対策について、取り上げる内容で取り上げるとしたものについて1つずつ見ていく。
【根本的解決】処理を実行するページをPOSTメソッドでアクセスするようにし、その「hiddenパラメータ」に秘密情報が挿入されるよう、前のページを自動生成して、実行ページではその値が正しい場合のみ処理を実行する
ちょっと文章を読むと分かりにくいが、実装見ればすぐにピンとくると思うので実装を見ていく。対策を行った場合の実際としては以下のようになる。
import Tokens from 'csrf';
...
app.locals.tokens = new Tokens();
router.post('/regist/confirm', validateReq(), async (req, res) => {
const {
session,
body: { shopId, shopName },
app: {
locals: { tokens }
}
} = req;
const error = validate(req);
const review = createReviewData(req);
// 省略
const secret = await tokens.secret();
const token = tokens.create(secret);
session.csrfSecret = secret;
res.render('./account/reviews/regist-confirm.ejs', {
shopId,
shopName,
review,
token // <- 確認画面にhiddenでtokenを仕込む
});
});
router.post(
'/regist/execute',
validateReq({ csrf: true }),
async (req, res, next) => {
const {
body: { shopId, shopName },
app: {
locals: { createTransaction, fsSql }
}
} = req;
const error = validate(req);
const review = createReviewData(req);
// 省略
const tran = createTransaction();
try {
await tran.begin();
// DBへの保存処理
await tran.commit();
} catch (err) {
next(err);
}
res.render('./account/reviews/regist-complete.ejs', { shopId });
}
);
export default (option = {}) => {
return (req, res, next) => {
const {
session,
body: { token },
app: {
locals: { tokens }
}
} = req;
// 省略
if (option.csrf && !tokens.verify(session.csrfSecret, token)) // <- 確認画面に仕込んだhiddenのtokenを検証
next(new Error('Invalid Token.'));
else delete session.csrfSecret; // <- もしtoken検証がOKであればsessionのcsrfSecretはお役御免なので削除
next();
};
};
<body>
...
<form ref="form">
<input type="hidden" id="token" name="token" value="<%= token %>" />
...
</form>
...
</body>
ソースコード全体は以下。
上記のように実装する事で、確認→完了という遷移する(データ登録のPOSTリクエストがサーバに送信される)時に、サーバにリクエストが届くと確認画面のinputのhiddenに設定されていたパラメータ(token)をサーバ側で読み取り、そのパラメータが確認画面をクライアントに返す時にhiddenに設定したパラメータであるか?の検証(秘密鍵で復号化する)を行えるようになる。これにより、CSRF攻撃を受けた際にそのリクエストに仮にtokenが含まれていても、不正なtokenであることを見抜け、正当なリクエストではないと判断でき、リクエストをブロックできるようになる。
実際に未対策時と同じようにログイン済みの画面を開いた状態で、攻撃サイトからPOSTリクエストをサーバに送ってみると、以下の動画の通りエラーが発生してデータが登録されないようになる(攻撃サイトからのPOSTリクエスト時にCookieがサーバに送信されてしまうが、tokenのverifyがエラーになるため登録処理が実行されない)。
※動画のエラー画面はExpressのデフォルトのエラーハンドリングで表示されているもの。本来は400エラーBad Requestなどを画面出すような感じになるだろうが、今回はデモなのでそこまでやっていない。ExpressのデフォルトのエラーハンドリングについてはExpressのデフォルトエラーハンドリングを参照。
※CSRF攻撃対策のtokenをCookieに設定してはダメ。なぜなら元々Cookieが攻撃サイトからのPOSTリクエスト時にリクエストヘッダにCookieが設定されてしまう事で、サーバ側はそのリクエストが正当なものかの判断ができなくなっていた事でCSRF攻撃が成立してしまうため(その事は対策でのポイントで取り上げた「参考」の資料でも書かれている)。
処理を実行する直前のページで再度パスワードの入力を求め、実行ページでは、再度入力されたパスワードが正しい場合のみ処理を実行する
この対策は、上記の【根本的解決】処理を実行するページをPOSTメソッドでアクセスするようにし、その「hiddenパラメータ」に秘密情報が挿入されるよう、前のページを自動生成して、実行ページではその値が正しい場合のみ処理を実行するでinputのhiddenにサーバで生成したtokenを新しく追加する事で対策したものを、ユーザの再認証で代替しようとするもの。
(具体的なサーバ側の実装は省略するが)イメージは以下の画面のようにパスワード入力を求めて、入力されたパスワードとセッションIDなどに基づくユーザ情報から分かるそのユーザのパスワードとの突合で、本人確認ができたら処理を継続する、というもの。
実装としては比較的簡単(ユーザ認証時のロジックの使いまわしになるので)と思われるが、ユーザビリティとしては微妙な感じになる気もするので、この対策を取る際には注意が必要だろう(もしやるとしたら、「おまけ」に書いたCAPTCHA(画像によるチェック)の利用の方がユーザビリティ的には良い気もする)。
Refererが正しいリンク元かを確認し、正しい場合のみ処理を実行する
この対策は、HTTPのリクエストヘッダのrefererから、リクエストが行われる時の直前のウェブページのアドレス(URL)を確認し、遷移が正しいか?に基づいてリクエストの正当性を検証するもの。例えば、登録→確認→完了という遷移であれば、完了の前は確認であるべきなので、その確認のURLか?を検証する。
(具体的なサーバ側の実装は省略するが)以下の画像のように、Expressであればreq.headers
でHTTPリクエストヘッダの中身を取得できるので、そのreferer
が確認し、確認画面のURLであるか?を検証する事で、この対策の実装は簡単にできる(緑の下線はアクセスログの出力で、どのリクエストか?を示すために今回画像に映している)。
※ただ、IPAのサイトにも書かれている通り、攻撃者に自身のWebサイトに罠を仕掛けられたり、ブラウザやセキュリティソフトなどの設定でRefererを送信しない設定になっている場合などには有効に機能しないので注意が必要(以下、公式からの引用)。
ウェブサイトによっては、攻撃者がそのウェブサイト上に罠を設置することができる場合があり、このようなサイトでは、この対策法が有効に機能しない場合があります。また、この対策法を採用すると、ブラウザやパーソナルファイアウォール等の設定でRefererを送信しないようにしている利用者が、そのサイトを利用できなくなる不都合が生じる可能性があります。本対策の採用には、これらの点にも注意してください。
まとめとして
CSRF攻撃に関して、攻撃の実例とその対策となる実装を見る事で理解を深める事ができたと思っている。特にCookieを使ってセッションを実現している場合には、きちんと対策をしないと簡単になりすましができてしまうので注意したいと思った(Cookieの場合、「おまけ」に書いたCookieのオプションSameSiteをStrict or Laxに設定するも必須になると思う)。
おまけ
今回はIPAのサイトに書かれている対策のみを見てきたが、他にも対策としてできる事があるのでそれを見ていく。
CookieのオプションSameSiteをStrict or Laxに設定する
こちらの対策はCookieを利用している場合に限定されるが、とても効果的な対策なので取り上げたい。
SameSite=Strict
やSameSite=Lax
を設定することで、そもそもリクエスト時にCookieをリクエストヘッダに含まれないようにする事が可能であり、これによりCSRF攻撃に対する対策ができていない場合、どうなるか?で見たようにCookieが攻撃者のサイトからのリクエストに含まれてしまうため、そのリクエストが正当なものか?の判断できないという事自体を防ぐことができる。
肝心の実装だが、Expressでexpress-sessionを利用しているのであれば簡単で以下のようにオプションの設定をするだけでいい(cookie.sameSiteを参照)。
app.use(
expressSession({
name: "prr.sid",
resave: false,
saveUninitialized: false,
cookie: {
"sameSite": 'strict' // <- ここを設定するだけ
},
secret: process.env.COOKIE_SECRET,
store
})
);
※単にExpressのres.cookie()で実装している場合、オプションのsameSite
を設定する事で同じ事が実現できる。
※ChromeだとCSRF攻撃に対する対策ができていない場合、どうなるか?で見たように、SameSiteの設定がないCookieではデフォルトでSameSite=Lax
が適用される(Changes to the default behavior without SameSiteを参照)。
※SameSiteについてはGoogleのSameSite Cookie の説明やSameSite cookiesを参照。一部のブラウザではサポートされていない模様だが、ほとんどのブラウザでサポートされているので、これを利用しない手はないと思われる。
CAPTCHA(画像によるチェック)の利用
こちらは処理を実行する直前のページで再度パスワードの入力を求め、実行ページでは、再度入力されたパスワードが正しい場合のみ処理を実行するの章で取り上げたパスワードを入力される方法の代わりに、画像による確認を使いCSRF攻撃を防止するもの。
具体的には、ユーザ(人間)が目視で確認しなければ分からないような画像を表示しその内容をユーザに入力させ、その内容を完了へ遷移する際のリクエスト(データ登録のPOSTリクエスト)に追加してサーバに送信し、サーバ側でその内容を検証し画像の内容と一致していれば処理を継続する、という仕組み。ポイントは人間でないと読み取れない内容を、POSTリクエスト時にサーバに送信させる事で、CSRF攻撃で勝手にPOSTリクエストを実行された場合、そのリクエストが機械である事が見破れ、無効なリクエストと判断できるようになる事。
実際の画面上でユーザに確認させるもののイメージは以下(CAPTCHAとはを参照)。
では実際に、このCAPTCHA(画像によるチェック)の利用を実装してみようと思うが、今どきはreCAPTCHAを使う方が現実的だと思うので、今回はreCAPTCHAを使った実装をする(reCAPTCHAについては「reCAPTCHA」って?スパム対策に効果的なreCAPTCHAをフォームに入れてみたなどを参照)。
利用するのはGoogle reCAPTCHA(利用方法の詳細はDeveloper's Guideに書かれている)。今回は一番ロボットの操作を排除できるタイプと思われるreCAPTCHA v2 (Invisible reCAPTCHA badge)を利用してみる。
実装の方法はInvisible reCAPTCHAとVerifying the user's responseに書かれている通りにすればよく、以下のようになる。
...
<head>
...
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<script>
function onSubmit(token) {
const form = document.getElementById('form');
form.action = `/account/reviews/regist/execute`;
form.submit();
}
</script>
</head>
<body>
...
<form id="form" ref="form" method="POST">
...
<div class="row mb-3">
<div class="col-sm-2"></div>
<div class="col-sm-10">
<button class="btn btn-secondary" ...>
修正
</button>
<button class="g-recaptcha" data-sitekey="your_site_key" data-callback='onSubmit'>
登録
</button>
</div>
</div>
</form>
...
</body>
import config from 'config';
import axios from 'axios';
export default (option = {}) => {
return async (req, res, next) => {
...
if (option.csrf) {
const params = new URLSearchParams();
params.append('secret', process.env.RECAPCHA_SECRET);
params.append('response', req.body['g-recaptcha-response']);
try {
const {
data: { success }
} = await axios.post(config.get('reCaptchaVerify'), params);
if (!success) next(new Error('Invalid Request by reCAPTCHA.'));
} catch (error) {
next(error);
}
}
next();
};
};
{
...
"reCaptchaVerify": "https://www.google.com/recaptcha/api/siteverify"
}
ソースコード全体は以下。
上記のようにする事で、画像のチェックを行っていない場合には、g-recaptcha-response
というキーがPOST時のパラメータが含まれないかでたらめになるので以下の動画のようにエラーにする事ができる(動画を見て分かる通り、今回もPOSTリクエスト時にセッションIDがCookieに設定されてしまっているが、reCAPTCHAのチェックによりCSRF攻撃を防ぐことができている)。
ユーザの正規のリクエストの場合には、以下の画像のようにg-recaptcha-response
がPOST時のパラメータに含まれ、それが有効なtokenなので登録ができるという感じ(siteverifyがOKであればtrueが返ってくる)。
※ちなみに、今回はInvisible reCAPTCHAを利用しているので、人間かどうか?の確認が必要な時のみ以下のように画像による確認が表示される(画像による確認が行われる基準は不明)。
1 | 2 |
---|---|
※画像認証という言い方でCAPTCHAが出てくる事があるようだが、IPAのCAPTCHAに書かれている通り、認証(ユーザ本人か?)の確認のためにCAPTCHAは利用できないので注意。あくまで機械か?人間か?の確認しかできないと思っておくべきだろう。ちなみに、今回のように攻撃サイトからのPOSTリクエストが意図した遷移で行われているか(正当なものか)?を確かめる上では有効になるだろう(CAPTCHAでの確認をしていない状態でのリクエスト=ユーザではなく何らかの機械によるリクエストと判断する事はできるようになるため)。