管理者用初期化URLを踏んでWebサービスのデータをふっとばした話

# 自己紹介

本職のエンジニアではありませんが、ちょっとICT系に詳しそうなやつって感じで、部署のサーバ管理を任されたりもしています。

# 背景

私の（当時所属していた）部署では、毎年、数週間かけて前年の各人の業務実績をとりまとめて一つの冊子（PDF）にするという仕事があり、この作業を少しでも自動化するため、Webサービスが内製されました。当初は単純に各ユーザが自分の業務実績一覧をテキストで用意してアップロードするというものでしたが、秘伝のタレのように毎年少しずつ改良されたり、大幅に作り直されて別システムから業務データを取り込んでからブラウザ上で編集できるようになったりしつつ、なんやかんやあって私が引き継ぎます。他にやりたい人もなく、ひとり鯖管です。OSはCentOS6でした。

このシステムでは、毎年新しいデータを編集するため、その作業開始時にデータを初期化する必要があります。この作業も自動化し、管理者権限でログインして管理者ページ（ここでは `https://hoge.example.com/kanri` としましょう）から起動できます。危険な作業なので、初期化しようとすれば警告が表示される仕様です。

そもそもWebサービスに初期化などという機能なんてと思われるかもしれませんが、そこは内製のシステム。前年の編集データはほんとうにまったく不要なので、単純に初期化する実装になっています。さらに後述しますが、問題の本質は初期化とは無関係です。

# 事故

## 何をした（つもり）か

各ユーザに依頼していた数週間の編集作業の第1回目の期限が過ぎ、アクセスも落ち付いたころ、管理者ページに一気に行きたかった私は、ブラウザのアドレスバーに `kanri` と打ち込み、閲覧履歴から目的のURL `https://hoge.example.com/kanri` を探してちゃちゃっと開きました。

## 何をやらかしたか

すると、管理者ページを開いただけのはずなのに、期待に反して、**初期化が完了**したことを表すページが表示されました。

まさかと思いつつ、アドレスバーを確認すると `https://hoge.example.com/kanri/init` のURLがありました。閲覧履歴にあった管理者ページではなく、その隣の初期化ページを開いてしまっていました。ユーザに編集作業を依頼する前に初期化をしていたので、それが閲覧履歴にあったわけです。出てほしい警告は一切出ませんでした。

**やらかした**と悟りました。ユーザは200人弱、みなさん極めて多忙、そんな中で時間を割いて集中的にやってもらった作業をすべてパーにしたというわけです。大規模な商用サービスに比べれば微々たるものですが、ユーザのみなさんに同じ作業をまたやってもらわなければいけないかもというプレッシャーで胃が痛みます。

## 何が消えたのか

このWebサービスではDBは使用せず、データはすべて特定のディレクトリにあるユーザごとのサブディレクトリに、複数のファイルに分けて置かれていました。このデータ用ディレクトリを確認しましたが、初期化したのですから当然もぬけの空です。その当時は内部の初期化コードまで承知していませんでしたが、ようは`rm`で大量のファイルとディレクトリを削除したということです。

# 応急処置

## あわてない

あわててはいけません。まずは落ち付きます。今なら「**全集中、鯖の呼吸**」とつぶやくべきところです。たぶんサーバと一体化し、最善の一手となるキーに一筋の光が見えます……（知らんけど）

閑話休題。削除してしまったファイルを回復させるための最善策を考えます。

ユーザによる編集作業はほぼ一段落していて数日前のバックアップはあるにはありましたが、戻してしまうと、その間に誰が編集したかつきとめて個別に依頼していく手間と精神的負担がかかります。バックアップを戻すのは最後の手段とします。

ですが、**最後の手段はある**。これは気分として重要です。

## データ保全

