JPCERT コーディネーションセンター

IDS04-J. ZipInputStream からファイルを安全に展開する

Java では、ZIP 形式や GZIP 形式のデータの読込み、作成、更新を行うための java.util.zip パッケージが提供されている。

java.util.zip.ZipInputStream を使って ZIP ファイルを展開する際には、考慮すべきセキュリティ上の注意点が多々存在する。まずひとつめの問題は、ZIP ファイルに記録されているファイル名情報にはディレクトリパスが含まれている可能性があるということである。このようなファイル名をそのまま使って展開すると、想定しているディレクトリの外部に展開されるかもしれない。この手法は、システムファイルを上書きさせる攻撃でよく使われ、ディレクトリトラバーサル あるいは path equivalence と呼ばれている。この脆弱性を防ぐには、展開処理の前に、ファイルパスを正規化(canonicalize)して適切な値であるかどうかを検証するとよい。[FIO16-J. Canonicalize path names before validating them]を参照。

ふたつめの問題は、展開処理がシステムリソースを大量に消費することがあるということだ。zip 圧縮の圧縮比はとても大きくなることがあることが知られている[Mahmoud 2002]。例えば、a が並んだ行とbが並んだ行が交互に現れるファイルを圧縮すると 1/200 以上にもなることがある。また、圧縮アルゴリズムに応じたデータをうまく用意すれば、さらに高い圧縮比を得られる可能性もある。この性質を悪用すると、ごく小さなサイズの細工された ZIP ファイルや GZIP ファイルの展開処理によりシステムリソースを大量に消費させる攻撃が可能になる。このようなファイルはzip 爆弾と呼ばれている。zip 爆弾の例としては、42.zip が知られている。これはサイズ42キロバイトzip ファイルで、なかには zip ファイルを16個含んでおり、個々の zip ファイルはさらに16個の zip ファイルを含む。このような入れ子構造が5階層に渡って続き、最終的には 4.3 ギガバイト (4 294 967 295 バイト、つまりおよそ 3.99 GiB) のファイルに展開される。全ての zip ファイルを展開すると、合計 4.5 ペタバイト (4 503 599 626 321 920 バイト、つまりおよそ 3.99 PiB) となる。このように zip 爆弾の典型的な構造は、高い圧縮比を得るために同一ファイルを複数含む形になっていることが多い。対策としては、このような構造を検知して展開処理を中断する、あるいはシステムリソースの使用量が一定の制限を超えたら展開処理を中断する、といった仕組みが必要である。どの程度の制限をかけるべきかについては、システム環境や想定される動作環境に依存する。

違反コード

この違反コード例では、展開しようとするファイルのファイル名が適切なものかどうかを検証しないまま FileOutputStream のコンストラクタに直接渡している。また、展開によってどのくらいリソースを消費するかもチェックしていない。したがって、展開処理は最後まで行われるかもしれないし、途中でリソースが消費し尽くされるかもしれない。

static final int BUFFER = 512;
// ...

public final void unzip(String filename) throws java.io.IOException{
  FileInputStream fis = new FileInputStream(filename);
  ZipInputStream zis = new ZipInputStream(new BufferedInputStream(fis));
  ZipEntry entry;
  try {
    while ((entry = zis.getNextEntry()) != null) {
      System.out.println("Extracting: " + entry);
      int count;
      byte data[] = new byte[BUFFER];
      // ファイルをディスクに書き出す
      FileOutputStream fos = new FileOutputStream(entry.getName());
      BufferedOutputStream dest = new BufferedOutputStream(fos, BUFFER);
      while ((count = zis.read(data, 0, BUFFER)) != -1) {
        dest.write(data, 0, count);
      }
      dest.flush();
      dest.close();
      zis.closeEntry();
    }
  } finally {
    zis.close();
  }
}
違反コード (getSize())

この違反コード例では、展開処理を始める前に、ZipEntry.getSize() を使って展開後のファイルサイズを確認している。しかし、zip ファイル中のファイルサイズは改変されている可能性があるため、getSize() で得られる値は信用できない。

static final int BUFFER = 512;
static final int TOOBIG = 0x6400000; // 100MB
// ...

public final void unzip(String filename) throws java.io.IOException{
  FileInputStream fis = new FileInputStream(filename);
  ZipInputStream zis = new ZipInputStream(new BufferedInputStream(fis));
  ZipEntry entry;
  try {
    while ((entry = zis.getNextEntry()) != null) {
      System.out.println("Extracting: " + entry);
      int count;
      byte data[] = new byte[BUFFER];
      // ファイルサイズが制限値を超えなければファイルをディスクに書き出す
      if (entry.getSize() > TOOBIG ) {
         throw new IllegalStateException("File to be unzipped is huge.");
      }
      if (entry.getSize() == -1) {
         throw new IllegalStateException("File to be unzipped might be huge.");
      }
      FileOutputStream fos = new FileOutputStream(entry.getName());
      BufferedOutputStream dest = new BufferedOutputStream(fos, BUFFER);
      while ((count = zis.read(data, 0, BUFFER)) != -1) {
        dest.write(data, 0, count);
      }
      dest.flush();
      dest.close();
      zis.closeEntry();
    }
  } finally {
    zis.close();
  }
}

