| 항목 | 내용 |
|---|---|
| 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의 객체는 클래스 기반 언어와 달리 "프로토타입 체인"이라는 연결 구조로 상속을
구현한다. 어떤 객체에서 속성을 읽으려 할 때 그 객체 자신에게 없으면, 엔진은 자동으로 그
객체의 "프로토타입"으로 넘어가 다시 찾아본다. 예를 들어 {}처럼 리터럴로 만든 평범한
객체는 별다른 설정이 없는 한 그 프로토타입이 바로 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__"]로 접근하는 순간 위에서 설명한
접근자가 발동해 실제 프로토타입 객체가 반환된다.
패치 전 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"]는 그저 일반 속성이고, 재귀 호출은 그 하위 객체만 건드리므로 아무 문제가
없다.
공격자가 애플리케이션의 어떤 입력 경로(예: URL 쿼리스트링을 파싱해 만든 객체, 서버가
돌려준 JSON 응답, 사용자가 채우는 폼 값 등)를 통해 다음과 같은 모양의 객체를 $.extend의
소스 인자로 흘려보낼 수 있다고 하자.
var malicious = JSON.parse('{"__proto__": {"isAdmin": true}}');
$.extend(true, {}, malicious);이제 위 재귀 병합 루프를 name = "__proto__"인 상태로 한 줄씩 따라가 보면:
src = target[name]→target.__proto__를 읽는다.target은{}이므로 그 프로토타입은Object.prototype이다. 즉src는Object.prototype에 대한 살아있는 참조가 된다.copy = options[name]→{"isAdmin": true}, 공격자가 넣은 값이다.target === copy검사는 통과한다 (둘이 같은 객체가 아니므로).deep이 참이고copy가 평범한 객체이므로 재귀 분기로 들어간다:clone = src && jQuery.isPlainObject(src) ? src : {}→src(=Object.prototype)는 평범한 객체로 판별되므로,clone은{}같은 새 객체가 아니라Object.prototype그 자체가 된다.target[name] = jQuery.extend(deep, clone, copy)→ 재귀 호출jQuery.extend(true, Object.prototype, {"isAdmin": true})가 실행된다.$.extend는 자신의 첫 번째 인자(target)를 직접 변형(mutate)해서 반환하는 함수이므로, 이 호출은 진짜로Object.prototype.isAdmin = true를 실행한 것과 동일한 효과를 낸다.- 마지막으로
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__를 거치지 않는
다른 프로토타입 오염 경로까지 막지는 못한다는 점은 이 패치의 범위를 넘어서는 별도
논의다.
- jQuery 3.4.0 릴리스 공지 — 공식 수정 발표. (검증됨, 1차 자료)
- 수정 PR #4333 — jquery/jquery — 실제 패치 diff 직접 확인. (검증됨, 1차 자료)
- jQuery 3.3.1
src/core.js원본 소스 — 취약 당시$.extend구현 전문 직접 확인. (검증됨, 1차 자료) - NVD CVE-2019-11358 — 공식 CVE 설명, CVSS, CWE-1321 분류. (검증됨)
- Snyk — SNYK-JS-JQUERY-174006 — 최초 보고/공개 경위 및 영향 범위 요약. (검증됨)
- Drupal Security Advisory SA-CORE-2019-006 — 내장 jQuery를 사용하는 대표적 하위 제품의 대응 사례. (검증됨)
for...in이__proto__를 열거하는 정확한 조건(엔진/환경별 차이,Object.create(null)등 특수 객체 여부)은 취약점의 핵심 트리거가 아니므로 깊이 검증하지 않았다 —JSON.parse()로 만든 일반 객체에 문자열 키"__proto__"를 데이터 속성으로 넣는 경로만으로도 공격이 성립함을 소스 분석으로 확인했다.- 실제 서비스에서 이 취약점이 구체적으로 어떤 애플리케이션 로직과 결합해 인증 우회나 원격 코드 실행까지 이어졌는지에 대한 사례별 세부 내용(예: 특정 CMS 플러그인)은 CVE 설명에 나열된 영향 제품 목록(Drupal, Backdrop CMS, Joomla! 등) 이상으로 개별 검증하지 않았다.