Blackhat USA 2018에서 브라우저 취약점을 찾는 새로운 트랜드로 JIT 컴파일러 취약점에 대한 발표(Attacking Client-Side JIT Compilers by Samuel Groß)가 있었습니다. 브라우저는 자바스크립트를 실행하기 위해 자바스크립트 엔진을 사용하고, 빠르고 효율적인 실행을 위해 JIT 컴파일러를 사용하여 자바스크립트를 컴파일하고 최적화도 수행합니다. 하지만 자바스크립트에서 발생하는 다양한 실행 흐름을 파악하는 것이 어렵기 때문에 컴파일 과정에서 취약점이 발생하기도 합니다.

JIT 컴파일러가 무엇이고 어떤 취약점들이 발생할 수 있는지 정리해 보았습니다.

자바스크립트 Basic

먼저 자바스크립트 내부에서 값과 객체를 표현하는 방법에 대한 기본적인 내용입니다.

JSValue

자바스크립트는 동적 타입 언어(Dynamically-Typed Language)이기 때문에 객체의 type 정보를 runtime에 저장하고 있습니다. 객체의 type 정보와 value 정보를 효율적으로 저장하기 위해서 태깅(Tagging)을 사용하여 8 바이트 안에 두 정보를 모두 인코딩하고 있습니다. 구글 크롬의 자바스크립트 엔진인 V8을 예시로 살펴보면, V8에서는 1 비트의 태깅을 사용하여 SMI(Small Integer)와 객체를 구분합니다.

0x0000004200000000 : 최하위 비트가 0이므로 SMI 타입입니다.

0x00000e0359b8e611 : 최하위 비트가 1이므로 객체를 가리키는 포인터입니다. 0x00000e0359b8e610을 주소값으로 사용합니다.

JSObject

모든 객체는 딕셔너리와 같은 형태로, 속성 이름인 key가 속성 정보인 value 정보와 매핑됩니다. 이때 효율적인 저장을 위해 Shape라는 개념을 사용합니다. Shape는 속성 이름과 해당 속성의 위치 정보를 저장하고 있고 속성의 값만 JSObject 내부에 저장합니다. 이렇게 Shape를 사용하면 동일한 속성을 갖는 객체가 여러개 등장한 경우에 중복되는 속성 정보를 여러번 저장하지 않기 때문에 불필요한 메모리 사용을 줄일 수 있습니다.

Untitled/Untitled.png

첫 번째 그림과 같이 동일한 속성을 갖는 객체는 같은 Shape를 사용합니다. Shape에서 속성 이름을 찾고 위치 정보를 알아낸 후 JSObject 내에 해당 위치에 기록된 속성 값을 로드합니다.

두 번째 그림과 같이 속성 정보가 추가된다면 transition chain을 형성합니다.

세 번째 그림과 같이 같은 속성으로 시작했지만 속성 정보가 달라지게 된다면 transition tree를 형성합니다.

자바스크립트 엔진

웹 브라우저, Node.js, React Native 등 다양한 곳에서 자바스크립트가 사용되고 있습니다. 이러한 자바스크립트를 실행하기 위해서는 자바스크립트 엔진이 필요합니다. 웹 브라우저 별로 살펴보면 Chrome에서는 V8, Firefox에서는 Spider Monkey, edge에서는 Chakra, Safari에서는 JavaScriptCore라는 이름의 자바스크립트 엔진이 사용되고 있습니다. 각 엔진마다 조금씩 다르긴 하지만 일반적으로 아래 그림과 같은 파이프라인을 가지고 있습니다.

Untitled/Untitled%201.png

먼저 Parser가 자바스크립트 코드를 해석하고 AST(Abstract Syntax Tree)를 만듭니다. Interpreter는 AST를 기반으로 Bytecode를 생성합니다. Interpreter는 Bytecode를 실행하여 자바스크립트 코드의 내용을 수행할 수 있습니다. 자바스크립트는 대부분 Interpreter로 실행됩니다. 하지만 자주 호출되는 함수에 대해서는 더 빠르게 실행하기 위해 기계어 코드로 컴파일을 하기도 합니다. Bytecode를 실행하면서 최적화에 필요한 프로파일링 데이터를 수집하고, JIT(Just-In-Time) 컴파일러는 Bytecode와 프로파일링 데이터를 이용하여 최적화된 기계어 코드를 생성합니다.

