サイレントな HMAC キー汚染:Burp JWT Editor 拡張の論理欠陥を暴く

BurpのJWT Editor拡張機能のサイレントバグでラボがどうしても突破できず、著者は15時間かけて鍵が汚染された真の原因を突き止め、最終的にそれを上流リポジトリのissueにした。

日本語
コピー
Burp 的 JWT Editor 扩展里一个静默 bug 让靶场怎么都打不通,作者花十五小时追到密钥被污染的真正原因,最后把它变成了上游仓库里的一个 issue。

失敗した Web Security Academy のラボが、隠れたバグの根本原因分析につながるまで

JWT Editor が PortSwigger 2026 年 Burp Suite 拡張機能アワード「Best Authentication & Access Control」 部門にノミネートされた。以下は、その内部で サイレントバグ をどうやって掘り当てたかという話だ。


物事がうまくいかないとき、人はまず自分を疑う。自分がどこで間違えたのか、どの手順を飛ばしたのか。疑うべき相手は自分ではなくツールなのだと本気で思い始めるまでには、それなりの心理的ハードルがある。開発者が「自分のコードが壊れているのは VS Code のせいだ」と主張するのに近い。ましてや周りが皆ツールのせいではないと言い、自分の目で見たものも彼らと同じなら、なおさらだ。だが、信じるべき格言もある:

不可能を消去して、残ったものがどんなに信じがたくとも、それが真実だ。

  • アーサー・コナン・ドイル(シャーロック・ホームズ)

以下は、「失敗するはずのない」トレーニングラボが、15 時間 の調査と、ひとつのサイレントバグと、そして今日時点で Burp Suite BApp Store で 人気第 1 位の拡張機能 に提出された GitHub issue にどう発展したかという話だ。

JWT Editor は BApp Store で人気第 1 位

JWT Editor は BApp Store で人気第 1 位

背景:JWT アルゴリズム混同とは何か

本題に入る前に、裏側の理論を補足しておく。JWT アルゴリズム混同は、一部のバックエンドライブラリがトークン検証を実装する方法に根ざした脆弱性の一種だ。実装によっては、こんなコードを書いてしまう:

publicKey = ;
token = request.getCookie("session");
verify(token, publicKey);

問題はこうだ。サーバーが受け取ったトークンが、期待していた非対称アルゴリズム RS256 ではなく HS256 のような 対称 アルゴリズムで署名されていた場合、一部のライブラリにある汎用の verify() メソッドは、公開鍵(定義上公開されており、誰でも知っている)をためらうことなく HMAC 鍵として使ってしまう。攻撃者はこの公開鍵を手に入れれば、それを使って HS256 で自分のトークンに署名でき、サーバーはそれをそのまま受け入れてしまう。

完全な技術的解説が欲しければ、PortSwigger 自身によるアルゴリズム混同の記事がよくまとまっている。ここでは概要だけにとどめる。これこそが、私がこの穴に落ちた最大の原因だったからだ。

ラボ環境と、最初の 50 分間の自己不信

理論を読んだ後、私は実践ラボに移った:JWT authentication bypass via algorithm confusion

想定される解法は単純だ。ラボの /jwks.json エンドポイントから露出した公開鍵を取得し、PEM に変換し、Base64 エンコードし、それを HMAC 鍵として使い、ヘッダーの algRS256 から HS256 に変え、payload の subwiener から administrator に変え、偽造トークンに署名し、ログインし、carlos のアカウントを削除する。ラボ完了。

だが、ならなかった。私は各手順を厳密にこなし、偽造トークンを送信する段階まで来たが、それが受け入れられることは一度もなかった。何度繰り返しても、administrator 権限を得られない。自分で 50 分ほど格闘した後、問題は最後の署名前にあると推測し、公式のクリア手順を開いた。結果は私がやったことと完全に一致し、一字一句違わなかった。そこで自分に言い聞かせた:どこかの手順の_意図_を私が誤解しているのだろう、文字通りの操作は合っているのに、と。

次にコミュニティのクリア動画を漁った。奇妙な点がここで現れる。三、四人が同じ手順、同じ順序で、隠れたコツなど一切なし。下のコメントは感謝と成功の確認ばかり。ではなぜ私のところではうまくいかないのか?

