Web システムの脆弱性まとめ その2 「認証の欠如による脆弱性」

品質保証QA脆弱性

Web システムの脆弱性まとめ、その2です。
今回解説するのは以下の内容となります。

・認証の欠如による脆弱性
 ・認証済の利用者を踏み台にした攻撃
 ・承認済のサーバを踏み台にした攻撃
 ・不適切・不十分な認証処理
 ・ユーザ権限の確認不十分

参考: 不正なリクエストデータに対する脆弱性
参考: 機密情報・認証情報の漏えいによる脆弱性・不適切なパーミッションによる脆弱性
参考: 不正なメモリ領域へのアクセスによる脆弱性


認証の欠如による脆弱性
一般的に使われる「認証」という言葉ですが、厳密には2種類の意味が存在します。

・当該利用者が本人であるかの検証
・当該利用者がそれを参照するあるいは実行する権限を持つかの検証

1つ目は認証【Authentication】(AuthN、AuthC)、2つ目は承認【Authorization】(AuthZ)と呼ばれ、これらは明確に区別されます。

Web システムにおける認証(AuthN)はたいていの場合、ID・パスワードなどのログイン認証となります。
認証機構自体は2段階認証など様々な対策が定着してきていますが、認証そのものをすり抜けるクロスサイトリクエストフォージェリや検証済みであることの証明の不備をつく攻撃などにはまだまだ脆弱性が潜んでいる可能性が高いかと思います。

また、認証(AuthN)ばかりに目が行きがちですが、ユーザ権限のチェック(承認)も非常に重要です。
一般利用者が管理者権限を行使できてしまってはシステムの運用が破綻してしまいますし、誰がどこまで見えていいのかを正しく把握しプログラムに落とし込まないと思わぬ情報漏えいの原因になってしまいます。
ありがちなのは「非表示にしているから」とか「リンクから辿れないから」という理由で権限のチェックを怠ることです。
GET パラメータをいじる、ページのソースを見る、といった簡単な作業により画面からでは到達できないページへも容易にたどり着くことができます。
権限の実行は、実行の都度確認しなければなりません。


認証済の利用者を踏み台にした攻撃
・クロスサイトリクエストフォージェリ(CSRFXSRF)(CWE-352 [jvndb])

[対象となるシステム]
ログインなどの認証の必要なシステム。

[脆弱性・攻撃方法]
ログインなどの認証機構を持つシステムに対して、すでに認証済(ログイン済)の利用者に罠を踏ませることによって、そのシステムが持つ認証機構をスルーして攻撃を行います。
罠自体はどこにあっても攻撃可能ですが、攻撃者は利用者を罠へ誘導する仕掛けも必要となります。

以下は自分のプロフィール情報を更新するフォームの例です。

<form action="/path/to/post.php" method="post">
 <input type="text" name="firstname"/>
 <input type="text" name="lastname"/>
 <br/>
 <input type="text" name="email"/>
 <input type="submit" name="submit" value="Update"/>
</form>

このページへのアクセスには、システムへのログインが必要です。

$firstname = $_POST['firstname'];
$lastname = $_POST['lastname'];
$email = $_POST['email'];

// ログイン済かどうか確認
if(!myfunc_isLoggedin()){ 
	throw new Exception('Error: You need to log in first.', -13);
}

myfunc_updateProfile($firstname, $lastname, $email);
echo 'プロフィール情報を更新しました。';

攻撃者は以下のような罠ページをどこかに公開し、なんらかの方法で利用者をこのページに誘導します。
利用者が当該システムにログイン状態のままこのページにアクセスした途端、プロフィール情報が書き換わってしまいます。

<Script>
function SendAttack(){
	form.email = "attacker@example.com";
	form.submit();
}
</Script>

<body onload="javascript:SendAttack();">
 <form action="https://url/path/to/post.php" id="form" method="post">
  <input type="hidden" name="firstname" value="Funny">
  <input type="hidden" name="lastname" value="Joke">
  <br/>
  <input type="hidden" name="email">
 </form>
</body>

[対処方法]
罠自体はシステムの外側に作られるため、基本的に罠の設置を防ぐことはできません。
できることは正規のページからリクエストが送信されたかどうかを確認することです。
リクエスト元のページを示すリファラが使えそうですが、ブラウザの設定で無効化されていたり攻撃者がリファラを偽造する可能性もあるため、あまりあてにはできません。

