← ALL RESEARCH
Field Notes · Disclosed

정적분석으로 NVIDIA NeMo에서 RCE 세 곳을 찾기까지

CVE-2026-24239 · NVIDIA NeMo Speech · 2026-04-26 신고 → 2026-09-22 발행
TL;DR
이 글의 순서
  1. 시작하며
  2. 방법론 — 취약점이 아니라 "무효화 패턴"을 찾는다
  3. 발견 — 세 개의 명시적 override
  4. weights_only가 실제로 막는 것 — Unpickler 내부
  5. 도달성 — 설정값 하나로 트리거되는가
  6. 로컬 검증 — pickle opcode 레벨까지
  7. 패치 검증 — 커밋이 아니라 배포된 소스로 확인
  8. 신고 & 타임라인
  9. 검증된 것과 검증 안 된 것
  10. 마치며

시작하며

PyTorch 2.6부터 torch.load의 weights_only 기본값이 True로 바뀌었다. 체크포인트 파일 안에 임의의 pickle이 섞여 있어도 텐서 이외의 객체는 역직렬화하지 않겠다는 뜻이다. 근데 이 기본값은 딱 하나의 전제 위에 서 있다 — 호출하는 쪽이 그 기본값을 건드리지 않아야 한다. 대형 ML 프레임워크는 전부 torch.load를 자기 코드 안에서 감싸서 쓰기 때문에, 딱 한 줄만 weights_only=False를 명시하면 사용자가 아무리 최신 PyTorch를 써도 보호가 사라진다. NeMo의 CLAUDE.md가 "Requires Python 3.10+, PyTorch 2.6+"라고 명시하고 있었기 때문에, 이 프로젝트가 안전 기본값을 스스로 깨고 있는 곳이 있는지가 궁금해졌다.

방법론 — 취약점이 아니라 "무효화 패턴"을 찾는다

코드를 한 줄씩 읽으면서 버그를 찾는 대신, 정확히 반대로 접근했다. 방어 코드(weights_only 안전 기본값)가 명시적으로 꺼져 있는 자리만 골라내는 게 목표였다. NeMo 저장소 전체에서 weights_only=False 리터럴을 검색하면, 결과는 두 종류로 나뉜다 — ① 정말로 체크포인트에 비-텐서 객체 (옵티마이저 상태, 커스텀 config 객체 등)를 실어야 해서 의도적으로 연 곳과, ② 그냥 관성적으로 복붙되어 남아 있는 곳. 이 구분을 하려면 결과 하나하나를 열어서 ['state_dict']처럼 텐서만 꺼내 쓰는지 확인해야 한다 — 텐서만 필요한데 pickle 전체를 열어주는 건 방어를 끌 이유가 없는 경우다.

이 방식으로 걸러낸 세 곳 모두 패턴이 동일했다: 함수는 torch.load(checkpoint_path, weights_only=False, ...)['state_dict'] 형태로 끝나는데, 그 뒤로는 state_dict의 텐서만 쓴다. 즉 안전 모드를 꺼야 할 이유가 코드 안 어디에도 없었다.

실제로 돌린 검색은 세 단계였다. 먼저 리터럴 검색으로 후보를 넓게 건진다.

1차 — 후보 수집
grep -rn "weights_only" --include="*.py" . \ | grep -v "weights_only=True" \ | grep "weights_only=False"

NeMo는 서브모듈·플러그인 구조라 이 시점에 이미 수십 건이 나온다. 여기서 노이즈를 걸러내는 게 2차 단계였다 — 각 호출부를 열어서 반환값이 어떻게 소비되는지를 본다. 변수명이나 바로 다음 줄에 ['state_dict'] 또는 ['model_state_dict'] 같은 텐서 전용 키 인덱싱이 오면 "방어를 끌 이유 없음" 후보로 분류하고, 반대로 옵티마이저 상태·스케줄러·커스텀 dataclass 등 비-텐서 객체를 실제로 꺼내 쓰는 코드가 뒤따르면 "의도된 사용"으로 분류해서 제외한다. 3차는 각 후보의 진입점을 찾아 사용자가 도달 가능한 값인지 — 하드코딩된 내부 경로가 아니라 config/CLI로 사용자가 지정하는 경로인지 — 확인하는 것이었다. 세 곳 모두 이 세 단계를 다 통과했다.

