본문 바로가기
hacking & security/vm & web browser

Chrome V8 BigInt Type-Confusion Bug

by 탁종민 2021. 1. 9.

(* poc code of this bug : https://github.com/zbvs/opensource-research/tree/master/v8/speculative_bigint )

V8 엔진에서 JIT 버그를 찾게 되었다. BigInt 타입에 대한 Type-Confusion 버그였는데 안타깝게도 BigInt의 특성 때문에 OOB Read까지만 가능했고 이 버그만으로는 Exploit이 가능하지는 않았다. Chrome Fullchain Exploit을 해보자고 팀으로 준비중비였는데 언젠가 써먹을 날이 오겠지 하고 냅 두었다가 어느 순간 패치가 되어있었다. 묵혀뒀던 이 버그에 대해 이야기를 하려 한다. 버그의 PoC는 V8 8.0 버전 쯤에서 테스트했었는데 아마 V8 7.5 ~ 8.4 정도 버전까지 유효했던 걸로 기억한다.

 

먼저 V8-JIT에 대해 설명하자면 V8-JIT는 자바스크립트 코드를 타입 피드백을 이용해 컴파일한다. 자바스크립트 오브젝트들은 모두 Map이라는 메타데이터 오브젝트를 참조하는데 사실 Map 오브젝트는 타입 정보이다. 똑같은 프로퍼티를 가지는 오브젝트들은 똑같은 Map을 공유한다. 결국 타입 피드백이라는건 Map정보이다. 아래 그림처럼 a 프로퍼티가 0x48에 위치한 오브젝트라면 그에 맞게 머신 코드로 컴파일 할 수 있다.

JIT Compile된 코드는 피드백에 쓰인 Map과 다른 Map을 가지는 오브젝트가 쓰이는지 여부를 항상 먼저 체크하고 다른 Map을 가지는 Object라면 Deoptimizing을 한다.

 

- FeedBack을 이용한 컴파일 예시

- 다른 Map을 가지는 Object가 왔을 때의 Deoptimizing

BigInt는 큰 숫자표현을 위해 사용되는 HeapObject인데 V8내부적으로는 Immutable 객체로 구현했다. 즉 한번 생성된 BigInt Object는 그 값을 변경할 수 없다. 나중에 설명하겠지만 이런 성질 때문에 Type-Confusion임에도 불구하고 OOB Read는 되지만 Exploit을 할 수는 없었다. BigInt의 내부적인 구조는 아래 그림과 같다.

 

만약 다음과 같이 BigInt를 생성하면

let bigint_1 = 0x1122334455667788AABBCCDDAABBCCDDn

다음과 같은 메모리 구조를 가진 BigInt Object가 생성될 것이다.

 

0x0 Map

0x8 2 ( 8바이트 *2 = 16바이트 크기 )

0x10 0x1122334455667788

0x18 0xAABBCCDDAABBCCDD

 

자 이제 다음 PoC를 통해 BigInt버그로 Crash를 발생시켜 보자.

var v1 = {};
v1["dd"] = 0n;
function _f_2(a0) {
    return v1["dd"] + 2n;
};
%PrepareFunctionForOptimization(_f_2);
_f_2(v1);
%OptimizeFunctionOnNextCall(_f_2);
v1["dd"] = [];
_f_2(v1);

 

아래 예시를 보면서 무슨 일이 일어난 건지 살펴보자. "dd" 프로퍼티는 Compile 할 때 Feedback을 통해 확인한 결과 BigInt타입이였다. 그래서 Array를 집어넣으면 원래는 Map 체크 후 BigInt 오브젝트가 아니니 Deoptimizing을 해야한다.  하지만 JIT Compiler가 BigIntAdd연산 코드를 생성할 때 Map 체크 코드를 생성해 주지 않았다. 그래서 Array를 넣었을 때 Deoptimizing을 하지 않고 그대로 가져다가 BigIntAdd에 써버려서 Crash가 난다.

 

문제가 되는 부분을 좀 더 상세히 설명하면 V8 JIT Pipeline중 Simplified Lowering 부분이다. V8은 BigInt Add를 JIT Compile 초기 단계에서 SpeculativeBigIntAdd라는 연산으로 표현한다. 이 연산은 "BigInt임을 가정하여 BigInt 덧셈을 수행한다" 라는 약간 추상적인 연산이다. 이 추상적인 연산은 Simplified Lowering단계에서 CheckBigInt 와 BigIntAdd 연산들로 구체화 된다.

그럼 Simplified Lowering 단계에서 정확히 무슨일이 일어났기에 Type-Confusion이 발생할까? 아래는 자바스크립트 코드와 이를 V8이 JIT Compile 할 때 Simplified Lowering 단계에서 IR Node들을 어떻게 변화시키는지를 Turbolizer ( https://github.com/v8/v8/tree/master/tools/turbolizer ) 를 이용해 분석한 것이다. Turbolizer는 v8에서 제공하는 분석 툴로 각 Pipeline단계에서 V8의 IR Node들과 바이트코드 및 머신코드를 비교분석 할 수 있게 해주는 툴이다.

var v1 = {}
v1["dd"] = 7n;
function _f_2(a0, a1) {
    return a0["dd"] + a1;
};

%PrepareFunctionForOptimization(_f_2);
_f_2(v1, 2n);
%OptimizeFunctionOnNextCall(_f_2);
_f_2(v1 , 4n);

SpeculativeBigIntAdd 연산은 SimplifiedLowering 단계를 거친 후 아래에서 볼 수 있듯이 CheckBigInt, BigIntAdd 연산으로 구체화 되었다. 그런데 BigIntAdd의 오른쪽 operand 노드에 대해서는 CheckBigInt 연산을 거치지만 왼쪽 노드에 대해서는 LoadField를 한 이후에 그대로 BigIntAdd에 사용한다. 왼쪽 BigIntAdd 과정을 바이트코드 비슷하게 표현하면 아래와 같다.

a1 = CheckBigInt (a1)
temp = LoadField a0[“dd”]     ( load 7n)
BigIntAdd temp, a1 //Doesn't check if temp has BigInt Map or not

 

지금까지는 BigInt연산을 Map 체크 없이 행한다는 것을 확인할 수 있었다. 그렇다면 이 버그로 무엇을 할 수 있을까? BigInt는 Immutable한 HeapObject이기 때문에 Type Confusion이 일어난다 해도 무엇인가를 Write 해서 변경하는 것은 불가능하다. 하지만 다행히 OOB Read는 가능하다.

아래의 그림은 BigInt Object의 구조와 Heap Number Object의 구조이다. HeapNumber는 소수를 표현할 때 쓰이는데 Value 필드가 BigInt의 length 필드와 겹치는 것을 알 수 있다. 만약 HeapNumber로 하여금 length가 아주 큰 BigInt로 착각하게 만든다면 OOB Read를 할 수 있다.

아래의 같은 PoC코드는 HeapNumber를 BigInt로 착각하게 만들어 BigIntAdd에서 덧셈을 이용해 leak을 할 수 있다. result로 반환된 BigInt는 OOB Read를 통해 Add를 한 것이므로 아주 큰 BigInt값을 가지고 있는데 이 데이터를 잘 출력하면 메모리 leak을 할 수 있다. 

var v1 = {};
v1["dd"] = 1n;
v1["dd"] = 1n;
function _f_2(a0) {
    return a0["dd"] + 2n;
};

%PrepareFunctionForOptimization(_f_2);
_f_2(v1);
%OptimizeFunctionOnNextCall(_f_2);

var buffer = new ArrayBuffer(8);
var u32   = new Uint32Array(buffer);
var f64   = new Float64Array(buffer);
u32[0] = 0x100;
u32[1] = 0x0;
v1["dd"] = f64[0];
let result_bigint = _f_2(v1);

 


(* poc code of this bug : https://github.com/zbvs/opensource-research/tree/master/v8/speculative_bigint ) 

I got to find JIT Bug in V8. It was Type-Confusion Bug for BigInt Object but unfortunately, It wasn't exploitable only with this bug and only can perform OOB Read because of BigInt's feature. I had a team and we planned to make Chrome Fullchain Exploit so I just decided to leave it for a full exploit someday in the future but recently I realized that it's been patched. I think it's time to talk about this bug. The PoC code was tested in V8 8.0 Version but it would work in between V8 7.5 ~ 8.4 Versions.

 

Let's talk about JIT first. JIT Compiler Compiles Javascript code with type feedback. There is a class called Map which contains Metadata about Objects. This Map class is actually type information and all the objects with same properties share a same Map. JIT Compiled code always checks whether the Maps of operand objects for operations are the same Maps with feedback first and if it's not the same map then it deoptimizes the code.

 

- Compile with FeedBack example

- Deoptimizing when an object with difference Map comes

 

BigInt is an HeapObject to express long and big integer and it has somewhat const feature in V8 internal point of view. If once a BigInt object is created we can't modify the inner value of the BigInt object. Because of this feature, I can't exploit V8 with this even though it is Type-Confusion bug. We can see the structure of Bigint in the figure below.

 

If we create a BigInt object like below

let bigint_1 = 0x1122334455667788AABBCCDDAABBCCDDn

 

We can get a BigInt structure like this.

 

0x0 Map 

0x8 2 ( 8byte * 2 = 16 byte )

0x10 0x1122334455667788

0x18 0xAABBCCDDAABBCCDD

 

This PoC code causes Crash with the BigInt bug.

var v1 = {};
v1["dd"] = 0n;
function _f_2(a0) {
    return v1["dd"] + 2n;
};
%PrepareFunctionForOptimization(_f_2);
_f_2(v1);
%OptimizeFunctionOnNextCall(_f_2);
v1["dd"] = [];
_f_2(v1);

We can explain what happened with the example code below. the property "dd" was BigInt type then V8 checks feedback compiling the _f_2 function. But when JIT Compiler created BigIntAdd operation code it didn't create Map check operation so even though the operand is Array type, it just uses it in BigIntAdd operation.

 

The root cause is in the Simplified Lowering phase in V8 JIT Pipeline. V8 express BigIntAdd operation as SpeculativeBigIntAdd operation in the initial phase of JIT Compile. This operation an abstract operation. This operation means it will assume operands are BigInt and will perform BigInt Add. This abstract operation is translated into more realistic operations in the Simplifed Lowering phase.

Then exactly what happened in the Simplified and caused Type-Confusion? We can see below Javascript and how V8 transforms IR nodes when it JIT Compiles them in Simplified Lowering phase with Turbolizer tools ( https://github.com/v8/v8/tree/master/tools/turbolizer ). Turbolizer is provided by V8 and it is a useful tool to compare and analyze IR nodes and bytecodes, created machine codes in each Pipeline phase.

var v1 = {}
v1["dd"] = 7n;
function _f_2(a0, a1) {
    return a0["dd"] + a1;
};

%PrepareFunctionForOptimization(_f_2);
_f_2(v1, 2n);
%OptimizeFunctionOnNextCall(_f_2);
_f_2(v1 , 4n);

 

As we can see below, the SpeculativeBigIntAdd operation has been detailed with BigIntAdd operation after Simplified Lowering phase. But the Simplified Lowering phase put Check Bigint operation for the right operand node but didn't put the check operation for the left operand node and just use them after LoadField. We can express it like bytecode as below.

a1 = CheckBigInt (a1)
temp = LoadField a0[“dd”]     ( load 7n)
BigIntAdd temp, a1 //Doesn't check if temp has BigInt Map or not

 

We have seen that V8 can run a BigIntAdd operation without Map checking. then what can we do with this bug? 

We can't write something with this even though it is Type Confusion because Bigint is something like const HeapObject. But we can perform OOB Read. figures below are the structure of a BigInt object and the structure of a Heap Number object. Heap Number is used to express float number and this has a value field which has the same offset with BigInt length field. If we can deceive the JIT Compiled code to mistake the HeapNumber for BigInt with huge length then we can perform OOB Read.

With the PoC code below, we can deceive JIT Compiled code to mistake the HeapNumber for BigInt and then can perform leak. The BigInt returned from the _f_2 JIT Compiled function would be a result of OOB Read and Add so it will have a big size number data. If we print it then we will see leaked data.

var v1 = {};
v1["dd"] = 1n;
v1["dd"] = 1n;
function _f_2(a0) {
    return a0["dd"] + 2n;
};

%PrepareFunctionForOptimization(_f_2);
_f_2(v1);
%OptimizeFunctionOnNextCall(_f_2);

var buffer = new ArrayBuffer(8);
var u32   = new Uint32Array(buffer);
var f64   = new Float64Array(buffer);
u32[0] = 0x100;
u32[1] = 0x0;
v1["dd"] = f64[0];
let result_bigint = _f_2(v1);

 

댓글