1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Laravel TokenGuard の認証バイパス(GHSA-f9x3-7vq9-cph6)をすぐ更新できないときの回避策

1
Last updated at Posted at 2026-10-21

はじめに

セキュリティ担当者はボーナス増額を要求すべきだと考えている @___nix___ です。
php2shell の記事の続きで、今回は Laravel 側の話です。

※PHPの話は以下をどうぞ

背景

php2shell と同じ時期に、Laravel にも critical のアドバイザリが出ています。

Laravel's built-in TokenGuard, when configured with a standard token-based API guard, may be bypassed to authenticate as an arbitrary existing user without knowledge of their valid API token.

未認証で他人になりすませるので、CVSS v4.0 は 9.2 です。

概要

まず確認したいのは次の3つです。

  • config/auth.php に 'driver' => 'token' のガードがある(Laravel 7 以前から移行したアプリは api ガードに残りがちです)
  • DB が MySQL/MariaDB(アドバイザリは「特定の DB 設定下」としています)
  • laravel/framework が修正版より前

アドバイザリ上の修正版は 13.29.0/12.68.0 です。別途タグを調べたところ、11 系は 11.56.1 に同じ型検査が入っていました(アドバイザリには載っていません)。6〜10 系は、調査時点の各系列の最新タグ(10 系なら 10.50.3)にも入っていませんでした。Sanctum・Passport のガード自体は対象外です。

今回はすぐ更新できない場合の回避策として、WAF で JSON を拒否する方法と、ガードを差し替える方法を試してみます。WAF のほうは、そのまま入れると効かない罠がありました!

詳細

何が起きるのか

TokenGuard はトークンを次の順で探します(12.67.0 のコード)。

return $this->request->query($this->inputKey)
    ?: $this->request->input($this->inputKey)
    ?: $this->request->bearerToken()
    ?: $this->request->getPassword();

input() は JSON ボディも読むので、{"api_token": [...]} のように配列を送れます。修正前はこれを型チェックせずに DB 検索に渡していて、配列だと whereIn になります。実際に 12.67.0 で配列を送ると、こんな SQL が飛びました。

select * from "users" where "api_token" in (?) limit 1 ["x"]

MySQL/MariaDB は文字列と数値を比べるとき暗黙に数値へ変換するので、数値として比較される条件だと意図しないユーザーに一致してしまうわけですね。(検証は SQLite なので、なりすまし自体は再現していません)

パッチ

修正は TokenGuard の2か所に is_string() を足しただけです。

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

validate() は hash の設定を見ずに検索するので、'hash' => true だけを修正の代わりにはできません。(外部入力が validate() に届くかはアプリ次第です)

対応

WAF で JSON を拒否する

12.67.0 の input() が JSON ボディを読むのは、isJson() が Content-Type に /json か +json を見つけたときです。正規の JSON 利用が無いなら、AWS WAF で Content-Type に json を含むリクエストを止めればつなぎになります。

[
  {
    "Name": "block-json-content-type",
    "Priority": 0,
    "Action": { "Count": {} },
    "Statement": {
      "ByteMatchStatement": {
        "FieldToMatch": { "SingleHeader": { "Name": "content-type" } },
        "PositionalConstraint": "CONTAINS",
        "SearchString": "json",
        "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }]
      }
    },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "block-json-content-type"
    }
  }
]

これを AWS CLI v2 で check-capacity に通してみます。

$ aws --version
aws-cli/2.36.8 Python/3.14.6 Linux/6.18.54.1-microsoft-standard-WSL2 update-exe/x86_64.ubuntu.24
$ aws wafv2 check-capacity --scope REGIONAL --region ap-northeast-1 --rules file://json.json
{
    "Capacity": 20
}

通りました。…が、同じ形で "SearchString": "multipart" にすると落ちます。

aws: [ERROR]: Invalid base64: "multipart"

え?!と思って調べると、AWS CLI v2 はバイナリ型の値(SearchString もそう)を既定で base64 として扱います。multipart は base64 として不正なので落ちますが、json は4文字でたまたま base64 として正しいので通ってしまいます。--debug で実際に送っている値を見てみます。

$ aws wafv2 check-capacity ... --rules file://json.json --debug 2>&1 | grep -o '"SearchString": *"[^"]*"'
"SearchString": "json"
$ aws wafv2 check-capacity ... --rules file://json.json --cli-binary-format raw-in-base64-out --debug 2>&1 | grep -o '"SearchString": *"[^"]*"'
"SearchString": "anNvbg=="