有効な対策としてはいくつかの方法がありますが、基本的な仕組みとしては、正規のページだという証明であるトークン(ランダムな文字列)を発行し、POST の処理を行う際にそれを照合する方法です。

以下は POST の元となるフォームにワンタイムトークンをhiddenで埋め込む方法です。

<?php
session_start();

// セキュアなトークンを生成しセションに保持
$token = random_bytes(32);
$_SESSION['token'] = $token;
?>

<form action="/path/to/post.php" method="post">
 <input type="text" name="firstname"/>
 <input type="text" name="lastname"/>
 <br/>
 <input type="text" name="email"/>
 <input type="hidden" name="token" value="<%=$token%>">
 <input type="submit" name="submit" value="Update"/>
</form>

POST 実行時にあらかじめセションに保持されたトークンと POST されたトークンを照合します。

<?php
session_start();

$firstname = $_POST['firstname'];
$lastname = $_POST['lastname'];
$email = $_POST['email'];
$token = $_POST['token'];

// ログイン済かどうか確認
if(!myfunc_isLoggedin()){
	throw new Exception('Error: You need to log in first.', -13);
}

// トークンを照合する
if($token != $_SESSION['token']){
	throw new Exception('Error: CSRF? Invalid argument.', -22);
}

myfunc_updateProfile($firstname, $lastname, $email);
echo 'プロフィール情報を更新しました。';
$_SESSION['token'] = '';
?>

こうすることで少なくとも当該利用者が正規のフォームページを1度表示させた状態でない限り、POST が実行されることはありません。
トークンの発行時刻も保持しておくことでトークンの有効期限を設定することもできるでしょう。

すべてのブラウザが対応しているわけではありませんが、サイトをまたがるリクエストで cookie を送信しないようにすることもできます。
例えば PHP では、php.iniini_set()にて以下の設定をLaxStrictにする、あるいは cookie を設定する際にsetcookie()optionssamesiteを指定します。
ログイン情報をセション内に管理しておくことで、クロスサイトでログイン情報が引き継がれなくなります。

// Lax:GETは許可、Strict:すべてNG
session.cookie_samesite = "Strict" # デフォルトは"None"
ini_set('session.cookie_samesite', 'Strict');

// PHP 7.3.0 以降で使える代替のシグネチャ
$options = array('samesite'=>'Strict');
setcookie($name, $value, $options);
 

承認済のサーバを踏み台にした攻撃
・サーバサイドのリクエストフォージェリ(SSRF)(CWE-918 [jvndb])

[対象となるシステム]
IP アドレスなどのアクセス制限のかかったサーバへアクセスするシステム。

[脆弱性・攻撃方法]
アクセス可能なサーバを経由して、直接アクセスできないサーバに攻撃を行います。
例えば公開された Web サーバから非公開な API サーバにアクセスする場合、公開された Web サーバは誰からでもアクセス可能にしますが、内部 API サーバは公開された Web サーバとだけ接続できれば良いため、通常プライベートサブネット内に構築されます。
Web システムの利用者はこの内部サーバに直接アクセスできませんが、リクエストデータに攻撃用のデータを仕込むことで公開された Web サーバを介して内部サーバを攻撃します。
SSRF は OS コマンドインジェクション、パス・トラバーサル、XXE などを利用して行われます。

以下は OS コマンドインジェクションによる SSRF 攻撃の例です。
リモートシェルrshを利用して内部サーバ上でコマンドを実行します。

$userName = $_POST['userName'];
$command = 'ls -l /home/' . $userName;
system($command);
// ユーザの指定
$_POST['userName'] = ";rsh 192.168.1.12 rm -rf /";
 ↓
// 実際に発行されるコマンド
ls -l /home/;rsh 192.168.1.12 rm -rf /

以下はパス・トラバーサルによる SSRF 攻撃の例です。
fopen()file_get_contents()はホスト名を指定することによりリモート接続が可能です。

$path = $_POST['path'];
$fp = fopen($path, 'r');
while($line = fgets($fp)) echo $line;
fclose($fp);
// PATHの指定
$_POST['path'] = 'ftp://192.168.1.12/path/to/file';
 ↓
// 実際に実行されるコード
$fp = fopen('ftp://192.168.1.12/path/to/file', 'r');

// PATHの指定
$_POST['path'] = 'http://192.168.1.12/api/alllist';
 ↓
