설명
Zip Slip은 압축 파일(zip 등) 내부 엔트리 이름을 그대로 사용해 대상 디렉터리 아래에 파일을 생성할 때 발생하는 경로 탐색 취약점입니다. 엔트리 이름에 ../, .., 절대 경로(/, C:\ 등) 같은 요소가 포함되면, 정규화/검증 없이 결합된 결과 경로가 대상 디렉터리 밖으로 벗어나 민감한 위치에 파일을 생성·덮어쓸 수 있습니다. 공격자는 악성 아카이브에 이러한 경로를 포함시켜 애플리케이션 권한으로 쓸 수 있는 의도하지 않은 위치에 파일을 기록하게 만들 수 있습니다. 또한 심볼릭 링크를 악용해 의도치 않은 경로로 쓰게 만들 가능성도 있습니다. 실제 영향은 파일 권한과 기록된 파일이 사용되는 방식에 따라 달라집니다.
잠재적 영향
- 임의 파일 쓰기/덮어쓰기: 프로세스에 쓰기 권한이 있는 위치에서 파일 생성이나 덮어쓰기가 발생할 수 있음
- 구성 파일 변조: 애플리케이션/서버 설정 파일을 바꿔 동작을 변경하거나 서비스 중단 유발
- 원격 코드 실행: 웹 루트에 악성 스크립트(WebShell) 업로드 또는 실행 경로의 바이너리/스크립트 교체로 코드 실행 가능
- 정보 유출 및 권한 상승: 권한 있는 프로세스가 쓰는 경로를 악용해 민감 정보 노출 또는 높은 권한 획득
해결 방법
- 경로 정규화 및 구성 요소 검증:
base.resolve(entryName).normalize()로 정규화한 뒤, 결과 경로가 반드시base디렉터리 아래인지Path.startsWith(base)로 확인 - 절대 경로/상위 디렉터리 거부: 이름이 절대 경로이거나 ../, ..\ 를 포함하면 즉시 거부
- 허용 목록 사용: 예상 가능한 파일/디렉터리 구조만 추출 허용
- 디렉터리 생성 시도 전 검증: 부모 디렉터리도 정규화·검증 후에만 Files.createDirectories 수행
- 심볼릭 링크 방어: 새 비공개 추출 디렉터리를 사용하고 링크 엔트리를 건너뛰며, 중첩 경로를 허용할 경우 각 부모 구성 요소가 심볼릭 링크가 아닌지 디렉터리 상대 API로 확인
- 덮어쓰기 방지:
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())을 그대로 new File(destDir, name)에 결합해 사용합니다. 공격자가 "../../etc/passwd" 같은 이름을 넣으면, 결합된 경로가 대상 디렉터리를 벗어나 프로세스가 쓸 수 있는 위치의 민감 파일을 생성·덮어쓸 수 있습니다.
- 변경 후: 추출마다 새 비공개 디렉터리를 만들고 하위 디렉터리 없는 일반 파일 이름만 허용합니다. 구성 요소 기반 경로 검사를 통과한 파일을
CREATE_NEW와NOFOLLOW_LINKS로 생성하므로 심볼릭 링크 부모를 통과하거나 기존 파일·링크를 자르지 않습니다. 디렉터리 구조가 필요하다면 각 부모를 디렉터리 상대 방식으로 생성·검증하는 별도 구현이 필요합니다.