JavaScriptOOP

프로토타입, 오염이냐 상속이냐

2022.09.11


Prototype이란?

JavaScript에서는 모든 객체들이 메소드와 속성들을 상속 받기 위한 템플릿으로써 prototype 객체를 가진다. 그리고 이 prototype 객체 또한 상위 prototype 객체로부터 메소드와 속성을 상속받을 수 있고, 그 상위 객체도 마찬가지다. 즉, 다른 객체에 정의된 메소드와 속성을 한 객체에서 사용할 수 있도록 해준다.


  1. instance.method() 가 호출될 경우, instancemethod()가 정의되어 있는지 확인한다.
  2. 없을 경우, instanceprototype 객체에 method()가 정의되어 있는지 확인한다.
  3. 또 없으면, 상위 prototype 확인...

    한 객체의 메소드와 속성들이 다른 객체로 복사되는 것이 아닌, prototype 체인을 타고 올라가며 접근할 뿐이다.


객체의 Prototype

const dog = {};

dog를 아무 속성 없도록 생성했지만, 이 강아지는 단순히 비어있는 친구가 아니다. console.log(dog) 를 실행해보면 어떤 결과가 나올까?

분명 아무 속성도 주지 않았는데, 여러가지가 있는 것을 확인해볼 수 있다. 이 속성이 어디서 온 것인에 대한 답을 prototype에서 찾아볼 수 있다.

JavaScript에서 사용하는 class의 개념은 함수에서 시작했다. 함수 그 자체는 class의 constructor 역할을 한다. 따라서 아래와 같이 코드를 작성할 수 있다.

function Cat() {
    this.says = function() { return "meow"; };
}
const myCat = new Cat();
Cat.prototype.canFly = function() { return false; };
console.log(myCat);

myCat instance에서 사용 가능한 함수들은 prototype 에 선언되게 된다.


Runtime에 prototype의 내부 값을 변경할 수 있다는 점과, prototype chaining을 활용하면, 상속을 구현할 수도 있고, exploit 하는데 활용할 수도 있을 것이다.


Prototype Inheritance

class 선언을 사용하지 않고, 함수를 사용해서 아래와 같이 Animal 객체를 생성해보자.

// 속성 정의는 생성자 안에서
function Animal(name, legs) {
    this.name = name;
    this.legs = legs;
}
// 메소드 정의는 prototype을 사용해서
Animal.prototype.say = function(word) { return word; };
Animal.prototype.toString = function() { return this.name + " with "+this.legs+" legs."; };

위의 Animal을 상속하는 Cat 객체를 만들고 싶다면 아래와 같이 하면 된다.

function Cat(name, legs, canFly) {
    Person.call(this, name, legs);
    // 여기서 this는 Cat을 가리킨다.
    this.canFly = canFly;
}
  • call() 은 이미 할당되어있는 다른 객체의 함수/메소드를 현재 객체(this)에 재할당할 때 사용한다.

여기서의 문제 : Cat이 자기 자신에 대한 참조만 가지고 있다는 것 즉, Animal에 작성되어있는 say() / toString() 메소드가 Cat을 통해서는 접근 불가능하다. 따라서 아래와 같이 CatprototypeAnimalprototype을 참조하도록 해야한다.

Cat.prototype = Object.create(Animal.prototype);

여기서의 문제: Cat의 constructor가 Animal 로 되어있다는 것 Cat.prototype에 Animal.prototype을 상속받은 객체를 할당했기 때문이고, 아래와 같이 consturcot를 Cat으로 지정해주어야한다.

Cat.prototype.constructor = Cat;

만약, Catsay(word) 할 때 마지막에 항상 "meow" 를 붙이는 식으로 말하게 하고 싶다면 아래와 같이 메소드를 다시 지정하면 된다.

Cat.prototype.say = function(word) { return word + " meow"; };

class 문법은 prototype을 통해 객체를 생성한 것과 다를바 없지만, prototype을 통해 method를 정의하는 것보다 코드의 가독성/유지보수 측면에서 편리하다.


Prototype Pollution

function isObject(v) { return typeof v === "object"; }
function merge(a, b) {
    for (const key in b) {
        if (isObject(a[key]) && isObject(b[key])) {
            merge(a[key], b[key]);
        } else {
            a[key] = b[key];
        }
    }
    return a;
}

