
JavaScriptCore의 패치된 취약점 악용
이것은 원래 밴쿠버에서 열린 pwn2own 대회에서 Fluoroacetate가 발견한 WebKit 취약점에 대한 익스플로잇입니다. 제가 이 버그를 발견한 것은 아니지만, 익스플로잇 개발 기술을 연습하기 위해 이 익스플로잇을 작성했습니다. 이 익스플로잇에 대한 원본 분석 글은 Zero Day Initiative의 여기에 있습니다. 이 분석 글은 매우 훌륭하고 취약점을 이해하는 데 큰 도움이 되었지만, 취약점을 검증하는 사람의 관점에서 작성되었습니다. 처음부터 이 익스플로잇을 설계하려고 할 때 몇 가지 핵심 세부 사항이 누락되어 있음을 발견했고, ZDI 분석 글에서 놓친 부분을 채우고 복잡한 익스플로잇을 처음부터 설계하는 방법에 대한 실용적인 기술을 얻고자 합니다.
다음 단계는 JavaScriptCore(JSC) 내에서 임의 코드 실행을 달성하기 위한 개요입니다.
악용될 취약점은 WebKit의 DFG JIT(Just-In-Time) 컴파일러가 생성한 코드에서 발생하는 정수 오버플로입니다. 이는 특히 compileNewArrayWithSpread 함수에서 발생합니다. 이 함수는 JavaScript 전개 구문을 사용하여 새 배열을 생성하는 코드가 DFG에 의해 JIT 컴파일될 때 호출됩니다.

JIT 컴파일된 코드 내에서는 먼저 배열의 크기를 계산합니다. 이는 배열 생성자에 전달된 각 인수의 길이를 더하여 수행됩니다. 각 덧셈에 대한 크기를 계산하면서 크기의 오버플로를 확인합니다. 그런 다음 이 함수에서 계산된 길이를 전달하여 compileAllocateNewArray 함수를 호출합니다.

compileAllocateNewArray는 이전에 계산된 길이를 emitAllocateButterfly에 전달합니다.

emitAllocateButterfly는 크기를 3비트 왼쪽 시프트하는데, 이는 8을 곱하는 것과 같습니다. 그러나 오버플로 확인이 없으므로 0x20000001과 같은 숫자가 0x8로 오버플로될 수 있습니다.
다음 C 프로그램은 이 취약점을 보여줍니다:


