← ALL CVE
Disclosed · GitHub CNA

셀 출력 파일명 하나로 출력 디렉터리를 벗어날 수 있었던 nbconvert

CVE-2026-114140 · GHSA-pjvx-prjx-h659 · jupyter/nbconvert · CWE-22
TL;DR
이 글의 순서
  1. 시작하며 — 형제 취약점이 남긴 자리
  2. 어떤 결함이었는가
  3. 왜 익스포터 네 개만 터지나
  4. 검증한 것
  5. 제약 — 실행하면 사라진다
  6. 패치는 어떻게 들어갔나
  7. 타임라인 & 크레딧
  8. 배운 점
  9. 마치며

시작하며 — 형제 취약점이 남긴 자리

먼저 공개된 건 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 같은 값을 넣으면 결합 결과가 출력 디렉터리 바깥을 가리킨다.

.ipynb output metadata.filename ExtractOutput Preprocessor join(output_files_dir, "../../../tmp/PWNED.png") 정규화 검사 없음 — 경로 구분자가 그대로 살아서 전달된다 의도한 경로 :: ./notebook_files/image.png 실제 경로 :: /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("../"처럼 디렉터리 성분만 있는 값)은 건너뛰고, 경로 성분이 있었다는 사실 자체를 경고로 남긴다. 조용히 고치지 않고 로그를 남긴다는 점이 좋다. 운영자가 이상한 노트북이 변환됐다는 걸 사후에 알 수 있다.

타임라인 & 크레딧

크레딧은 리포터 5인 공동이다 — y011d4, HOHK0923, toannguyen3107, NovaHunter06, mohammedix88. 패치는 Yann-P, 조율은 martinRenou가 맡았다. 같은 결함을 여러 명이 비슷한 시기에 따로 집어낸 건이고, 나는 그중 한 명으로 등재됐다. 단독 발견이 아니다.

형제 어드바이저리가 공개된 뒤 같은 클래스를 훑어본 사람이 여럿이었다는 뜻이기도 한데, 이건 오히려 당연한 결과에 가깝다. 하나가 공개되면 같은 모양의 다른 입구는 금방 찾힌다.

배운 점

어드바이저리 하나는 패턴 하나를 알려준다. 끝난 취약점이 아니라 시작점으로 읽어야 한다. "첨부파일 파일명이 정화 안 됐다"는 보고는 사실 "이 코드베이스는 사용자 제공 파일명을 신뢰한다"는 더 큰 사실의 한 사례다. 같은 신뢰 가정을 공유하는 코드가 어디 또 있는지 묻는 게 다음 질문이다.

같은 역할의 함수를 전부 세어 보는 게 싸다. 여기서는 노트북에서 바이너리를 꺼내 파일로 쓰는 전처리기가 몇 개인지 세는 것만으로 두 번째 입구가 나왔다. 깊게 파기 전에 넓게 세는 쪽이 비용 대비 효율이 좋을 때가 많다.

정화는 검사보다 변환이 낫다. 패치가 ..를 탐지해 거부하는 대신 basename()으로 잘라낸 건 의도적이다. 거부는 우회 목록과 싸우는 일이고, 변환은 싸울 대상 자체를 없앤다.

마치며

결국 한 줄짜리 결함이었다. filename = out.metadata["filename"], 여기서 끝이다. 대단한 익스플로잇 체인도 메모리 조작도 없다. 그런데 nbconvert는 주피터 생태계에서 노트북을 문서로 뽑는 사실상의 표준이고, 그 변환은 보통 사람 손이 아니라 파이프라인에서 돈다.

영향 범위를 결정하는 건 결함의 정교함이 아니라 그 코드가 놓인 자리라는 걸 다시 확인했다.

작성자: Alert_K (ANH) 관련 CVE: CVE-2026-114140 참고: GHSA-pjvx-prjx-h659