Acknowledgement: このコードの脆弱性は、Giancarlo Pellegrino (the Technical University of Darmstadt in Germany) および Davide Balzarotti (EURECOM in France) によって指摘された。

適合コード

以下の適合コードでは、zip アーカイブ内の各エントリについて、展開を行う前にファイル名を検証し、ファイル名が不正なものであった場合には展開処理全体を中止している。ファイル名が不正なものだけ展開をスキップする、あるいは安全な場所に展開する、というやり方もあるだろう。

while ループの中では、各エントリの展開後のサイズを確認しており、そのサイズが大き過ぎる場合には例外をスローしている(ここでは約100MBを上限としている)。zip アーカイブに含まれるファイルサイズは信頼できないため、ZipEntry.getSize() メソッドは使っていない。さらに zip アーカイブに含まれるエントリ数もチェックしており、その数が1024を超える場合には例外をスローしている。

static final int BUFFER = 512;
static final long TOOBIG = 0x6400000; // ファイルサイズの上限, 100MB
static final int TOOMANY = 1024;      // エントリ数の上限
// ...

private String validateFilename(String filename, String intendedDir)
      throws java.io.IOException {
  File f = new File(filename);
  String canonicalPath = f.getCanonicalPath(); 

  File iD = new File(intendedDir);
  String canonicalID = iD.getCanonicalPath();
  
  if (canonicalPath.startsWith(canonicalID)) {
    return canonicalPath;
  } else {
    throw new IllegalStateException("File is outside extraction target directory.");
  }
}

public final void unzip(String filename) throws java.io.IOException {
  FileInputStream fis = new FileInputStream(filename);
  ZipInputStream zis = new ZipInputStream(new BufferedInputStream(fis));
  ZipEntry entry;
  int entries = 0;
  long total = 0;
  try {
    while ((entry = zis.getNextEntry()) != null) {
      System.out.println("Extracting: " + entry);
      int count;
      byte data[] = new byte[BUFFER];
      // ファイル名が不正でないこととファイルサイズが大き過ぎないことを
      // 確認してファイルをディスクに書き出す
      String name = validateFilename(entry.getName(), ".");
      if (entry.isDirectory()) {
        System.out.println("Creating directory " + name);
        new File(name).mkdir();
        continue;
      }
      FileOutputStream fos = new FileOutputStream(name);
      BufferedOutputStream dest = new BufferedOutputStream(fos, BUFFER);
      while (total + BUFFER <= TOOBIG && (count = zis.read(data, 0, BUFFER)) != -1) {
        dest.write(data, 0, count);
        total += count;
      }
      dest.flush();
      dest.close();
      zis.closeEntry();
      entries++;
      if (entries > TOOMANY) {
        throw new IllegalStateException("Too many files to unzip.");
      }
      if (total + BUFFER > TOOBIG) {
        throw new IllegalStateException("File being unzipped is too big.");
      }
    }
  } finally {
    zis.close();
  }
}
リスク評価

ルール

深刻度

可能性

修正コスト

優先度

レベル

IDS04-J

P2

L3

Automated Detection
ツール バージョン チェッカー 説明
The Checker Framework

2.1.3

Tainting Checker Trust and security errors (see Chapter 8)
SonarQube
6.7

S5042

Expanding archive files is security-sensitive

関連ガイドライン

MITRE CWE

CWE-409, Improper Handling of Highly Compressed Data (Data Amplification)

Secure Coding Guidelines for Java SE, Version 5.0

Guideline 1-1 / DOS-1: Beware of activities that may use disproportionate resources

関連する脆弱性
脆弱性 説明
Zip Slip

Zip Slip は、アーカイブファイルの展開処理におけるディレクトリトラバーサルの脆弱性である。アーカイブファイル中のファイルパスを検証しないまま出力先ファイルパスとして使用しているため、想定しているディレクトリの外にファイルを展開し、例えば既存のシステムファイルを上書きしてしまう可能性がある。すなわち、細工した zip ファイルを展開させることで既存の実行ファイルを上書きし、被害者のシステム上で実行させるという攻撃が可能になる。Snyk は、この脆弱性について関係者との間で事前に調整を行い、2018年6月5日に一般公表した(https://snyk.io/blog/zip-slip-vulnerability/)。

実装の詳細 (Android)

このルールに関連する事例として、Android Master Key vulnerability (ZipEntry の使用に関連した脆弱性) と、中国の研究者が発見した別の攻撃手法がある。

参考文献

[Mahmoud 2002]

Compressing and Decompressing Data Using Java APIs

[Seacord 2015] Image result for video icon IDS04-J. Safely extract files from ZipInputStream LiveLesson
翻訳元

これは以下のページを翻訳したものです。

IDS04-J. Safely extract files from ZipInputStream (revision 110)

Top へ

Topへ
最新情報(RSSメーリングリストTwitter