프로토타입 오염이란...

ZLP042·2023년 6월 5일

잘못된 정보가 포함되어 있을 수 있습니다.

Prototype Pollution이 뭘까? 직역해보면 객체 오염이다. 말 그대로, 자바스크립트에서 의도하지 않은 객체 프로퍼티 변경을 통해, 코드를 조작할 수 있는 가능성이 발생하는 취약점이다.

Наследование и цепочка прототипов - developer.mozilla.org 참고하세요
JavaScript에서 모든 객체는 프로토타입이라는 내부 링크를 가지고 있다. 프로토타입은 해당 객체의 속성과 메서드를 상속하기 위해 사용되는 거다. 객체에서 속성이나 메서드를 찾을 때, JavaScript는 해당 객체에 속성 또는 메서드가 없으면 프로토타입 체인을 따라 올라가며 그 속성과 메서드를 검색한다. 이를 통해 객체는 프로토타입 체인에 연결된 다른 객체의 속성과 메서드를 사용할 수 있다. 그리고 이게 바로 문제점이 된다. 자. 누구나 이 사실은 알고 있다. 대부분의 모든 JavaScript 객체는 기본적으로 Object.prototype을 상속받게 되어있다(물론 뭐 다 그런건 아닐수 있지만, 대부분이 그렇다.) 이 프로토타입은 모든 객체에서 사용 가능한 기본 메서드이 포함되어 있다. 예를 들면 hasOwnProperty 같은 함수 말이다. 그리고 이를 다른 말로 하면, Object.prototype을 수정하게 되면, 이 변경 사항이 모든 객체에 영향을 미치게 될 수 있다는 소리다.

MDN WEB DOCS를 읽어보자

MDN WEB DOCS에 의하면, 각 객체는 다른 객체를 가리키는 프로토타입을 가지고 있고, 프로토타입 객체도 자체적인 프로토타입을 가지며, 이런식으로 연쇄가 이어지다가 프로토타입 속성이 null인 객체로 끝날 때까지 계속된다고 한다. null은, 프로토타입이 없다를 의미하며, 이는 끝을 의미하는 것이다. 또한 빨간 박스 윗부분에는 더 중요한 내용이 들어 있다. 대충 읽어보면, JS의 상속 모델은, 고수준 객체지향 언어인 Java나 C++와 같은 언어에 익숙한 개발자들에게는 익숙하지 않을수 있고, ES2015 표준에서 class라는 키워드가 더 이상 예약어가 아닌, 실제 사용되는 키워드가 되었지만, JS의 클래스는 프로토타입 기반 상속 모델 기반이라는 점도 알 수 있다.

또한 다른 자료에 의하면 프로토타입 오염은 악의적인 공격보다는 인간의 실수로 인해 더 잘 발생한다고 한다. 외부(내부) 라이브러리나 외부(내부) 소스에서 가져온 객체에 새로운 메서드를 추가한다고 해보자. 그러면 그 라이브러리의 코드가 객체의 프로토타입이나 전역 객체의 프로토타입을 변경하는 경우가 있다. 즉, 다른 코드 영역에서도 프로토타입 체인의 변경이 일어난다는 건데, 이렇게 되면 뭐,,,, 예상치 못한 동작이 발생하게 될 수도 있다. (사실 생각보다 흔하긴 하다. nodejs 같은거는 원래 외부 라이브러리 떡칠해서 코딩하는 언어니까, nodejs 쓰면 더 그럴 것이다. 애초에 js 자체가.... ㅎ)

그나저나, 이 취약점을 통해 어떻게 공격이 가능하다는 걸까? 같은 동작을 하지만, 방식이 다른 3가지 예시를 볼 것이다.

Yea 객체에 yea 프로퍼티를 만들어보자

const yea = {};
const zzap = JSON.parse('{"__proto__":{"yea": "예아"}}');
Object.assign(yea, zzap);
console.log(yea.yea)


결과는 보시다시피, 콘솔에 "예아"가 출력되게 된다.

먼저, yea라는 빈 객체를 생성한다. 이 객체는 아무런 프로퍼티가 없다. 그냥 비어 있다. {}다. 그리고 나서, zzap에다가 {"__proto__":{"yea": "예아"}}라는 JSON 문자열을 파싱에 할당한다. 당연한거지만, 이게 JSON 형식의 문자열이라 파싱이 가능한거다. 어쨌든 그러면 zzap의 __proto__ 프로퍼티에는 새로운 객체가 들어가는데, "예아"라는 값을 가진 yea라는 프로퍼티가 추가된다. 예아.

자, 지금까지 우리는, 빈 객체 yea와, {"__proto__":{"yea": "예아"}} 라는 값을 가진 객체 zzap을 가지고 있다. 이제, 우리는 Object.assign()을 이용해 yea 객체에 zzap 객체를 합치게 된다. 이 작업은 yea 객체에 zzap 객체의 프로퍼티를 복사한다. 그리고 나서, yea.yea를 콘솔에다 찍는데, 그러면 "예아"가 출력된다.

이제 좀 어려운 예제를 가져다가 살펴보자.

"Advanced 예아"

이번에는 좀 더 어려운 코드를 살펴볼 거다. 근데, 하는 짓은 "Yea 객체에 yea 프로퍼티를 만들어보자"와 똑같다. 사실 어려울것도 없다. 그냥 뭐... 코드 몇줄이 추가됐을 뿐이다.

