技術約7分で読めます

LaravelのtokenガードとMySQLで他人としてログインできた認証バイパス

いけさん目次

TL;DR

対象 config/auth.php で 'driver' => 'token' のガードを使い、ユーザーのテーブルがMySQLかMariaDBにあるLaravelアプリ。SanctumとPassportは対象外
影響 認証なしの攻撃者が、APIトークンを知らないまま既存ユーザーとして認証される(CVSS v4.0 9.2)
対処 laravel/frameworkを12.68.0以降か13.29.0以降へ。どちらも2026年8月25日に出ているが、入っているバージョンは composer show で確かめる


Laravelの組み込みのトークン認証(TokenGuard)に、他人としてログインできてしまう脆弱性があったとして、2026年10月19日にGitHubのアドバイザリ GHSA-f9x3-7vq9-cph6 が公開された。
GHSAはGitHubが脆弱性情報に振る番号で、CVSSは脆弱性の深刻度を0〜10で表す指標だ。
深刻度はCVSS v4.0で9.2(Critical、最も高い区分)、共通の脆弱性番号であるCVE番号は2026年10月21日時点で振られていない。
報告者のHristijan Manasijev氏はXの投稿で、MySQL/MariaDBでは認証されていない攻撃者が既存の被害者ユーザーとしてログインできたと書いている。

修正版のlaravel/framework 12.68.0と13.29.0は、アドバイザリより2か月近く前の2026年8月25日にリリースされている(v12.68.0、v13.29.0)。
修正が出回ってから詳細を公表する順番だったので、8月末以降に更新したアプリは、たいてい直ったバージョンになっている。
ただし composer.json のバージョン指定によっては上がっていないので、入っているバージョンを composer show で確かめる。

対象になる条件

Laravelでは、リクエストを送ってきたのが誰なのかを確かめる仕組みをガードと呼び、config/auth.php の guards にどの方式(driver)で確かめるかを書く。
driver が token のガードは、リクエストに含まれるトークン文字列とusersテーブルの api_token 列を突き合わせてユーザーを決める、Laravel組み込みのいちばん単純な方式だ。
アドバイザリには、この標準のトークン方式のガードを使っていて、データベースがMySQLかMariaDBのときに起きると書かれている。

項目対象対象外
ガードのdrivertokensession、Sanctum(sanctum)、Passport(passport)
データベースMySQL、MariaDBアドバイザリはMySQL/MariaDB以外を条件に挙げていない
laravel/framework(13.x)13.29.0より前13.29.0以降
laravel/framework(12.x)12.68.0より前12.68.0以降

SanctumはLaravel公式のAPIトークン認証パッケージ、PassportはOAuth2で認証するパッケージで、現在のLaravelでAPI認証を作るときはこのどちらかを使うことが多い。
どちらも独自のガードdriverを登録するので、このアドバイザリの対象に入っていない(Laravel News)。

token のガードは、Laravel 7までの新規プロジェクトの雛形の config/auth.php に api ガードとして最初から入っていた(laravel/laravelの7.xブランチ)。
8.xの雛形からは消えているが、7以前に作ったプロジェクトをバージョンアップしてきた場合は、設定ファイルにそのまま残っていることがある。
auth:api のミドルウェアを付けたルートがあり、api ガードが次の設定なら対象になる。

'api' => [
    'driver' => 'token',
    'provider' => 'users',
    'hash' => false,
],

自分のアプリで確かめる

プロジェクトのルートで、token のガードがあるかと、入っているlaravel/frameworkのバージョンを確かめる。

grep -n "'driver' => 'token'" config/auth.php
composer show laravel/framework | grep versions
composer audit

composer audit は、インストール済みのパッケージを公開済みのアドバイザリと照らし合わせるComposerのコマンドで、対象のバージョンならGHSA-f9x3-7vq9-cph6が一覧に出る。
vendor ディレクトリがない状態で composer.lock だけを調べたいときは composer audit --locked を使う。
更新は composer update laravel/framework で、12.xなら12.68.0以降、13.xなら13.29.0以降になっていれば修正済みだ。

配列の0がトークンと一致する仕組み

