Node.js 웹 애플리케이션을 프로토타입 오염 및 요청 밀수 공격으로부터 방어하기
Grace Collins
Solutions Engineer · Leapcell

소개
빠르게 발전하는 웹 개발 환경에서 Node.js는 확장 가능하고 고성능의 서버 측 애플리케이션을 구축하기 위한 초석으로 부상했습니다. 비동기, 이벤트 기반 아키텍처와 방대한 라이브러리 생태계는 Node.js를 인기 있는 선택지로 만들었습니다. 그러나 큰 힘에는 큰 책임이 따르며, 웹 애플리케이션의 복잡성이 증가함에 따라 정교한 보안 위협도 함께 등장합니다. 이 중 프로토타입 오염(Prototype Pollution)과 요청 밀수(Request Smuggling)는 Node.js 웹 서비스의 무결성과 가용성을 손상시킬 수 있는 특히 악랄한 취약점으로 두드러집니다. 이 글에서는 이러한 공격의 작동 방식을 자세히 살펴보고, 잠재적 영향을 설명하며, Node.js 애플리케이션을 강화하기 위한 실행 가능한 전략을 제공합니다. 이러한 위협을 이해하고 강력한 방어를 구현하는 것은 좋은 관행일 뿐만 아니라, 오늘날 상호 연결된 디지털 세계에서 민감한 데이터를 보호하고 사용자 신뢰를 유지하는 데 필수적입니다.
위협 이해하기
방어 전략에 대해 자세히 알아보기 전에 이러한 공격과 관련된 핵심 개념에 대한 명확한 이해를 확립해 보겠습니다.
핵심 용어
- 프로토타입 체인(Prototype Chain): JavaScript에서 객체는 다른 객체로부터 속성과 메서드를 상속받을 수 있습니다. 이 상속은 프로토타입 체인을 통해 이루어집니다. 모든 JavaScript 객체는 내부 속성
[[Prototype]]
(대부분의 환경에서는__proto__
)을 가지고 있으며, 이는 프로토타입 객체를 가리킵니다. 객체에서 속성에 접근하려고 할 때 해당 속성이 객체 자체에 직접 없으면 JavaScript는 객체의 프로토타입에서 이를 찾고(찾지 못하면 그 프로토타입의 프로토타입에서 찾는 식으로)Object.prototype
(거의 모든 프로토타입 체인의 루트)에 도달하거나 속성이 발견될 때까지 검색합니다. - 프로토타입 오염(Prototype Pollution): 이 취약점을 통해 공격자는 객체의 프로토타입(일반적으로
Object.prototype
)의 속성을 삽입하거나 수정할 수 있습니다. 거의 모든 객체는Object.prototype
을 상속받기 때문에, 이를 오염시키면 프레임워크나 다른 라이브러리에 의해 간접적으로 생성된 애플리케이션 내의 임의 객체에 영향을 미칠 수 있습니다. - 요청 밀수(Request Smuggling): 이는 공격자가 HTTP 요청의 경계를 해석하는 방식의 불일치를 악용하는 기술입니다. 프론트엔드 서버에 대해 하나의 요청처럼 보이는 조작된 요청을 보내고 백엔드 서버에는 두 개 이상의 요청으로 보이게 함으로써, 공격자는 보안 제어를 우회하고, 승인되지 않은 리소스에 접근하거나, 캐시를 오염시킬 수 있습니다.
- HTTP/1.1
Content-Length
헤더: 메시지 본문의 크기를 옥텟(8비트 바이트) 단위로 지정합니다. - HTTP/1.1
Transfer-Encoding
헤더: 안전한 전송을 보장하기 위해 메시지 본문에 적용된 인코딩을 지정합니다. 가장 흔한 값은chunked
로, 메시지 본문이 여러 청크로 구성됨을 의미합니다.
프로토타입 오염: 작동 방식 및 영향
프로토타입 오염은 JavaScript 객체와 프로토타입 체인의 동적인 특성을 이용합니다. 이 취약점은 일반적으로 JavaScript 함수가 입력을 적절하게 검증하지 않고 객체를 재귀적으로 병합하거나 JSON 데이터를 처리할 때 발생하여, 공격자가 사용자 제어 데이터에 __proto__
를 키로 삽입하거나 수정할 수 있게 합니다. 이러한 데이터가 다른 객체에 병합될 때, 병합 로직이 __proto__
를 키로 확인하지 않고 속성을 직접 할당하면 의도치 않게 Object.prototype
을 수정할 수 있습니다.
유틸리티 함수가 기본 구성을 사용자 제공 설정과 병합하는 일반적인 시나리오를 생각해 보겠습니다.
// 단순화된 취약한 병합 함수 function merge(target, source) { for (const key in source) { if (key === '__proto__' || key === 'constructor') { // 기본 확인, 하지만 종종 간과되거나 불충분함 continue; } if (target[key] instanceof Object && source[key] instanceof Object) { merge(target[key], source[key]); // 재귀적 병합 } else { target[key] = source[key]; } } return target; } const defaultConfig = { env: 'production', db: { host: 'localhost' } }; // 공격자 제어 입력 const maliciousInput = JSON.parse('{"__proto__": {"isAdmin": true}}'); // 병합 함수가 키를 제대로 sanitization하지 않으면 이런 일이 발생합니다: // merge(defaultConfig, maliciousInput)은 이 간단한 예에서 Object.prototype을 직접 오염시키지 않습니다. // 하지만 병합 함수가 Object.prototype의 프록시인 target을 직접 사용하거나 // 중첩된 병합이 포함된 경우 문제가 됩니다. // 보다 직접적인 오염 예시를 설명해 보겠습니다. const vulnerableMerge = (target, source) => { for (const key in source) { if (key === '__proto__') { Object.assign(target, source); // 데모를 위한 직접 할당, 실제는 더 복잡함 } else if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { if (!target[key] || typeof target[key] !== 'object') { target[key] = {}; } vulnerableMerge(target[key], source[key]); // 재귀 호출 } else { target[key] = source[key]; } } }; // 오염 예시 const userControlledObject = {}; vulnerableMerge(userControlledObject, JSON.parse('{"__proto__": {"isAdmin": true}}')); // 이제 새로 생성된 객체는 이 속성을 상속받을 수 있습니다. const newUser = {}; console.log(newUser.isAdmin); // true (이런!)
프로토타입 오염의 영향은 심각할 수 있으며, 애플리케이션 충돌로 인한 서비스 거부(Denial of Service), 관리자 플래그 삽입을 통한 권한 상승, 특정 프로토타입 속성에 의존하는 프레임워크 내부 조작을 통한 원격 코드 실행(RCE) 등을 포함합니다(예: 템플릿 엔진 구성).
프로토타입 오염 방어 전략
-
입력 유효성 검사 및 Sanitization: 가장 효과적인 방어는 사용자 제어 입력을 신중하게 유효성 검사하고 sanitization하는 것입니다. 특히 객체 병합이나 역직렬화와 관련된 경우에 그렇습니다.
__proto__
및constructor
를 키로 사용하는 것을 방지합니다.function safeMerge(target, source) { for (const key in source) { // __proto__ 및 constructor.prototype 명시적 금지 if (key === '__proto__' || key === 'constructor') { continue; } if (target[key] instanceof Object && source[key] instanceof Object) { // target[key] 자체가 일반 객체인지 확인하여 내장 프로토타입 오염 방지 if (Object.getPrototypeOf(target[key]) === Object.prototype) { safeMerge(target[key], source[key]); } else { target[key] = source[key]; // 또는 오류로 처리 } } else { target[key] = source[key]; } } return target; }
-
데이터 전용 객체에
Object.create(null)
사용: 주로 데이터를 저장하고Object.prototype
을 상속할 필요가 없는 객체를 생성할 때,Object.create(null)
을 사용하여 생성합니다. 이는 프로토타입이 없는 객체를 생성하여Object.prototype
오염으로부터 안전하게 만듭니다.const dataContainer = Object.create(null);
-
Object.prototype
고정 (프로덕션에서는 권장하지 않음): 이론적으로 가능하지만,Object.freeze(Object.prototype)
를 사용하여Object.prototype
을 고정하는 것은 극도의 주의를 기울여야 합니다. 많은 라이브러리와 프레임워크가Object.prototype
수정 또는 확장에 의존할 수 있으며, 이를 고정하면 예상치 못한 동작이나 오류가 발생할 수 있습니다. -
내장 보호 기능이 있는 라이브러리 사용: 보안을 염두에 두고 설계되었으며 프로토타입 오염에 대한 알려진 보호 기능이 있는 라이브러리를 활용합니다(예:
lodash.merge
는 패치되었지만 항상 버전을 확인하세요).
요청 밀수: 작동 방식 및 영향
HTTP 요청 밀수는 HTTP 메시지 본문의 길이가 결정되는 방식의 모호성을 이용하는 공격입니다. HTTP/1.1은 메시지 본문 길이를 나타내기 위해 Content-Length
및 Transfer-Encoding
이라는 두 가지 주요 헤더를 제공합니다. 프론트엔드 서버(예: 리버스 프록시 또는 로드 밸런서)와 백엔드 서버가 이러한 헤더를 다르게 해석하면, 공격자는 첫 번째 요청의 본문에 두 번째 불법 요청을 "밀수"할 수 있습니다.
일반적인 공격 벡터는 다음과 같습니다.
- CL.TE (
Content-Length
는 프론트엔드,Transfer-Encoding
은 백엔드): 프론트엔드가Content-Length
를 사용하고 백엔드가Transfer-Encoding
을 사용합니다. - TE.CL (
Transfer-Encoding
은 프론트엔드,Content-Length
는 백엔드): 프론트엔드가Transfer-Encoding
을 사용하고 백엔드가Content-Length
를 사용합니다. - TE.TE (둘 다
Transfer-Encoding
사용, 다른 해석): 둘 다Transfer-Encoding
을 사용하지만 다르게 해석합니다(예: 하나는 잘못된 청크 인코딩을 처리하고 다른 하나는 처리하지 않음).
개념적인 CL.TE
공격을 설명해 보겠습니다.
POST /search HTTP/1.1 Host: vulnerable.com Content-Length: 13 Transfer-Encoding: chunked 0 <-- 0이라는 청크 크기는 청크 본문의 끝을 알립니다. <-- 빈 줄, 첫 번째 청크 부분을 끝냄 GET /admin HTTP/1.1 Host: vulnerable.com Foo: bar
프론트엔드( Content-Length: 13
해석): 요청 본문 전체를 `0
GET /admin...`으로 봅니다. 이 전체 블록을 백엔드로 전달합니다.
백엔드( Transfer-Encoding: chunked
해석): `0
부분을 첫 번째 요청의 청크 본문 끝으로 처리합니다. 이후의
GET /admin HTTP/1.1...`는 프론트엔드 서버로부터의 동일한 연결에서 발생하는 별도의 새 요청으로 처리됩니다.
영향은 심각할 수 있습니다:
- 웹 애플리케이션 방화벽(WAF) 우회: 악의적인 요청이 합법적인 요청 내에 숨겨질 수 있습니다.
- 내부 엔드포인트 접근: 밀수된 요청은 신뢰할 수 있는 역프록시에서 발생한 것으로 보일 수 있으므로 내부 API 또는 관리 인터페이스에 접근할 수 있습니다.
- 캐시 중독: 공격자가 프록시로 하여금 합법적인 URL에 대한 악의적인 응답을 캐싱하게 만드는 요청을 밀수할 수 있으며, 이는 후속 사용자에게 영향을 미칩니다.
- 세션 하이재킹: 밀수된 요청 내에서 쿠키 또는 세션 토큰을 조작합니다.
요청 밀수 방어 전략
Node.js http
모듈은 Node.js 서버 자체 내에서의 요청 밀수 문제에 대해 일반적으로 강력하지만, 주로 Node.js 애플리케이션과 업스트림 프록시/로드 밸런서 간의 상호 작용에서 취약점이 발생합니다.
-
일관된 HTTP 파싱 보장: 가장 중요한 방어는 애플리케이션 스택(로드 밸런서, 프록시, Node.js 서버)의 모든 구성 요소가 일관되고 엄격한 HTTP/1.1 파서를 사용하도록 보장하는 것입니다.
- 구성: 프론트엔드 프록시(Nginx, HAProxy, AWS ELB/ALB 등)를 HTTP/1.1 사양을 엄격하게 적용하도록 구성합니다. 특히
Content-Length
와Transfer-Encoding
헤더를 모두 포함하는 요청을 거부하거나 모호하지 않게 처리해야 합니다. - 통일된 표준: 이상적으로는 모든 구성 요소가
Content-Length
에만 의존하거나Transfer-Encoding: chunked
를 일관되게 처리해야 합니다. - 둘 다 금지: 프록시가
Content-Length
와Transfer-Encoding
헤더를 모두 포함하는 요청을 거부하도록 구성합니다. 이는 일반적인 공격 벡터입니다.
- 구성: 프론트엔드 프록시(Nginx, HAProxy, AWS ELB/ALB 등)를 HTTP/1.1 사양을 엄격하게 적용하도록 구성합니다. 특히
-
HTTP/2로 업그레이드: HTTP/2(및 HTTP/3)는 메시지 본문 결정에서
Content-Length
및Transfer-Encoding
의 모호성이 없기 때문에 요청 밀수를 어렵게 또는 불가능하게 만드는 프레임 기반 메시지 구조를 사용합니다. 가능한 경우 인프라를 HTTP/2 종단 간(End-to-End)으로 구성합니다. 클라이언트-프록시 연결만 HTTP/2이고 프록시-백엔드 연결이 HTTP/1.1인 경우에도 위험은 여전히 존재할 수 있습니다. -
모호성 발생 시 연결 종료: 프록시에 대한 강력한 방어 메커니즘은 HTTP 헤더의 모호성이 감지되면 클라이언트 연결을 즉시 종료하는 것입니다. 이는 공격자가 동일한 연결에서 여러 요청을 보내는 것을 방지합니다.
-
정기적인 보안 스캔 및 테스트: 특수 보안 스캐너를 사용하고 침투 테스트를 수행하여 인프라의 잠재적인 요청 밀수 취약점을 식별합니다. Burp Suite의 "HTTP Request Smuggler" 확와 같은 도구는 매우 효과적일 수 있습니다.
-
통합 프록시 또는 관리형 서비스 사용: 잘 관리되고 검증된 리버스 프록시 또는 관리형 로드 밸런싱 서비스(AWS ALB 또는 Google Cloud Load Balancer 등)에 의존하면 위험을 크게 줄일 수 있습니다. 이러한 서비스는 종종 강력한 HTTP 파싱 및 보안을 염두에 두고 설계되었습니다. 항상 최신 보안 버전으로 업데이트되었는지 확인합니다.
결론
프로토타입 오염 및 요청 밀수와 같은 정교한 위협으로부터 Node.js 웹 애플리케이션을 보호하려면, 기본 JavaScript 메커니즘과 HTTP 프로토콜에 대한 깊은 이해와 예방 조치의 철저한 구현이 필요합니다. 사용자 입력을 세심하게 검증하고, 견고한 객체 병합 전략을 설계하며, 애플리케이션 스택 전체에서 일관되고 엄격한 HTTP 파싱을 보장함으로써 개발자는 시스템을 크게 강화할 수 있습니다. 사전 예방적인 보안 관행은 복원력 있고 신뢰할 수 있는 웹 서비스를 구축하는 데 가장 중요합니다.