SQLインジェクションに対してプリペアードステートメントが有効であるかを試してみるに引き続き、Webアプリの脆弱性を見る。

今回はタイトルにあるクロスサイトリクエストフォージェリ(CSRF)について見るけれども、とりあえず最初にこの用語を日本語訳してみる。

クロスサイトというのがどういう風に訳せば良いのかわからないが、リクエストはサーバに送るデータで、フォージェリは偽造という意味なので、サーバに送る何らかのデータを偽装するという意味になる。


実際にどういうことなのか?をサンプルコードを用いて見ることにする。

CSRFを試す為のファイルの構成は下記の通り。

csrf
├── index.php
└── transfer.php

サンプルコードのコンセプトは銀行系の送金用のWebアプリで、Webアプリにログインした後に、指定の口座番号に送金出来る仕組みとなっている。


index.php

<h2>ようこそ、○○さま</h2>
<p>※あなたは既にログインしているとします。</p>

<h3>送金システム</h3>
<form action="transfer.php" method="get">
	<div>
		<div>送金先口座番号</div>
		<div><input type="text" name="acc"></div>
	</div>
	<div>
		<div>金額</div>
		<div><input type="number" name="amount"></div>
	</div>
	<div>
		<input type="submit">
	</div>
</form>

index.phpは(今回は用意していないが)ログインフォームからログインした後に表示される送金システムのページとする。


transfer.php

<?php
if(isset($_GET["acc"]) && isset($_GET["amount"])){
	echo "口座番号" . $_GET["acc"] . "宛に" . $_GET["amount"] . "円を送金しました。";
}else{
	echo "送金に失敗しました。";
}

transfer.phpは送金内容の確認ページとする。


今回のシステムの仕様は、



上記のようなフォームが表示され、各項目に入力して送信すると、


口座番号1234567宛に100円を送金しました。

上記のような送信完了ページが表示されるようにしたい。


ここで一点、超重要なのが、送金は必ず入力フォームのページを介して行う必要があるということ。




それでは試してみる。

今回のフォームはhttp://example.com/のURLに設置したことにして、設置したWebアプリには既にログインした状態になっていることにする。


ある日私のメールボックスに知らない人から下記のような文章が届いた。

面白いページを見つけたよ
https://usa.ku/gMjp8z

私は何も疑いもせずにこのリンクをクリックしたら、

口座番号99999宛に10000円を送金しました。

突然、見覚えのあるWebアプリで送信完了のページが開いてしまった。

どうやら、口座番号99999宛に1万円が勝手に送金されてしまったようだ。


なぜ、こうなってしまったのか?

メールの文中に記載されているhttps://usa.ku/gMjp8zのリンクは、短縮URLのサービスを利用していて、実際のURLはhttp://example.com/transfer.php?acc=99999&amount=10000であった。

短縮URL - Wikipedia


実際のURLに、送金に関するGETパラメータが混じっていたのだ。

既にWebアプリにログインしていたため、リンクに記載されているGETパラメータが有効になってしまった。

GET - HTTP | MDN




今回の内容からCSRF対策を考えると、

・目的の処理の実行時に、入力フォームを介しているか?を確認し、介していない場合はエラーにする

・重要な処理ではGETパラメータを使用せず、必ずPOSTパラメータを使用する

POST - HTTP | MDN


今回は前者の方のみ触れる事にする。

入力フォームを必ず使用するようにする仕組みとしてhidden値を利用するということがあるので、サンプルコードにhidden値の仕組みを加えてみる。


index.php

<h2>ようこそ、○○さま</h2>
<p>※あなたは既にログインしているとします。</p>

<h3>送金システム</h3>
<form action="transfer.php" method="get">
	<div>
		<div>送金先口座番号</div>
		<div><input type="text" name="acc"></div>
	</div>
	<div>
		<div>金額</div>
		<div><input type="number" name="amount"></div>
	</div>
	<div>
		<?php
			//hidden値
			$hiddenValue = md5(time());
			session_start();
			$_SESSION["hiddenValue"] = $hiddenValue;
		?>
		<input type="hidden" name="hiddenValue" value="<?php echo $hiddenValue; ?>">
		<input type="submit">
	</div>
</form>

index.phpでは、特定されにくい値を設けて、セッションに格納しつつ、hidden型のinputタグで作成した値を入れておく。


transfer.php

<?php
session_start();
if($_GET["hiddenValue"] == $_SESSION["hiddenValue"] && isset($_GET["acc"]) && isset($_GET["amount"])){
	echo "口座番号" . $_GET["acc"] . "宛に" . $_GET["amount"] . "円を送金しました。";
}else{
	echo "送金に失敗しました。";
}
$_SESSION["hiddenValue"] = md5(time());	//hidden値の更新

transfer.phpでは、セッションの値に入れたhidden値とindex.phpから送信されたGETのhidden値が一致するか?の確認を加えた。

追加した処理により、必ずindex.phpのページから送信しなければならなくなった。


ここで一点重要なのが、最後にhidden値の更新が入っていることで、transfer.phpが表示されている時にリダイレクトを押して、二重送信の誤作動を回避する。


これを踏まえた上で、再び。https://usa.ku/gMjp8zのリンクをクリックしてみると、

送金に失敗しました。

意図通り、送金に失敗した。


関連記事

SOY2HTMLでセキュアなフォームを設置する - HTMLForm編