Zip Slip (압축 해제 경로 조작)

Zip Slip (Archive Extraction Path Traversal)

설명

Zip Slip은 압축 파일의 엔트리 경로(entryName, fileName 등)를 검증하지 않고 파일 시스템 경로로 사용해 쓸 때 발생하는 취약점입니다. 공격자는 엔트리 이름에 "../"(디렉터리 트래버설) 또는 절대 경로(C:, /tmp 등)를 넣어, 의도된 추출 디렉터리 밖의 임의 위치에 파일을 생성하거나 덮어쓸 수 있습니다. 악용 시 웹 루트에 스크립트를 심어 원격 코드 실행을 유발하거나, 시스템/애플리케이션 설정 파일을 덮어써 서비스 장애를 일으킬 수 있습니다.

잠재적 영향

  • 임의 파일 쓰기/덮어쓰기: 공격자가 애플리케이션 권한으로 임의 경로에 파일을 생성·수정해 웹셸 업로드, 설정 변조를 유발할 수 있음.
  • 원격 코드 실행: 웹 루트, 크론/시작 스크립트 경로 등에 파일을 쓰면 코드가 실행될 수 있음.
  • 서비스 거부(DoS): 중요한 구성 파일, 바이너리, 로그를 덮어써 서비스 중단 또는 장애 유발 가능.
  • 데이터 무결성 훼손: 애플리케이션 데이터/로그 변조로 추적 회피 및 보안 가시성 저하.

해결 방법

  • 경로 검증: 엔트리 경로를 path.normalize한 뒤 절대 경로 여부(path.isAbsolute)와 상위 디렉터리 요소를 확인하고, path.resolve로 계산한 최종 경로가 목적지 디렉터리 내부인지 path.relative로 검사하세요.
  • 우회 차단: Windows 드라이브 표기(예: C:)를 거부하고, ZIP 외부 속성의 UNIX 파일 형식을 확인해 심볼릭 링크 엔트리를 건너뛰세요.
  • 허용 목록 기반: 필요한 확장자/디렉터리만 추출(allowlist)하고 나머지는 스킵하세요.
  • 파일 생성 정책: 신뢰할 수 있는 부모 아래에 새 전용 추출 디렉터리를 만들고, 기존 파일 덮어쓰기를 금지(open/write 시 'wx')하며, 디렉터리는 안전하게 생성하세요.
  • 라이브러리 옵션: zip 처리 라이브러리의 안전 옵션(엔트리 필터, symlink 차단)이 있으면 활성화하세요.

예시

변경 전

javascript
// 비준수: 엔트리 경로를 그대로 사용해 추출 (Zip Slip 취약)
const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");

function extractUnsafe(zipPath, destDir) {
  const zip = new AdmZip(zipPath);
  zip.getEntries().forEach((entry) => {
    const outPath = path.join(destDir, entry.entryName); // 검증 없음
    const data = entry.getData();
    // 공격자가 entry.entryName에 "../../../../var/www/html/shell.js"를 넣으면 외부로 탈출 가능
    fs.mkdirSync(path.dirname(outPath), { recursive: true });
    fs.writeFileSync(outPath, data); // 임의 위치 파일 생성/덮어쓰기
  });
}

변경 후

javascript
// 준수: 새 전용 디렉터리에서 엔트리 형식과 최종 경로를 검증한 뒤 쓰기
const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");

function isUnixSymlink(entry) {
  // ZIP 외부 속성의 상위 16비트에는 UNIX 파일 모드가 저장됩니다.
  const unixMode = entry.header.attr >>> 16;
  return (unixMode & 0o170000) === 0o120000;
}

function extractSafe(zipPath, destParent) {
  const zip = new AdmZip(zipPath);
  const parentReal = fs.realpathSync(destParent);
  const destReal = fs.mkdtempSync(path.join(parentReal, "extract-"));
  fs.chmodSync(destReal, 0o700);

  for (const entry of zip.getEntries()) {
    // 1) UNIX 심볼릭 링크 엔트리 거부
    if (isUnixSymlink(entry)) continue;

    // 2) 경로 정규화
    const normalized = path.normalize(entry.entryName);

    // 3) 빈 경로/절대 경로/드라이브 문자/상위 디렉터리 요소 차단
    if (normalized === "" || normalized === ".") continue;
    if (path.isAbsolute(normalized)) continue;
    if (normalized.split(path.sep).includes("..")) continue;
    if (/^[A-Za-z]:/.test(normalized)) continue;

    // 4) 최종 경로 계산 후 목적지 안쪽인지 확인
    const targetPath = path.resolve(destReal, normalized);
    if (
      path.relative(destReal, targetPath) !== ".." &&
      !path.relative(destReal, targetPath).startsWith(".." + path.sep) &&
      !path.isAbsolute(path.relative(destReal, targetPath))
    ) {
      if (entry.isDirectory) {
        fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 });
        continue;
      }

      // 5) 새 전용 디렉터리 안에서만 생성하고 기존 파일은 덮어쓰지 않음
      fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
      const data = entry.getData();
      fs.writeFileSync(targetPath, data, { flag: "wx", mode: 0o600 });
    }
  }

  return destReal;
}

설명:

  • 변경 전: 엔트리 이름(entryName)을 검증 없이 path.join으로 결합해 파일을 씀으로써, "../"가 포함된 엔트리가 목적지 디렉터리 밖으로 탈출해 임의 파일 생성/덮어쓰기가 가능합니다.
  • 변경 후: 신뢰할 수 있는 부모 아래에 새 전용 추출 디렉터리를 만들고, ZIP 외부 속성에서 UNIX 심볼릭 링크 형식인 엔트리를 실제로 거부합니다. 엔트리 경로를 정규화한 뒤 절대 경로·드라이브 문자·상위 디렉터리 요소를 차단하고, path.resolve와 path.relative로 최종 경로의 디렉터리 경계를 확인합니다. 검증을 통과한 파일만 wx 모드로 새로 생성하므로 기존 파일도 덮어쓰지 않습니다.

참조