事もあろうにIPAがやらかしちゃった、例の情報漏えいの原因が気になったので考察してみました。
IPAの公式情報
ITパスポート試験における個人情報等の漏えいについて
ここから明確になっている事実は
- 外的要因ではなくプログラムに内在するバグが原因であった。
- 複数同時アクセスにより問題の事象が発生した。
と言う事。
なお、問題の原因となったバグは修正済みとのこと。
事象
詳細は先のページにある通りなので、問題のCSVに関して要点だけ挙げるとこの通り。
ハッキリしているのはこの2点。
厳密な話をすると「サーバ処理でCSVファイルを物理的に作っていたかどうか」は不明で、あくまで「CSVファイルをダウンロードした」と言う事しか確定しない。
(CSV形式文字列をレスポンスに直接ブッ込んでた可能性もあるので、APサーバでCSVファイルを物理的に作ってたかどうかまでは確定できない)
原因とされるバグについての考察
なかなか興味深い事象だったので、昼間とかにTwitterの方でも少し議論してたんで、それを軽く纏めてみる。
仮説その①
まず最初は「同時実行」が問題のトリガになっている事から、以下のような原因を考えた。
やはり「同時にアクセスした」という条件がキーだろうから、年月日時分秒でしか区別してなかったという手抜き実装説が現実的だと思うんだよなあ。
— エル@ぱずどらふれんず (@ellnorePZDR297) 2018年3月14日
ただ同一ファイルに対して複数からぶっ込んでよく落ちなかったな、という点だけが物凄く不可解。納得いかないから検証コード書きたい。
ちょっと纏めると以下のような感じ。
CSVファイルを一度テンポラリに出力してからダウンロードする方式を取っていたと仮定。
このテンポラリへの一時出力のパス(ファイル名ないしは中間ディレクトリ)に、 ユーザIDやセッションIDなどのユニーク性の高い情報を使用せず、タイムスタンプ(年月日時分秒)のみといったかなり手抜きの実装をしていた可能性。
これにより、中間出力するテンポラリファイルパスがたまたま一致して、ダブルで書き込まれた説。
最初はわりと短絡的にこういうケースを想像したんだけど、Tweetに載せた通り一つだけどうしても不可解な事がある。
複数アクセスで物理的に単一のファイルにアクセスしたら、普通は先勝ちになって後の方は例外出て落ちるのが自然。
故に、上記の仮説はちょっと考え難い。
細かい話は端折ると、普通は単一のファイルに対して書き込みアクセスできるのは単一のプロセスのみ(実際にはWebアプリケーションだからプロセスは一本でスレッドが違うだけなので、厳密には話が違うけど)のハズなので、両方動いてCSVファイルがキメラ化して同じのがダウンロードされるという結果には至らないと思うんだよね。
と言うか、そもそもテンポラリのファイルパスが衝突するような雑な設計で作った事が無いから、マルチスレッドで同一ファイルに同時アクセスなんてやった事無いんで、今度ちょっと試してみようと思う。
仮説その②
次に考えたのは、どこかしらでstaticの良くない使い方をしている部分があって、そこが悪さをしたのではないか説。
要はこんな話。
static List<String[]> csvdata = new ArrayList<String[]>;
— ねこ爺 (@kuro_toro_cat) 2018年3月14日
とかやってたんじゃないかなーってはちょっとおもった。
一瞬似たような事を考えたんだけど、でもこれはこれで考え難い。
と言うのも、多分、問題の事象と同様のキメラCSVを作って同一ダウンロードさせる事を再現するのは簡単だと思う。
でも、もしこれが原因だったら同時アクセスって言うほど「同時」である必要が無いから、もっと頻繁にバグるだろうからリリースまでバグが残っている事が不自然になってしまう。
static List<String[]> csv = new ArrayList<>();
例えば、もし本当に↑のようなコードがあった場合、以下のようになる筈。
普通にやるとインスタンス生成直後しかクリアされた状態がないので、「前回処理したデータが残り続ける」という事になり、2回目、3回目とCSVを出力するとそのたびにデータが増えていく。というめちゃくちゃ解り易いバグが出るので、絶対気付く。
仮にそれを間違った対処として、毎回
clear()
してから使う、みたいな処置をしたとしても、そうなると今度は「ワケの解らないタイミングで勝手にクリアされてデータが減ってダウンロードされる(場合がある)」みたいな事が発生すると思われるので、やっぱりこれも気付き易い。
(単体テストでは気付かないだろうけど、結合かそれ以降の複数人で叩いてる時に何かしら問題が出る筈)若しくは、
foreach
で列挙している途中でコレクションが勝手にクリアされる、というタイミングも考えられるので、どこかしらで落ちる奴が必ず出て来るはず。
(列挙中にコレクション操作したら普通落ちる)
と言う事で、上記のようなコードだと、類似する事象の再現は割と簡単に出来ると思うが、それ以上にバグり過ぎるので気付かない訳がない、と言う事でやはり没。
仮説その③
FileIO
で被ったら普通落ちるし、かと言ってオンメモリの static
が悪さしてたとしたら頻繁にバグる筈だから気付かない筈がない。
と言う事で「CSVをキメラ化しつつ、更にそれを正常にダウンロードさせる」って意外と難しいぞ、という話になったんですよね。
で、最終的にこの仮説に落ち着いたんです。
一時的にCSV形式のデータをDBのテーブルに書き込む方式を取っていたと仮定。
あとは仮説①と同様、タイムスタンプと何か程度の雑なキー管理しかしてなくて、テーブルにCSVデータを書き込む時に同時アクセスでダブって、データがキメラ化したという説。
帳票を出力する前にワークテーブルに帳票用に整形したデータを突っ込んで、帳票側ではシンプルにデータを持っていくだけ、というやり方。 たかがCSV如きにこの方式を採ると言うのは若干考え難いんだけど、Excel帳票とかでは割と見る構造なので、元々あったそれに合わせたとかって可能性は無くはない。
この説がかなり現実的じゃないかなーと思う。
仮説①のダメなところ
というのも、やはり前述の通りファイルIOでダブったとしたら先勝ち後負けで落ちる可能性が高いので、事象の再現は難しそう。
例えばloggerみたいに遅延書き込みするキューに放り投げるような形にしとけばパラで書き込んで落ちないような作りにも出来るけど、それって意図してそういう実装にしない限り有り得ないので今回のケースではやはり考え難い。
ということで、まず仮説①系のテンポラリファイルのパスがダブった説は微妙。
仮説②のダメなところ
問題の事象と類似した動作を再現するのは仮説①より可能性があるものの、それ以外でもバグらせ放題なので現実的にこれが原因というのは考え難い。
前述ほど単純な形でなく、「Servletのフィールドをワーク的に使ったのでは」という説もあったし、ぼくもどちらかと言うとそっちに近い(ぼくはもう少し別な、キャッシュするような構造を考えてた)けど、たかがCSV出力処理にそんな事やるかなぁ、、、と言うのがちょっと不自然な所。
仮説③のイイところ
仮説③の優秀なのは以下の点。
- これならデータの混在(CSVのキメラ化)をうまく説明出来る。
- しかもファイルIOとかでダブった場合、CSVとしての構文を崩す可能性もあるが、DBで混在した場合はそういう問題が発生しない。
- 物理ファイルでダブったら落ちる可能性のほうが高いが、DBにINSERTする時のキー管理が甘かった説なら特に落ちる危険はない。
つまり、問題の事象の再現性が極めて高い。
この方式で落ちる可能性があるとしたらINSERT時の一意制約違反くらいだけど、こういう構造なら十中八九シーケンスを使ってるだろうから、逆に一意制約違反は起き難い気がする。
いや、下手をするとワークテーブルだからって言ってキーすら設定していない可能性までワンチャン有り得る。
有力説
という仮説③を提唱してたら、同じベクトルでこんな提言があった。
以下、おいらの予想https://t.co/uDKmbB5WUV
— 木菟@不良SE (@se_mimizuku) 2018年3月14日
おそらく、
— 木菟@不良SE (@se_mimizuku) 2018年3月13日
・作表用のテーブルがある。
・↑のテーブルロックで直列化する前提で検索条件指定していない。
・でも、実際はレンジロック
・お察し
見たいな感じじゃないかな。🤔
なるほど!!
一番現実的に有り得そうな仮説じゃないかなぁ、これ。
結論
ということで、テンポラリファイル説から始まり、static領域説を経て、最終的にはワークテーブル説に着地しました。
何か他にも考えられるケースはあるかも知れないですが、ぼくは今回のバグはこんな原因だったのではないかと予想しました。
皆はどう考えるでしょう、なんか新説あったら教えて下さい。