☘️

駆け出しエンジニアの皆さんに知ってほしい脆弱性のこと。

2020/09/26に公開

セキュリティは難しいです。

ですが、プログラミング初学者の皆さんは必要以上に萎縮せず、どんどんアプリケーションを作り、公開することにチャレンジして欲しいと私は思っています。

一方、事実として、脆弱なアプリケーションが公開されている(サーバ上でアクセス可能な状態になっている)だけで、全く無関係な第三者が被害を被る可能性があることは知っておく必要があります。

それはWordPressを使った単なるWebサイトであったとしても同じです。

また、あなたのアプリケーションが破壊されて困らないものであったり、
個人情報を保持していないものであったとしても
、です。

だから、知らなかった、では済まされないこともあります。

この記事では、PHPのソースを例に、
特にプログラミング初学者が生み出しやすいアプリケーションの脆弱性について、
具体的なコードを挙げながら解説します。

なお、本記事のサンプルコードはもっぱら(フレームワークを使っていない)素のPHPで書いていますが、
「Laravel使ってるから大丈夫でしょ」と思った方 ほど、ぜひ最後まで読んでいただきたいです。

1. XSS(クロスサイト・スクリプティング)

XSSの基本パターン

例えば、ECサイトの検索画面に、以下のようなコードがあったとします。

//sample1-1-A 検索フォーム search.php
<form action="result.php">
	<input type="text" name="keyword">
	<button type="submit">検索</button>
</form>
//sample1-1-B 検索結果画面 result.php
<h2><?php echo $_GET['keyword']; ?>の検索結果</h2>

もし入力欄に、

<script>location.href = 'http://example.com'</script>

という文字列を入力して検索したら、どうなるでしょうか。

検索結果画面には以下のようなHTMLが出力され、

<h2><script>location.href = 'http://example.com'</script>の検索結果</h2>

ユーザはJavaScriptによって、即座に別のサイトにリダイレクトされてしまいます。

こんなことをする人はいないと思うかもしれません。
しかし、$_GET で取得するのは、あくまでURLのパラメータです。
もし、攻撃者が、こんなURLをSNS等に投稿したらどうなるでしょうか?

http://あなたのURL/result.php?keyword=上記のスクリプト

SNSによってはURLの末尾は省略されて見えず、
また、あなたのサイトのOGP(サイト名や画像などのプレビュー)が表示されている可能性もあります。

しかし、これをクリックした人のブラウザには、先ほどと同じJavaScriptが出力され、
即座に攻撃者のサイトにリダイレクトさせられる(あるいはウィルスの入ったファイルのダウンロードを開始させられる)ことになります。

あなたのサイトには何の害もないかもしれません。

しかし、あなたのサイトが攻撃の踏み台にされ、無関係な第三者が被害を受けてしまいます。

あるいは、一見するとあなたのサイトがウィルスをばら撒いているように見えてしまうかもしれません。

XSSの対策

対策はただひとつで、全ての危険な変数の出力をエスケープすることです。

//sample1-2 result.php
<h2><?php echo htmlspecialchars($_GET['keyword'], ENT_QUOTES); ?>の検索結果</h2>

「危険な変数」とは、$_GET や $_POST、あるいはこれらを代入した変数全てを含みます。
$_COOKIE も、ユーザが変更できる可能性があるので、同様です。

「出力」とは、echo文やprint文、sprint関数などの全てです。

「エスケープ」の方法として最もシンプルなのは、htmlspecialchars() 関数です。

< や > や " などの特殊記号を &lt; &gt; &quot; のような「実体参照」に変換してくれるため、上記のような攻撃を無効化することができます。

なお、Google Chrome などで実際にやってみるとわかるのですが、
ブラウザによっては、上記のような危険なパラメータを検知すると、自動的に画面を殺してくれたりする場合もあります。

が、それはあくまで一部のブラウザが善意で対処してくれているだけであり、それに依存してはいけません。

XSSのバリエーション

XSSが発生するのは、画面上に文字列として表示される箇所に限りません

例えば以下のような、href属性等の中であったとしても

//sample1-3
<a href="detail.php?id=<?php echo $_GET['id']; ?>">詳細</a>

$_GET['id'] に以下のような文字列を入れられてしまえば、XSSは成立します。

"></a><script>location.href = 'http://example.com'</script><a href="

↓出力結果

<a href="detail.php?id="></a><script>location.href = 'http://example.com'</script><a href="">詳細</a>

あるいは、即時にはリダイレクトされないものの、リンク先を差し替えてしまうようなXSSも可能です。