修正前のTokenGuardは、受け取ったトークンが空でないかだけを確かめて、データベースの検索に渡していた。
修正前のv13.28.0のコードでは、トークンをクエリ文字列、リクエストボディ、Authorization: Bearer ヘッダーの順に探して取り出す(TokenGuard.php)。
ボディがJSONなら値は文字列とは限らないので、{"api_token": [0]} のように配列も送れる。
文字列の "0" ならPHPの empty() は空と判定するが、[0] は要素が1つある配列なので空と判定されず、そのまま検索に使われる。

ユーザーを探すEloquentUserProviderは、渡された値が配列なら whereIn()、それ以外なら where() で条件を組み立てる(EloquentUserProvider.php)。
配列の [0] を渡すと、api_token 列が整数の0と等しい行を探す次のようなSQLになり、first() で取るので結果は1件に絞られる。

select * from users where api_token in (0) limit 1

MySQLは、文字列と数値を比較するとき両方を浮動小数点数に変換してから比べ、数字で始まらない文字列は0として扱う(MySQLのリファレンス)。
リファレンスにも SELECT 0 = 'x6'; が真になる例と、VARCHAR列を = 0 で検索すると全行が返る例が載っていて、strict SQLモードでもこの変換は起きる。
そのため、数値として読むと0になる api_token(英字で始まるものなど)を持つユーザーは in (0) の条件に一致し、TokenGuardは返ってきた1件のユーザーを認証済みとして扱う。
1abc のように0以外の数字で始まるトークンは、1などの別の数値になるので一致しない。

flowchart TD
    A["JSONで api_token に [0] を送る"] --> B["TokenGuard: 空ではないので通過"]
    B --> C["EloquentUserProvider: 配列なので whereIn"]
    C --> D["MySQL: api_token 列と整数0を数値で比較"]
    D --> E["0に変換されるトークンが一致"]
    E --> F["返った1件のユーザーとして認証される"]

SQLに並び順の指定がないので、一致した中のどのユーザーが返るかはデータベース側で決まり、攻撃者は相手を選べない。
アドバイザリは影響として、トークンを持たない攻撃者が別のユーザーとして認証され、そのガードで守られたルートでアカウントを乗っ取られると説明している。

config/auth.php で 'hash' => true にすると、データベースにはトークンのSHA-256ハッシュを保存し、修正前のコードも受け取ったトークンを hash('sha256', $token) に通してから検索する。
PHP 8の hash() は配列を渡すと型エラーを投げるので、コード上はこの設定だと検索の前に処理が止まるはずだ。
ただアドバイザリはhashの設定で対象を分けていないので、hashを有効にしたアプリも修正版に上げる必要がある。

修正の中身

修正のコミット(9b21ce0)は、TokenGuardの user() と validate() で、トークンが文字列かを is_string() で確かめる条件を足すもので、本体の変更は2行だ。

-        if (! empty($token)) {
+        if (is_string($token) && ! empty($token)) {
-        if (empty($credentials[$this->inputKey])) {
+        if (! is_string($credentials[$this->inputKey] ?? null) || empty($credentials[$this->inputKey])) {

配列や数値のトークンはデータベースの検索に渡る前に弾かれ、認証は失敗する。
同じコミットで、api_token に [0] を渡すと user() がnullを返すテストも追加されている。

アドバイザリの修正版は12.xと13.xだけだが、11.xの v11.56.1(2026年8月25日にタグ付け)のTokenGuardにも同じ is_string() の条件が入っている。
一方、10.x と9.xのブランチのTokenGuardは、2026年10月21日時点で修正前の ! empty($token) だけの判定のままだ。
アドバイザリには更新以外の回避策が載っていない。
修正の中身から考えると、更新できないアプリでは、token のガードをSanctumなどに切り替えるまでの間、文字列以外の api_token をミドルウェアで弾く処理を自分で入れておく手がある。

同じ日に公開されたJSON:APIリソースの脆弱性

同じ日には、同じ報告者による中程度の脆弱性 GHSA-5wcm-3v2w-8362(CVSS v3.1 6.5)も公開されている。
Laravel 12.45で入ったJSON:APIリソースで、読み取り専用のエンドポイントへのリクエストから関連テーブルの中身を消せたというもので、12.68.0と13.27.0で直っている。