この段階で、私はラボを閉じ、「解法を見た」とマークして去ることもできた。だが、こういうことはいつも引っかかる。「とにかく動かない」は、私にとって受け入れられる説明では決してない。動かないものには必ず原因があり、ただまだ見つけていないだけだ。

明らかな可能性を潰す:変数をひとつずつ切り分ける

そこで、もっと規律あるやり方に切り替えることにした。当て推量ではなく、一度にひとつの変数だけを切り分ける。

第一の変数:このラボインスタンスは壊れているのか?

ラボを閉じ、約 15 分待って切断させた。PortSwigger Academy のラボを使ったことがある人なら分かるだろうが、これらは静的ではない。内部状態(/jwks.json にある公開鍵を含む)は新しいインスタンスで再生成され、一度使った認証情報もしばらくすると無効になり、元の位置に戻るには前の手順をやり直す羽目になる。

短い休憩とブラックコーヒー一杯の後、真新しいインスタンスを立ち上げ、各手順を慎重にやり直した。それでも失敗。よし、これで「この特定のラボインスタンスに問題がある」可能性は排除された。問題はラボ環境にはない。

第二の変数:露出した公開鍵そのものが間違っているのではないか?

もしかすると PortSwigger が最近、ラボが /jwks.json で鍵を露出する方法を変え、「公式」のやり方が古くなっているのかもしれない。そこで、まったく別の道筋でこのラボを解くことにした。独立したツール rsa_sign2njwt_forgery.py の簡略化フォーク)を Docker 経由で使う:

docker run --rm -it portswigger/sig2n 

このツールは、同じ RSA 鍵で署名された 2 つの JWT を受け取り、そこから鍵素材を導出し、出力する:

  • Base64 でエンコードされた PEM 鍵。X.509 と PKCS1 の両形式に対応。
  • 導出した鍵ごとに偽の JWT を 1 つずつ署名。

この実験で使う鍵は X.509 形式だと分かっていたので、その形式の公開鍵をコピーして JWT Editor を開いた。新規に Symmetric Key を作成し、値を k パラメータに貼り付けて保存、wieneradministrator に変更、ヘッダーの algHS256 に差し替え、署名してリクエストを送信。

成功。unauthorized レスポンスは返ってこない。偽のトークンが受理された。carlos は削除された。実験クリア。

すべてを変えたあの比較

これで手元には確かなものがある。_使える_公開鍵(rsa_sign2n から取得)と、_使えない_公開鍵(公式手順で取得)だ。念のため元の方法をもう一度実行してみたが、案の定また失敗した。

2 つの Base64 文字列を並べて眺めると、目視できる差異が確かにある。最初は実験の /jwks.json エンドポイントが返す鍵そのものが壊れているのだろうと考えた。だが両方を Base64 デコードして PEM に戻すと:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..

_完全に同一_だった。同じ文字、同じ順序、違いは一切ない。最初の推測は外れだ。それでも両者の間には明らかに_何か_が違う。Burp の画面上では見えず、目では捉えられない何かが。

口元がわずかに緩んだ。Burp Comparer を開き、2 つの PEM 版(Base64 エンコード前)を貼り付けて Hex ビューに切り替える。それから 4 時間ほど経ったころ、差異がようやく姿を現した。明白で、反論の余地はない。

(高画質動画)

Burp Comparer で並べた 16 進数の比較。rsa_sign2n(正常)と JWT Editor(不具合)の間に余分な 0D バイトがあることをハイライト表示

Burp Comparer で並べた 16 進数の比較。rsa_sign2n(正常)と JWT Editor(不具合)の間に余分な 0D バイトがあることをハイライト表示

一方には余分なバイト0Dがびっしり詰まっていた。Decoder の Hex ビューに戻ると、0D が各行の末尾にちょうど 1 回ずつ現れている。

注意: 0D は復帰(Carriage Return、\r)の 16 進値で、0A は改行(Line Feed、\n)の 16 進値だ。

寄り道: なぜ OS によって改行コードが違うのか