" onclick="location.href='http://example.com'

↓出力結果

<a href="detail.php?id=" onclick="location.href='http://example.com'">詳細</a>

入力フォームの value 属性の中なども、見落としやすいので注意が必要です。

埋め込み型XSS

例えば投稿サイトやブログのコメントフォームのように、
ユーザの入力値がデータベース等に保存され、それを表示する機能をもつアプリケーションの場合、

ユーザが入力フォームにJavaScriptタグやaタグ、iframeタグなどを記述して送信するだけでも、XSSが成立し得ます。

仮にその入力欄が、input や textarea タグではなく、
select タグ等、ユーザが直接入力できないインターフェイスになっていたとしても同じです。

POSTデータを改竄して送信することは、非常に容易だからです。
(例えば、Google Chromeのディベロッパーツールで、フォーム内のHTMLを書き換えて送信する等)

この場合、データベースに保存する前にエスケープする方法と、出力時にエスケープする方法の2通りが考えられます。

しかし、プログラムは時間とともに変更されていくものであり、
過去に一時的に脆弱な状態が存在した、あるいは今後発生する可能性も考えると、
最後の出力時にエスケープしておく方が、より安全度は高いと言えます。

万一、両方でエスケープしてしまうと、
< → &lt; → &amp;lt; となってしまい、
脆弱性こそありませんが、アプリケーションとしては不具合になってしまいます。

2. CSRF(クロスサイト・リクエスト・フォージェリ)

CSRFの基本パターン

例えば、以下のように、商品レビューを投稿するフォームがあったとします。

//sample2-1-A 投稿フォーム input.php
<form action="post.php" method="post">
	<label>タイトル</label>
	<input type="text" name="title">
	<label>本文</label>
	<textarea name="content"></textarea>
	<button type="submit">投稿</button>
</form>
//sample2-1-B 保存処理 post.php
$title = $_POST['title'] ?? null;
$content = $_POST['content'] ?? null;

//入力値チェック(ざっくり)
if (!$title || !$content) {
    throw new Exception('invalid input');
}

//DBに保存
$pdo->prepare('insert into posts(title, content) values (:title, :content)');
$pdo->execute([
    ':title' => $title,
    ':content' => $content,
]);

もし、攻撃者が、攻撃者の保有しているサイトに、以下のようなコードを埋め込んだら、どうなるでしょうか。

//sample2-2-A index.html
<iframe src="forgery.html" width="1" height="1">

幅1px 高さ1px の見えない iframe です。
そして、iframe の中身は以下です。

//sample2-2-B forgery.html
<body onload="document.forgery.submit()">
<form name="forgery" action="http://あなたのサイト/post.php" method="post">
    <input type="hidden" name="title" value="爆破予告">
    <input type="hidden" name="content" value="◯月◯日に国会議事堂を爆破する">
</form>
</body>

運悪くこのページ(index.html)に、全く無関係な第三者である山田さんがアクセスしたとします。

山田さんが全く気づかないうちに、見えないiframeの中でフォームが自動送信され、あなたのサイトに、爆破予告が投稿されてしまいます。

しかも、その際あなたのサーバのアクセスログに記録されるのは、
山田さんのIPアドレスとUserAgentです。

あなたのサイトにスパムが投稿されるだけでなく、無関係な第三者にその疑いが向けられてしまうのです。

実際にこの手口により、殺害予告の冤罪で、全く無関係な人が逮捕された例があります。

注意しなければならないのは、例えば会員制のSNSのように、
ログインしたユーザしかアクセスできないような画面であってもこの攻撃は可能であることです。

サイトにログインした状態の人が、そのブラウザで上記のようなページにアクセスすれば、
投稿が実行されてしまうからです。

過去には mixi やアメブロなどの大手が運営するサービスでも、
本人が知らないうちに記事を投稿させられてしまうトラブルがありました。
それも、このようなCSRFの脆弱性を突かれたものです。

CSRFの対策

CSRFの対策としては「トークン」を用いる方法が一般的です。

<?php
//sample2-3-A 投稿フォーム (input.php)

session_start();

//トークンの生成
$token = bin2hex(openssl_random_pseudo_bytes(16));
$_SESSION['token'] = $token;
?>
<form action="post.php" method="post">
	<input type="hidden" name="token" value="<?php echo $token; ?>">
	<label>タイトル</label>
	<input type="text" name="title">
	<label>本文</label>
	<textarea name="content"></textarea>
	<button type="submit">投稿</button>
</form>
<?php
//sample2-3-B 保存処理 (post.php)

