.ipynb셀 출력 메타데이터의filename이 정규화 없이 경로 조합에 그대로 들어가,../로 출력 디렉터리 밖에 파일을 쓸 수 있었다.--to markdown · latex · rst · asciidoc에서만 터진다.ExtractOutputPreprocessor를 타는 익스포터만 해당된다.- nbconvert 7.17.2에서
os.path.basename()으로 패치. CVE-2026-114140 (CVSS v4 8.8 High) 발행, 리포터 5인 공동 크레딧.
시작하며 — 형제 취약점이 남긴 자리
먼저 공개된 건 GHSA-4c99-qj7h-p3vg(CVE-2026-39377)였다.
nbconvert가 셀 첨부파일(attachment)의 파일명을 정화하지 않고
파일시스템에 그대로 넘겨서, ../로 출력 디렉터리 밖에 파일을
쓸 수 있다는 내용이다. ExtractAttachmentsPreprocessor가
원인이었고 7.17.1에서 막혔다.
문제는 nbconvert에서 "노트북 안의 바이너리를 꺼내 파일로 떨군다"는 역할을
하는 전처리기가 하나가 아니라는 것이다. 첨부파일을 꺼내는
ExtractAttachmentsPreprocessor가 있고, 셀 실행 결과(output)를
꺼내는 ExtractOutputPreprocessor가 따로 있다. 둘 다
"사용자가 준 이름으로 출력 디렉터리에 쓴다"는 같은 일을 한다.
한쪽 입구만 막혔다면 다른 쪽은 어떤지 보는 게 자연스럽다. 결론부터 말하면
ExtractOutputPreprocessor는 그대로 열려 있었다.
어떤 결함이었는가
핵심은 nbconvert/preprocessors/extractoutput.py 한 줄이다.
출력에 metadata.filename이 붙어 있으면, nbconvert는 그 값을
그대로 최종 파일명으로 채택한다.
nbconvert/preprocessors/extractoutput.py:116-119 (패치 전)
if ext is None:
ext = "." + mime_type.rsplit("/")[-1]
if out.metadata.get("filename", ""):
filename = out.metadata["filename"] # <-- 정규화 없음
if not filename.endswith(ext):
filename += ext
이 filename이 뒤에서 output_files_dir과 결합돼
디스크에 기록된다. os.path.join()은 경로 구분자를 의미 그대로
해석할 뿐 "상위로 올라가지 마라"는 검사를 하지 않는다. 그래서
../../../tmp/PWNED.png 같은 값을 넣으면 결합 결과가 출력
디렉터리 바깥을 가리킨다.
공격자가 쥐는 건 경로·파일명·확장자·내용 전부다. 내용은
출력의 data에 base64로 넣은 값이 그대로 디코딩돼 기록되니,
PNG가 아니어도 상관없다. 프로세스 권한이 닿는 곳이면 어디든 쓴다.
어드바이저리가 authorized_keys 덮어쓰기를 영향 예시로 든 것도
같은 맥락이다.
왜 익스포터 네 개만 터지나
영향받는 변환 대상은 --to markdown, --to latex,
--to rst, --to asciidoc 네 개다.
HTML 익스포터는 해당되지 않는다. 이유는 단순한데, 알고 나면 이 결함의
성격이 분명해진다.
HTML은 이미지를 문서 안에 base64 data URI로 박아 넣을 수
있다. 그래서 출력을 파일로 꺼낼 이유가 없고,
ExtractOutputPreprocessor가 아예 돌지 않는다. 반면 마크다운·
LaTeX·reST·AsciiDoc은 본문과 이미지가 분리된 포맷이다.
이미지를 디스크에 떨구고 본문에서 상대경로로 참조해야 한다. 그 "떨구는"
단계가 바로 취약한 경로다.
즉 이건 특정 익스포터의 버그가 아니라, 노트북 안의 데이터를 파일시스템으로 내보내는 경로 전반에 공통으로 걸리는 문제다. 첨부파일 쪽(CVE-2026-39377)과 출력 쪽(이 건)이 같은 모양으로 따로 난 것도 그래서다.
검증한 것
PoC는 노트북 한 장이면 된다. 실행된 적 없는 셀에
display_data 출력을 직접 써 넣고, 메타데이터에 탈출 경로를
박는다. 데이터는 1x1 PNG면 충분하다.
poc.ipynb (cells 발췌)
"cells": [{
"cell_type": "code", "id": "abc123", "execution_count": 1,
"metadata": {}, "source": "# PoC",
"outputs": [{
"output_type": "display_data",
"metadata": {
"filename": "../../../tmp/PWNED.png"
},
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
}]
}]
재현
$ jupyter nbconvert --to markdown poc.ipynb
$ ls -l /tmp/PWNED.png # 출력 디렉터리 밖에 생성됨
확인한 범위는 여기까지다. 권한 상승이나 실제 서비스 대상 실증은
하지 않았다. authorized_keys 덮어쓰기는 결함의 도달
범위를 설명하는 시나리오지 직접 돌려본 결과가 아니다.
제약 — 실행하면 사라진다
이 취약점에는 현실적인 전제가 하나 붙는다. Jupyter에서 그 셀을
실행하면 출력이 새로 덮어써진다. 조작해 둔 outputs
블록이 진짜 실행 결과로 교체되면서 악성 메타데이터도 같이 날아간다.
그래서 공격이 성립하려면 피해자가 셀을 실행하지 않은 채로
변환만 돌려야 한다. 흔한가? 흔하다. 남이 준 노트북을 그대로
문서로 뽑는 CI 파이프라인, 리포트 자동 생성, 제출물 일괄 변환 같은 데서는
실행 없이 nbconvert만 때리는 경우가 많다.
CVSS v4 벡터가 PR:L(낮은 권한 필요)과
UI:A(능동적 사용자 상호작용)로 잡힌 것도 이 맥락이다.
그럼에도 쓰기 대상과 내용을 모두 통제할 수 있다는 점 때문에
영향도는 전부 High로 매겨졌다.
CVSS v4 — 8.8 High
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:A/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
패치는 어떻게 들어갔나
수정은 ce4bd99, 릴리스는 7.17.2다. 방향은 교과서적이다 —
경로를 검사하지 않고, 경로 자체를 없앤다.
extractoutput.py (패치 후)
if out.metadata.get("filename", ""):
fname = out.metadata["filename"]
# Sanitize: use only the basename to prevent path traversal
filename = os.path.basename(fname)
if not filename:
self.log.warning(
"Output filename '%s' is invalid (empty basename), skipping",
fname,
)
continue
if filename != fname:
self.log.warning(
"Output filename '%s' contained path components, "
"using basename '%s'",
fname,
filename,
)
os.path.basename()은 디렉터리 성분을 통째로 버린다.
../../../tmp/PWNED.png은 PWNED.png가 되고,
출력 디렉터리 밖으로 나갈 방법이 사라진다. 블랙리스트로
..를 걸러내는 방식과 달리 인코딩 우회 같은 변형에
흔들리지 않는다.
빈 basename("../"처럼 디렉터리 성분만 있는 값)은 건너뛰고,
경로 성분이 있었다는 사실 자체를 경고로 남긴다. 조용히 고치지 않고
로그를 남긴다는 점이 좋다. 운영자가 이상한 노트북이 변환됐다는 걸
사후에 알 수 있다.
타임라인 & 크레딧
- 2026-04-21 — 형제 취약점
GHSA-4c99-qj7h-p3vg(CVE-2026-39377, 첨부파일 경로탈출) 공개. 7.17.1에서 패치. - 2026-10-12 —
GHSA-pjvx-prjx-h659공개. CVE-2026-114140 발행, 7.17.2에서 패치(ce4bd99).
크레딧은 리포터 5인 공동이다 — y011d4,
HOHK0923, toannguyen3107, NovaHunter06,
mohammedix88. 패치는 Yann-P, 조율은
martinRenou가 맡았다. 같은 결함을 여러 명이 비슷한 시기에
따로 집어낸 건이고, 나는 그중 한 명으로 등재됐다. 단독 발견이 아니다.
형제 어드바이저리가 공개된 뒤 같은 클래스를 훑어본 사람이 여럿이었다는 뜻이기도 한데, 이건 오히려 당연한 결과에 가깝다. 하나가 공개되면 같은 모양의 다른 입구는 금방 찾힌다.
배운 점
어드바이저리 하나는 패턴 하나를 알려준다. 끝난 취약점이 아니라 시작점으로 읽어야 한다. "첨부파일 파일명이 정화 안 됐다"는 보고는 사실 "이 코드베이스는 사용자 제공 파일명을 신뢰한다"는 더 큰 사실의 한 사례다. 같은 신뢰 가정을 공유하는 코드가 어디 또 있는지 묻는 게 다음 질문이다.
같은 역할의 함수를 전부 세어 보는 게 싸다. 여기서는 노트북에서 바이너리를 꺼내 파일로 쓰는 전처리기가 몇 개인지 세는 것만으로 두 번째 입구가 나왔다. 깊게 파기 전에 넓게 세는 쪽이 비용 대비 효율이 좋을 때가 많다.
정화는 검사보다 변환이 낫다. 패치가
..를 탐지해 거부하는 대신 basename()으로
잘라낸 건 의도적이다. 거부는 우회 목록과 싸우는 일이고, 변환은
싸울 대상 자체를 없앤다.
마치며
결국 한 줄짜리 결함이었다. filename = out.metadata["filename"],
여기서 끝이다. 대단한 익스플로잇 체인도 메모리 조작도 없다. 그런데
nbconvert는 주피터 생태계에서 노트북을 문서로 뽑는 사실상의
표준이고, 그 변환은 보통 사람 손이 아니라 파이프라인에서 돈다.
영향 범위를 결정하는 건 결함의 정교함이 아니라 그 코드가 놓인 자리라는 걸 다시 확인했다.