그런데 자바스크립트는 동적 타입 언어이다보니 runtime이 아니면 알 수 없는 정보들이 있습니다. 예를 들면 함수의 인자로 전달받은 객체의 type은 runtime이 아니면 알 수 없습니다. 이런 함수를 컴파일 하기 위해 프로파일링 데이터가 필요합니다. 이전의 실행에서 자주 등장했던 type이 다음 호출에서도 사용될 것이라고 추측하고 코드를 생성하게 됩니다. 하지만 해당 type이 다시 사용될지는 보장된 것이 아니라서 이를 보호하기 위한 코드(Speculation guards)도 추가됩니다. 객체가 예상된 type이 맞는지 검사 후 틀렸다면 최적화를 해제하고 Interpreter로 돌아가서 Bytecode를 실행합니다. 예상된 type이 맞는지 검사하는 방법으로는 객체가 가리키고 있는 Shape가 특정 Shape가 맞는지 확인하는 방법을 사용합니다.

JIT 컴파일러 취약점

컴파일은 일반적으로 개발자가 수행하지만 자바스크립트 엔진의 경우엔 사용자가 작성한 자바스크립트를 JIT 컴파일러가 컴파일하도록 유도할 수 있습니다. 하지만 JIT 컴파일러가 항상 완벽한 기계어 코드를 생성하지는 않습니다. 코드 생성 과정이나 최적화 과정에서의 잘못된 처리로 인해 여러가지 취약점이 발생할 수 있습니다.

JIT 컴파일러의 최적화 방법은 여러가지가 있습니다. 그 중에서 취약점이 자주 발견되었던 Bounds-Check Elimination, Redundancy Elimination, Escape Analysis, Loop-Invariant Code Motion 최적화에 대해 정리해 보았습니다.

Bounds-Check Elimination

var buf = new Uint8Array(0x1000);
function foo(i) {
  i = i & 0xfff;
  return buf[i];
}

for (var i=0; i<1000; i++)
  foo(i);
// IR code
len = LoadArrayLength buf
i = And i, 0xfff
CheckBounds i, len
GetByVal buf, i

인덱스 변수를 이용해 배열의 원소에 접근할 때 해당 변수가 유효한 인덱스 범위 내에 있는지 검사하기 위해 bound-check 코드가 생성됩니다.

// IR code
len = LoadArrayLength buf
i = And i, 0xfff
// CheckBounds i, len
GetByVal buf, i

하지만 변수 값의 범위를 분석하여 항상 유효한 범위 내에 있다고 판단되면 검사 코드를 제거할 수 있습니다. 이때 컴파일러가 계산한 변수 값의 범위가 실제 값의 범위와 다르거나 bound-check 코드를 잘못 생략하게 될 경우 버그가 발생할 수 있습니다.

Edge

Firefox

Safari

Redundancy Elimination

JIT 컴파일러는 이전의 실행에서 자주 등장했던 type이 다시 사용될 것이라고 가정하고 코드를 생성하기 때문에 이 가정에 대한 검사를 수행하는 코드도 추가됩니다. 이 코드는 해당 객체에 접근하기 전에 추가되는데, 그러다보니 불필요하게 중복되는 경우가 생깁니다.

fuction foo(o) {
  return o.a + o.b;
}

위 자바스크립트 코드의 경우엔 객체 o에 대한 검사가 필요합니다.

// IR code
CheckShape o, shape1
r0 = Load[o+0x18]

CheckShape o, shape1 // Redundancy
r1 = Load[o+0x20]

r2 = Add r0, r1
CheckNoOverflow

Return r2