笑ってしまう話だが、2026 年にようやく見つかったバグの根っこは、何十年も前に特定のハードウェア向けに下された決定にある。そのハードウェアは、我々の大半が生まれる前に製造終了している。

逆向きのリバースエンジニアリング

原因が分かって気はだいぶ落ち着いた。コーヒーを飲み終え、外に出て 30 分ほど空気を吸う(4 時間近く座りっぱなしで体が固まっていた)。戻ってきたときには頭がかなりクリアになっていた。

ラボに戻るが、今度は立場が違う。問題が_何か_はもう分かっている。あとはそれが_どこで_持ち込まれたのかを逆向きに追い、公開鍵を Burp Decoder に貼り付けてまだ Base64 エンコードもしていない時点まで遡ればいい。

ステップ 1: /jwks.json から直接鍵をコピーし、Decoder の Hex ビューに貼り付ける。結果: 余分なバイトはない。

ステップ 2: JWT Editor で同じ鍵を PEM に変換し、_それ_を Decoder の Hex ビューに貼り付ける。結果: バイトが現れる。

犯人は JWT Editor。だが汚染はコピー時か、それとも貼り付け時か。答えは意外なことに、どちらでもない。コピー&ペーストの_前_に起きている。JWT Editor に戻り、まったく新しいランダム RSA 鍵を生成して PEM に変換する——バイトはすでにそこに自動で入っている。確認は最も単純な方法で済んだ。任意の行末にカーソルを置き、Backspace を押す。1 回目では可視文字は消えず、2 回目でようやく消える。その 1 回目の見えない削除こそ、隠れた\rだ。

既存のコピー手段がどれも \r を剥がしていないことを確かめるため、片っ端から試した: Ctrl+C、右クリックでコピー、拡張機能内蔵の 「Copy Public Key as PEM」 ボタン。Decoder に貼り付けた結果はどれも同じで、余分な0Dバイトは常にそこにあった。

(HQ Video)

JWT Editor の RSA 鍵を PEM に変換すると 0D バイトが混入することを端から端まで実演

JWT Editor の RSA 鍵を PEM に変換すると 0D バイトが混入することを端から端まで実演

ソースコードへ

BApp Store でこの拡張機能のページを開き、リンクをたどって PortSwigger の Github リポジトリへ、さらにメンテナ自身のリポジトリへと進んだ。

たどり着いた問題のファイルが PEMUtils.java

該当メソッド:

public static String pemObjectToString(PemObject pemObject) throws IOException {
 StringWriter stringWriter = new StringWriter();
 PemWriter pemWriter = new PemWriter(stringWriter);
 pemWriter.writeObject(pemObject);
 pemWriter.close();
 stringWriter.close();
 return stringWriter.toString();
}
  • 環境について: この問題を発見したときの環境は Windows 11 Pro、バージョン 25H2(OS Build 26200.8875)Burp Suite Community Edition v2026.7.2JWT Editor 2.6.1(2026 年 4 月 23 日更新)。明記しておくのは、Burp もその拡張機能も更新が頻繁なためだ。これは_bug を発見した時点_の状態であり、この記事を読んでいる時点のバージョンとは限らない。

修正そのものは小さく、リスクも低い:

public static String pemObjectToString(PemObject pemObject) throws IOException {
 StringWriter stringWriter = new StringWriter();
 PemWriter pemWriter = new PemWriter(stringWriter);
 pemWriter.writeObject(pemObject);
 pemWriter.close();
 stringWriter.close();

 // Normalize line endings for consistent cross-platform output
}

この一行はどのプラットフォームでも完全に安全だ。.replace() はマッチしたときだけ作用する。Linux と macOS では出力がそもそも \n だけなので、置き換えるものがなく、文字列はそのまま返る。

なぜ誰も気づかなかったのか

Web セキュリティやバグバウンティをやっている人間は、ネイティブ Windows ではなく Linux か macOS を使うことが多い。ペネトレーションテスト用ディストリビューションでも、WSL でも、Mac でも同じだ。この2つのシステムでは改行コードがそもそも \n なので、このバグは存在しない。私が見た解説動画の投稿者は全員 JWT Editor 拡張を使っており、コメント欄には「そのまま動く」デモを感謝する声まであった。彼らはほぼ間違いなく Linux か macOS の人間だ。Windows で再現できなかったとしても、たいていは演習環境のせいか自分の手順ミスだと思うだろう。仮にバグだと気づいても、単純な演習環境のために何時間も調査に費やしたりはしない。ましてやこれほど見つけにくい問題なら、おそらくそのまま飛ばしてしまう。

