説明
Zip Slipは、展開先ディレクトリの内側に保存されることを確認せず、アーカイブのエントリ名からファイルを作成する問題です。../、..\、絶対パスなどを含む名前によって、指定ディレクトリの外へ書き込み先が変わるおそれがあります。悪意のあるアーカイブは、アプリケーションが書き込める場所にファイルを作成・上書きする可能性があります。シンボリックリンクも書き込み先の変更に使われます。影響はファイル権限と、その後のファイルの使用方法によって異なります。
想定される影響
- ファイルの作成・上書き: プロセスが書き込める意図しない場所にファイルが作成されるおそれがあります。
- 設定の改ざん: アプリケーションやサーバーの設定が変更され、動作の変化やサービス中断につながる可能性があります。
- コード実行: 実行可能なWeb領域へのスクリプト配置や、後で実行されるプログラムの置換により、コードが実行されるおそれがあります。
- 情報漏えい・権限昇格: 強い権限を持つプロセスが後で使うファイルの改ざんにより、情報公開やアクセス権限の拡大につながる可能性があります。
対処方法
base.resolve(entryName).normalize()で正規化し、パス要素を比較するPath.startsWith(base)で保存先がbaseの内側に収まることを確認してください。- 絶対パスや
../・..\などの親ディレクトリへの移動を拒否してください。 - 想定したファイル・ディレクトリ構造だけを許可してください。
Files.createDirectoriesを呼ぶ前に親パスを検証してください。- 新しい非公開ディレクトリへ展開し、リンクのエントリを除外してください。入れ子のパスが必要なら、ディレクトリを基準にした操作で各親要素を検証し、シンボリックリンクをたどらないようにしてください。
CREATE_NEWとNOFOLLOW_LINKSを使い、既存ファイルの置換やリンクの追跡を防いでください。- 展開処理は必要最小限の権限で実行してください。
例
変更前
java
import java.io.*;
import java.util.zip.*;
public class UnsafeUnzipper {
public void extract(InputStream zipStream, File destDir) throws IOException {
ZipInputStream zis = new ZipInputStream(zipStream);
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
// エントリ名を保存先へ直接結合
File outFile = new File(destDir, entry.getName());
if (entry.isDirectory()) {
outFile.mkdirs();
continue;
}
outFile.getParentFile().mkdirs();
try (OutputStream os = new FileOutputStream(outFile)) {
byte[] buf = new byte[8192];
int len;
while ((len = zis.read(buf)) != -1) {
os.write(buf, 0, len);
}
}
}
}
}
変更後
Java 11以降の例です。destDirはアプリケーションが管理し、他のユーザーが変更できないディレクトリにしてください。展開後のサイズとエントリ数には、別途上限を設けてください。
java
import java.io.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
public class SafeUnzipper {
public void extract(InputStream zipStream, File destDir) throws IOException {
// アプリケーション管理下に今回の展開専用の非公開ディレクトリを作成
Path serviceRoot = destDir.toPath().toRealPath();
Path base = Files.createTempDirectory(serviceRoot, "extract-")
.toRealPath();
try (ZipInputStream zis = new ZipInputStream(zipStream)) {
ZipEntry entry;
byte[] buf = new byte[8192];
while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName();
Path entryPath = Path.of(name);
// 通常のファイル名だけを許可し、リンクになり得る親ディレクトリを作らない
if (entry.isDirectory() || name.isBlank() || entryPath.isAbsolute()
|| name.indexOf('/') >= 0 || name.indexOf('\\') >= 0
|| name.equals(".") || name.equals("..")) {
throw new IOException("Invalid zip entry name: " + name);
}
Path target = base.resolve(entryPath).normalize();
if (!target.startsWith(base) || !target.getParent().equals(base)) {
throw new IOException("Zip Slip detected: " + name);
}
Set<OpenOption> options = Set.of(
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS);
try (SeekableByteChannel channel = Files.newByteChannel(target, options);
OutputStream os = Channels.newOutputStream(channel)) {
int len;
while ((len = zis.read(buf)) != -1) {
os.write(buf, 0, len);
}
}
}
}
}
}
説明:
- 変更前:
entry.getName()を保存先に直接結合します。../../etc/passwdのような名前は指定ディレクトリの外を指し、プロセスに権限があれば機密ファイルを作成・上書きする可能性があります。 - 変更後: 展開ごとに非公開ディレクトリを作り、サブディレクトリを含まない通常のファイル名だけを受け入れます。パス要素による範囲確認と
CREATE_NEW・NOFOLLOW_LINKSにより、親のシンボリックリンクをたどったり、既存ファイルやリンクを切り詰めたりしません。階層を扱う場合は、各親ディレクトリを相対的に作成・検証する実装が別途必要です。