객체 o에 접근하기 전에 JIT 컴파일러가 가정한 type과 같은지 검사를 수행하게 되는데 첫 번째 검사 이후에도 객체 o가 shape1을 사용한다는 것은 변함없기 때문에 두 번째 검사는 불필요한 중복입니다. 이렇게 동일한 control-flow에 있는 검사 코드는 첫 번째 코드를 제외하고는 제거됩니다.

function foo(o, f) {
  var a = o.a;
  f();
  return a + o.b;
}

foo(o, () => {
  delete o.b;
});

하지만 위의 코드와 같이 o의 shape가 중간에 변경된다면 중복이라고 판단한 검사 코드를 제거했을 때 문제가 발생할 수 있습니다.

Chrome

Edge

Safari

Escape Analysis

JavaScript에서 객체는 일반적으로 힙에 할당되지만 escape analysis를 사용하면 최적화할 수 있습니다. 새로 할당된 객체에 대한 참조가 객체를 생성한 함수의 밖으로 벗어나지 않는다면 해당 객체는 힙에 할당할 필요가 없습니다. 대신 스택을 사용하는 로컬 변수로 최적화합니다.

function foo() {
  let tmp = [];
  tmp[0] = 1;
  return tmp[0];
}

print(foo());

위의 예시 코드에서는 tmp 객체의 참조가 foo 함수를 벗어나지 않습니다. tmp 객체는 최적화 과정을 거치면 힙 대신 스택에 할당됩니다.

function foo() {
  let tmp = [];
  tmp[0] = tmp;
  return tmp[0];
}
 
print(foo());

하지만 위와 같은 상황에서 함수 스코프를 벗어난 참조를 감지하지 못하고 힙에 할당해야 하는 객체를 스택에 할당하게 된다면 초기화되지 않은 스택 값이 참조되는 등 문제가 발생할 수 있습니다.

Chrome

Edge

Firefox

Loop-Invariant Code Motion (LICM)

루프 내에 항상 동일한 결과를 생성하는 코드를 루프 불변 코드(loop-invariant code)라고 합니다. 이런 코드는 루프 밖으로 이동시켜서 최적화할 수 있습니다.

function v7(v8) {
  for (let v9 in v8) {
    const v10 = v8[-698666199];
  }
}

최적화의 한 가지 예시로 위 코드는 아래와 같은 IR 코드를 생성합니다.

// IR code 1

// Loop Header
len = LoadArrayLength v8

// Loop body
CheckShape v8, expected_shape
CheckBounds -698666199, len
GetByVal v8, -698666199

여기서 Loop Header에서 로드되는 변수에 의존적이지 않은 코드는 루프 불변 코드로 판단되어 아래와 같이 최적화될 수 있습니다.

// IR code 2

CheckShape v8, expected_shape

// Loop Header
len = LoadArrayLength v8

// Loop body
CheckBounds -698666199, len
GetByVal v8, -698666199

루프 밖으로 이동된 루프 불변 코드는 여러번 실행시키지 않기 때문에 실행 속도를 향상시킵니다.

// IR code 3

CheckShape v8, expected_shape
GetByVal v8, -698666199

// Loop Header
len = LoadArrayLength v8

// Loop body
CheckBounds -698666199, len

하지만 GetByVal 또한 Loop Header에서 로드되는 변수에 의존적이지 않기 때문에 루프 불변 코드로 인식될 수 있습니다. GetByVal은 CheckBounds를 통해 해당 인덱스가 유효한 범위인지 검사 하고 검사 결과에 따라서 실행되어야 하는 코드이기 때문에 루프 불변 코드로 인식되면 문제가 발생할 수 있습니다.

Safari

References

  1. https://saelo.github.io/presentations/blackhat_us_18_attacking_client_side_jit_compilers.pdf
  2. https://i.blackhat.com/asia-19/Fri-March-29/bh-asia-Li-Using-the-JIT-Vulnerability-to-Pwning-Microsoft-Edge.pdf
  3. https://mathiasbynens.be/notes/shapes-ics
  4. https://v8.dev/blog/disabling-escape-analysis