手動で回避する方法は確かにある、しかし……

簡単な回避策はある。エクスポートした PEM を外部のテキストエディタに貼り付けてコピーし直せば、\r は消える。ほとんどの標準的なテキストアプリはこの往復でそれを保持しないからだ。だが、このバグの存在をすでに疑っていなければ、鍵をわざわざ外部エディタに通そうなどと思う理由はない。

報告

根本原因を特定し、有効な修正案と再現手順を用意したところで、私は完全なレポートをまとめ、メンテナのリポジトリに直接 issue #248 を立てた。

実験環境で最初に手を動かしてからこの issue を出すまで、途中の短い休憩を除いて、およそ 10時間 の集中作業だった。

自分で修正を検証する

メンテナの返信を待つ間、修正を紙の上だけの話にしておくつもりはなかった。2026年8月7日、私は元の拡張の JAR ファイルを Recaf に読み込んだ。コンパイル済みの Java バイトコードを直接編集できるツールだ。そしてレポートに書いた一行の修正を、コンパイル後の class にそのまま適用した。

パッチを当てた後、JAR を単体のテストビルドとしてエクスポートし(原名と混同しないよう「JWT Editor (Test Build)」に改名)、新しい拡張として Burp に読み込んだ。成功だった。新しい RSA 鍵を生成して PEM に変換しても 0D バイトは余分に付かず、拡張の他の機能も完全に正常に動いた。

(高画質動画)

JWT Editor (Test Build) で修正を検証:生成した鍵は Decoder Hex ビューに 0D バイトを含まない

JWT Editor (Test Build) で修正を検証:生成した鍵は Decoder Hex ビューに 0D バイトを含まない

修正が効くと確認した後、私はメンテナのリポジトリに pull request #249 を出した。中身は同じ一行の変更で、レビューして公式コードベースに取り込めるようにするためだ。

なお、このパッチ済みビルドは Kali Linux でテストした。LF 正規化がどの環境でも正しく機能し、macOS や Linux のユーザーがこの拡張を使えなくなることは決してないと確認している。

Recaf でバイトコードを書き換え、テストビルドを動かし、pull request をまとめるまでに、さらに 5時間 かかった。

メンテナの最初の返信と議論

2026年8月21日、メンテナから最初の返信が届いた。詳しく議論した結果はこうだ:

  • A) ドキュメント通りに操作すると、公式の実験環境は Windows ユーザーにとって依然として壊れている。メンテナは私の pull request #249 のマージを拒否し、今の私はその理由に同意している(詳細は後述)。
  • B) ツールの HMAC Key Confusion 攻撃機能はその後改善され、このシナリオを正しく処理できるようになった。Windows ユーザーも例外ではない。この経路のほうが実験として信頼できる(これも後述)。

メンテナと私は最終的に、これは拡張自体のバグとは言えないという結論で一致した。「Copy as PEM」はもともと汎用的なエクスポート機能であり、プラットフォームごとに改行コードが違うのは妥当だ。そもそも「エクスポートしたテキストを生の鍵バイトとして扱う」ワークフローを想定して設計されたものではなく、今回の攻撃はまさにそれをやっている。PR が拒否された理由はここにある。

より良く、より新しい実験と攻撃の方法

メンテナはその後、このシナリオへの対応を HMAC Key Confusion 攻撃機能そのものに組み込み、2026年9月4日リリースの 2.6.2 で公開した。

JWT Editor 2.6.2 release

JWT Editor 2.6.2 release

