zip パス トラバーサル

OWASP カテゴリ: MASVS-STORAGE: ストレージ

概要

zip パス トラバーサルの脆弱性(ZipSlip とも呼ばれます)は、圧縮されたアーカイブの処理に関連します。ここでは、ZIP 形式を例としてこの脆弱性を説明しますが、TAR、RAR、7z などの他の形式を扱うライブラリでも同様の問題が発生する可能性があります。

この問題の背景にある原因は、ZIP アーカイブ内にパックされた各ファイルが完全修飾名で保存されているため、スラッシュやドットなどの特殊文字を使用できることです。java.util.zip パッケージのデフォルト ライブラリは、アーカイブ エントリの名前にディレクトリ トラバーサル文字(../)があるかどうかをチェックしないため、ターゲットのディレクトリ パスにアーカイブから抽出した名前を連結するときには特別な注意が必要です。

外部ソースから取得した ZIP からの抽出を行うコード スニペットやライブラリを検証することが非常に重要です。このようなライブラリの多くは、zip パス トラバーサルに対して脆弱です。

影響

zip パス トラバーサルの脆弱性を使用すると、任意のファイルを上書きできます。影響は状況によって異なりますが、多くの場合、コードの実行など、セキュリティ上の重大な問題につながる可能性があります。

リスクの軽減

この問題を軽減するには、各エントリを抽出する前に、ターゲットパスが宛先ディレクトリの子であることを常に確認する必要があります。以下のコードは、宛先ディレクトリが安全である(デベロッパーのアプリのみが書き込み可能で、攻撃者の管理下にはない)ことを前提としています。そうでない場合、symlink 攻撃など、他の脆弱性が発生しやすくなる可能性があります。

Kotlin

companion object {
    @Throws(IOException::class)
    fun newFile(targetPath: File, zipEntry: ZipEntry): File {
        val name: String = zipEntry.name
        val f = File(targetPath, name)
        val canonicalPath = f.canonicalPath
        if (!canonicalPath.startsWith(
                targetPath.canonicalPath + File.separator)) {
            throw ZipException("Illegal name: $name")
        }
        return f
    }
}

Java

public static File newFile(File targetPath, ZipEntry zipEntry) throws IOException {
    String name = zipEntry.getName();
    File f = new File(targetPath, name);
    String canonicalPath = f.getCanonicalPath();
    if (!canonicalPath.startsWith(targetPath.getCanonicalPath() + File.separator)) {
      throw new ZipException("Illegal name: " + name);
    }
    return f;
 }

既存のファイルが誤って上書きされないように、抽出処理を開始する前に、宛先ディレクトリが空になっていることを確認することも必要です。そうしなかった場合、アプリのクラッシュや極端な場合はアプリの侵害のリスクが生じます。

Kotlin

@Throws(IOException::class)
fun unzip(inputStream: InputStream?, destinationDir: File) {
    if (!destinationDir.isDirectory) {
        throw IOException("Destination is not a directory.")
    }
    val files = destinationDir.list()
    if (files != null && files.isNotEmpty()) {
        throw IOException("Destination directory is not empty.")
    }
    ZipInputStream(inputStream).use { zipInputStream ->
        var zipEntry: ZipEntry
        while (zipInputStream.nextEntry.also { zipEntry = it } != null) {
            val targetFile = File(destinationDir, zipEntry.name)
            // ...
        }
    }
}

Java

void unzip(final InputStream inputStream, File destinationDir)
      throws IOException {
  if(!destinationDir.isDirectory()) {
    throw IOException("Destination is not a directory.");
  }

  String[] files = destinationDir.list();
  if(files != null && files.length != 0) {
    throw IOException("Destination directory is not empty.");
  }

  try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) {
    ZipEntry zipEntry;
    while ((zipEntry = zipInputStream.getNextEntry()) != null) {
      final File targetFile = new File(destinationDir, zipEntry);
        …
    }
  }
}

参考資料