const express = require("express");
const app = express();
app.use(express.json());
app.get("/", (req, res) => {
    const userObject = {};
    
    /** 
    * 🔨 userObject 뚝딱뚝딱 🔨
    */ 
    
    if (userObject.isAdmin) res.end("<h1>you are admin</h1>");
    else res.end("<h1>you are not admin</h1>");
});

// #1
app.post("/", (req, res) => {
    const result = { status: 200 };
    merge(result, req.body);
    res.json(result);
});

// #2
app.post("/dos", (req, res) => {
    merge({}, req.body);
    res.end("dos attack");
});

app.listen(3000);

위와 같은 express 서버가 있다고 해보자.

  • GET: / : userObject를 생성하고, isAdmin 속성 여부에 따라 다른 결과를 보여준다.
  • POST: / : req.body로 전달받은 내용과 result 에 있는 정보를 병합시킨 후 사용자에게 다시 전달해준다.

1. 만약, 악의적인 사용자가 아래와 같은 내용을 '/'로 POST request를 보냈다면?

{
  "__proto__": {
    "isAdmin": true
  }
}

악의적인 사용자가 요청을 보내기 전에는 모든 사용자가 '/'로 접근했을 때, you are not admin 메세지를 봤겠지만, 이제는 모든 사용자가 you are admin 이라는 화면을 보게 된다.


이러한 현상이 생긴 두 가지 원인을 서버 코드에서 찾아볼 수 있다.

  1. merge 함수에서 적절하지 않은 key 값은 건너뛰도록 했더라면... lodash에서는 어떤 value에 대해, 그 key가 적절한지 다음과 같이 확인한다. 링크
  2. 최초에 userObject 를 선언할 때 isAdmin: false 로 초기화 시켰더라면... userObject.isAdmin이 지정되어있지 않다보니, prototype chaining에 의해 merge 함수에 의해 설정된 Object.isAdmin 의 값을 갖게 된다.

2. POST: /dos 로 아래와 같은 내용을 보낸다면?

{
  "__proto__": {
    "toString": "아무 값이나 입력해버려",
    "valueOf": "DoS 공격하기 위함이니까"
  }
}

이 다음번의 모든 request들은 500 Internal Server Error를 겪게 된다. prototype pollution은 DoS(Denial of Service) 공격에 사용될 수도 있다.


Prototype Pollution이 일어나는 경우

prototype pollution을 야기하는 경우를 세가지로 나눠볼 수 있다. 1. Object recursive merge 위에서 작성한 merge 함수가 그 예시다. 최상위 객체인 Objectprototype 값에 접근하여 모든 객체의 template을 수정하게 된다. 2. Property definition by path 어떤 라이브러리들에서는 객체의 값을 접근할 때 path 를 통해 접근할 수 있게 하는데, 이 때 path 값으로 __proto__.key 를 전달하여 특정 value를 set 할 수 있다. 3. Object clone function clone(obj) { return merge({}, obj); } 같은 식으로 코드를 작성하면, obj에 수정된 prototype의 key, value로 template을 망가트릴 수도 있다.


이러한 Pollution을 막기 위해서는?

  1. Prototype을 freeze 시켜버린다.
Object.freeze(Object.prototype);
Object.freeze(Object);
({}).__proto__.something = 1111;
console.log(({}).something); // undefined
  1. JSON input에 대한 검증 : 불필요한 속성값들에 대한 접근을 reject 시켜버린다.
  2. Map 을 사용한다. 링크
  3. prototype이 없는 객체를 생성한다.
const obj = Object.create(null);
console.log(obj.__proto__); // undefined
console.log(obj.constructor); // undefined

'내가 만든 이 객체는 깨끗한가?'

꼭 Prototype Pollution 기법을 이야기하지 않더라도, 내가 만든 객체들간의 상속 관계가 서로를 "오염"시키고 있지는 않은지 생각해볼 수 있을 것 같다.

  • 객체간의 책임을 명확히 부여해주었는가?
  • SOLID 원칙을 고려했을 때, 위반하는 부분은 어디인가?
  • 불필요한 상태를 부여함으로써 객체를 "오염"시키지는 않았는가?

갑작스러울 수 있지만, 상속을 구현할 수 있는 prototype이 서비스의 취약점이 될 수도 있는 부분은 사람을 참 많이 닮지 않았는가? 서로 서로 협력 관계를 구축하면서도, 한 사람으로 인해 전체 조직이 와해될 수도 있고, 반대로 한 사람으로 인해 모두가 하나가 될 수 있듯이.

For as by one man's disobedience many were made sinners, so by the obedience of one shall many be made righteous. - Romans 5:19



References