Skip to content

Latest commit

 

History

History
240 lines (198 loc) · 14 KB

File metadata and controls

240 lines (198 loc) · 14 KB

jQuery $.extend(true, ...)의 Object.prototype 오염 (CVE-2019-11358)

메타데이터

항목 내용
CVE ID CVE-2019-11358
영향 버전 jQuery 3.4.0 미만 전 버전 (Drupal, Backdrop CMS 등 다수 제품이 내장 사용)
CWE CWE-1321 (Prototype Pollution)
CVSS v3.1 6.1 (Medium), AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
공개일 2019-03-26 (보고), 2019-04-20 (패치 및 CVE 공개)
수정 커밋 jQuery PR #4333 → 3.4.0

개요

jQuery의 $.extend(true, target, ...sources)는 여러 객체를 재귀적으로 "깊은 병합(deep merge)"해서 target에 합쳐주는 유틸리티다. 문제는 이 함수가 병합할 속성 이름을 고를 때 for (name in options)options 객체의 모든 열거 가능한(enumerable) 속성 이름을 그대로 순회하면서, __proto__라는 이름을 다른 평범한 속성 이름과 전혀 다르게 취급하지 않았다는 데 있다. JavaScript에서 __proto__는 보통의 데이터 속성이 아니라 객체 자신의 프로토타입(대개 Object.prototype)에 대한 살아있는 참조를 가리키는 접근자(accessor)다. 공격자가 JSON 등을 통해 {"__proto__": {"어떤속성": "값"}} 형태의 데이터를 $.extend의 병합 대상(source)으로 흘려보낼 수 있는 상황이라면, 재귀 병합 로직이 이 __proto__ 속성을 "재귀적으로 병합해야 할 하위 객체"로 착각하고, 그 결과 전역적으로 공유되는 Object.prototype에 공격자가 원하는 속성을 직접 심어(오염시켜)버린다. 이렇게 오염된 속성은 이후 그 페이지(또는 서버사이드 Node.js 환경)에서 만들어지는 사실상 모든 평범한 객체에 나타나게 되어, 애플리케이션 로직에 따라 인증 우회, 서비스 거부, 심지어 원격 코드 실행으로까지 이어질 수 있는 광범위한 파급력을 가진다.

사전 지식

JavaScript의 프로토타입 체인과 __proto__

JavaScript의 객체는 클래스 기반 언어와 달리 "프로토타입 체인"이라는 연결 구조로 상속을 구현한다. 어떤 객체에서 속성을 읽으려 할 때 그 객체 자신에게 없으면, 엔진은 자동으로 그 객체의 "프로토타입"으로 넘어가 다시 찾아본다. 예를 들어 {}처럼 리터럴로 만든 평범한 객체는 별다른 설정이 없는 한 그 프로토타입이 바로 Object.prototype이며, 이는 모든 평범한 객체가 공통으로 바라보는 단 하나의 공유 객체다.

   obj = {}                          obj2 = {}                  obj3 = {}
     │  (자기 속성 없음)                 │                          │
     └──────────────┬───────────────────┴──────────────┬───────────┘
                     ▼                                  ▼
              Object.prototype  (모든 평범한 객체가 공유하는 단 하나의 객체)
              { toString, hasOwnProperty, ... }

__proto__는 이 프로토타입 연결을 읽고 쓸 수 있게 해주는 특수한 접근자 속성이다. obj.__proto__라고 쓰면 obj의 프로토타입 객체(위 그림에서 Object.prototype) 자체에 대한 참조가 반환된다 — 복사본이 아니라 진짜 그 객체다. 그리고 for...in 반복문은 기본적으로 "열거 가능한" 속성만 순회하는데, 오래된 JavaScript 엔진/환경에 따라 __proto__가 열거 가능한 속성으로 나타나는 경우가 있었고, JSON.parse()로 만들어진 평범한 객체에 "__proto__"라는 문자열 키를 가진 일반 데이터 속성을 넣는 것은 항상 가능하다 — 이 경우 그 객체 자체는 특별할 것 없는 일반 속성처럼 보이지만, 그 객체를 다른 곳에서 for (name in obj)로 순회하면 name이라는 변수에 문자열 "__proto__"가 그대로 할당되고, 이어서 obj[name], 즉 obj["__proto__"]로 접근하는 순간 위에서 설명한 접근자가 발동해 실제 프로토타입 객체가 반환된다.

$.extend(true, ...)의 재귀 병합 로직