이 취약점을 사용하여 JavaScript 엔진이 크기 0x20000001의 배열을 할당했다고 속일 수 있지만 실제로는 1개의 JSValue(8바이트)에 대한 공간만 할당됩니다. 이로 인해 경계를 벗어난(OOB) 읽기 및 쓰기(R/W) 프리미티브가 발생하며, 이를 활용하여 임의 R/W를 달성하고 최종적으로 원격 코드 실행(RCE)을 달성할 수 있습니다.
OOB 읽기가 발생하는 것을 확인하기 위해 JSC의 AddressSanitizer(ASAN) 빌드에서 이 취약점을 트리거해 보겠습니다.
이를 위해 WebKit 디렉터리에서 다음 명령을 실행할 수 있습니다:```bash Tools/Scripts/set-webkit-configuration --asan Tools/Scripts/build-jsc --jsc--only --debug
이것은 ASAN이 활성화된 JSC의 디버그 빌드를 빌드하여 취약점을 성공적으로 트리거했는지 여부를 확인할 수 있게 합니다.
다음은 exploit.js의 첫 번째 버전입니다.```javascript
function jitMe(array){
return [...array]
}
let dummy = [1.1]
for(let i = 0; i < 200; i++){
jitMe(dummy);
}
let a = []
let len = 0x20000001
for(let i = 0; i < len; i++){
a[i] = 1.1
}
jitMe(a)
이것을 실행하면 다음과 같은 오류가 발생합니다:
Program terminated with signal SIGKILL, Killed. The program no longer exists.
제 추측으로는 너무 큰 배열을 할당하려고 할 때 너무 많은 메모리가 소비되었기 때문입니다. 이를 확인하기 위해 compileNewArrayWithSpread 내부에 m_jit.breakpoint() 호출을 추가하여 JIT 컴파일된 코드에 중단점을 추가했습니다. 이는 JIT 컴파일된 코드에 int3 명령어를 추가하는 역할을 합니다.
중단점을 추가한 후에도 해당 중단점이 적중되지 않는다는 것을 발견했고, 그런 다음 길이 0x20001을 테스트하기로 결정했습니다. 그 후 코드가 전혀 컴파일되지 않고 있다는 것을 깨달았기 때문에 DFG 컴파일러를 활성화하기 위해 반복 횟수를 더 추가했습니다.```javascript function jitMe(array){ for(let i = 0; i < 0x4000; i++){ let x = 1 + 1 } return [...array] }
let dummy = [1.1] for(let i = 0; i < 60; i++){ print(i) jitMe(dummy); }
let a = []
let len = 0x20000001
for(let i = 0; i < len; i++){ a[i] = 1.1 }
jitMe(a)
프로그램을 그대로 테스트하면 여전히 SIGKILL이 발생하지만, 더 작은 길이로 테스트하면 중단점이 적중됩니다. 이 시점에서 JSC가 거대한 배열을 처리하려 할 때 메모리가 부족한 것으로 보입니다.
이 문제를 해결하기 위해 더 작은 `a` 배열을 할당하고, 전개 구문을 사용하여 손상된 배열을 생성할 때 이를 여러 번 사용하여 다음과 같은 exploit.js를 만들기로 결정했습니다.```
function jitMe(array){
for(let i = 0; i < 0x4000; i++){
let x = 1 + 1
}
return [...array, ...array, ...array, ...array, ...array, ...array, ...array, ...array, ...array, ...array, ...array, ...array, ...array, ...array, ...array, ...array]
}
let dummy = [1.1]
for(let i = 0; i < 100; i++){
print(i)
jitMe(dummy);
}
let a = []
let len = 0x20000010 / 0x10
for(let i = 0; i < len; i++){
a[i] = 1.1
}
jitMe(a)
이 코드를 사용하여 SIGKILL 없이 중단점에 도달할 수 있었습니다! 보통 그렇듯이, 한 문제를 해결하면 또 다른 문제가 발생하는데, 이번에는 SIGABORT가 발생했습니다... gdb의 bt 명령어를 사용하면 operationNewArrayWithSize가 호출되었고, 이어 create가 호출된 것을 확인할 수 있습니다.
JIT 컴파일된 코드가 operationNewArrayWithSize를 호출하는 것은 이상해 보이며, 어떤 이유로 JIT 코드가 JavaScript 엔진의 느린 경로(slow path)를 택해야 했을 것입니다.

compileAllocateNewArrayWithSize에서 operationNewArrayWithSize로의 탈출(bailout)이 실제로 존재하는 것을 볼 수 있습니다. 그런 다음 정확히 왜 느린 경우로 탈출하는지 알아내야 합니다.
compileNewArrayWithSpread에서 shouldConvertLargeSizeToArrayStorage가 false로 설정되어 있으며, 느린 경로는 컴파일된 코드에 포함되지 않음을 확인할 수 있습니다.
따라서 느린 경로가 emitAllocateJSObject 내 어딘가에서 발생하는 것이 타당합니다.

emitAllocateJSObject는 emitAllocateJSCell을 호출하고, 이는 다시 emitAllocate를 호출합니다.


WebKit 할당자(Allocator)의 작동 방식을 모르면 이는 상당히 혼란스러워 보입니다. 따라서 저는 몇 개의 중단점을 추가하고 gdb에서 단계별로 실행해보기로 결정했습니다.
emitAllocateButterfly에 의해 호출된 emitAllocateVariableSized에 설정된 중단점에 도달한 후, 다음과 같은 어셈블리 코드를 보게 됩니다:
이는 JIT 컴파일러가 여기서 생성한 코드에 해당합니다:
할당 크기에 0xf를 더한 후 오른쪽으로 4비트 시프트하는 것을 볼 수 있습니다. 그런 다음 느린 경로 분기에 해당하는 0x1f6과 비교됩니다. 이후 서브스페이스 할당자(subspace allocator)를 rsi로 이동시키고, 수행된 계산을 기반으로 이 포인터를 인덱싱합니다. 그런 다음 emitAllocateWithNonNullAllocator에 배치된 중단점으로 계속 이동하여 다음 어셈블리 코드를 확인합니다:

이는 JIT 컴파일러가 여기서 생성한 코드에 해당합니다:
이제 어셈블리의 일부를 단계별로 실행했으므로 무슨 일이 일어나고 있는지 조금 더 맥락을 알게 되었습니다. 두 개의 명령어를 더 진행하면 점프를 수행할 것임을 확인합니다:

C++ 코드를 살펴보면 이는 이 할당자의 빈 리스트(free list)에 남은 공간이 없음을 의미하며, 따라서 팝(pop) 경로를 택할 것임을 추론할 수 있습니다.