session_start();

//トークンチェック
if (
    empty($_POST['token'])
    || empty($_SESSION['token'])
    || $_POST['token'] !== $_SESSION['token']
) {
    throw new Exception('token mismatched');
}

//入力値取得
$title = $_POST['title'] ?? null;
$content = $_POST['content'] ?? null;

//入力値チェック(ざっくり)
if (!$title || !$content) {
    throw new Exception('invalid input');
}

//DBに保存
$pdo->prepare('insert into posts(title, content) values (:title, :content)');
$pdo->execute([
    ':title' => $title,
    ':content' => $content,
]);

送信前の画面でランダムな文字列(トークン)を生成し、
セッションに格納しつつ、画面の input type=hidden 等にセット。

送信後の画面で、POSTされたトークンと、セッションに格納されたトークンが一致するかをチェックし、
不一致であればエラーとする、という仕組みです。

攻撃者のページでは、トークンをあなたのサイトのセッションに入れることができないため、
上記のチェックでエラーとなり、攻撃を阻止できます。

CSRFが問題になるのは、ユーザ操作によってデータが作成/更新されたり、メールが送信されたりする画面全てです。

通常こうした画面はPOSTで実装するため、POSTリクエスト全てに共通処理として、トークンチェックを実装するのが安心です。

AJAXで更新処理を行う場合などにも、同様の実装が必要です。

また、生成するトークンは、もちろん、攻撃者が「予測困難」なものである必要があります。

例えば mt_rand() 関数の返す値などは、厳密には法則性があるため、
「暗号学的に安全」ではない(=予測される可能性がある)とされています。

ここでは、openssl_random_pseudo_bytes() という、ランダムなバイト文字列を生成する関数を使用しています。

3. SQLインジェクション

SQLインジェクションの基本パターン

例えばECサイトのマイページに、以下のような、注文履歴を取得する処理があったとします。

//sample3-1
$status = $_GET['status'];

$sql = "select * from orders where stasus = {$status} and user = {$self}";
$stmt = $pdo->query($sql);

$data = [];
while ( $row = $stmt->fetch(PDO::FETCH_ASSOC) ) {
    $data[] = $row;
}

//$dataを画面に表示

ユーザが見れるのは自身の購入履歴だけなので、
$self にはログイン中のユーザのIDが入っているという前提です。
発送状況で絞り込めるよう、GETパラメータの status が SQLに入っています。

もし、この $_GET['status'] に、
以下のような文字列が送信されてきたら、どうなるでしょうか。

1 or 1 = 1; select * from orders where 1 = 1

実行される $sql は、以下のようになります。

select * from orders where stasus = 1 or 1 = 1; select * from orders where 1 = 1 and user = 123

セミコロンはSQLの切れ目を表すので、2つのSQLが実行されることになります。
実際に、このようにセミコロン区切りで2つのSQLを書いても、PDO::query() は動作します。

そして、上記の実装の場合、$data に入るのは、1つめのSQL(下記)の取得結果です。

select * from orders where stasus = 1 or 1 = 1;

一見するとよくわからないSQL文ですが、
or の後の条件式 1 = 1 は常に成立するので、結果、テーブルの全行が取得されることになります。

つまり、個人情報を含む他人の注文履歴を全て見れてしまうのです。

あるいは、$_GET['status'] に、
以下のような文字列が送信されてきたら、どうなるでしょうか。

1; delete from orders; select * from orders where 1 = 1

今度は、以下の3つのSQLが実行されることになります。

select * from orders where stasus = 1;
delete from orders;
select * from orders where 1 = 1 and user = 123

何が起こるかは、お分かりですね。

SQLインジェクションの対策「プリペアドステートメント」

上記の実装の問題点は、ユーザが操作可能な変数を、直接SQL文に結合していることです。

最も安全なのは、MySQL等に用意されている、「プリペアドステートメント」の仕組みを使うことです。

PDOであれば、以下のように記述します。

//sample3-2
$status = $_GET['status'];

$sql = "select * from orders where stasus = :status and user = :user";
$stmt = $pdo->prepare($sql);
$stmt->execute([
    ':status' => $status,
    ':user' => $self,
]);

$data = [];
while ( $row = $stmt->fetch(PDO::FETCH_ASSOC) ) {
    $data[] = $row;
}

//$dataを画面に表示

パタメータとしてセットされた値が数値であれば数値として、文字列であればエスケープされた文字列として処理されるため、
これだけでSQLインジェクションは回避されます。

