요약 (TL;DR)
- AI 에디터는
__proto__ 및 constructor.prototype을 포함하여 소스 객체의 모든 키를 신뢰하는 재귀적 객체 병합(recursive object merges) 코드를 작성합니다.
- 정교하게 조작된 단 하나의 페이로드(payload)가
Object.prototype을 조용히 덮어씌워, 서버가 재시작될 때까지 Node 프로세스 내의 모든 객체를 감염시킵니다.
- 해결책은 루프 상단에 세 가지 키에 대한 가드(guard)를 추가하는 것입니다: 재귀를 수행하기 전에
__proto__, constructor, prototype을 건너뛰어야 합니다.
나는 Cursor에게 deep merge 함수를 작성해 달라고 요청했습니다. 기본 설정 위에 환경별 설정을 레이어링(layering)해야 했기 때문입니다. 이는 내가 수십 번은 만들어 본 패턴입니다. Cursor가 생성한 함수는 15줄이었고, 중첩된 객체(nested objects)를 올바르게 처리했으며, 내가 작성한 모든 테스트를 통과했습니다.
사흘 뒤, 나는 사용자 JSON을 해당 함수로 전달하는 모든 엔드포인트에 {"__proto__": {"isAdmin": true}}를 POST 할 수 있다는 사실을 발견했습니다. 그러면 서버는 이후의 모든 객체가 isAdmin이 true로 설정된 것으로 취급하게 됩니다. 단 한 번의 요청으로 Node 프로세스 전체가 침해되었습니다. 이 감염은 서버가 재시작될 때까지 지속되었습니다.
Cursor가 작성한 함수는 prototype pollution (CWE-1321)의 가장 전형적인 형태였습니다.
취약한 코드
키 필터링 없이 객체 키를 반복하는 재귀적 병합 함수는 Object.prototype으로 향하는 직접적인 쓰기 채널이 됩니다. 소스 객체에 있는 __proto__라는 이름의 키는 그 시점 이후 프로세스에서 생성되는 모든 객체의 프로토타입 체인(prototype chain)을 덮어씌웁니다.
// CWE-1321 - AI가 생성한 deep merge, 키 검증 없음
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
...
이 함수는 합리적으로 보입니다. JavaScript의 deep merge에 관한 수백 개의 튜토리얼에 등장하는 정확한 패턴입니다. AI 에디터들은 해당 튜토리얼들로부터 이를 학습했으며, 그 튜토리얼들에는 키 가드(key guard)가 포함되어 있지 않은 경우가 거의 없습니다.
왜 이런 일이 계속 발생하는가
AI 에디터들이 Prototype Pollution (프로토타입 오염)이 가능한 merge (병합) 함수를 생성하는 이유는, 재귀적 merge (재귀적 병합) 패턴이 온라인에서 가장 많이 복사되는 JavaScript (자바스크립트) 코드 조각 중 하나이며, 해당 코드 조각의 압도적 다수가 안전 검사 (safety check)를 누락하고 있기 때문입니다. 모델은 이 문제가 안전하게 해결된 사례보다, 기능적으로 올바르고 결과적으로 정확한 병합 값을 반환하도록 해결된 사례를 훨씬 더 많이 학습했습니다.
이 취약점이 살아남는 데에는 더 미묘한 이유도 있습니다. 위험한 동작은 사용자 제어 입력 (user-controlled input)이 함수로 흘러 들어갈 때만 나타납니다. 하드코딩된 fixture (픽스처) 객체를 대상으로 작성된 단위 테스트 (unit test)에서는 결코 이 문제가 발생하지 않습니다. 함수는 테스트를 통과하고, 병합되며, 배포됩니다. 다음 세대 모델을 위한 학습 데이터는 이를 올바른 구현으로 기록합니다.
이 패턴에 대한 실제 CVE (Common Vulnerabilities and Exposures) 사례들이 존재합니다. lodash는 prototype-pollutable merge (프로토타입 오염 가능 병합) 문제로 CVE-2019-10744 (CVSS 9.8)를 받았습니다. jQuery도 유사한 문제(CVE-2019-11358)가 있었습니다. 여러 Express middleware (미들웨어) 패키지들도 이 문제로 인해 패치되었습니다. 이것은 이론적인 예외 사례가 아닙니다.
해결 방법 (The Fix)
재귀가 내려가기 전에 prototype chain (프로토타입 체인)에 도달할 수 있는 모든 키를 차단하십시오. 세 가지 키가 전체 공격 표면 (attack surface)을 커버합니다: __proto__, constructor, 그리고 prototype입니다. 루프 상단에 continue 문 하나를 추가하는 것이 해결책의 전부입니다.
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
...
신뢰할 수 없는 입력을 처리하는 dict (딕셔너리) 병합에 대한 Python (파이썬) 대응 방식은 다음과 같습니다:
BLOCKED_KEYS = {'__proto__', '__class__', '__subclasshook__'}
...
보완적인 접근 방식 중 하나는 merge (병합) 대상(target)을 Object.create(null)로 초기화하는 것입니다. 이는 __proto__ 접근을 통해 오염될 수 없는, 프로토타입이 없는 객체입니다. 이를 대체재가 아닌 방어 심층 (defense-in-depth) 조치로 사용하십시오. constructor.prototype 공격은 Object.create(null)를 우회할 수 있으므로, 키 검사는 여전히 필요합니다.
자주 묻는 질문 (FAQ)
자주 묻는 질문 (FAQ)
질문: JavaScript에서 프로토타입 오염(prototype pollution)이 실제로 악용 가능한 보안 버그로 이어지나요?
답변: 예. lodash CVE-2019-10744 (CVSS 9.8)가 가장 명확한 예시입니다. 성공적인 공격은 프로세스 내의 모든 객체에서 isAdmin, role, 또는 authenticated와 같은 속성을 덮어씁니다. 신뢰할 수 없는 객체에서 속성을 읽어와 그에 따라 동작하는 모든 것은 권한 상승(privilege escalation)이나 인증 우회(auth bypass)로 이어지는 경로가 됩니다.
질문: TypeScript나 linter를 사용해서 타입 레벨에서 이를 잡아낼 수 있나요?
답변: 아니요. TypeScript는 Object.keys가 string[]를 반환하는 것으로 타입을 지정하며, 이는 정확합니다. 하지만 타입 레벨에서는 `