初期化がrmであったことは幸いです。truncateなどしていればどうなっていたことか。UNIX系は昔から使っているし、i-nodeを中心としたファイルシステムの概略も把握しているし、rmしてもファイルがすぐには消えないことも知っていますが、ほっておくと消えていくので時間との戦いです。このへんはどんなOSでも同じでしょう。とはいえ、リモートサーバなので下手にシングルユーザモードなどにしてネットワークアクセスができなくなると困ります。幸いこのサーバは他の用途で使っていないので、httpdを含む不要そうなサービスをすばやく止め、ディスクアクセスを最小限にします。

次に、ファイルシステムをこのままいじっていくわけにいかないので、どこかにファイルシステムのイメージを置いてゆっくり作業したいところです。このサーバにはメインのファイルシステムが一つしかないので、イメージを一時的にも同じサーバ内には置けません。sshできる方向はローカルからサーバのみ。ddはroot権限がいる。最短時間で。ということで、sshdのPermitRootLoginをyesにし、rootのパスワードはさすがに使いたくなかったので気休めですがuid=0の別アカウントを急遽用意して、このアカウントでローカルからddしました。今にして思えば、デバイスのアクセス許可を自分に出すだけでもよかったですね。

```
local# ssh uid0@hoge.example.com dd if=/dev/hogeroot ibs=1M >hoge.img
```

これも幸いですが、このファイルシステムは16GBほどしかなく、25分ほどでイメージの転送が完了しました。やらかして、45分後です。

さらに、このファイルは原本として死守する必要があるので、別の作業用ファイルにコピーしておきます。

```
local# cp hoge.img hoge.wrk
```

## ファイル回復

ここからは腰を据えて作業できます。

まずは、ext4のファイルシステムでrmしてしまったファイルをどう回復すればよいか、ググります。出てきた https://www.no-title.com/linux/extundelete で、extundelete というコマンドがあることを知り、インストールします。指定したファイルシステムから指定したパスのファイルを回復するようです。

```
local# apt install extundelete
```

次に、ddしたファイルシステムイメージに対してどうすればファイルシステムとしてアクセスできるか、ググります。出てきた http://ng3rdstmadgke.hatenablog.com/entry/2016/10/06/064434 によれば、ファイルをループデバイスとして設定してやればマウントもできるようです。`loop14` は適当です。他のが使われていたようなので、使われていない最小の番号にしました。マウントは作業には不要ですが、ファイルシステムが認識されているかの確認用です。

```
local# losetup /dev/loop14 hoge.wrk
local# mount -o ro /dev/loop14 /mnt
```

これら、参考にした平易な記事を書いてくださっているみなさんには感謝です。

さて、準備はできました。extundeleteは動くでしょうか。とりあえずパスのすぐわかる出力先PDFを指定して試します。

```
local# extundelete --restore-file /var/www/hoge/data/20xx/hoge.pdf /dev/loop14
...
Unable to restore inode 915251 (var/www/hoge/data/20xx/hoge.pdf): Space has been reallocated.
...
```

Unable...、だめなのか。reallocated...、もう上書きされたのか。遅かったのか。

他のファイルも試します。多数ありますが、パスは機械的に生成されるので、その一覧を生成して一気に与えました。

```
local# extundelete --restore-files /tmp/filelist.txt /dev/loop14
...
Successfully restored file /var/www/hoge/data/20xx/....
Successfully restored file /var/www/hoge/data/20xx/....
Successfully restored file /var/www/hoge/data/20xx/....
Successfully restored file /var/www/hoge/data/20xx/....
...
```

すごい。Successfully!! じゃんじゃん回復して `RECOVERED_FILES` というディレクトリに作成されていきました。ごく一部に回復できなかったファイルが報告されましたが、これも幸いなことに、手動で回復できるものでした。

## 復旧

実際には、ほかにいくつもリストからもれたファイルやリンクなどがあったのですが、それらも手作業で回復できました。あとから落ち付いてマニュアルを読むと `--restore-directory` というオプションがあり、こちらの方が断然楽ができたみたい。

最後にファイルをサーバにコピーし、祈りながら各サービスを復帰させたところ、件のWebサービスがやらかし前のまま無事に動作していることが確認できました。安堵しました。

