아카이브 추출 중 임의 파일 접근 (Zip Slip)

아카이브 엔트리 경로 검증 누락으로 추출 위치 밖에 파일을 쓰는 문제

설명

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로 생성하므로 심볼릭 링크 부모를 통과하거나 기존 파일·링크를 자르지 않습니다. 디렉터리 구조가 필요하다면 각 부모를 디렉터리 상대 방식으로 생성·검증하는 별도 구현이 필요합니다.

참조