PHP セキュリティ実習:XSS・CSRF・セッションを PHP 8.5 で攻撃して確かめる
出力先ごとの XSS、空トークンを通した CSRF チェック、実際の Set-Cookie で見るセッション固定、bcrypt と argon2id の実測、コードが動いたアップロード。
シリーズ:PHP 講座 5/6(上級)。 PHP でフォームと PDO のクエリは書けるようになり、いよいよインターネットに公開する段階の方向けです。読み終えると、出力先に合ったエスケープ、穴のない CSRF チェック、セッション固定対策、計測にもとづくパスワードハッシュのコスト選び、他人のコードを実行させないアップロード処理ができるようになります。
ログイン、コメント欄、アップロードボタンのある PHP サイトを担当しているなら、これは実際に悪用される間違いの一覧です。IPA の「安全なウェブサイトの作り方」のチェックリストで「XSS 対策済み」に印を付けたのに突破されるのは、たいてい「どこに出力するか」を区別していないときです。以下の例はすべて 127.0.0.1 だけで待ち受ける PHP のビルトインサーバーに curl で攻撃し、応答をそのまま残しました。この講座のために最初に書いた CSRF チェックが空のトークンを通してしまったことも、隠さず載せています。
実行環境: PHP 8.5.6 CLI とビルトインサーバー(php -S 127.0.0.1:8851)、curl 8.7.1、Apple Silicon Mac(M1 Pro)、macOS。例はすべて汎用のもので、実在のサービスのコードではありません。出力中のパスは ~/demo/ に短縮しています。
データベース側は第 4 回 PDO・SQLite と SQL インジェクション で扱いました。10 年物のコードで実際にどう直したかは レガシー PHP の SQLi・XSS を出力を変えずに直した記録 をご覧ください。
XSS:同じ入力、4 つの出力先
XSS(クロスサイトスクリプティング)は、攻撃者が入れた HTML や JavaScript が別の利用者のブラウザで動くことです。リクエストの値がそのまま返るのが反射型(検索欄)、保存されて全員に表示されるのが格納型(コメント)。対策はどちらも同じで、出力するときに、出力先に合わせてエスケープすることです。1 つのパラメータを 5 か所に出すページです。
<?php
// xss.php — one input, five output contexts. Run: php -S 127.0.0.1:8851 -t www
declare(strict_types=1);
$q = $_GET['q'] ?? '';
function e(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
header('Content-Type: text/html; charset=UTF-8');
?>
<p>1 raw: <?= $q ?></p>
<p>2 escaped: <?= e($q) ?></p>
<a href="<?= e($q) ?>">3 href, escaped</a>
<input value=<?= e($q) ?>>
<script>
var a = "<?= e($q) ?>"; // 5a htmlspecialchars in JS
var b = <?= json_encode($q, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>; // 5b "pretty" flags
var c = <?= json_encode($q, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>; // 5c safe
</script>
q=</script><script>alert(1)</script> を送ったときの応答です。
<p>1 raw: </script><script>alert(1)</script></p>
<p>2 escaped: </script><script>alert(1)</script></p>
...
var a = "</script><script>alert(1)</script>";
var b = "</script><script>alert(1)</script>";
var c = "<\/script><script>alert(1)<\/script>";
1 は典型的な穴です。意外なのは b でした。json_encode は既定で / を \/ にしますが、まさにこれが </script> でブロックが閉じるのを防いでいました。日本語が \uXXXX になるのを嫌って JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES を決まり文句のように付けるコードはよく見かけますが、その瞬間に保護が消えます。HTML パーサーは、JavaScript の文字列の中かどうかに関係なく、最初の </script> でスクリプトを終わらせます。
htmlspecialchars が動いていても役に立たない例がさらに 2 つあります。
q=javascript:alert(1)
<a href="javascript:alert(1)">3 href, escaped</a>
q=x onfocus=alert(1) autofocus
<input value=x onfocus=alert(1) autofocus>
javascript:alert(1) にはエスケープ対象の文字がないので、リンクをクリックすると実行されます。URL はエスケープの前に、スキームを許可リスト(http・https)で確認します。引用符のない属性は空白 1 つで新しい属性が始まるため、引用符なしで抜け出せます。属性は必ず引用符で囲みましょう。そうすれば ENT_QUOTES 付きの htmlspecialchars で足ります。
a は別の形で壊れます。入力 Tom "the cat" O'Neil の結果です。
var a = "Tom "the cat" O'Neil";
var b = "Tom \"the cat\" O'Neil";
var c = "Tom "the cat" O'Neil";
<script> の中ではブラウザは実体参照を戻しません。利用者名が " という文字のまま表示されます。これだけならデータの不具合ですが、var id = <?= e($id) ?>; のように引用符のない位置なら、1;alert(1) は特殊文字を 1 つも含まないのでそのまま実行されます。JSON_HEX_* を 4 つ付けた json_encode は型の合った JavaScript リテラルを作り、< > & ' " を決して出力しません。
不正な UTF-8:2 種類の失敗
Shift_JIS のまま残った古いデータのように、UTF-8 でないバイトが混ざるとどうなるか。q=caf%E9(Latin-1 の é、1 バイト)を送ると、JSON_THROW_ON_ERROR の側が出力の途中で止まりました。
Fatal error: Uncaught JsonException: Malformed UTF-8 characters, possibly incorrectly encoded in ~/demo/www/xss.php:15
Stack trace:
#0 ~/demo/www/xss.php(15): json_encode('caf\xE9', 4194319)
フラグがなければ json_encode は false を返し、var c = ; という構文エラーになります。このエラー画面が訪問者にファイルのパスまで見せている点にも注意してください。本番では display_errors をオフにします。htmlspecialchars のフラグにも落とし穴がありました。
htmlspecialchars("caf\xe9") → string(6) "caf�" (既定フラグに ENT_SUBSTITUTE を含む)
htmlspecialchars("caf\xe9", ENT_QUOTES) → string(0) ""
ENT_QUOTES だけを渡すと既定フラグを置き換えるので ENT_SUBSTITUTE が抜け、不正な入力は空文字列になります。常に ENT_QUOTES | ENT_SUBSTITUTE を一緒に渡しましょう。
コメント掲示板の格納型 XSS
プリペアドステートメントで保存するので SQL インジェクションはない小さな掲示板です。?raw=1 ならエスケープを飛ばします。
$ curl -b j1 -d 'body=<script>alert(document.cookie)</script>' '.../board.php?nocsrf=1'
saved
$ curl -b j1 '.../board.php?raw=1'
<li><script>alert(document.cookie)</script></li>
$ curl -b j1 '.../board.php'
<li><script>alert(document.cookie)</script></li>
プリペアドステートメントが守るのはクエリであって、ページではありません。保存は入力のまま、エスケープは出力時に。保存前にエスケープすると JSON API でデータが壊れ、&lt; のような二重エスケープが起きます。
CSRF:random_bytes、hash_equals、そして空トークンの穴
CSRF(クロスサイトリクエストフォージェリ)は、攻撃者のページが被害者のブラウザに、被害者のクッキー付きでこちらのサイトへリクエストを送らせる攻撃です。一般的な対策は、セッションに秘密のトークンを置き、状態を変えるすべてのフォームにそれを送り返させることです。
function csrf_token(): string {
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32)); // 64 hex chars, 256 bits
}
random_bytes は OS の暗号論的乱数を使います。mt_rand・rand・uniqid は予測できるので、トークンに使ってはいけません。
よくある間違い:何も送らなくても通る CSRF チェック
間違い。 最初に書いたコードです。
function csrf_check(): void {
$sent = $_POST['csrf'] ?? '';
if (!is_string($sent) || !hash_equals($_SESSION['csrf'] ?? '', $sent)) {
http_response_code(403);
exit("403 bad CSRF token\n");
}
}
トークンなし、間違ったトークン、配列(csrf[]=x)は 403 で止まり、正しいトークンは 200 でした。実際に起きたこと: ログインしただけで、csrf_token() を呼ぶページを一度も開いていない被害者です。
$ curl -c v1 -b v1 -d user=alice .../login.php
id=9dd562e445a4cb40820d8ec11a1b560f user=alice
$ curl -i -b v1 -d "body=attack&csrf=" .../board.php
HTTP/1.1 200 OK
saved
セッションにトークンがないので $_SESSION['csrf'] ?? '' は ''、攻撃者も '' を送り、hash_equals('', '') は true です。修正: 保存済みトークンがなければ失敗にします。
function csrf_check(): void {
$expected = $_SESSION['csrf'] ?? null;
$sent = $_POST['csrf'] ?? null;
if (!is_string($expected) || $expected === '' || !is_string($sent)
|| !hash_equals($expected, $sent)) {
http_response_code(403);
exit("403 bad CSRF token\n");
}
}
修正後、同じリクエストは HTTP/1.1 403 Forbidden でした。is_string の確認も要ります。ないと hash_equals は false を返す代わりに例外を投げます。
PHP Fatal error: Uncaught TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, null given
#0 Command line code(1): hash_equals(Object(SensitiveParameterValue), Object(SensitiveParameterValue))
(トレース中の SensitiveParameterValue は、秘密の値がログに残らないよう PHP が引数を隠したものです。)
=== ではなく hash_equals を使う理由
=== は最初に違うバイトで止まり、hash_equals は常に最後まで見ます。各ケース 500 万回比較した時間(3 ラウンド中 3 回目)です。
| 比較 | 4,096 バイト | 64 バイト |
|---|---|---|
===・先頭バイトが違う |
26.8 ns | 26.5 ns |
===・最後のバイトが違う |
154.8 ns | 28.1 ns |
hash_equals・先頭 |
2,122.9 ns | 73.1 ns |
hash_equals・最後 |
2,113.8 ns | 72.7 ns |
長い文字列では差がはっきり出ます(27 対 155 ns)。64 文字のトークンなら差は 2ns 未満で、ネットワーク越しに測るのは難しいでしょうが、hash_equals のコストは惜しむほどのものではありません。== は論外です。PHP 8.5 でも "0e1234" == "0e9999" は bool(true) でした。どちらも指数表記の数値 0 に見えるからで、md5("240610708") == md5("QNKCDZO") も true でした。
セッション:セッション固定、session_regenerate_id、クッキー属性
セッション固定は、攻撃者が被害者のログイン前にセッション ID を決めておき、ログイン後にその ID を使う攻撃です。ログインスクリプトです。
<?php
require __DIR__ . '/common.php'; // session_start() lives here
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// pretend the password was checked here
if (($_GET['fix'] ?? '') === '1') {
session_regenerate_id(true); // new id, delete the old session file
}
$_SESSION['user'] = $_POST['user'] ?? 'nobody';
}
echo 'id=', session_id(), ' user=', $_SESSION['user'] ?? '-', "\n";
PHP の既定値(session.use_strict_mode=0)では次のとおりです。
$ curl -i -b 'PHPSESSID=attacker1234567890' .../login.php
id=attacker1234567890 user=-
$ curl -i -b 'PHPSESSID=attacker1234567890' -d user=alice .../login.php
id=attacker1234567890 user=alice
# 後で攻撃者が同じクッキーで:
$ curl -b 'PHPSESSID=attacker1234567890' .../login.php
id=attacker1234567890 user=alice
サーバーは自分で発行していない ID を受け入れ、Set-Cookie も送らず、ログイン後の攻撃者は alice になりました。?fix=1 で session_regenerate_id(true) を呼ぶと新しい ID が発行され、仕込んだ ID は空でした。
Set-Cookie: PHPSESSID=cd27da816f4f065991013f1f3357da6e; path=/
id=cd27da816f4f065991013f1f3357da6e user=alice
$ curl -b 'PHPSESSID=attacker2234567890' .../login.php
id=attacker2234567890 user=-
php.ini に手を加えない状態のヘッダーです。
X-Powered-By: PHP/8.5.6
Set-Cookie: PHPSESSID=cbe551da3f65b2298e9aafd66cf69633; path=/
HttpOnly がないので JavaScript からクッキーが読め(XSS が 1 つあればアカウント乗っ取り)、Secure も SameSite もなく、PHP のバージョンまで公開しています。設定を 5 つ付けて起動し直しました。
php -d session.use_strict_mode=1 -d session.cookie_httponly=1 -d session.cookie_secure=1 \
-d session.cookie_samesite=Lax -d expose_php=0 -S 127.0.0.1:8852 -t www
$ curl -i -b 'PHPSESSID=attacker3234567890' .../login.php
Set-Cookie: PHPSESSID=c820dbbc39f17601dd73818788753c12; path=/; secure; HttpOnly; SameSite=Lax
id=c820dbbc39f17601dd73818788753c12 user=-
strict モードは仕込まれた ID を拒否してすぐ新しい ID を出し、X-Powered-By も消えました。strict モードと、ログイン時・権限変更時の session_regenerate_id(true) は両方使います。SameSite=Lax はクロスサイトの POST をほぼ防ぎますが、CSRF トークンの代わりではなく第二の防衛線です。
テスト時の落とし穴を 1 つ。クッキーに Secure が付いているのに、curl は次のリクエストで平文の http://127.0.0.1(と http://localhost)にクッキーを送り返しました。curl も最近のブラウザと同様、localhost を安全なオリジンとして扱います。実際のホストの平文 HTTP では返ってこないので、Secure の挙動は本番ドメインで確認してください。
古いチューニング設定を 8.5 で試すとこう出ます。
PHP Deprecated: PHP Startup: session.sid_length INI setting is deprecated in Unknown on line 0
PHP Deprecated: ini_set(): Enabling session.use_trans_sid INI setting is deprecated
既定の ID は 16 進 32 文字(128 ビット)で調整は不要です。URL に ID を載せる use_trans_sid はログや Referer から漏れます。
パスワード:password_hash・password_verify とコストの実測
md5・sha1・hash('sha256') でパスワードを保存してはいけません。速いからで、テーブルを盗んだ攻撃者が欲しいのはまさにその速さです。password_hash はソルトと遅いアルゴリズムを選び、password_verify は一定時間で比較します。8.5 の PASSWORD_DEFAULT は cost 12 の bcrypt でした。
PASSWORD_DEFAULT: $2y$12$pSB6GmFI8jk.DSQgPsxnmezkJ5yMuJWMIolLGojMotf9ecXex0.KO
argon2id available: yes
bcrypt cost 10 hash median 58.7 ms (min 58.6, max 60.3) verify 58.8 ms
bcrypt cost 12 hash median 235.3 ms (min 234.1, max 246.3) verify 241.4 ms
argon2id default hash median 156.0 ms (min 151.9, max 167.7) verify 156.3 ms $argon2id$v=19$m=65536,t=4,p=1
それぞれ 9 回の中央値です。bcrypt は cost が 1 上がるごとに 2 倍(+2 で 58.7 → 235.3ms)。argon2id はメモリも使います。ハッシュ 1 回でプロセスの最大常駐メモリが 25.5MB から 89.6MB に増え、m=65536 の 64MiB と一致しました。libargon2 が PHP のアロケーターの外で確保するため、memory_get_peak_usage() には現れません。小さな共用サーバーで同時ログインが 20 件あれば 1GB を超えます。
8.5 で確かめた bcrypt の制限が 2 つあります。NUL バイトを含むパスワードは ValueError: Bcrypt password must not contain null character を投げ、先頭 72 バイトしか使われません。a を 72 個並べたハッシュに str_repeat('a', 72) . 'TOTALLY DIFFERENT' を渡しても password_verify は true でした。73 バイトのパスワードでもエラーは出ませんでした。長いパスフレーズを許すなら、argon2id にはこの制限がありません。
選び方:本番のハードウェアで 1 回のログインが 100〜300ms 程度になるよう決め、古いハッシュはログイン時に password_needs_rehash() で上げます(演習 2)。
ファイルアップロード:ブラウザが教える情報は信用しない
$_FILES のうち tmp_name と size 以外はすべてクライアントが送った値です。type と name を信じるエンドポイントです。
<?php
// upload_bad.php — DO NOT COPY. Trusts the browser's name and type.
$f = $_FILES['f'] ?? null;
if (!$f || $f['error'] !== UPLOAD_ERR_OK) { http_response_code(400); exit('error code ' . ($f['error'] ?? 'none') . "\n"); }
echo "client says type=", $f['type'], " name=", $f['name'], "\n";
if (!str_starts_with($f['type'], 'image/')) { http_response_code(415); exit("images only\n"); }
move_uploaded_file($f['tmp_name'], __DIR__ . '/uploads/' . $f['name']); // name chosen by the client!
echo "stored at /uploads/", $f['name'], "\n";
PHP_OS_FAMILY を表示するだけの 1 行の hello.php を、curl で PNG と偽って送ります。
$ curl -F 'f=@hello.php;type=image/png' .../upload_bad.php
client says type=image/png name=hello.php
stored at /uploads/hello.php
$ curl .../uploads/hello.php
this ran on the server: Darwin
中身が無害なだけで、これはリモートコード実行です。PHP がすでにやってくれることもあります。name からディレクトリは取り除かれます(filename=../escaped.php は escaped.php で届く)。ただし元のパスは $_FILES['f']['full_path'] に "../../etc/escaped.php" として残るので、このキーをパスに使ってはいけません。バイト列を読む finfo も必要条件にすぎません。本物の 1×1 PNG の後ろに PHP の 1 行を付けたファイルは image/png と判定され、悪いエンドポイントが poly.php として保存すると、そのまま実行されました。
すべてのテストを通った版は、名前・拡張子・置き場所をサーバーが決めます。
<?php
// upload_good.php — decide everything on the server.
declare(strict_types=1);
const ALLOWED = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp'];
$f = $_FILES['f'] ?? null;
if (!is_array($f) || !is_int($f['error']) || $f['error'] !== UPLOAD_ERR_OK) {
http_response_code(400); exit('upload error ' . ($f['error'] ?? 'none') . "\n");
}
if ($f['size'] > 1_000_000) { http_response_code(413); exit("too large\n"); }
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']); // look at the bytes
if (!isset(ALLOWED[$mime])) { http_response_code(415); exit("rejected: content is $mime\n"); }
$name = bin2hex(random_bytes(16)) . '.' . ALLOWED[$mime]; // our name, our extension
$dir = dirname(__DIR__) . '/safe_uploads'; // outside the document root
if (!move_uploaded_file($f['tmp_name'], "$dir/$name")) { http_response_code(500); exit("move failed\n"); }
echo "stored as $name ($mime)\n";
hello.php を cat.png として送信 → rejected: content is text/x-php
dot.png を x.php として送信 → stored as 730012bfe318b504c45210f535042d0b.png (image/png)
300KB(上限 200K) → upload error 1 (UPLOAD_ERR_INI_SIZE)
1.2MB(post_max_size 1M) → HTTP/1.1 400, upload error none
最後の行はよく混乱するところです。post_max_size を超えると PHP は本文全体を捨てるので $_FILES も $_POST も空になり、ログには PHP Warning: PHP Request Startup: POST Content-Length of 1200209 bytes exceeds the limit of 1048576 bytes だけが残ります。「何も届かなかった」を独立したエラーとして扱いましょう。ポリグロット PNG はドキュメントルートの外に .png として保存されるので、Web サーバーが実行することはありません。
セキュリティヘッダーと「headers already sent」の落とし穴
ヘッダーは本文より先に送る必要があります。
$nonce = base64_encode(random_bytes(16));
header_remove('X-Powered-By');
header("Content-Security-Policy: default-src 'self'; script-src 'nonce-$nonce'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
if (($_SERVER['HTTPS'] ?? '') === 'on') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); // only over HTTPS
}
curl で見ると、リクエストごとに新しい nonce の付いた CSP が出て、X-Powered-By はありません。nonce 方式の CSP では <script nonce="..."> だけが動くので、どこかでエスケープを忘れても注入された <script> は止まります。ただしブロック自体はブラウザの仕事で、curl だけを使った今回は確認していません。frame-ancestors 'none' は X-Frame-Options: DENY の代わりになり、HSTS は HTTPS のときだけ送ります。
<?php の前に空白が 1 つあり、その後で header(...) を呼ぶファイルは、ビルトインサーバーでは問題なく動きました。そこでの ini_get('output_buffering') は "4096" で、空白は 4KB のバッファに入っていたのです。flush() を入れると本当の警告が出ました。
Warning: Cannot modify header information - headers already sent in ~/demo/www/late2.php on line 4
手元では動き、output_buffering=0 のサーバーや、ヘッダーの前に 4KB 以上出力するページでは壊れるコードです。PHP だけのファイルでは閉じタグ ?> を省略し、ファイル先頭の BOM にも気を付けてください。
判断表
| 状況 | やること | 上で見た根拠 |
|---|---|---|
| HTML 本文・引用符付き属性 | htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') |
<img …> が文字として出力 |
href・src |
http/https だけ許可してからエスケープ |
javascript: が通過 |
<script> の中 |
JSON_HEX_* 4 つ付きの json_encode |
JSON_UNESCAPED_SLASHES で脱出 |
| 状態を変える POST | セッショントークン + hash_equals + 空を拒否 |
空トークンで 200 |
| ログイン | use_strict_mode=1 + session_regenerate_id(true) |
仕込んだ ID が alice に |
| セッションクッキー | HttpOnly; Secure; SameSite=Lax |
既定では 3 つともなし |
| パスワード保存 | PASSWORD_DEFAULT + password_needs_rehash |
cost 12 で 235ms |
| アップロード | finfo + 許可リスト + ランダム名 + ルート外 |
.php が実行された |
正直な限界とバージョンメモ
- 実行はすべて PHP 8.5.6 だけです。以下のバージョン情報は PHP の変更履歴にもとづくもので、古いバージョンを実際に動かして確認してはいません。
htmlspecialcharsの既定フラグがENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401になったのは 8.1(それ以前は単一引用符を既定でエスケープしなかった)、$_FILES[...]['full_path']は 8.1、トレースのSensitiveParameterValueは 8.2、bcrypt の既定 cost が 10 から 12 になりsession.sid_length・use_trans_sidが非推奨になったのは 8.4 です。 - ビルトインサーバーはシングルスレッドの開発用です。どのディレクトリで
.phpを実行するかは Apache、nginx + PHP-FPM、LiteSpeed が決めます。アップロードの検査より「ドキュメントルートの外」が重要なのはそのためです。 - XSS は curl が受け取った HTML を読んで確認し、ブラウザでは実行していません。CSP のブロックと
SameSiteの挙動は説明だけです。 - 比較時間の表は 1 プロセス内の計測です。リモートの攻撃者がネットワーク越しに 2ns の差を測れるかは示していません。要点は、
hash_equalsを使えばその問い自体がなくなることです。
演習
1. 利用者名でクリックを記録するボタンです。htmlspecialchars も適用済みです。名前が x');alert(1);// のとき、なぜ突破されるのか、どう直すかを答えてください。
<button onclick="track('<?= e($name) ?>')">Like</button>
解答
ブラウザは JavaScript を実行する前に属性内の実体参照を戻します。出力は track('x');alert(1);//') で、戻した後の JS エンジンには track('x');alert(1);//') が見えます。まず JavaScript 用に、次に HTML 属性用にエンコードします。
<button onclick="track(<?= e(json_encode($name, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR)) ?>)">Like</button>
出力は track("x');alert(1);\/\/") で、ブラウザが戻すと 1 つの文字列引数になります。さらに良いのは、値を data- 属性に入れ、ハンドラーはスクリプトファイルから付けることです。
2. 数年前に bcrypt cost 10 で作ったハッシュを、パスワードの再設定なしで現在の既定値に上げてください。
解答
function login(string $password, string &$storedHash): bool {
if (!password_verify($password, $storedHash)) return false;
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
$storedHash = password_hash($password, PASSWORD_DEFAULT); // save this to the DB
}
return true;
}
cost 10 のハッシュで実行すると $2y$10$ -> true -> $2y$12$ と出力されました。平文のパスワードはログインの瞬間にしかないので、再ハッシュできるのはそのときだけです。
前回: 第 4 回:PDO・SQLite と SQL インジェクション · 次回: 第 6 回:OPcache・JIT・プロファイリング
関連記事:レガシー PHP の SQLi・XSS を出力を変えずに直した記録 · 第 2 回:関数・フォーム・ビルトインサーバー
よくある質問
<script> の中に出す値も htmlspecialchars で十分ですか?
十分ではありません。<script> の中ではブラウザが実体参照を戻さないので、引用符は " という文字のまま入り、引用符のない数値の位置なら特殊文字なしでコードが動きます。JSON_HEX_TAG・JSON_HEX_AMP・JSON_HEX_APOS・JSON_HEX_QUOT を付けた json_encode を使ってください。見やすさのために JSON_UNESCAPED_SLASHES を付けたら </script> で抜け出せました。
hash_equals を使った CSRF チェックが空のトークンを通すのはなぜ?
セッションにトークンが一度も保存されていなければ $_SESSION['csrf'] ?? '' は空文字列になり、hash_equals('', '') は true です。ログインしただけでフォームのページを一度も開いていない利用者が実際に攻撃できました。保存済みトークンがない、または空なら拒否します。
PHP 8.5 の password_hash はどれくらい時間がかかりますか?
M1 Pro で 9 回の中央値は、bcrypt cost 10 が 58.7ms、PHP 8.4 から既定の cost 12 が 235.3ms、既定設定(64MiB・4 パス)の argon2id が 156.0ms でした。password_verify もハッシュ化と同じ時間がかかります。