API 上 SearchString は base64 で送る値なので、既定のままだと json を base64 として解いたバイト列(8e ca 27)を探すルールになるはずです。これでは普通の application/json に一致しません(´・ω・`)(実際に Web ACL に配備して一致を見るところまではやっていません)

CLI で入れるなら、"SearchString": "json" のまま --cli-binary-format raw-in-base64-out を付けましょう。anNvbg== と書く手もありますが、それは base64 モード(既定)のときだけです。(マネジメントコンソールの JSON エディタや Terraform での挙動は今回は試していません)

ここで確かめたのは容量の計算と CLI が送る値までなので、配備したら JSON のリクエストを実際に投げて Count に載るか見ておきましょう。Count の間はこのルールでは止まらないので、観測期間と Block に切り替える期限は先に決めておきます。

共有の Web ACL なら、Host やパスで対象アプリに絞り、Priority も既存ルール(先に Allow で抜けるものなど)と合わせてください。WAF とアプリで Content-Type の評価が食い違わないか(重複ヘッダなど)も確認しておくと安心です。

JSON 拒否は入力経路を1つ塞ぐだけで、TokenGuard の型チェック漏れ自体は残ります。アプリ独自の入力変換や validate() の呼び出しは別に確認しておきましょう。

ガードを差し替える

JSON を止められない場合は、12.68.0 の修正をアプリ側に移植します。以下は 12.67.0 で検証したコードです。他の系列では TokenGuard の中身が違う(5.6 系には $hash がありません)ので、その版の TokenGuard と AuthManager::createTokenDriver() を元に型チェックだけ足してください。user() と validate() は 12.68.0 と同じ処理です(コメントを除いて diff で一致を確認しました)。

app/Auth/StringOnlyTokenGuard.php
<?php
namespace App\Auth;

use Illuminate\Auth\TokenGuard;

class StringOnlyTokenGuard extends TokenGuard
{
    public function user()
    {
        if (! is_null($this->user)) {
            return $this->user;
        }

        $user = null;

        $token = $this->getTokenForRequest();

        if (is_string($token) && ! empty($token)) {
            $user = $this->provider->retrieveByCredentials([
                $this->storageKey => $this->hash ? hash('sha256', $token) : $token,
            ]);
        }

        return $this->user = $user;
    }

    public function validate(array $credentials = [])
    {
        if (! is_string($credentials[$this->inputKey] ?? null) || empty($credentials[$this->inputKey])) {
            return false;
        }

        $credentials = [$this->storageKey => $credentials[$this->inputKey]];

        return (bool) $this->provider->retrieveByCredentials($credentials);
    }
}

登録は既存の AppServiceProvider の boot() に足して、token ドライバを差し替えます(抜粋です。use 文は namespace の後に置きます)。中身は AuthManager::createTokenDriver() と同じで、クラスだけ変えています。config/auth.php はそのままで大丈夫です。

AuthManager は一度作ったガードをキャッシュするので、ほかの処理が先に api ガードを解決していると差し替わりません。適用先でも get_class(Auth::guard('api')) で確認し、Octane やキューワーカーのような常駐プロセスは再起動しましょう。

app/Providers/AppServiceProvider.php
use App\Auth\StringOnlyTokenGuard;
use Illuminate\Support\Facades\Auth;

public function boot(): void
{
    Auth::extend('token', function ($app, $name, array $config) {
        $guard = new StringOnlyTokenGuard(
            Auth::createUserProvider($config['provider'] ?? null),
            $app['request'],
            $config['input_key'] ?? 'api_token',
            $config['storage_key'] ?? 'api_token',
            $config['hash'] ?? false
        );

        $app->refresh('request', $guard, 'setRequest');

        return $guard;
    });
}

試してみる

修正前の 12.67.0 に SQLite でユーザーを1人作り、auth:api のルートに送ってみます。users テーブルへのクエリ数も数えています。

差し替え前:

-- string token (query)
{"user_id":1}
   users queries: 1 select * from "users" where "api_token" = ? limit 1 ["secret-token-1"]
-- array token (JSON body)
{"message":"Unauthenticated."}
   users queries: 1 select * from "users" where "api_token" in (?) limit 1 ["x"]

差し替え後:

-- GET /me
{"guard_class":"App\\Auth\\StringOnlyTokenGuard","user_id":null,"validate_array":false,"validate_string":true}
-- string token (query)
{"user_id":1}
   users queries: 1 select * from "users" where "api_token" = ? limit 1 ["secret-token-1"]
-- array token (JSON body)
{"message":"Unauthenticated."}
   users queries: 0

/me はガードのクラス名と validate() の結果を返すだけの確認用ルートです。配列のトークンは DB を引く前に弾かれて 401、正規の文字列トークンはそのまま通りました!

ちなみに検証環境で composer require laravel/framework:12.67.0 をしたら、Composer に止められました。

found laravel/framework[v12.67.0] but these were not loaded, because they are affected by security advisories ("PKSA-d5tc-s1qs-h781").

今回の Composer の設定では、アドバイザリの出ている版は入れさせてもらえませんでした。(検証のために policy.advisories.ignore-id で外しています)

終わりに

WAF の JSON 拒否はデプロイ無しで入れられますが、CLI の base64 の罠に気付かないと、普通の application/json を検出できない設定になってしまいます。配備したら JSON のリクエストを投げて Count に載るか、Block にしたら止まるかまで見ておきましょうね。
ガードの差し替えは、今回試した 12.67.0 では公式の修正と同じ検査をそのまま入れられたので、更新までのつなぎとして有力だと思います。もちろん更新できるならそれが一番です!

一言

json がたまたま base64 として正しい、というのは中々の引きの悪さでした。

もし気に入って頂けたらここでのいいね&フォローとX( @___nix___ )でのフォローもお願い致します。(少し元気を貰えますw)

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?