ユーザ入力に限らず、変数をSQLに組み込む際には必ずプリペアドステートメントを使う、と習慣づけておくことがベストです。

プリペアドステートメントが使えないケース

しかし、中にはプリペアドステートメントが使えない(使いづらい)ケースも存在します。

//sample3-3
$statuses = $_GET['statuses'];
$in = join(",", $statuses);

$sql = "select * from where statuses in ({$in}) and user = {$self}";
$stmt = $pdo->query($sql);

$_GET['statuses'] はチェックボックスから送信されてくる値で、数値の配列という想定です。

上記のコードでは、例えば $_GET['statuses'] に以下のような値を入れられると、SQLインジェクションが成立します。

array(
    "0",
    "1) or 1 = 1; select * from orders where status in (0",
    "1"
)

上記の配列がカンマ区切りで結合され、以下のようなSQLが実行されることになります。

select * from where statuses in (0,1) or 1 = 1; select * from orders where status in (0,1) and user = 123

しかし、以下のようなコードはエラーになります。

//sample3-4
//これはエラー
$statuses = $_GET['statuses'];
$sql = "select * from orders where stasus in(:statuses) and user = :user";
$stmt = $pdo->prepare($sql);
$stmt->execute([
    ':statuses' => $statuses,
    ':user' => $self,
]);

そのため、時にはSQLに変数を直接組み込まざるを得ない(※)ケースもあるのですが、
想定外の値が入ることがないよう、
きちんと値のチェックもしくはエスケープをする必要があります。

//sample3-5
$statuses = array_map('intval', $_GET['statuses']);
$in = join(",", $statuses);

$sql = "select * from where statuses in ({$in}) and user = :user";
$stmt = $pdo->prepare($sql);
$stmt->execute([
    ':user' => $self
]);

$_GET['statuses'] の全ての要素に intval() を適用しているため、全ての要素が必ず整数値になります。
これであれば、先ほどのような値が送信されてきたとしても、実行されるSQLは以下のようになります。

select * from where statuses in (0,1,1) and user = 123

※上記の例の場合、ひと工夫すればIN句もプリペアドステートメントで処理することも可能です。

4. その他の脆弱性

動的なSQLの構築

例えば、ECサイトのマイページに、自身の会員情報を更新できる画面があるとします。

//sample4-1-A profile.php
<form action="profileUpdate.php" method="post">
    <label>氏名</label>
    <input type="text" name="fullname" value="<?php echo htmlspecialchars($fullname, ENT_QUOTES); ?>">
    <label>メールアドレス</label>
    <input type="email" name="email" value="<?php echo htmlspecialchars($email, ENT_QUOTES); ?>">
    <label>電話番号</label>
    <input type="text" name="phone" value="<?php echo htmlspecialchars($phone, ENT_QUOTES); ?>">
    <input type="hidden" name="token" value="<?php echo $token; ?>">
    <button type="submit">保存</button>
</form>
//sample4-1-B profileUpdate.php

//トークンチェック(略)

$fullname = $_POST['fullname'] ?? null;
$email = $_POST['email'] ?? null;
$phone = $_POST['phone'] ?? null;

//バリデーション(略)

$sql = "update users set fullname = :fullname, email = :email, phone = :phone where id = :user";
$stmt = $pdo->prepare($sql);
$stmt->execute([
    ':fullname' => $fullname,
    ':email' => $email,
    ':phone' => $phone,
    ':user' => $self, //ログイン中のユーザ
]);

上記は問題のない実装ですが、項目が非常に多いフォームだったりすると、以下のような実装を考える方もいるでしょう。

//sample4-2 profileUpdate.php

//トークンチェック(略)

$arr_set = [];
$params = [];

foreach ($_POST as $key => $value) {
    $arr_set = "{$key} = :{$key}";
    $params[":{$key}"] = $value;
}

//バリデーション(略)

$sql = "update users set " . join(",", $arr_set) . " where id = :user";
$params[":user"] = $self; //ログイン中のユーザ

$stmt = $pdo->prepare($sql);
$stmt->execute($params);

$_POST に入っている全ての key => value から動的に set 句を作り、実行しています。
確かにコードは非常にすっきりします。

しかし上記は非常に危険なコードです。

例えば、users テーブルに、保有ポイント残高を格納する "point" というカラムががあったとします。
ポイントは現金の代わりに購入に使えるもので、ユーザ自身が決して自由に変更できてはいけないカラムです。

もし、ユーザがブラウザ上でフォームを不正にいじって、以下のようなパラメータを送信してきたら、どうなるでしょうか。