패치 전 jQuery의 src/core.js에 있는 구현은 다음과 같다(단순화한 핵심 부분):

jQuery.extend = jQuery.fn.extend = function () {
  var options, name, src, copy, copyIsArray, clone,
    target = arguments[0] || {},
    i = 1, length = arguments.length, deep = false;

  if (typeof target === "boolean") {
    deep = target;
    target = arguments[i] || {};
    i++;
  }

  for (; i < length; i++) {
    if ((options = arguments[i]) != null) {
      for (name in options) {                 // (1) 속성 이름을 그대로 순회
        src = target[name];
        copy = options[name];

        if (target === copy) { continue; }     // 순환 방지용 검사 (패치 전에는 이것뿐)

        if (deep && copy && (jQuery.isPlainObject(copy) ||
            (copyIsArray = Array.isArray(copy)))) {
          clone = src && jQuery.isPlainObject(src) ? src : {};
          target[name] = jQuery.extend(deep, clone, copy);  // (2) 재귀 병합
        } else if (copy !== undefined) {
          target[name] = copy;
        }
      }
    }
  }
  return target;
};

정상적인 사용 예를 들면 $.extend(true, {}, {a: {b: 1}}, {a: {c: 2}})처럼, 여러 설정 객체를 깊은 단계까지 하나로 합칠 때 쓴다. name"a"처럼 평범한 키라면 target["a"]는 그저 일반 속성이고, 재귀 호출은 그 하위 객체만 건드리므로 아무 문제가 없다.

취약점 분석

name === "__proto__"일 때 벌어지는 일

공격자가 애플리케이션의 어떤 입력 경로(예: URL 쿼리스트링을 파싱해 만든 객체, 서버가 돌려준 JSON 응답, 사용자가 채우는 폼 값 등)를 통해 다음과 같은 모양의 객체를 $.extend의 소스 인자로 흘려보낼 수 있다고 하자.

var malicious = JSON.parse('{"__proto__": {"isAdmin": true}}');
$.extend(true, {}, malicious);

이제 위 재귀 병합 루프를 name = "__proto__"인 상태로 한 줄씩 따라가 보면:

  1. src = target[name]target.__proto__를 읽는다. target{}이므로 그 프로토타입은 Object.prototype이다. 즉 srcObject.prototype에 대한 살아있는 참조가 된다.
  2. copy = options[name]{"isAdmin": true}, 공격자가 넣은 값이다.
  3. target === copy 검사는 통과한다 (둘이 같은 객체가 아니므로).
  4. deep이 참이고 copy가 평범한 객체이므로 재귀 분기로 들어간다: clone = src && jQuery.isPlainObject(src) ? src : {}src(=Object.prototype)는 평범한 객체로 판별되므로, clone{} 같은 새 객체가 아니라 Object.prototype 그 자체가 된다.
  5. target[name] = jQuery.extend(deep, clone, copy) → 재귀 호출 jQuery.extend(true, Object.prototype, {"isAdmin": true})가 실행된다. $.extend는 자신의 첫 번째 인자(target)를 직접 변형(mutate)해서 반환하는 함수이므로, 이 호출은 진짜로 Object.prototype.isAdmin = true를 실행한 것과 동일한 효과를 낸다.
  6. 마지막으로 target.__proto__ = (반환된 Object.prototype)을 대입하지만, 이는 원래 프로토타입을 그대로 다시 대입하는 것이라 겉보기엔 아무 일도 없었던 것처럼 보인다 — 그러나 부작용으로 5번 단계에서 전역 공유 객체가 이미 변형돼 버린 뒤다.
   [공격 전]                                  [$.extend(true, {}, {"__proto__":{"isAdmin":true}}) 실행 중]

   target = {}  ---.__proto__--->  Object.prototype        target["__proto__"] 접근
                                    { toString, ... }              │
                                                                    ▼  src = Object.prototype (살아있는 참조!)
                                                              clone = src  (새 객체 아님, 원본 그대로)
                                                                    │
                                                    jQuery.extend(true, clone, {"isAdmin": true})
                                                                    │  clone.isAdmin = true  (in-place mutate)
                                                                    ▼
                                            Object.prototype
                                            { toString, ..., isAdmin: true }   <- 오염됨

   [공격 후: 이 페이지(혹은 서버 프로세스)의 모든 평범한 객체가 영향을 받는다]

   var anyObj = {};
   anyObj.isAdmin;        // true  (자기 속성은 없지만 프로토타입 체인에서 발견됨)

   var another = {};
   another.isAdmin;       // true  (전혀 관계없는 객체도 동일하게 오염됨)

