安易に解凍するでない
Zipファイルをサーバー上で解凍するコードを書いたら、SonarQubeに怒られました。
ファイルコピー系は最新の注意を払いましょう。
SonarQube:コードの静的解析ツール
はじめに
一見シンプルに見えるZipファイルの解凍処理ですが、実はセキュリティリスクが潜んでいるんです。この記事では、どんな問題があるのか、そしてどう対策すべきかを解説します。
今回の記事で解説するセキュリティ対策はあくまで例であり、「これを真似すれば対策万全」という訳ではありません。実際の運用環境に応じて適切な対策を検討してください。
やろうとしたこと
サーバー上でZipファイルを解凍する処理を実装しようとしました。具体的には以下のような処理です:
- 実行サーバー上でアップロードされたZipファイルを解凍
-
ZipInputStreamを使って解凍するコードを実装 - 各エントリを読み取りながら展開
最初に書いたコード(問題あり)
public void unzip(InputStream inputStream, Path destDir) throws IOException {
try (ZipInputStream zis = new ZipInputStream(inputStream)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
Path destPath = destDir.resolve(entry.getName());
if (entry.isDirectory()) {
Files.createDirectories(destPath);
} else {
Files.createDirectories(destPath.getParent());
try (OutputStream os = Files.newOutputStream(destPath)) {
byte[] buffer = new byte[8192];
int len;
while ((len = zis.read(buffer)) > 0) {
os.write(buffer, 0, len);
}
}
}
zis.closeEntry();
}
}
}
このコードは一見正常に動作しそうに見えますが、実は重大なセキュリティ上の問題を抱えています。
何がダメだったのか
SonarQubeに指摘されたのは、主に以下の2つの問題です:
1. Zip爆弾(Zip Bomb)への対策がない
Zip爆弾とは?
圧縮率が異常に高いZipファイルのことです。例えば、数キロバイトのZipファイルが解凍すると数テラバイトになるような悪意のあるファイルです。このようなファイルを解凍すると、ディスク容量を使い果たしたり、メモリ不足でサーバーがダウンしたりする可能性があります。
例:解凍ツールでは事前検知不可能、46MBが4.5PBへと膨れ上がるZIP爆弾がネット上で公開中【やじうまWatch】 - INTERNET Watch
具体的には以下のチェックが必要です:
- エントリ数の制限:解凍後にとんでもないファイル数になるのを防ぐ
- 解凍後の総ファイルサイズの制限:シンプルにデカすぎるファイルではないか
- 圧縮率のチェック:デカいファイルが小さく圧縮されていないか
2. パストラバーサル攻撃への対策がない
パストラバーサル攻撃とは?
Zipファイル内のエントリ名に ../../etc/passwd のような相対パスやシンボリックリンクを含めることで、意図しないディレクトリにファイルを書き込ませる攻撃手法です。
参考:パストラバーサルとは【用語集詳細】
例えば、Zipファイルに ../../../important-file.txt というエントリが含まれていた場合、解凍先ディレクトリの外にファイルが作成されてしまう危険性があります。
修正したこと
上記の問題を解決するために、以下の対策を実装しました:
Zip爆弾対策
- エントリ数の制限: 1つのZipファイルに含まれるエントリの上限を設定
- ファイルサイズの制限: 解凍後の総サイズと各ファイルのサイズを監視
- 圧縮率のチェック: 異常に高い圧縮率を検出
パストラバーサル対策
-
パスの正規化:
normalize()を使用してパスを正規化 - 範囲チェック: 解凍先が指定ディレクトリ内に収まるかを確認
-
シンボリックリンクの無効化:
LinkOption.NOFOLLOW_LINKSオプションを指定 -
Canonical Pathの確認:
getCanonicalPath()で最終的な実パスをチェック
修正後のコード(セキュア版)
public void unzip(InputStream inputStream, Path destDir) throws IOException {
// セキュリティ制限の設定
final int MAX_ENTRIES = 10000; // 最大エントリ数
final long MAX_SIZE = 1024 * 1024 * 1024; // 最大サイズ: 1GB
final double MAX_COMPRESSION_RATIO = 100.0; // 最大圧縮率
int entryCount = 0;
long totalSize = 0;
try (ZipInputStream zis = new ZipInputStream(inputStream)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
// エントリ数チェック
if (++entryCount > MAX_ENTRIES) {
throw new IOException("エントリ数が多すぎます");
}
// ファイルサイズと圧縮率チェック
long compressedSize = entry.getCompressedSize();
long uncompressedSize = entry.getSize();
if (uncompressedSize > MAX_SIZE) {
throw new IOException("ファイルサイズが大きすぎます");
}
if (compressedSize > 0 && uncompressedSize / compressedSize > MAX_COMPRESSION_RATIO) {
throw new IOException("圧縮率が異常です");
}
// パスの正規化
Path destPath = destDir.resolve(entry.getName()).normalize();
// パストラバーサル対策
if (!destPath.startsWith(destDir.toAbsolutePath())) {
throw new IOException("不正なパスです");
}
if (!destPath.toFile().getCanonicalPath().startsWith(destDir.toFile().getCanonicalPath())) {
throw new IOException("不正なシンボリックリンクです");
}
if (entry.isDirectory()) {
Files.createDirectories(destPath);
} else {
Files.createDirectories(destPath.getParent());
// シンボリックリンクを追跡しないオプションを指定
try (OutputStream os = Files.newOutputStream(destPath,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS)) {
byte[] buffer = new byte[8192];
int len;
totalSize += uncompressedSize;
if (totalSize > MAX_SIZE) {
throw new IOException("合計サイズが大きすぎます");
}
while ((len = zis.read(buffer)) > 0) {
os.write(buffer, 0, len);
}
}
}
zis.closeEntry();
}
}
}
セキュリティ対策のポイント解説
1. 制限値の設定
final int MAX_ENTRIES = 10000; // 最大エントリ数
final long MAX_SIZE = 1024 * 1024 * 1024; // 最大サイズ: 1GB
final double MAX_COMPRESSION_RATIO = 100.0; // 最大圧縮率
これらの値は、システムの要件に応じて調整してください。
2. パスの検証
Path destPath = destDir.resolve(entry.getName()).normalize();
if (!destPath.startsWith(destDir.toAbsolutePath())) {
throw new IOException("不正なパスです");
}
normalize()でパスを正規化し、startsWith()で解凍先ディレクトリ配下かどうかを確認しています。
3. Canonical Pathによる最終チェック
if (!destPath.toFile().getCanonicalPath().startsWith(destDir.toFile().getCanonicalPath())) {
throw new IOException("不正なシンボリックリンクです");
}
シンボリックリンクやハードリンクを辿った後の実際のパスでもチェックを行います。これにより、より高度な攻撃にも対応できます。
まとめ
Zipファイルの解凍は、一見単純な処理に見えますが、以下のようなセキュリティリスクが潜んでいます:
- Zip爆弾: 異常に大きなファイルや多数のエントリによるDoS攻撃
- パストラバーサル: 意図しないディレクトリへのファイル書き込み
- シンボリックリンク悪用: リンクを使った不正なファイルアクセス
対策としては:
- エントリ数、ファイルサイズ、圧縮率の制限を設ける
- パスの正規化と範囲チェックを行う
- シンボリックリンクを追跡しないオプションを使用する
- Canonical Pathで最終的な実パスを確認する
特に、外部からアップロードされたZipファイルを処理する場合は、必ずこれらの対策を実装しましょう。SonarQubeのような静的解析ツールを活用すると、このような潜在的な脆弱性を早期に発見できるのでおすすめです!