// 実際に実行されるコード
$fp = fopen('http://192.168.1.12/api/alllist', 'r');

参考: 不正な OS コマンドの注入
参考: 不正なパス名の注入
参考: XML ドキュメントによる不正な外部エンティティ参照

[対処方法]
リクエストデータに不正なデータが存在しないか確認することはもちろん大切です。
特にリモート接続を試みるキーワード、例えば IP アドレスや URL、r系コマンド、ftp://などのスキームが混ざっていないかのチェックは、SSRF 攻撃の有効な対策と言えるかと思います。
しかしバリデーションチェックだけですべての SSRF 攻撃を防ぐことは困難であり、万全とは言えません。
やはり内部サーバ側へアクセスするときの厳格なルール決めや意図したアクセス以外を拒否するシステム設定などを行うことで初めて SSRF 攻撃へのきちんとした対策を講じたことになるということを理解しておきましょう。
例えば開発で必要なリモート接続は公開用 Web サーバとは完全に切り離されたアクセス権限で行うべきですし、内部 API サーバの呼び出しにはシーケンスルールの導入や署名・アクセストークンの付与などを検討すべきでしょう。

以下は内部 API サーバが呼び出された時に毎回必ず行う認証チェック処理の例です。
GET パラメータに署名が付与されているか、また署名が正規のものなのかを毎回チェックします。

// GETパラメータに署名がなければエラー(SSRFの疑い)
if(!isset($_GET['sign'])){
	throw new Exception('Error: SSRF? Invalid argument.', -22);
}

// GETパラメータに付与された署名のチェック
$signature = myfunc_sigdecode($_GET['sign'], $publicKey_foo.bar.com);
if($signature != 'foo.bar.com signed.'){
	throw new Exception('Error: SSRF? Invalid argument.', -22);
}


不適切・不十分な認証処理
・不適切な認証(CWE-287 [jvndb])

[対象となるシステム]
ログインなどの認証の必要なシステム。

[脆弱性・攻撃方法]
認証メカニズムの不備や不十分な確認によって、認証処理をスルーできてしまう脆弱性です。

以下は認証処理を行う PHP のプログラム例です。
利用者がすでにログインしているかを判定し、さらに Administrator なら管理者用のタスクを実行します。

$loggedin = (isset($_COOKIE['loggedin']))&&($_COOKIE['loggedin'] == 'true');
$username = isset($_COOKIE['username']) ? $_COOKIE['username'] : null;

if(!$loggedin){
	throw new Exception('Error: You need to log in first.', -13);
}
if($username == 'Administrator'){
	myfunc_doAdministratorTasks();
}

このコードは「ログイン済かどうか」や「どのユーザか」の判定を cookie にのみ依存しているため、cookie を改ざんすることで認証の仕組みをスルーすることができてしまいます。
例えば以下のように cookie を HTTP リクエストヘッダに埋め込みます。

GET /path/to/test.php HTTP/1.1
Cookie: user=Administrator
Cookie: loggedin=true

[body of request]

以下のコードはユーザ名を受け取って当該ユーザのプロフィール画像を表示する PHP のプログラム例です。
このページではログイン済かどうかの確認を行いますが、生成されたイメージタグから URL を抜き出せば画像を直接参照できてしまいます。

define(DS, DIRECTORY_SEPARATOR);
define(DOC_ROOT, '/var/www/html/');
$username = $_POST['username'];

// ログイン済みかどうか
if(!myfunc_isLoggedin()){
	throw new Exception('Error: You need to log in first.', -13);
}

$user = myfunc_getUserByName($username);
$userPath = myfunc_getUserPath($user->user_id);
$image= DOC_ROOT.$userPath.DS.$user->profileImage;

echo '<img src="<%=$image%>">'."\n";

[対処方法]
cookie は比較的改ざんされやすいリソースです。
基本的にはログイン済かどうかの確認は、cookie よりもセションを利用すべきでしょう。
ログイン処理直後にどのユーザでログインしたかの情報をセションに保持しておき、これを確認することでログイン済とみなします。
さらに「どのユーザがアクセスしているのか」という判定においても、セションに保持された情報以外(例えば GET パラメータなど)は信用しないようにしましょう。
また、セションの有効期限を設定しておくことで、無操作時間によるログアウト機能にそのまま利用できます。

