はじめに
セキュリティ担当者はボーナス増額を要求すべきだと考えている @___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 で一致を確認しました)。
<?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 やキューワーカーのような常駐プロセスは再起動しましょう。
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)