オープンリダイレクト脆弱性とは
例えば、こんな感じのプログラムがあったとする。
<?php
$url = (string)filter_input(INPUT_GET, 'redirect');
// GETパラメータで受け取ったURLにリダイレクトさせる
header('Location: ' . $url);
exit;
このPHPファイルにアクセスすると、GETパラメータで指定したURLにリダイレクトされる。そしてこのプログラムには問題が一つあり、それは渡されたURLの検証を一切していないってこと。
https://example.com/sample.php?redirect=http://evil.example.com
# https://example.com/sample.phpはオープンリダイレクト脆弱性のあるプログラムのURL
# http://evil.example.comはフィッシングサイト等のURLとする
要するに、こんな感じのリンクをうっかり踏んでしまうと、フィッシングサイトにリダイレクトされてしまうって訳だ。もしもexample.comというのが多くの人が利用している著名なサイトのドメインだったりすると、まさかそんなサイトに罠が潜んでいるとは思わず踏んでしまう人も少なからずいることだろう。(まあこの例だと、リンクの文字列を最後まで確認してれば回避可能ではあるが)
この外部から入力された値による任意のページへのリダイレクト機能が、ログイン画面に実装されていると危険性がグッと増す。
例えば、ログイン成功したときの遷移先を、巧妙に複製した偽のログイン画面に設定する。
すると、「IDかパスワードでも入れ間違えたかな?」と勘違いしたユーザは、その偽のログイン画面にIDとパスワードを入力してしまう。結果、攻撃者の手元にIDとパスワードが渡ってしまうという訳。
対処
URLのチェックって非常に実装が難しいため、オープンリダイレクト脆弱性を生まないための最も有効な対策は「外部からURLの入力を受付け、そのURLにリダイレクトするような機能をそもそも作らないこと」とか何とか。
それでもどうしてもそういった機能が必要であれば
①リダイレクト先として許可するURLの一覧を用意して、パラメータで渡された文字列がその一覧に存在する場合はリダイレクトさせる(ドメインだけでなく、スキーム名からクエリ文字列まで完全一致していることをチェックする)
②パラメータではURL文字列を直接やり取りせず、何らかの符号を使い、その符号に対応するURLにリダイレクトさせる(?redirect=login だったらログイン画面にリダイレクトさせる、?redirect=dashboard なら会員メニューにリダイレクトさせる…など)
③リダイレクト先として許可するドメインの一覧を用意し、パラメータで渡されたURL文字列内のドメインがその一覧に存在するものだったらリダイレクトさせる
といったチェックを追加するということが考えられる。ちなみに今回の仕事では①で実装した。一番カッチリしてて、なおかつ実装が単純なので良い気がする。
リダイレクト先に動的にパラメータ渡したい時とかは③で実装するほかないが…
PHPで①を実装するなら
<?php
function is_redirect_url_allowed () {
$allowed_url_list = [
'https://allow2.example.com/?hoge=aiueo',
'http://allow3.example.com/hogehoge.html',
'https://allow4.example.com/login.php',
];
$url = (string)filter_input(INPUT_GET, 'redirect');
return isset($allowed_url_list[$url]);
}
③を実装するなら
<?php
function is_redirect_url_allowed () {
$allowed_domain_list = [
'allow2.example.com',
'allow3.example.com',
'allow4.example.com',
];
$url = (string)filter_input(INPUT_GET, 'redirect');
// URL文字列を各構成要素に分解する
$parsed_url = parse_url($url);
// そもそもURLとして解釈できない文字列であればfalse
if ($parsed_url === false) {
return false;
}
// scheme(プロトコル)が未定義ならfalse
if (isset($parsed_url['scheme']) === false) {
return false;
}
// schemeがhttpでもhttpsでもなければfalse
if ($parsed_url['scheme'] !== 'http' && $parsed_url['scheme'] !== 'https') {
return false;
}
// host(ドメイン)が未定義ならfalse
if (isset($parsed_url['host']) === false) {
return false;
}
return isset($allowed_domain_list[$parsed_url['host']]);
}
みたいな感じになると思う。
③の実装について一つ注意点があって、古いバージョンのPHPだとparse_urlで特定のパターンのURLをうまく分解できないバグが潜んでいるらしく、この実装だとすり抜けられることがある。(そういうことがあるから、リダイレクト機能はそもそも作らない方が良いと言われるんだな…)基本中の基本の話だけど、PHPのバージョンはできるだけ新しいものを維持することをオススメする。