발견 — 세 개의 명시적 override

nemo/collections/speechlm2/parts/pretrained.py (신고 시점 line 258)
return torch.load(checkpoint_path, weights_only=False, map_location='cpu')['state_dict']
nemo/collections/speechlm2/models/nemotron_voicechat.py:136
checkpoint_state = torch.load(checkpoint_path, weights_only=False, map_location='cpu')['state_dict']
nemo/collections/tts/models/easy_magpietts_preference_optimization.py:87
torch.load(cfg.reference_model_ckpt_path, map_location="cpu", weights_only=False)['state_dict']

세 곳 다 CWE-502(Deserialization of Untrusted Data)에 해당한다. state_dict 키로 인덱싱해서 텐서만 쓰는 코드가, 그 인덱싱을 하기 전에 이미 pickle 전체를 무제한으로 역직렬화해버린다 — 방어가 뚫리는 시점과 안전한 값을 실제로 쓰는 시점 사이에 간격이 있다는 게 이 클래스 취약점의 본질이다.

weights_only가 실제로 막는 것 — Unpickler 내부

torch.load는 내부적으로 pickle을 쓴다. 일반 pickle.Unpickler는 GLOBAL opcode를 만나면 모듈 경로만 보고 import 후 그 심볼을 그대로 호출할 수 있다 — 이게 __reduce__가 임의 코드 실행으로 이어지는 근본 메커니즘이다. weights_only=True는 pickle 포맷 자체를 바꾸는 게 아니라, PyTorch가 자체 구현한 _weights_only_unpickler.Unpickler로 교체하는 것이다. 이 클래스는 find_class()를 오버라이드해서, torch.Tensor, torch.Size, collections.OrderedDict 등 텐서 역직렬화에 실제로 필요한 소수의 클래스만 담은 allowlist(_get_allowed_globals())에 없는 심볼을 만나면 UnpicklingError를 던지고 즉시 중단한다. 즉 os.system이나 임의 클래스의 __reduce__는 애초에 find_class() 단계에서 걸러진다 — 실행 시점이 아니라 이름 해석 시점에서 차단하는 화이트리스트 방식이다.

weights_only=False를 명시하면 이 안전한 Unpickler 대신 일반 pickle.Unpickler가 그대로 쓰인다 — allowlist 자체가 존재하지 않는 경로로 돌아간다. NeMo 세 곳의 문제는 정확히 이 지점이다: 코드가 "이 값은 신뢰할 수 있다"고 스스로 선언해버린 것이다.

도달성 — 설정값 하나로 트리거되는가

코드에 존재하는 것과 실제로 도달 가능한 것은 다른 문제다. 세 지점의 호출 체인을 손으로 따라갔다.

caller chain — sink #1
SpeechLM2 train/inference (문서화된 진입점) └─ maybe_load_pretrained_models(model) # pretrained.py:401 ├─ init_perception_from_checkpoint(..., cfg.pretrained_perception_from_s2s) │ └─ _load_checkpoint_state(...) │ └─ torch.load(..., weights_only=False) ← sink #1 └─ load_pretrained_model(..., cfg.pretrained_s2s_model) └─ _load_checkpoint_state(...) ← 같은 sink