function WeLoveYea(a, b) {
  function WeHateYea() {}
  WeHateYea.prototype = b;
  a.__proto__ = new WeHateYea();
}
const yea = {};
const zzap = JSON.parse('{"yea": "예아"}');
WeLoveYea(yea, zzap);
console.log(yea.yea); // 예아. 예아!!!!!!


먼저 첫째 줄에 있는 WeLoveYea라는 함수를 보자. 이 함수는, ab. 즉 2개의 매개변수를 받는다. 그 다음 줄에 있는 WeHateYea 함수는 아무 역할도 하지 못한다.

그리고 우리는, 다음 줄에서 WeHateYea 함수의 Prototype Object를 b 객체로 설정한다. 그러면 이게 뭔 소리나면, new WeHateYea() 코드로 생성된 인스턴스는 b 객체의 method, property를 상속받을 수 있다는 거다. 그리고, 다음줄에서는 a__proto__를 조작한다. a 객체의 __proto__new WeHateYea()로 생성한 인스턴스로 설정한다. 이렇게 하면 a 객체는 WeHateYea 객체의 method, property에 접근이 가능해진다. 만세!

그리고, WeHateYea 함수 선언 이후에는, yea와 짭이라는 객체를 만들어서, 각각 {}, {"yea": "예아"}라는 값을 할당한다. 그 이후에는 WeLoveYea 함수에 yeazzap을 전달한다. (a: yea, b: 짭)

함수를 호출하면, 간단히 말해서, yea 객체가 WeHateYea 객체의 프로퍼티와 메소드를 상속한다. 즉, yea 객체는 zzap 객체와 WeHateYea 객체의 method, property에 접근이 가능해진다는 거다.

자세히 설명하자면, 호출된 함수 내부에 선언된 WeHateYea라는 내부 함수가 있지 않는가? 그 내부 함수(WeHateYea 함수)의 프로토타입 객체가 zzap 객체로 설정되게 된다. 따라서 이제부터, WeHateYea로 생성된 모든 것들은 zzap 객체의 method, property에 접근이 가능해진다. 그리고, 함수의 마지막 줄로 인해 yea 객체의 __proto__ 속성이 WeHateYea 함수로 생성한 객체로 설정되게 된다. 이렇게 되면 계속 말했듯이 yea 객체는 WeHateYea 객체의 method, property에 접근이 가능해진다.

어쨌든 이래서 나중에 콘솔에 yea.yea 찍을때 yeayea 프로퍼티 값인 "예아"가 출력되는 거다. 이는 zzap 객체의 yea 프로퍼티 값인 "예아"를 yea 객체가 상속받았기 때문이다. 아주 간단하다.

"Basic 예아" (이해 못한 자를 위한)

이해를 못한 자들을 위해 좀 더 쉬운 예제를 보도록 하자. 이번 예제에서는 Object.assign같은 어려운 함수는 사용하지 않겠다.

const yea = {};
const zzap = { yea: "예아" };
yea.__proto__ = zzap;
console.log(yea.yea);


이번 코드는 확실히 간단하다. 딱 4줄로 끝내겠다.

  • yea 객체는 빈 객체로 초기화된다
  • zzap 객체는 { yea: "예아" }라는 값을 가지고 있다.
  • yea.__proto__를 사용하여 yea 객체의 프로토타입을 zzap 객체로 설정해, zzap 객체의 프로퍼티를 상속받도록 한다.
  • yea 객체의 yea 프로퍼티 값을 찍는다. 이 값은 알다시피, "예아"다.

어떻게 "예방" 할 수 있을까?

일단, 프로그래밍을 아주 신중하게 하고, 외부 코드와의 상호 작용에서 주의를 엄청 하면, 뭐... 예방할 수 있을 거다. 하지만 우리는 실수를 밥먹듯이 하는 인간이므로, 이런 조언은 매우 비현실적이다. 아래와 같은 방법을 사용해보자. (그냥 아예 JS를 최소한으로 쓰는것도 방법이다.)

hasOwnProperty()를 사용하라

.hasOwnProperty()를 사용하여 속성이 실제 객체에 속한 것인지 프로토타입을 통해 상속된 것인지를 감지할 수 있다. 근데, .hasOwnProperty()도 덮어쓰고 동작을 변경해버리면 할 수 있는게 없다. 즉, 딱히 좋은 방지책은 아니라는 거다.

Map을 쓰자.

키/값 구조인데다가? 심지어는? Object에서 발생하는 대부분의 문제를 해결할 수 있다. 그러니까 딱히 꼭 Object가 필요한거 아니면 Map을 쓰자.

Object.create의 인자에 null을 넘겨라

객체 생성 시, Object.create(null);을 이용해 객체를 생성하면, __proto__constructor가 undefined가 된다.

let yea = Object.create(null);
yea.__proto__ // undefined
yea.constructor // undefined

prototype을 얼려라. 냉동 프로토타입 만들기(?)

nodejs에서만 쓸 수 있는 방법인것 같다. (일단 M$ Edge는 안됨.)

({}).__proto__.a = 123;
console.log(({}).a)

이러면 결과는 알다시피 123이다. 근데, 아래처럼 쓰면, undefined다.

Object.freeze(Object.prototype);
Object.freeze(Object);
({}).__proto__.a = 123;
console.log(({}).a)

즉, 그냥 ObjectObject.prototype을 냉동해버리면 된다. 얼어라 얍!

결론

자바스크립트 대체 희망 331일째

0개의 댓글