- NVIDIA NeMo 코드베이스에서
weights_only=False를 명시적으로 넘기는 지점을 정적분석(grep 기반)으로 훑어서, PyTorch 2.6+가 기본으로 막아주는 안전장치를 코드가 스스로 무효화하고 있는 세 곳(SpeechLM2 / NemotronVoiceChat / MagpieTTS DPO 체크포인트 로더)을 찾았다. - 각 지점이 실제로 문서화된 설정값(사전학습 encoder 경로, DPO reference 모델 경로)을 통해 도달 가능한지 호출 체인을 손으로 추적해서 확인하고, 로컬 환경에서 무해한 마커 파일을 쓰는 최소 PoC로 실행 여부만 검증했다.
- 2026-04-26 NVIDIA PSIRT에 신고, 09-17 케이스 접수(#6119087), 09-22
Security Bulletin 5885로 CVE-2026-24239 발행. v3.0에서 세 지점 모두
weights_only=False가 제거된 것을 GitHub API로 직접 재확인했다.
시작하며
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의 텐서만 쓴다.
즉 안전 모드를 꺼야 할 이유가 코드 안 어디에도 없었다.
실제로 돌린 검색은 세 단계였다. 먼저 리터럴 검색으로 후보를 넓게 건진다.
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
return torch.load(checkpoint_path, weights_only=False, map_location='cpu')['state_dict']
checkpoint_state = torch.load(checkpoint_path, weights_only=False, map_location='cpu')['state_dict']
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 세 곳의 문제는 정확히
이 지점이다: 코드가 "이 값은 신뢰할 수 있다"고 스스로 선언해버린
것이다.
도달성 — 설정값 하나로 트리거되는가
코드에 존재하는 것과 실제로 도달 가능한 것은 다른 문제다. 세 지점의 호출 체인을 손으로 따라갔다.
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를 설명할 때
쓰는 것과 같은 종류의 교과서적 증명이다.
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 객체가 실제로 어떻게 인코딩되는지 보면
구조가 명확해진다.
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 삭제)이 반영된 형태다.
268: checkpoint_state = torch.load(checkpoint_path, map_location="cpu")["state_dict"]
285: return torch.load(checkpoint_path, map_location='cpu')['state_dict']
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로 실행만 확인하고, 나머지는 벤더 패치와 공개 발행을 기다리는 — 이번 건은 그 사이클을 처음부터 끝까지 밟아본 경험이었다.