PHP では php.iniini_set()等でセションに関する様々な設定が可能です。
正しい設定を行うことによってセキュリティの向上が見込めますので、あまり確認したことのない方は見直しを行うべきでしょう。

以下は特にセキュリティに関わる設定の抜粋です。

// サーバ側で初期化していないセションIDを受け付けない
// セションフィクセーション攻撃(ID固定化)に有効
session.use_strict_mode = 0 // 1にする

// セションIDをcookieに保存する
// 0にしてはいけない(URL埋め込みになってしまう)
session.use_cookies = 1
session.use_only_cookies = 1

// SSLでのみセションcookieを送信する
;session.cookie_secure = // 1にすべき

// JavaScriptからアクセスできなくする
// すべてのブラウザが対応しているわけではない
// XSS攻撃に有効
session.cookie_httponly = // 1にすべき

// サイトをまたがるリクエストでcookieを送信しないようにする
// すべてのブラウザが対応しているわけではない
// CSRF攻撃に有効
session.cookie_samesite = // Lax(GETは許可)かStrict(すべてNG)にすべき

// セションの有効期限
// 1440秒過ぎるとリクエストごとに1/1000の確率でセションが削除される
session.gc_probability = 1
session.gc_divisor = 1000
;session.gc_maxlifetime = 1440 // 60x60x24くらいにしておくと良い


ユーザ権限の確認不十分
・重要な機能に対する認証の欠如(CWE-306 [jvndb])
・認証の欠如(CWE-862 [jvndb])

[対象となるシステム]
利用者ごとにできること(ユーザ権限)の一部に違いが存在するシステム。

[脆弱性・攻撃方法]
利用者ごとにリソースへのアクセス権や機能の実行権が異なる場合、各々の利用者ごとに与えられた権限の確認が必要となります。
権限の確認が不十分な場合、情報の漏えいや許可されていない管理者権限の実行などの問題が発生します。
例えば以下のようなユーザ権限の違いが考えられます。

・管理者権限(管理者か非管理者か)
・利用可能な機能(ロール設定)
・チームや部署、プロジェクトなどの論理グループの参照範囲(所属しているか否か)
・システム内で作成されたリソース(自身が作成したものか否か)
・プライベートなメッセージ(メッセージの当事者)

以下は権限確認の不十分な PHP のプログラム例です。
支店番号を受け取ってその支店の従業員一覧の情報を表示しますが、様々な番号を指定することで当該利用者がどの支店に勤めているかに関わらず他支店の従業員情報を見ることができます。

$no = $_GET['branch_no'];

// 従業員リスト取得
$employees = myfunc_getEmployeesByBranchNo($no);

foreach($employees as $employee){
	echo '<div>'."\n";
	echo 'name: '.$employee->name;
	echo 'address '.$employee->address;
	echo 'tel: '.$employee->tel;
	echo 'age: '.$employee->age;
	echo '</div>'."\n";
}

以下はプライベートメッセージの履歴を表示するプログラムです。
他者同士の ID を GET パラメータに指定することで、自分の関わっていないメッセージまで参照できてしまいます。

$user_id = $_GET['id'];
$user_id2 = $_GET['id2'];

$messages = myfunc_getPrivateMessages($user_id, $user_id2);
myfunc_displayMessages($messages);

[対処方法]
まず、GET パラメータを直接操作するテストパターンは必ずテスト工程に盛り込みましょう。
テストの実行が容易であり、この種の脆弱性を見つけやすいです。

例えば当該企業に所属する従業員は「すべての支店の従業員情報を見ても良い」という社内規則・コンプライアンスなのであれば、従業員のみがログイン可能なシステムにおいて「従業員情報の参照」は情報漏えいにあたらず、このプログラムに脆弱性はありません。
このようにユーザ権限の違いは利用者の都合や状況によって変わってくるため、まずはどのような権限の違い(参照範囲)があるのかをしっかり把握し、要件定義に盛り込むことが重要です。

以下はプライベートメッセージの履歴を表示するプログラムの修正案です。
話者の一人は必ず自分自身になります。

$my_id = $_SESSION['login']['user_id'];
$your_id = $_GET['id'];

$messages = myfunc_getPrivateMessages($my_id, $your_id);
myfunc_displayMessages($messages);



続きます。

Web システムの脆弱性まとめ その1
Web システムの脆弱性まとめ その3
Web システムの脆弱性まとめ その4

コメント