pretrained_perception_from_s2s는 SpeechLM2 파인튜닝 레시피에서 업스트림 encoder 체크포인트를 지정하는, 문서화된 표준 설정 필드다. 즉 "공개된 encoder 체크포인트를 받아서 파인튜닝을 시작한다"는 지극히 정상적인 사용 흐름이 그대로 공격 표면이 된다. reference_model_ckpt_path(sink #3)도 MagpieTTS DPO 학습의 표준 설정 필드로 동일 패턴이었다. sink #2(NemotronVoiceChat)는 함수 시그니처와 import 그래프까지는 확인했지만, 모든 진입 스크립트를 직접 실행해보지는 않았다 — 이 부분은 리포트에도 그렇게 명시했다.

로컬 검증 — pickle opcode 레벨까지

pickle의 __reduce__를 이용해 로드 시점에 임의 코드가 실행되는지만 확인하는, 파괴적이지 않은 최소 PoC를 로컬 환경에서 돌렸다. 마커 파일 하나를 쓰는 것 외에 아무 동작도 하지 않는다 — 이건 PyTorch 공식 문서가 weights_only를 설명할 때 쓰는 것과 같은 종류의 교과서적 증명이다.

build_payload.py
class Exploit: def __reduce__(self): return (os.system, ("touch RCE_PROOF.txt",)) torch.save({"state_dict": {"w": torch.zeros(1)}, "x": Exploit()}, "perception.ckpt")

torch.load(checkpoint_path, weights_only=False, ...)['state_dict'] — NeMo 소스의 해당 줄을 그대로 로컬에서 실행하면 마커 파일이 생성됐다. torch 2.11+cpu, 패치 없이. weights_only=False가 명시돼 있으니 PyTorch 버전을 올려도 이 경로는 막히지 않는다는 것도 이 시점에 같이 확인했다.

패리티갭(암묵적 기본값 문제)이었다면 torch 업그레이드로 막혔겠지만, 이건 코드가 방어를 켜서 끄는 쪽이라 코드 수정 없이는 안 막힌다.

"정말로 find_class() 단계에서 걸리는지"를 눈으로 확인하려고 pickletools.dis로 페이로드를 직접 디스어셈블해봤다. 저장된 pickle 바이트스트림 안에서 Exploit 객체가 실제로 어떻게 인코딩되는지 보면 구조가 명확해진다.

pickletools.dis(payload) — 실제 출력 발췌 (protocol 4)
36: \x8c SHORT_BINUNICODE 'x' 40: \x8c SHORT_BINUNICODE 'nt' 45: \x8c SHORT_BINUNICODE 'system' 54: \x93 STACK_GLOBAL 56: \x8c SHORT_BINUNICODE 'touch RCE_PROOF.txt' 78: \x85 TUPLE1 80: R REDUCE 82: u SETITEMS

STACK_GLOBAL이 그 지점이다(구버전 protocol의 GLOBAL opcode와 동일한 역할 — 스택에 쌓인 모듈명 'nt'·함수명 'system' 두 문자열을 꺼내 getattr(import_module('nt'), 'system')로 심볼을 해석한다. 이 값은 Windows에서 os.system의 실제 구현 모듈이라 'nt'로 찍혔다 — 리눅스였으면 'posix'였을 것이다. weights_only=True 경로였다면 이 STACK_GLOBAL을 만난 순간 find_class()가 allowlist에 없는 심볼이라고 판단해 UnpicklingError로 즉시 중단했을 자리다. 바로 다음 REDUCE opcode가 그렇게 해석된 함수를 앞서 쌓인 인자("touch RCE_PROOF.txt")와 함께 실제로 호출하는 지점이고, 이게 torch.load가 값을 리턴하기도 전에 — ['state_dict'] 인덱싱 근처도 못 간 시점에 — 실행된다는 게 핵심이다.

패치 검증 — 커밋이 아니라 배포된 소스로 확인

벤더가 "패치했다"고 말하는 것과 실제로 고쳐진 것은 다른 문제라서, NVIDIA-NeMo/Speech 저장소의 v3.0.0 태그를 GitHub API로 직접 받아 세 파일을 다시 열었다. 신고했던 weights_only=False 리터럴은 셋 다 사라졌고, 같은 줄의 torch.load 호출 자체는 그대로 남아 있었다 — 정확히 내가 제안했던 최소 수정(불필요한 override 삭제)이 반영된 형태다.

v3.0.0 — pretrained.py (패치 후)
268: checkpoint_state = torch.load(checkpoint_path, map_location="cpu")["state_dict"] 285: return torch.load(checkpoint_path, map_location='cpu')['state_dict']
v3.0.0 — nemotron_voicechat.py:136 / easy_magpietts_preference_optimization.py:87
checkpoint_state = torch.load(checkpoint_path, map_location='cpu')['state_dict'] torch.load(cfg.reference_model_ckpt_path, map_location="cpu")['state_dict']

weights_only를 아예 지정하지 않으면 torch 2.6+ 기본값인 True가 그대로 적용된다 — NeMo가 요구하는 최소 PyTorch 버전과도 맞아떨어지는, 가장 단순한 형태의 수정이다. pretrained.py 쪽 줄 번호가 258 → 268/285로 밀린 건 그 사이 다른 리팩터링이 들어갔기 때문이고(_load_checkpoint_state 함수가 갈라진 것으로 보인다), 패턴 자체는 세 곳 다 동일하게 정리됐다.

신고 & 타임라인

2026-04-26 NVIDIA PSIRT에 세 지점을 하나의 리포트로 묶어서 신고했다 (같은 날 TransformerEngine 건도 별도로 신고). 수정 제안은 단순했다 — weights_only=False를 지우고 True로 바꾸면 ['state_dict'] 인덱싱만 쓰는 이 세 함수는 그대로 동작한다는 것. 09-17 PSIRT가 케이스 번호 6119087로 접수를 알려왔고, 이름 표기를 두 차례 정정한 뒤 09-22 Security Bulletin 5885로 CVE-2026-24239가 발행됐다. NeMo Speech 3.0 소스를 직접 받아서 세 지점을 다시 확인해보니, 셋 다 정확히 신고한 그대로 weights_only=False가 제거돼 있었다 — 제안한 수정이 그대로 반영됐다.

검증된 것과 검증 안 된 것

처음 리포트에는 CVSS 8.8(AV:N)로 적어 보냈는데, NVIDIA가 최종 발행한 값은 7.8, AV:L이다 — 벤더 트라이아지가 공격 벡터를 Network가 아니라 Local로 판단한 것으로 보인다. 이 글에 적은 CVSS는 처음 제출한 값이 아니라 NVD에 실제로 발행된 값을 기준으로 삼았다.

같은 날 신고한 TransformerEngine 건은 이 글을 쓰는 시점 기준 공개된 어드바이저리나 CVE를 찾지 못했다 — 미확인이다. 확인되는 대로 별도 글로 남길 예정이고, 여기서는 다루지 않는다. 마찬가지로 huntr.com에 준비 중이라고 언급했던 병행 제출 건도 지금 시점에 공개된 흔적이 없어 공개 여부를 단정하지 않았다.

마치며

이 건에서 남는 건 "취약점을 찾는 법"보다 "무효화 패턴을 찾는 법"에 더 가깝다. 방어 기본값이 잘 갖춰진 프레임워크에서는, 취약점이 코드 로직의 실수가 아니라 그 방어를 끄는 한 줄에 숨어 있는 경우가 많았다. grep 한 번으로 시작해서 손으로 도달성을 증명하고, 로컬 PoC로 실행만 확인하고, 나머지는 벤더 패치와 공개 발행을 기다리는 — 이번 건은 그 사이클을 처음부터 끝까지 밟아본 경험이었다.

작성자: Alert_K (ANH) — NVIDIA 공식 Acknowledgements 페이지 등재명: Hwang JUNHA 관련 CVE: CVE-2026-24239 참고: NVD · CVE.org · NVIDIA Security Bulletin 5885 · NVIDIA Product Security Acknowledgements