$_POST['point'] = 99999999

大損害ですね。

他にも、自身のユーザIDを変更して、他人の情報を盗んだり、他人のクレジットカードで購入したりすることも、できてしまうかもしれません。

あるいは、管理者と一般ユーザが同一テーブルに格納されており、"role" カラムの値によって切り分けられている場合、
このカラムを更新すれば、管理者権限を奪うこともできてしまいます。

これを防ぐためには、面倒でも sample4-1-B のような実装をするか、
以下のように、パラメータが意図したものであるかどうかをチェックする必要があります。

//sample4-3

//送信されてくるはずのフィールドのリスト
$whitelist = ['fullname', 'email', 'phone', 'zip', 'pref', 'address', 'street', 'birthday'];

$inputs = [];
foreach ($whitelist as $key) {
    $inputs[$key] = $_POST[$key] ?? null;
}

Laravelの複数代入

「動的にSQL作るなんて凝ったことしていないから大丈夫!」と思ったあなた。

この問題は、特にLaravelなどのフレームワークを使った時には、いとも簡単に起こります

//sample4-4 (Laravel)
$inputs = $request->all();

App\User::find($self)->update($inputs);

このコードがやっているのは sample4-2 と全く同じことで、
送信されたパラメータのキー全てを更新してしまう可能性があります

Laravelでは、これを「複数代入」と呼んでいます。

意図しないカラムの更新を防ぐため、
Laravel の Model クラスには $fillable プロパティおよび $guards プロパティが用意されており、
これらを適切に設定しておくことで、上記のような問題は回避できます

もちろん、以下のように、update() の引数のパラメータを個別に渡したり、
受け取るパラメータを絞ったりしておくと、より安全です。

//sample4-5 (Laravel) 引数のパラメータを個別に渡す
$inputs = $request->all();

App\User::find($self)->update([
    'fullname' => $inputs['fullname'],
    'email' => $inputs['email'],
    'phone' => $inputs['phone'],
]);
//sample4-6 (Laravel) 受け取るパラメータを絞る
$inputs = $request->only('fullname', 'email', 'phone');

App\User::find($self)->update($inputs);

5. 安全性の担保

ひとめで安全だとわかるコーディング

ここまでにご紹介した脆弱性は、ベテランのエンジニアであっても非常に見落としやすいものです。

小規模なアプリケーションであれば、リリース前にソースコードを全て見直すこともできるかもしれませんが、
機能数が多くなったり、複数人での開発となると、それは現実的ではありません。

そのため、単に注意して実装するだけでなく、「ひとめで対策済みであるとわかる」 ような工夫が必要です。

例えば、XSS であれば、危険な変数のみをエスケープしていると、
変数をセットしている部分を全て追わなければ、安全であるかどうかが判断できません。

中身に関係なく、画面上に変数を出力する全ての箇所に htmlspecialchars() がついていれば、それだけで XSS 対策済であると判断できます

さらに、例えば以下のような関数を作り、

function eh($str) {
    echo htmlspecialchars($str, ENT_QUOTES);
}

ビュー内では echo や print を使わず、出力には全て上記の関数を使うことをルール化します。

そうしておけば、リリース前に全てのビューファイルを "echo", "print" で検索するだけで、
対策漏れの疑いがある箇所を、簡単に洗い出すことができます。

フレームワークを使っていれば安全、という誤解

Laravel 等のフレームワークには、簡単な実装で XSS や CSRF 等の対策ができる仕組みが用意されています。

しかし、Laravel を使っていればそれだけで安全、というわけではありません

あくまで仕組みが用意されているだけで、使い方を間違えれば、やはり脆弱性は生まれます。

例えば、危険な変数を

{!! $var !!}

で出力していれば XSS が成立します。

更新を伴う処理を get や any のルーティングで行っていれば、CSRF も成立し得ます。

DB::query() や Model::whereRaw() の引数に、ユーザの入力値に由来する変数を直接結合していれば、SQLインジェクションだって起こりえます。

フレームワークはあくまで道具です。

脆弱性がなぜ生まれるのか、
フレームワークが何をしてくれているのかを理解した上で、
正しく使うことが大切です。

更新履歴

9/27
sample1-2 他 : htmlspecialchars() から ENT_QUOTES が漏れていたので追記しました。恥ずかしい・・・
sample2-3-B : トークンの比較を厳密比較にしました。
はてブにてご指摘いただいた prograti さん、ありがとうございます。

9/29
フレームワーク (Laravel) についての記述を追加しました。

Discussion