本系列由三部分组成,重点介绍了查找和利用现代Web浏览器中的JavaScript引擎漏洞所涉及的技术挑战,并评估了当前的漏洞利用缓解技术。被利用的漏洞CVE-2020-9802已在iOS 13.5中修复,而两个缓解绕过(CVE-2020-9870和CVE-2020-9910)已在iOS 13.6中修复。
2020年浏览器渲染器漏洞会是什么样子?我在今年一月已着手回答这个问题。因为这是我在计算机科学中最喜欢的领域之一,所以我想找到一个JIT编译器漏洞,我特别感兴趣的是试图找到(新的)类型的漏洞,而我的Fuzzer很难找到这些漏洞。
由于WebKit(在iOS上,可能很快就会在ARM驱动的MacOS上)可以说具有目前最复杂的利用漏洞缓解,包括硬件支持的缓解,如PAC和APRR,因此将重点放在WebKit,或者实际上是它的JavaScript引擎JavaScriptCore(JSC)似乎是合适的。
简要介绍JIT引擎,特别是公共子表达式消除(CSE)优化。
解释源于不正确CSE的JIT编译器漏洞CVE-2020-9802,以及如何利用它在JSC堆上进行越界读取或写入。
深入讨论WebKit的呈现器利用IOS的缓解措施,特别是:结构ID随机化、Gigacage、指针身份验证(PAC)和APRR之上的JIT强化(本质上是按线程的页面权限)、它们的工作方式、潜在的弱点,以及在利用漏洞开发期间如何绕过它们。
可以在这里找到本博客文章系列附带的概念验证利用代码。它在iOS 13.4.1上的Mobile Safari和MacOS 10.15.4上的Safari 13.1上进行了测试。
本系列力求让没有浏览器开发背景的安全研究人员和工程师能够理解。它还试图解释用于(和滥用)利用漏洞开发的各种JIT编译机制。但是,应该注意的是,JIT编译器可能是Web浏览器最复杂的攻击面之一(所利用的漏洞可能特别复杂),因此对初学者并不特别友好。另一方面,在其中发现的漏洞通常也是最强大的漏洞之一,很有可能在未来相当长的一段时间内保持可利用状态。
由于目前关于JIT编译器的公共资源很多,因此本节只对JavaScript JITing进行简短的2分钟介绍/复习。
由于JIT编译的成本很高,因此只对重复执行的代码执行JIT编译。因此,函数foo将在解释器(或廉价的“基准”JIT)内执行一段时间。在此期间,将收集价值配置文件,对于foo,如下所示:
稍后,当优化JIT编译器最终开始工作时,它首先将JavaScript源代码(或者更可能是解释器字节码)转换成JIT编译器自己的中间代码表示。在JavaScriptCore的优化JIT编译器DFG中,这是由DFGByteCodeParser完成的。
这里,GetById和ValueAdd是相当通用的(或高级)操作,能够处理不同的输入类型(例如,ValueAdd也可以连接字符串)。
接下来,JIT编译器检查值配置文件,并根据它们推测将来将使用类似的输入类型。这里,它会推测o总是某种JSObject和x和yInt32s。但是,由于不能保证推测总是正确的,编译器必须保护推测,通常使用廉价的运行时类型检查:
还要注意GetById和ValueAdd是如何专用于更高效(和不那么通用)的GetByOffset和ArithAdd操作的。在DFG中,这种推测性优化发生在多个地方,例如,已经在DFGByteCodeParser中。
此时,IR代码实质上是类型化的,因为推测保护允许类型推理。接下来,执行许多代码优化,例如循环不变代码移动或常量折叠。DFG所做优化的概述可以从DFGPlan中提取。
最后,现在优化的IR被降为机器码。在DFG中,这是由DFGSpeculativeJIT直接完成的,而在FTL模式下,DFG IR首先被降低到B3,这是另一个IR,在其自身被降低到机器代码之前进行进一步的优化。
此优化背后的思想是检测重复计算(或表达式)并将其合并到单个计算中。作为示例,请考虑以下JavaScript代码:
进一步假设已知a和b是原始值(例如数字),则JavaScript JIT编译器可以将代码转换为以下内容:
并且通过这样做可以在运行时节省一次ArithMul操作。这种优化称为公共子表达式消除(CSE)。
在这里,编译器无法在CSE期间消除第二个属性加载操作,因为在这两个操作之间的函数调用可能会更改.a属性的值。
在JSC中,在DFGClobberize中,对操作是否可以接受CSE(以及在什么情况下)的建模是在DFGClobberize中完成的。对于ArithMul、DFGClobberize州:
这里的PureValue的def()表示计算不依赖于任何上下文,因此当给定相同的输入时,它将始终产生相同的结果。但是,请注意,PureValue是由操作的ArithMode参数化的,它指定操作是否应该处理(例如,通过跳出到解释器)整数溢出。这种情况下的参数化可防止两个对整数溢出处理不同的ArithMul操作相互替代。处理溢出的操作通常也称为“已检查”操作,而“未检查”操作是指不检测或处理溢出的操作。
这实质上是说,该操作产生的值取决于NamedProperty";抽象堆。因此,只有在两个GetByOffset操作之间没有写入NamedProperties抽象堆(即,写入包含属性值的内存位置)的情况下,才能消除第二个GetByOffset。
这可能导致CSE将选中的ArithNegate替换为未选中的ArithNegate。在ArithNegate(对32位整数求反)的情况下,整数溢出只能在一种特定情况下发生:当对INT_MIN:-2147483648求反时。这是因为2147483648不能表示为32位有符号整数,因此-INTMIN会导致整数溢出,并再次导致INTMIN。
通过研究DFGClobberize中的CSE Defs,思考为什么某些PureValue(以及哪些PureValue)需要使用ArithMode参数化,然后搜索缺少该参数化的情况,发现了该缺陷。
这现在教导CSE将ArithNegate操作的arithMode(未选中或选中)考虑在内。因此,具有不同模式的两个ArithNegate操作不能再相互替换。
请注意,这种类型的错误很可能很难通过模糊检测出来,因为。
模糊器将需要在相同的输入上创建两个ArithNegate操作但具有不同的ArithMode,
模糊器将需要触发ArithMode中的差异很重要的情况,在这种情况下这意味着它将需要取反INT_MIN值,并且,
除非引擎具有自定义的“消毒器”,以便在早期检测这些类型的问题,而且除非进行了差异模糊处理,否则Fuzzer仍然需要以某种方式将这种情况转变为内存安全违规或断言失败。如下一节所示,这一步可能是最困难的,也是极不可能偶然发生的。
下面显示的JavaScript函数通过此bug实现了任意索引(在本例中为7)对JSArray的越界访问:
以下是如何构建此PoC的逐步说明。在本节的末尾,还提供了上述函数的注释版本。
首先,ArithNegate仅用于对整数求反(更通用的ValueNegate操作可以求反所有JavaScript值),但在JavaScript规范中,数字通常是浮点值。因此,有必要向编译器“提示”输入值始终为整数。这很容易实现,方法是首先执行逐位运算,这将始终产生32位有符号整数值:
这样,现在就可以构造一个未检查的ArithNegate操作(使用该操作,稍后将对已检查的操作执行CSE):
在这里,在DFGFixup阶段期间,n的否定将被转换为未经检查的ArithNeg操作。编译器能够省略溢出检查,因为求反的值的唯一用法是按位或,这对溢出的“正确”值的行为是相同的:
接下来,需要构造一个以n作为输入的检查过的ArithNegate操作。获得ArithNegate的一种有趣的方式(稍后会变得清晰)是让编译器强度-将ArithAbs操作减少为ArithNegate操作。只有当编译器能够证明n将是负数时,才会发生这种情况,这很容易实现,因为DFG的IntegerRangeOptimization过程是路径敏感的:
在这里,在字节码解析期间,对Math.abs的调用将首先降低为ArithAbs操作,因为编译器能够证明调用总是会导致执行MathAbs函数,因此将其替换为ArithAbs操作,该操作具有相同的运行时语义,但在运行时不需要调用函数。编译器实质上是以这种方式内联Math.abs。稍后,IntegerRangeOptimization会将ArithAbs转换为选中的ArithNegate(必须选中ArithNegate,因为不能排除n的int_min)。因此,IF语句中的两个语句实质上变成(在伪DFG IR中):
此时,使用int_min为n调用编译错误的函数将导致i也为int_min,即使它实际上应该是一个正数。
这本身就是一个正确性问题,但还不是一个安全问题。将此bug转变为安全问题的一种(可能也是唯一的)方法是滥用在安全研究人员中已经很流行的JIT优化:边界检查消除。
返回到IntegerRangeOptimization过程,i的值已经被标记为正数。但是,要进行边界检查消除,还必须知道该值小于要索引的数组的长度。这很容易实现:
现在触发错误时,我将为int_min,因此将通过比较并执行数组访问。但是,边界检查将被删除,因为IntegerRangeOptimization错误地(尽管从技术上讲不是它的错)确定I总是在边界内。
在可以触发错误之前,必须对JavaScript代码进行JIT编译。这通常只需通过大量执行代码即可实现。但是,如果推测访问在边界内,则只会将对ARR的索引访问权限(由SSALoweringPhase)降低到CheckInBound(稍后将被删除)和未检查边界的GetByVal。如果在基线JIT中解释或执行期间经常观察到访问是越界的,则不会出现这种情况。因此,在函数的“训练”期间,有必要使用合理的边界索引:
然而,不便的是,越界索引(在RCX中)将始终是INT_MIN,因此在阵列后面访问0x80000000*8=16 GB。虽然可能是可利用的,但它并不完全是最好的利用原语。
使用任意索引实现OOB访问的最后一个技巧是从i中减去一个常量,这将把int_min换成一个任意的正数。因为(DFG编译器)认为我始终是正的,所以减法将变为未检查,因此溢出将不会被注意到。
然而,由于减法使关于下限的整数范围信息无效,因此之后需要额外的`IfI>;0‘检查以再次触发边界检查移除。此外,由于减法会将训练期间使用的整数变成越界索引,因此只有在输入值为负的情况下才有条件地执行减法。幸运的是,DFG编译器(到目前为止)还不够聪明,无法确定该条件永远不应该为真,在这种情况下,它可以完全优化减法:)。
有了所有这些,下面再次显示了从头开始的函数,不过这一次带有注释。当JITed并为n指定int_min时,它会导致将控制值(0x0000133700001337)越界写入内存中arr后面的JSArray的长度字段。请注意,此步骤的成功取决于正确的堆布局。但是,由于该错误足够强大,可以利用它进行受控的OOB读取,因此可以确保在触发内存损坏之前存在正确的堆布局。
此时,可以构建两个低级漏洞利用原语addrof和fakeobj。Addrof(Obj)原语返回内存中给定JavaScript对象的地址(双精度):
这些原语很有用,因为它们基本上允许两件事:打破堆ASLR,以便将受控数据放在已知地址,并提供一种构造假对象并将其“注入”到引擎中的方法。但在第2部分中有更多关于利用的内容。
这两个原语可以使用具有不同存储类型的两个JSArray来构造:通过将存储(未装箱/原始)Double的JSArray与存储JSValue(例如,可以是指向JSObjects的指针的装箱/标签值)的JSArray重叠:
注意noCoW变量有点不直观的用法。它用于防止JSC将数组分配为写入时复制数组,否则将导致错误的堆布局。
我希望这已经是一个“非标准”JIT编译器bug的有趣演练。请记住,有许多更容易利用的(JIT)漏洞。另一方面,利用(到目前为止)并不是微不足道的,这一事实也允许在此过程中涉及到大量的JSC和JIT编译器内部。
第2部分将展示实现任意读/写原语与addrof和fakeobj原语不同的方法。