例のURLを踏んでから復旧完了まで約6時間で済み、最初の編集作業が終わった時期だったのでサーバの停止に気づいたユーザはごく少数です。ましてや、裏でこんなことがあったと知っている人はいません。

# 原因と対策

## 何が問題だったか

そもそも、初期化のページに行くには警告が出るはずでした。しかしそれが出ず、閲覧履歴から開くだけで初期化されてしまったのが問題です。

なぜそんなことが起きたか、みなさんはもう想像がついていることでしょう。

それは、初期化ページのURLをGETメソッドで叩くだけで、初期化が行われる仕様だったせいです。管理者ページからそのリンクをクリックしたときだけ、 `onclick` で `confirm()` を呼んで警告を出すしくみでした。閲覧履歴からそのページを開けば、 `confirm()` など実行するはずもなく、GETメソッドでいきなり叩いてしまい、初期化が実行されてしまったわけです。

## 再発しないための対策

あたりまえですが、Webサービスの内部状態を変化させる処理は、GETメソッドで起動してはいけません。Webサービスの初期化も当然内部状態を変化させます。例のリンクが、formのボタンにもなっていないただのリンクであるところで気付いて修正しておくべきでした。ボタンでもGETを使っていれば同じですが。

対策としては、POSTメソッドでアクセスしたときだけ初期化するように変更しました。初期化した結果は常に同じで羃等性があるので、DELETEを使うという手もあったかもしれません。

### CSRFに関する追記（2020/12/9）

この記事に直接だけでなく、はてなブックマークやTwitterでも多数のコメントをいただき、ありがとうございます。その中で特にCSRFに関して強く言及いただきました。CSRFについては無対策であったため、本記事をそのままなぞって、GETをPOST等に修正しさえすればOKとされてしまわないよう、追記します。

GETの場合はCSRF以前の話で、自分ひとりで勝手にやらかしてしまいます。POST等にすればいわゆる自爆はなくなるのですが、URLを知っている攻撃者がPOST等のリクエストを生成するリンクやボタンを設置し、被害者がログイン状態でそれを踏むと、そのリクエストを実行してしまうというCSRF攻撃が成立してしまいます。重要な処理（商品購入や送金、メールアドレス変更、Webサービス初期化:sweat_smile:、等）の最終確定時にCSRF対策が必要です。

本件のような内部利用システムでは、CSRF対策はRefererチェックという簡便な方法ですむことも多いと思われます。不特定多数が利用する商用システムでのCSRF対策には、きちんとセッションを管理し、最終確定前のページでセッションIDなどのトークンを埋め込んで、最終確定ページでトークンを確認してから処理を実行するのが一般的です。セッション管理にはノウハウが多数あるようで、独自実装よりもフレームワーク利用がよいかもしれません。これらの詳細は[徳丸本](https://www.amazon.co.jp/dp/4797393165)などをご参照ください。

# 結論

やらかしてしまったものの、次のような幸運が重なり、最小限の影響だけで復旧できました。

* アクセスのほとんどない期間だった。
* rmによるファイルの単純な削除だった。
* サーバが他の用途に使用されていなかった。
* ファイルシステムが小さく短時間でイメージが転送できた。
* SSDではなくHDDだった。

大事なことなのでもう一度言います。Webサービスの内部状態を変化させる処理は、GETメソッドで起動してはいけません。

現在の自分はわかっていても、過去の自分も含めてみんながそう実装している保証はありません。「GET POST 使い分け」みたいなキーワードで検索して上位に出てくる解説Webページでも、このことにちゃんと触れているものはかなり少ないようです。特に、完全な「初期化」は普通のWebサービスにはなかなか存在しない処理ですし、パラメータも必要ないので、GETで実装してしまうというミスがあっても不思議ではありません。管理者用の機能の実装は手を抜きがちですが、前任者のコードも自分の過去コードでさえも信用せず確認する習慣を忘れずに。