phpMyAdmin 5.2系のWebAuthnキー登録がAAGUID生バイト保存でTypeErrorになる
目次
TL;DR
現象 予備実装 CustomServer が動くphpMyAdmin 5.2.1から5.2.3で、WebAuthnのセキュリティキーを登録しようとすると画面に Internal error: TypeError が出て設定が保存されない
原因 予備の実装 CustomServer がAAGUID(認証器の機種を示す16バイト)をBase64化しないで、生のバイト列のままJSONへ入れている。
AAGUIDは実質ランダムな16バイトのため、UTF-8として正しくない並びになる。json_encode() が false を返し、strict_types 指定のファイルから文字列型の引数へ bool が渡って TypeError になる
実測 2026年10月29日、127.0.0.1 限定のDocker環境で実際の登録フローを通して確認した。
AAGUIDがすべて 0x00 なら登録は通ったが、0x80 以上のバイトを含む3通りはすべて TypeError で失敗した。登録できたレコードを公式tarball側の環境へ移すと、二要素認証の画面が出る前に Safe\Exceptions\UrlException でログインできなくなる。同じレコードの aaguid だけをUUID文字列に直すと二要素認証が成功した
修正状況 開発ブランチの QA_5_2 と master では2026年9月29日のコミットでUUID文字列へ変換するよう修正済み。リリースにはまだ入っておらず、最新のリリースは2025年10月8日の5.2.3のまま
対象 CustomServer が動く環境のみ。公式サイトのtarballやzip、Docker公式イメージには web-auth/webauthn-lib が同梱されていて CustomServer は使われない。リリースタグから composer install --no-dev で入れた場合も同梱の composer.lock に本番依存として載っているためインストールされる
前回のphpMyAdmin 5.2.3のWebAuthn二要素認証バイパスの検証で、署名検証の欠陥とは別に、同じ CustomServer がセキュリティキー登録時のレコードを壊す問題も見つかった。
署名を検証しない話はそちらに整理したが、ここでは登録した認証器レコードが破損して二要素認証が使えなくなる問題を扱う。
セキュリティキーの登録でTypeErrorが出る
phpMyAdminには二要素認証の方式として「Hardware Security Key (WebAuthn/FIDO2)」がある。
ユーザー設定の二要素認証の画面でこれを選ぶと、ブラウザがセキュリティキーに登録リクエストを送り、phpMyAdminが応答を検証して設定ストレージへ保存する。
web-auth/webauthn-lib が入っていない環境では、この検証を自前の CustomServer クラスが行う。
ライブラリが無くてもWebAuthnを動かすための予備実装だ。
この CustomServer が動く環境でセキュリティキーを登録すると、保存の段階で失敗する。
画面のエラー表示はこれだけになる。
Internal error: TypeError
phpMyAdminのエラーハンドラが詳細を隠すため、画面からは原因が分からない。
二要素認証の設定は保存されないため、次回ログイン時も二要素認証は有効にならない。
CustomServerでAAGUIDがBase64化されない原因
AAGUIDは認証器の機種を識別する16バイトの値で、WebAuthnの仕様ではUUIDとして定義されている。
登録応答の authenticatorData の中に生バイトで入ってくる。
CustomServer はこの16バイトを2つの関数を通してそのまま使っている。
authData を分解する getAuthenticatorData() が、AAGUIDを生バイトで切り出す。
/** Authenticator Attestation GUID */
$aaguid = $authDataStream->take(16);
切り出した値は $attestedCredentialData['aaguid'] に入り、ここでは変換されない。
登録応答を検証する parseAndValidateAttestationResponse() が、そこから認証器レコードを組み立てて返す。
同じ返り値のなかで、ほかの3つのフィールドは明示的にBase64 URLセーフ形式へ変換している。
$encodedCredentialId = sodium_bin2base64(
$authenticatorData['attestedCredentialData']['credentialId'],
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
);
$encodedCredentialPublicKey = sodium_bin2base64(
$authenticatorData['attestedCredentialData']['credentialPublicKey'],
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
);
$userHandle = sodium_bin2base64(
sodium_base642bin($creationOptions['user']['id'], SODIUM_BASE64_VARIANT_ORIGINAL),
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
);
return [
'publicKeyCredentialId' => $encodedCredentialId,
'type' => 'public-key',
'transports' => [],
'attestationType' => $attestationObject['fmt'],
'aaguid' => $authenticatorData['attestedCredentialData']['aaguid'],
'credentialPublicKey' => $encodedCredentialPublicKey,
'userHandle' => $userHandle,
'counter' => $authenticatorData['signCount'],
];
ほかの3つはBase64に変換した値を渡しているのに対し、aaguid だけ getAuthenticatorData() が返した生バイトをそのまま入れている。
UUID文字列への変換が抜けている状態だ。
この返り値はそのままJSONへシリアライズされて、設定ストレージの pma__userconfig テーブルの config_data 列に入る。
Docker環境での登録フロー検証
phpMyAdminの実際の登録フローを最後まで通して、実際に何が保存されるかを確かめた。
検証環境
2つの環境を別々のDocker Composeで立て、どちらも 127.0.0.1 からのみアクセスできるようにした。
CustomServer が動くほうを「CustomServer側」、web-auth/webauthn-lib が入ったほうを「ライブラリ側」と呼ぶ。
| 項目 | CustomServer側 | ライブラリ側 |
|---|---|---|
| phpMyAdmin | 5.2.3 | 5.2.3 |
| 入手元 | Gitのタグ RELEASE_5_2_3 | 公式サイトのtarball |
web-auth/webauthn-lib | 無し(vendor/web-auth を削除) | v3.3.12 |
| 動くWebAuthn実装 | CustomServer | WebauthnLibServer |
| PHP | 8.2.35 | 8.2.35(bcmath・gmp 拡張入り) |
| スキーム | HTTP | HTTPS(自己署名証明書) |
| ポート | 18080 | 18443 |
| データベース | MySQL 8.0 | MySQL 8.0 |
ライブラリ側に bcmath と gmp を入れているのは、暗号計算の拡張機能が無いと楕円曲線の署名検証を行う前に Requires GMP or bcmath extension. で止まるためだ。
HTTPSにしているのは、webauthn-lib がスキームを検証して Invalid scheme. HTTPS required. で例外を投げるためだ。
どちらの環境も class_exists('Webauthn\Server') の結果によって読み込まれる実装が分かれるので、CustomServer側は false、ライブラリ側は true になることをコンテナ内で確認した。
登録リクエストの送信テスト
phpMyAdminの登録は index.php?route=/preferences/two-factor への2回のPOSTで進む。
1回目は 2fa_configure=WebAuthn だけを送る。
phpMyAdminが getCredentialCreationOptions() でチャレンジなどを生成し、セッションへ保存したうえで、応答HTMLの data-creation-options 属性にJSONを埋め込む。
2回目は同じパラメータに加えて webauthn_creation_response を送る。
内容はブラウザが本来作る登録応答のJSONで、clientDataJSON と attestationObject をBase64で入れたものだ。
CustomServer::parseAndValidateAttestationResponse() が確かめる条件は次の項目になる。
clientDataJSONのtypeがwebauthn.createであること- チャレンジが1回目のものと一致すること
originのホストがリクエストのホストと一致することauthDataの先頭32バイトがホストのSHA-256と一致することauthDataのフラグのUser Present(ユーザー存在確認)ビットが立っていることattestationObjectのfmtがnoneでattStmtが空であること
これらを満たす応答をPythonで組み立てた。
P-256の鍵を生成し、COSE形式の公開鍵をCBORでエンコードして authData に入れている。
実物のセキュリティキーは使っていないので、AAGUIDの16バイトは自由に指定できる。
保存されたJSON
AAGUIDをすべて 0x00 にして登録すると成功した。
pma__userconfig の config_data に入った値は次のとおりだ(一部を省略)。
{"2fa":{"backend":"WebAuthn","settings":{
"userHandle":"uWvKafjzCMr_qrGal8Zy3CsnjMaxGzDNxcDOvmJ-gc8",
"credentials":{"oaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2+v8A=":{
"publicKeyCredentialId":"oaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2-v8A",
"type":"public-key",
"transports":[],
"attestationType":"none",
"aaguid":"\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
"credentialPublicKey":"pQECAyYgASFYIMcanRPTFu2i8NQciTugui9e1qVy8GbeDRw71ae2M7b3IlggqED-nDQ7lsuV8swEl-uwQ-fSp-HMno8ENBosrEr2Mfs",
"userHandle":"uWvKafjzCMr_qrGal8Zy3CsnjMaxGzDNxcDOvmJ-gc8",
"counter":0}}}}}
publicKeyCredentialId、credentialPublicKey、userHandle はBase64の文字列になっている。
aaguid だけが \u0000 を16個並べた値、つまり16バイトの生バイト列をJSONのエスケープで表現したものが入った。
PHP側で json_decode() すると、この値は長さ16の文字列になり、中身はヌルバイト(0x00)16個になる。36文字のUUID文字列ではない。
AAGUIDを4通り変えて試した結果
同じ登録フローでAAGUIDだけを変えて4回試した。
| AAGUID | 0x80 以上のバイト | UTF-8として正しい | 登録結果 | config_data |
|---|---|---|---|---|
00000000000000000000000000000000 | 含まない | 正しい | 成功 | 605バイト書き込まれた |
2fc0579f814410bd0000000000000000 | 含む | 正しくない | 失敗 | 書き込みなし |
cb69481e8ff7403993ec0a2729a154a8 | 含む | 正しくない | 失敗 | 書き込みなし |
6028b017b1d44c02b4b3afcdafc96bb2 | 含む | 正しくない | 失敗 | 書き込みなし |
失敗した3回は画面に Internal error: TypeError が出て、config_data の書き込み自体が行われなかった。
成否は0x80 以上のバイトを含むかどうかで分かれる。
ランダムなAAGUIDでUTF-8が通る確率
AAGUIDはUUIDのため実質ランダムな16バイトで、この16バイトがたまたまUTF-8として妥当になる確率を、UUIDを20万個生成して計算した。
| 項目 | 件数 | 割合 |
|---|---|---|
| 生成したUUID | 200,000 | 100% |
0x80 以上のバイトを含む | 200,000 | 100% |
| UTF-8として正しい | 16 | 0.0080% |
json_encode() が失敗する | 199,984 | 99.9920% |
16バイトすべてが 0x7F 以下になる確率自体が2の16乗分の1程度なので、0x80 以上のバイトを含むのはほぼ確実だ。
そして 0x80 以上のバイトを含む並びがUTF-8として正しいのは、連続するバイトが多バイト文字の並びと偶然一致した場合に限られる。
そのため登録が成功するのは、AAGUIDがすべて 0x00 のような特殊な場合に限られる。
一部のプラットフォーム認証器(端末内蔵認証器)はこの値を返すので動くケースもあるが、大半のセキュリティキーでは失敗する。
TypeErrorが発生する流れ
graph TD
A["認証器が返すauthenticatorData"] --> B["CustomServerが take(16) で<br/>AAGUIDを生16バイトのまま取り出す"]
B --> C{"16バイトはUTF-8として正しいか"}
C -->|正しい| D["json_encode が成功して登録される"]
C -->|正しくない| E["json_encode が false を返す"]
E --> F["escapeString に bool が渡り TypeError"]
D --> G["aaguid だけ生バイトで保存される"]
G --> H["webauthn-lib が読めず UrlException"]
UserPreferences::save() はユーザー設定をJSONにしてSQL文へ埋め込む。
$config_data = json_encode($config_array);
if ($has_config) {
$query = 'UPDATE ' . $query_table
. ' SET `timevalue` = NOW(), `config_data` = \''
. $dbi->escapeString($config_data)
. '\''
// ...
PHPの json_encode() は、渡された配列の中にUTF-8として正しくない文字列があると、例外を投げずに false を返す。
戻り値の型が string|false になる。
UserPreferences.php の先頭には declare(strict_types=1); があり、DatabaseInterface::escapeString() の第1引数は string 型で宣言されている。
public function escapeString(string $str, $link = self::CONNECT_USER)
strict_types の指定があるファイルからは、bool を string の引数へ暗黙に変換して渡せないため、ここで TypeError になる。
save() は json_encode() の戻り値を検証していないため、escapeString() の呼び出しでエラーになる。
コンテナ内でPHPから同じ呼び出しを再現すると、次のログが出た。
json_encode result type: bool
json_last_error_msg: Malformed UTF-8 characters, possibly incorrectly encoded
escapeString(false) => TypeError: PhpMyAdmin\DatabaseInterface::escapeString():
Argument #1 ($str) must be of type string, bool given
登録できたレコードを公式tarballへ移すとエラーになる
AAGUIDがすべて 0x00 で登録できたレコードも、保存された形式が web-auth/webauthn-lib の期待する形と違うため、ライブラリ入りの環境へ移行すると読み込めなくなる。
webauthn-lib v3.3.12 の PublicKeyCredentialSource::createFromArray() は aaguid を次のように解釈する。
switch (true) {
case 36 === mb_strlen($data['aaguid'], '8bit'):
$uuid = Uuid::fromString($data['aaguid']);
break;
default: // Kept for compatibility with old format
$decoded = base64_decode($data['aaguid'], true);
$uuid = Uuid::fromBytes($decoded);
}
36文字ならUUID文字列として扱い、それ以外はBase64文字列としてデコードしたうえで16バイトへ戻す仕様だ。
16バイトの生バイト列はどちらの条件にも当てはまらない。
このファイルは use function Safe\base64_decode; を読み込んでいて、Safe 版の base64_decode() は失敗すると false を返す代わりに例外を投げる。
ヌルバイト16個はBase64として正しくないため、ここで Safe\Exceptions\UrlException が発生する。
CustomServer 側で登録したレコードを、UNHEX() を使ってライブラリ側の pma__userconfig へバイト単位でそのまま書き込み、正しい署名でログインを試した。
レコードの aaguid | 長さ | パスワード認証後 | 二要素認証 |
|---|---|---|---|
生16バイト(CustomServer 側が保存したまま) | 16バイト | Internal error: Safe\Exceptions\UrlException: An error occured | 画面に到達せず |
00000000-0000-0000-0000-000000000000 に書き換え | 36文字 | 二要素認証の画面が出る | 正しい署名で成功 |
2行目は1行目と同じレコードで、aaguid の値だけをUUID文字列に置き換えたものだ。
ほかのフィールドは変えておらず、差はこの1項目だけだ。
生バイトのほうはパスワード認証の応答を組み立てる段階でエラー停止するため、二要素認証の入力欄すら表示されない。
trustPath はphpMyAdmin側で補っている
webauthn-lib の createFromArray() は aaguid 以外のフィールドのチェックも厳密で、クラスのプロパティ名を走査して $data に無ければ The parameter "..." is missing で例外を投げる。
CustomServer が返すレコードには trustPath が無いが、phpMyAdminの WebauthnLibServer が読み込み時に補っている。
private function read(): array
{
$credentials = $this->twoFactor->config['settings']['credentials'];
foreach ($credentials as &$credential) {
if (isset($credential['trustPath'])) {
continue;
}
$credential['trustPath'] = ['type' => EmptyTrustPath::class];
}
return $credentials;
}
2つの実装のあいだのレコード形式の違いについて、phpMyAdminは trustPath は補っているが、aaguid は補っていないため例外が発生する。
逆に WebauthnLibServer が保存するときは jsonSerialize() を通るので、aaguid は $this->aaguid->toString()、つまり36文字のUUID文字列で書かれる。
ライブラリ側の環境で登録したレコードを CustomServer の環境へ移す場合は、生バイトを渡されても CustomServer が aaguid を参照しないため問題にならない。
開発ブランチでの修正状況
CustomServer.php の変更履歴では、この行は2026年9月末に直っている。
$aaguid = $this->uuidFromBytes($authDataStream->take(16));
追加された uuidFromBytes() は16バイトを小文字のUUID文字列へ変換する。
private function uuidFromBytes(string $bytes): string
{
$uuid = bin2hex($bytes);
return strtolower(sprintf(
'%s-%s-%s-%s-%s',
substr($uuid, 0, 8),
substr($uuid, 8, 4),
substr($uuid, 12, 4),
substr($uuid, 16, 4),
substr($uuid, 20, 12)
));
}
これで createFromArray() の36文字の分岐に入る。
修正コミットは 63949c36b「Fix WebAuthn\CustomServer credential save」で、コミット作成日(AuthorDate)は2026年9月28日、QA_5_2 へのコミット日時は2026年9月29日だ。
master へは2026年9月30日のマージコミット 1a7bf606 で反映されている。
同じコミットでテストも修正されており、期待値が生バイトからUUID文字列へ変わっている。
- 'aaguid' => hex2bin('00000000000000000000000000000000'),
+ 'aaguid' => '00000000-0000-0000-0000-000000000000',
修正前のテストは hex2bin() で作った生バイトを使っていて、しかも値はすべて 0x00 だった。
すべて 0x00 はUTF-8として正しいので json_encode() は失敗しない。テストが通っていたのはそのためで、0x80以上のバイトを含むAAGUIDを通すテストは無かった。
各ブランチ・タグでの CustomServer.php の状態をまとめた。
| リファレンス | uuidFromBytes | aaguid の形式 | コードの状態 |
|---|---|---|---|
RELEASE_5_2_1(2023年2月8日) | 無し | 生16バイト | 未修正 |
RELEASE_5_2_2(2025年1月21日) | 無し | 生16バイト | 未修正 |
RELEASE_5_2_3(2025年10月8日) | 無し | 生16バイト | 未修正 |
QA_5_2(2026年10月29日時点) | 有り | 36文字のUUID文字列 | 修正済み |
master(2026年10月29日時点) | 有り | 36文字のUUID文字列 | 修正済み |
WebAuthnのサポート自体は2022年12月19日のコミット f143d2a70「Add support for Web Authentication API」で入り、リリースとしては5.2.1が最初となる。5.2.0には CustomServer.php が存在しない。
修正は2026年10月29日時点でどの公式リリースにも入っていない。最新リリースは2025年10月8日の5.2.3のままだ。
なお、別の記事で扱った署名を検証しない問題は、QA_5_2 と master の CustomServer.php でもまだ直っていない。
今回修正されたのは aaguid の形式にとどまる。
影響する環境
影響を受けるのは CustomServer が実際に使われる環境だ。
| 入手方法 | web-auth/webauthn-lib | 実装 | この問題 |
|---|---|---|---|
| 公式サイトのtarball・zip(5.2.1から5.2.3) | 同梱 | WebauthnLibServer | 影響なし |
| Dockerの公式イメージ | 同梱 | WebauthnLibServer | 影響なし |
Gitのタグ RELEASE_5_2_3 を composer install --no-dev | インストールされる | WebauthnLibServer | 影響なし |
Gitの QA_5_2 を composer update のあと --no-dev | 削除される | CustomServer | 2026年9月29日以前のコミットは影響あり、以降は修正済み |
Gitの master を composer update のあと --no-dev | 削除される | CustomServer | 2026年9月30日以前のコミットは影響あり、以降は修正済み |
リリースタグでライブラリが残る理由
web-auth/webauthn-lib はどのリファレンスでも composer.json の require-dev に書かれている。
RELEASE_5_2_1・RELEASE_5_2_2・RELEASE_5_2_3・QA_5_2 は ^3.3 系、master は ^5.3 だ。
本来は開発用依存のため、省いてインストールすると入らないはずだが、同梱の composer.lock に記載されているセクションが違う。
| リファレンス | composer.lock | web-auth/webauthn-lib のセクション | --no-dev の結果 |
|---|---|---|---|
RELEASE_5_2_1 | 有り | packages(v3.3.12) | 残る |
RELEASE_5_2_2 | 有り | packages(v3.3.12) | 残る |
RELEASE_5_2_3 | 有り | packages(v3.3.12) | 残る |
QA_5_2 | 無し | — | 入らない |
master | 有り | packages-dev(v5.3.9) | 除外される |
composer install は composer.json ではなく composer.lock に従うため、packages に載っていれば --no-dev でもインストールされる。
master のように packages-dev に載っている場合は省かれる。
QA_5_2 にはlockが無いため composer update が走り、require-dev は開発用として扱われる。
実際に CustomServer が動くのは、開発ブランチを --no-dev で入れた環境か、ライブラリが入っていない配布物を使っている環境になる。
対処
CustomServer が動いているかはファイル構成で確認できる。
ls vendor/web-auth/webauthn-lib/src/Server.php
ファイルがあればライブラリ版なので今回の問題は当てはまらない。無ければ CustomServer が動いている。
CustomServer の環境でセキュリティキーの登録が Internal error: TypeError になるなら、AAGUIDが原因と思われる。
対処はライブラリを追加することだ。QA_5_2 なら ^3.3.2、master なら ^5.3 が require-dev に書かれているので、その版を composer require で追加すれば解決する。
ライブラリを入れる際の注意点として、GMPか bcmath のどちらかが無いと Requires GMP or bcmath extension. で失敗する。phpMyAdminの composer.json の推奨には載っていないため、composer install の出力からは気づきにくい。
すでに CustomServer で登録してある認証器は、ライブラリ版では読み込めない。
二要素認証の画面が出る前に UrlException が発生するため、phpMyAdminの画面からは削除できない。
pma__userconfig の config_data を直接直すか、該当ユーザーの行を削除して登録し直すことになる。
config_data を直す場合は aaguid の値を16バイトの生バイト列から36文字のUUID文字列へ置き換える。
16進で32文字並んだ値を8-4-4-4-12の位置で区切って小文字にすればよい。ヌルバイト16個なら 00000000-0000-0000-0000-000000000000 だ。
Gitから取得したコードなら、QA_5_2 は2026年9月29日以降、master は9月30日以降のコミットで取得すれば CustomServer のままでも直っている。ただし署名検証は入っていないため、ライブラリ版への切り替えを推奨する。
検証した範囲
- phpMyAdmin 5.2.3の実際の登録フローをHTTPで実行し、
pma__userconfigに保存された値をMySQLから読み戻した - AAGUIDを4通り変えて登録の成否を比較した
- 保存されたレコードをバイト単位でそのままライブラリ版の環境へ移し、正しい署名でログインをテストした
aaguidだけをUUID文字列に変えた比較用データで成功することを確認したjson_encode()がfalseを返し、escapeString()でTypeErrorになることをコンテナ内のPHPで再現した- リリースタグと開発ブランチの
CustomServer.phpを取得してuuidFromBytes()の有無を比べた - 5つのブランチ・タグの
composer.jsonとcomposer.lockを取得し、web-auth/webauthn-libがrequire-devにあることと、lockの記載セクションを確かめた