왜 파급력이 큰가

Object.prototype은 프로세스(또는 페이지) 전체에서 단 하나만 존재하며 모든 평범한 객체가 공유한다. 그래서 한 번의 오염으로 그 시점 이후 만들어지는 모든 신규 객체는 물론, 이미 만들어져 있던 객체(자기 속성이 없는 이름에 대해서는 프로토타입 체인을 다시 확인하므로)까지 영향을 받는다. 실제로 어떤 피해로 이어지는지는 오염시킨 속성 이름과 애플리케이션 코드가 그 이름을 어떻게 쓰는지에 달려 있다 — 예를 들어 어떤 코드가 if (obj.isAdmin) { ... }처럼 객체 자신에게 그 속성이 있는지 확실히 확인하지 않고 단순히 존재 여부만 검사한다면 권한 검사가 무력화될 수 있고, 서버사이드 Node.js에서 사용하는 다른 라이브러리가 프로토타입에 오염된 값을 신뢰하면 서비스 거부나 원격 코드 실행으로까지 이어진 사례들이 이 취약점 클래스(CWE-1321) 전반에서 보고된 바 있다. jQuery 자체는 브라우저에서 흔히 서버 응답이나 URL 파라미터를 객체로 변환해 UI 로직에 사용하기 때문에, 그 변환 결과가 $.extend(true, ...)의 소스로 들어가는 경로가 드물지 않았다는 점이 이 CVE의 CVSS를 6.1(Medium)로 끌어올린 배경이다.

수정 방법

수정은 PR #4333을 통해 이루어졌고, src/core.js의 순환 방지 검사 바로 그 줄에 name === "__proto__" 조건 하나를 추가하는 매우 단순한 형태다.

             for ( name in options ) {
                 copy = options[ name ];

+                // Prevent Object.prototype pollution
                 // Prevent never-ending loop
-                if ( target === copy ) {
+                if ( name === "__proto__" || target === copy ) {
                     continue;
                 }

이 한 줄은 재귀 병합 루프가 name"__proto__"인 경우를 만나면, src/copy가 무엇이든 상관없이 그 속성을 완전히 건너뛰도록(continue) 만든다. 즉 앞서 분석한 공격 흐름의 1단계(target[name]으로 Object.prototype 참조를 얻는 것) 자체를 실행하지 않게 되므로, 이후의 clone = src, 재귀 jQuery.extend() 호출, Object.prototype 변형이 전부 일어나지 않는다.

   [패치 후]

   $.extend(true, {}, {"__proto__": {"isAdmin": true}})
        │
        ▼
   for (name in options):  name = "__proto__"
        │
        ▼
   if (name === "__proto__" || target === copy) { continue; }   <- 여기서 즉시 건너뜀
        │
        ▼
   target["__proto__"]에 대한 어떤 읽기/쓰기도 발생하지 않음
        │
        ▼
   Object.prototype은 이 호출로 인해 전혀 변형되지 않음

이 수정은 jQuery 3.4.0(2019-04-20 릴리스)에 포함됐다. __proto__라는 이름 하나만 차단하는 방식이라 표적화된 최소 수정이지만, Object.prototype 오염 클래스의 가장 흔한 공격 벡터(__proto__ 키를 통한 직접 접근)를 정확히 겨냥한다는 점에서 실질적인 방어 효과가 있었다. 다만 이 방식이 constructor.prototype처럼 __proto__를 거치지 않는 다른 프로토타입 오염 경로까지 막지는 못한다는 점은 이 패치의 범위를 넘어서는 별도 논의다.

참고 자료

미해결/불확실 지점

  • for...in__proto__를 열거하는 정확한 조건(엔진/환경별 차이, Object.create(null) 등 특수 객체 여부)은 취약점의 핵심 트리거가 아니므로 깊이 검증하지 않았다 — JSON.parse()로 만든 일반 객체에 문자열 키 "__proto__"를 데이터 속성으로 넣는 경로만으로도 공격이 성립함을 소스 분석으로 확인했다.
  • 실제 서비스에서 이 취약점이 구체적으로 어떤 애플리케이션 로직과 결합해 인증 우회나 원격 코드 실행까지 이어졌는지에 대한 사례별 세부 내용(예: 특정 CMS 플러그인)은 CVE 설명에 나열된 영향 제품 목록(Drupal, Backdrop CMS, Joomla! 등) 이상으로 개별 검증하지 않았다.