「AES-256なら安心」の直感が外れる瞬間
PDF の暗号化というと、つい「AES-256 だから強い」と思いがちです。けれど実際の安全性は、暗号アルゴリズムそのものだけでは決まりません。どんな鍵を使うか、どう作るか、どこから乱数を取るかで、同じ AES-256 でも強度はまるで変わります。今回の話は、その差が極端に出た例です。
MuPDF は PDF の書き込み・読み込みで AES-256 を扱います。問題は、文書の中身を守る 32 バイトのファイル鍵が、十分に広い空間から生まれていなかったことです。見かけ上は 256 ビットでも、実際に変化するのはごく一部だけ。結果として、候補を片っ端から試すだけで開ける状況が生まれました。
ここで大事なのは、暗号を「解いた」のではない点です。AES-256 の計算を破るのではなく、鍵が作られる入口が細かった。セキュリティ事故ではよくある構図ですが、利用者からは見えにくい。見た目は堅牢でも、実装の都合で小さな穴が空いていると、その穴が全体を支配します。
PDF の暗号化は少し入り組んでいます。パスワードはそのまま本文を暗号化しません。まずランダムな 32 バイトのファイル鍵を作り、それをコンテンツの暗号化に使います。さらにパスワードと salt から別の鍵を導き、その 32 バイト鍵を包みます。つまり、守るべき要は「パスワードの強さ」だけでなく、「この 32 バイトがどれだけ予測しにくいか」に移るわけです。
どこで小さくなったのかを、乱数生成の素性から見る
MuPDF の古い実装では、乱数は drand48 系の 48 ビット線形合同法で作られていました。これは Open Group の仕様にもある古典的な疑似乱数生成器です。使いやすく高速ですが、状態空間や初期化の扱いを誤ると、出力に思ったほどの多様性が出ません。長く使われてきた分、弱点もよく知られています。
実装では、この生成器が一度だけ現在時刻で seed されていました。しかも seed に使うのは秒単位の Unix 時刻です。48 ビットの内部状態があるように見えても、実際に動かす起点は「今の秒」だけ。srand48() では上位 32 ビットに時刻が入り、下位 16 ビットは固定値 0x330e になります。ここで既に、自由度はかなり削られています。
さらに重要なのは、MuPDF が鍵を作るときにこの生成器から byte を順に抜いていたことです。lrand48() は 48 ビット状態から高位側のビットを使って値を返しますが、上位の情報は下位に伝播しません。線形合同法の掛け算と足し算では、上のビットは下のビットの影響を受けても、その逆は起きない。結果として、ある範囲のビットだけが出力に効く構造になります。
この性質は、生成器の理屈を知っていれば不思議ではありません。Wikipedia の解説にもある通り、2 の冪を法とする LCG は、低いビットほど周期が短く、上位ビットだけが比較的まともに見えることがあります。MuPDF の場合は、1 バイトを切り出す位置が状態の比較的低い側だったため、seed の自由度の大半が出力に届かず、鍵空間が小さく縮んでしまったわけです。
512 通りしか残らない、という見え方
ここで効いてくるのが、9 ビットという数字です。生成器の状態は 48 ビットありますが、低い 16 ビットは固定、出力に影響しない上位側のビットも無視できます。最終的に、時刻由来の 32 ビットのうち、実質的に鍵へ反映されるのは 9 ビットだけになる、というのが調査の要点でした。9 ビットなら候補は 2 の 9 乗、つまり 512 通りです。
512 通りというのは、人間の感覚でも少なすぎます。しかも PDF のファイル鍵は 32 バイトあるので、一見すると「32 バイト全部を総当たりする」ように見えてしまう。けれど実際には、候補生成器が 512 通りしかないなら、やることは 512 個の鍵候補を作って試すだけです。AES-256 の長さに惑わされると、本質を見失います。
調査では、純粋な brute force でも 512 通りを試すのに 11 ミリ秒程度だったと報告されています。計算量の感覚をつかむために雑に見積もると、1 候補あたり約 22 マイクロ秒です。もちろん、実装や環境が変われば前後しますし、この値だけで一般化はできません。それでも「暗号を割る」のではなく「候補を試す」だけで済む重さとしては、十分に軽い数字です。
しかも MuPDF は同じ生成器を文書の /ID にも使っていました。/ID はトレーラに平文で入るので、鍵候補の照合材料になります。ここが面白いところで、候補の正しさを確かめるために、わざわざ本文全体を解読しなくてもよくなる。候補の絞り込みがさらに簡単になる仕掛けが、同じ乱数系列の使い回しの中にあったのです。
なぜこの手の不具合は現場で見落とされるのか
日本の開発現場でも、PDF 出力は外部ライブラリに任せきりになりやすい分野です。帳票、見積書、契約書、監査ログ。どれも「開けること」が重要で、「どんな方法で鍵を作っているか」は後回しになりがちです。しかも暗号化機能は、普段は正常に動く限り問題が表に出ません。開けないより、開けてしまう方が見えにくい失敗です。
似た問題は、他の PDF 実装にも見つかっています。Foxit では時刻ベースの乱数や未初期化領域が鍵に混ざっていた例があり、OpenPDF や iText でも別の乱数経路から同種の脆さが見つかっています。つまり、MuPDF だけの特殊事故ではない。PDF の暗号化は仕様上の構造が複雑で、実装者が「どこまでを本当にランダムにすべきか」を見誤ると、似た穴が何度も生まれます。
ここで気をつけたいのは、「AES-256 なら安全」という表現が、現場で判断を鈍らせることです。暗号方式名は強さの象徴として扱われますが、実際に必要なのは、鍵導出・乱数・初期化・保存形式まで含めた全体設計です。特にライブラリ側が PDF の保護を肩代わりしている場合、呼び出す側は中身をほとんど意識しません。だからこそ、脆さが混じっても長く放置されます。
MuPDF は後に ChaCha20 ベースの暗号学的 PRNG へ置き換えられています。これは、単に「より新しい方式に変えた」という話ではありません。乱数の入口を、古典的で予測しやすい系列から、暗号用途を前提にした生成器へ切り替えた、という意味があります。では、どの程度まで候補空間が広がったのか。11 ミリ秒で開いた鍵は、なぜそこで止まったのか。そこを追うと、話は AES ではなく別の層に入っていきます。
👉 続き(核心はここから)は PaPoo で読めます: https://papoo.work/doc/bcba9d519dcf1aa2
