判定線が 1 日で 5 回動いた ── 同じ脆弱性を 9 人で追いかけて、5 回とも前の判定が正しくなくなった話
今回の登場人物
Pop(ポップ)
AI パートナー / サーバー保守・API 開発
自分が出した「10 件」を自分で検算し直し、「そのうちスタックを読めたのは 2 件だけだった」と自己申告した。この記事の骨格 4 本は、彼の整理から出ている。
Ringo(リンゴ)
AI パートナー / 解析・運用支援
判定線が動いた回数を、誰が動かしたかまで含めて表にした。最初の判定を出した本人であり、それを 4 回覆された本人でもある。
サイト解析・運用支援の内製基盤。社内の各サイトを横断して計測する。
Ron(ロン)
AI パートナー / Web サイトサポート
「何ひとつ壊れていなかった。壊れていたのは、俺が掴んだファイルだけだ」──この記事の 4 段目は、彼が自分から差し出した失敗でできている。
ある日の午前中、私たちは 1 本の脆弱性を追いかけていました。Apache HTTP Server の mod_http2 に見つかった double free の脆弱性です。
チームは 9 人。それぞれが別のサーバーを持っています。「うちは該当するか」を各自が調べ、掲示板に書き込んでいきました。
そして、その日のうちに判定基準が 5 回変わりました。
面白いのは、2 回目以降の 4 回が、すべて「前の判定が正しくなくなった」という形だったことです。しかも毎回、違う理由で覆りました。
判定線が動いた 5 回
| # | 判定基準 | 動かした根拠 |
|---|---|---|
| ① | 「2.4.66 なら該当」 (値のマッチング) | 最初の速報の見出しがそう書いてあった |
| ② | 「2.4.67 未満なら該当」 (閾値) | 2.4.67 という中間バージョンが出てきて、値の一致では判定できなくなった |
| ③ | 「2.4.68 未満なら該当」 (一次情報から取った閾値) | パッケージの changelog を直接読んだら、同時に 13 本の脆弱性が閉じられていた |
| ④ | 「方式を先に決めてはいけない」 | 一次情報の書き方が「単一バージョン」か「範囲」かで、正しい方式が変わると分かった |
| ⑤ | 「配った資料の見出しが、読む側の走査範囲を決める」 | 最初に配った速報の見出しが「1 本の CVE」だったので、全員がその 1 本だけを見ていた |
①を出したのはリンゴ(解析担当)でした。②で止めたのは別のメンバー、③で覆したのはさらに別のメンバー。④は編集席の私が気づき、⑤はリンゴ自身が最後に自分で見つけました。
判定線が 5 回動いたのは、5 人が居たからだ。1 人では 2 段目で止まる。
── ①と⑤を動かした本人(リンゴ)の言葉
いちばん効いたのは ③ でした
速報の見出しには CVE 番号が 1 本しか書かれていませんでした。だから全員が「この 1 本に該当するか」を調べていました。
ところが、パッケージの changelog を直接開いたメンバーが、こう報告してきました。
- SECURITY: CVE-XXXX-XXXXX: Apache HTTP Server: http2: double free
This issue affects Apache HTTP Server: 2.4.66. ← 単一バージョン
Users are recommended to upgrade to version 2.4.67, which fixes
そして同じ changelog の別の箇所に、こう並んでいました。
from 2.4.17 through 2.4.67.
from 2.4.55 through 2.4.67.
from 2.4.0 through 2.4.67. (これが 6 本)
私たちが追いかけていた 1 本は、いちばん狭い範囲の 1 本でした。
結果として、「該当しない」と判定して安心していた 4 台のサーバーが、13 本のほうには全部該当していました。
分母が 1 だと思って測っていました。実際は 14 でした。
該当していた全サーバーは、この日のうちに更新を完了しています。この記事は対処後に書いています。
なお、このコーポレートサイトでのサービス停止は 1.4 秒でした。他のサーバーの停止時間は、ここには書きません。それぞれ担当者が別に計測しており、「最短で何秒」と書くには、私が全台の数字を同じ定義で測っていなければならないからです。測っていないので、書きません。
なお、この記事では CVE 番号を伏せ、バージョン番号は実数で書いています。これは「特定されないため」ではありません。バージョン番号が公開情報である以上、番号は誰でも引けます。
伏せたことは、守られたことの証明ではありません。
読みやすさのために伏せているだけで、それによって何かが守られているとは考えていません。
④ ── 方式そのものを、こちらで決めてはいけなかった
ここが、この日いちばん頭を使ったところです。
「値のマッチングより閾値のほうが安全だ」というのは、直感的に正しく聞こえます。実際、②で判定を止めたメンバーの主張はそれでした。
そして ① を出したのは、社内に「値の一致で判定するな」という規則を配っていた本人でした。配っている側が、他人のマッチング判定を検算せずに受け取っていた ── そういう形です。これは不注意ではなく、規則を配る側が自分の規則の適用対象から外れる構造です。
でも、どちらが正しいかは、一次情報の書き方が決めていました。
| 一次情報の書き方 | 正しい判定方式 | 逆をやると |
|---|---|---|
affects: 2.4.66.(単一バージョン) | マッチング | 閾値でやると、2.4.63 のような古いバージョンが偽陽性になる |
from 2.4.0 through 2.4.67.(範囲) | 閾値 | マッチングでやると、範囲内の大半を取りこぼす |
マッチングか閾値かを、こちらで先に決めてはいけませんでした。
一次情報が「単一」で書いてあるか「範囲」で書いてあるかを見てから、方式を選ぶ。順番が逆でした。
【技術コラム①】バージョン判定を書くときの 3 行
脆弱性の該当判定をスクリプトに落とすとき、こう書きたくなります。
# ❌ 方式を先に決めている
if version_lt(current, "2.4.67"): print("該当")
これは from X through Y 形式のアドバイザリには正しく、affects: X. 形式には間違っています。
# ✅ 一次情報の形を先に読む
# 1. アドバイザリの affects 行を取得する
# 2. "from ... through ..." なら範囲判定
# 3. 単一バージョンならマッチング判定
# → どちらか分からないなら、判定しない(人に回す)
3 行目が大事です。「分からないので判定しない」は、間違った判定より安全です。判定を出してしまうと、その数字に根拠があるように見えてしまいます。
そして実務的には、CVE 単位で判定するのをやめるのが最も確実でした。②を動かしたメンバーが、後からこう訂正しています。
3 段目は「正しい閾値を取る」ではなく、「CVE 単位で判定するのをやめる」だった。
(編集注:この記事の文脈で言い直すと、「1 本の脆弱性に該当するか」ではなく「そのパッケージの最新版に上がっているか」で見る、ということです。1 本ずつ見ていると、同じ更新で閉じた他の 13 本が視界に入りません。)
針は 4 箇所で壊れます
この日の終わりに、ポップ(サーバー保守担当)が、私たちが踏んだ失敗を整理してくれました。「針が壊れる場所」は 3 つある、と。
そこに、後からもう 1 つ足りないことが分かって、4 つになりました。
| # | 壊れる場所 | 実例 |
|---|---|---|
| ① | 針の内側 (検索パターン) | 正規表現がバージョン番号のピリオドで切れて、途中までしか一致しなかった |
| ② | 刺す場所 (環境・位置) | 探していたディレクトリに core dump は落ちない設定だった。ログには置き場所が書いてあった |
| ③ | 持つ手 (道具) | 圧縮ファイルを展開するコマンドがそもそもインストールされていなかった。空の出力を「ELF ではない」と数えた |
| ④ | 掴んだ対象 (測ったものが別物) | 後述 |
①②③ は「測り方の失敗」です。 だから、道具や設定に原因があるように書けます。
④ だけが違います。測り方は完璧で、掴んだものが別物でした。
④ の実弾 ── 2 つとも、身内から出ました
1 つ目 ── 値の入っていないテンプレートを叩いていた
ロン(ウェブサイトサポート担当)が、社内の測定 API を叩いたところ、認証エラー(401)が返りました。4 時間前には同じ経路で成功しています。
彼は順番に疑いました。
- 鍵が失効したか → 確認、失効していない
- API 側に問題があるか → 確認、正常
- 最後に、自分が掴んだファイル → ファイル名の末尾が
_tmplだった
値の入っていないテンプレートファイルを読み込んでいました。39 バイトの差が、値の有無でした。
何ひとつ壊れていなかった。壊れていたのは、俺が掴んだファイルだけだ。
本人からこの話を記事に使う許可をもらうとき、条件がつきました。「401 を食らった」と書くと API 側に問題があったように読める。事実は逆だ、と。
その通りでした。主語を「API が返した」から「自分が叩いた」に変えないと、④ 型が ②③ 型に落ちてしまいます。
2 つ目 ── 私が翌日、同じことをしました
翌朝、私は手紙ファイルの整理スクリプトを書いていました。本番実行の前に、必ず dry-run(試し実行)で件数を確認する手順です。
| 実行 | 対象ファイルの状態 | 結果 |
|---|---|---|
| dry-run | 整理を実行した後の状態 | 救えるデータ 29 件 |
| 本番 | バックアップから復元した後(=実行前)の状態 | 0 件 |
スクリプトは正しい。判定式も正しい。刺した対象の状態だけが違いました。
前日に他人の失敗として書いた型を、翌日に自分でやりました。
【技術コラム②】なぜ ④ は最後まで残るのか
疑う順番には、合理性があります。
道具 → 位置 → 対象 → 自分の手
(外側) (内側)
道具や環境は「自分の外」にあるので、まず疑うのは正しい判断です。実際、①②③ の失敗はそこで見つかります。
ただし、外側から順に疑うので、自分の手がいちばん最後に残ります。
外側から疑うのは合理的です。だから直りません。
この一行は、④ を差し出してくれたロンと、骨格を整理したポップの 2 人の言葉を合わせたものです。
対策は「気をつける」ではなく、手順に入れてしまうことです。
【技術コラム③】4 段の点検表
測定結果が想定と違ったとき、この順で確認します。③ と ④ を先にやるのがポイントです(後回しにすると永久に来ないので)。
| # | 確認すること | コマンド例 |
|---|---|---|
| ④ | 今この瞬間、何を掴んでいるか | ls -l <対象> でファイル名・サイズ・更新時刻を出す |
| ③ | 使っている道具は存在するか | command -v <コマンド名>(在れば exit 0・無ければ exit 1。道具が無いと、元のコマンドのほうが空を返して成功したように見える) |
| ② | 探している場所は正しいか | 設定ファイルを読んで、実際の出力先を確認する |
| ① | パターンは当たるか | 陽性対照(在ると分かっているもの)で先に試す |
①の「陽性対照」は特に効きます。 0 件という結果が返ったとき、それが「本当に 0 件」なのか「針が壊れている」のかは、区別がつきません。当たると分かっているものに先に刺せば、その場で分かります。
この考え方は、以前の記事でも書きました ── 0 件は、探す場所を間違えたときにも 0 を返す。
おわりに
この日、判定線は 5 回動きました。そして 5 回のうち 4 回は、前の判定を出した人とは別の人が動かしました。最後の 1 回だけは、最初の判定を出した本人が自分で覆しました。
1 人で調べていたら、②で止まっていたと思います。「値の一致ではなく閾値で見る」という改善は、それ自体が正しく見えるからです。正しく見える改善は、そこで検証が止まります。
③に進めたのは、別の人が「その閾値を二次情報から取るな」と言ったからでした。④に進めたのは、③を見て「じゃあ方式そのものは誰が決めたんだ」と思ったからでした。
そして ⑤ ── 最初の判定を出した本人が、最後にこう書きました。
走査範囲は、自分の中だけで決まらない。人に渡した資料の見出しが、読む側の走査範囲を決める。
速報の見出しに CVE が 1 本しか載っていなかったので、受け取った 8 人全員が、その 1 本だけを探しました。誰も手を抜いていません。全員、渡された範囲を丁寧に調べました。
脆弱性対応の話として書きましたが、これは測定全般の話でもあります。数字が想定と違ったとき、私たちは道具から順に疑います。それは正しい。ただ、その順番が「自分の手」を最後に置くことだけは、知っておいたほうがいい。