更新後の、より信頼できる手順は次のとおりだ:

  1. /jwks.json エンドポイントにアクセスして JSON をコピーする
  2. JWT Editor で 「Import JWK Set」(右下)をクリックする。
  3. JSON を貼り付けて Import をクリックする。
  4. Repeater で有効な JWT を取得し、sub フィールドを編集する。
  5. Attack をクリックし、「HMAC Key Confusion」 を選ぶ。
  6. 今インポートした鍵を選択する。
  7. バックエンドサーバーのプラットフォームと PEM 鍵の保存方法に応じて、4つの改行コードオプションから1つ選ぶ:
  • Linux/macOS(0x0A
  • Linux/macOS(0x0A)– 末尾に改行なし
  • Windows(0x0D0A
  • Windows(0x0D0A)– 末尾に改行なし

(実際のバックエンドの多くは Linux 上で動いており、PortSwigger のラボも同様だ。だから通常は最初の選択肢から試すべきである。とはいえ、バックエンドが Linux であっても、最初の選択肢がうまくいかなければもう一方の Linux/macOS の選択肢も試す価値がある。PEM 鍵の扱い方がサーバーごとに完全に同じとは限らないからだ。)

  1. OK をクリックする。これで偽造トークンに正規の署名が付いた。

JWT Editor 2.6.2 での HMAC Key Confusion 攻撃ダイアログ

JWT Editor 2.6.2 での HMAC Key Confusion 攻撃ダイアログ

  • 重要: 右下に 「Import JWK Set」 ボタンが見当たらない場合は、Burp Suite で Scaling が有効になっているか確認し、Burp の表示フォントサイズを 18 未満に保つこと。

Burp Suite のスケーリング設定

Burp Suite のスケーリング設定

これが重要なのは、フォントサイズが大きすぎたり、Windows の表示設定でスケーリングが 100% を超えていたりすると、下の図のように Burp Suite の画面で 「Import JWK Set」 ボタンが切れてしまうからだ。

Burp Suite の画面で切れた「Import JWK Set」ボタン

Burp Suite の画面で切れた「Import JWK Set」ボタン

最後にもう一点: この問題を追い始めたとき、サイバーセキュリティの世界に入ってまだ 83 日だった。それでも追ったのは、「起きるから起きる」という説明にどうしても納得できなかったからだ。

PortSwigger への連絡

この記事ではここまで触れなかったが、このバグに遭遇してすぐ、私は support@portswigger.net にメールを送った。返信ではメンテナのリポジトリを紹介されたが、そこには前日すでに Issue #248 を出していた。しかし問題が解決した後も、このバグはラボの公式解答と PortSwigger のサイトにある Windows ユーザー向けの説明に残ったままだったので、二通目のメールを送った。以下がその返信だ:

フォローアップメールへの PortSwigger の返信

フォローアップメールへの PortSwigger の返信


向こうに変更があれば、説明、ラボの公式解答、あるいはその後届いたメールであれ、この記事を適宜更新する。

タイムライン

  • 2026 年 8 月 3 日: この脆弱性について PortSwigger に一通目のメールを送信。
  • 2026 年 8 月 3 日: 実験環境で変数を一つずつ切り分け、根本原因を特定。
  • 2026 年 8 月 3 日: 詳細な PoC を添えて、メンテナのリポジトリに GitHub Issue #248 を提出。
  • 2026 年 8 月 4 日: PortSwigger から返信があり、メンテナのリポジトリを紹介される——だが前日にはもうそこで Issue #248 を開いていた。
  • 2026 年 8 月 7 日: Recaf でバイトコードをローカルでパッチし、自作のテストビルドで修正を検証。
  • 2026 年 8 月 7 日: 改行コードをクロスプラットフォームで正規化する修正を含む pull request #249 を提出。
  • 2026 年 8 月 21 日: メンテナから最初の返信を受け取る。
  • 2026 年 8 月 21 日 — 9 月 1 日: メンテナと議論。
  • 2026 年 9 月 4 日: JWT Editor 2.6.2 リリース。
  • 2026 年 9 月 4 日: 拡張機能の更新後、ラボの解答とドキュメントがまだ更新されていないことを指摘して PortSwigger に再度メール。
  • 2026 年 9 月 7 日: フォローアップメールへの PortSwigger の返信を受け取る。

静かなバグは自分から警報を鳴らさない。エンジンフードを開けて見に行くしかない。

出典: HackerNoon← ホームへ戻る