本文详细分析了苹果Webkit使用到的6个安全功能:
- Jit write protection
Webkit中的JavaScriptCore(JSC)引擎在使用JIT技术时,将jit存放的内存做了写和执行权限分离的保护,用到了两个技术:APRR和Separated WX Heap。
Apple自研芯片新增了一个快速权限映射机制APRR,它将arm公版中的页表中原本的8个权限组合扩展为了16个,并提供了一些列新的寄存器来配合这个机制,可以让应用程序不用使用Mmap/mprotect系统调用,就能设置页表的权限,不仅提高了访问页表的速度,还能将一个页表设置为只执行权限,这使得基于这一技术的webkit从性能和安全都得到了极大的提升。关于APRR可以参考博主ios系列中关于PPL技术的分析。
苹果公司将APRR做了一些封装提供给app使用,JSC在初始化中会有如下的一些调用:
JavaScriptCore/jit/ExecutableAllocator.cpp
苹果公司在pthread中提供封装了对jit操作的一些辅助函数,pthread_jit_write_protect_*系列函数最终会通过msr指令调用aprr机制。
系统在启动时会自动将每个进程的每个线程内存设置为不可写只执行状态,注意是每个线程都能独立的设置只执行权限, 如果一个线程与另一个线程共享一块内存,其中一个线程打开了写权限,对另一个线程来讲仍然是只执行的。对于JIT来讲,需要先调用pthread_jit_write_protect_np(false)关闭只执行,打开写权限,写入JIT code后,在调用pthread_jit_write_protect_np(true)恢复执行权限。
我们看下libpthread的相关代码:
void
实际上还是使用了os_thread_self_restrict_rwx_to_rx系统库提供的c接口。
可以看到使用pthread_jit_write_protect_np接口可以关闭只执行内存,会带来一些安全问题,苹果的做法是增加了一些entitlement,只对特定的app进行授权。
com.apple.security.cs.allow-jit
请参考:
Porting Just-In-Time Compilers to Apple Silicon | Apple Developer Documentation
PTHREAD_JIT_WRITE_PROTECT_NP(3) (keith.github.io)
pthread.c (apple.com)
当硬件不支持APRR时, JSC使用了一种叫做Separated wx heap的技术,首先我们得看下JSC是如何初始化JIT用到的内存的:
computeCanUseJIT->canUseAssembler->enableAssembler->ExecutableAllocator::initializeUnderlyingAllocator->initializeJITPageReservation
首先设置jit的内存大小,对于arm64默认使用128M内存,因为arm Near jump指令最大的寻址范围即为128M,但是JSC还使用了一种叫做ISLAND的技术,可以寻址512M内存,这个后续在展开讲。
auto tryCreatePageReservation = [] (size_t reservationSize) {
调用PageReservation::tryReserve进一步调用OSAllocator::tryReserveUncommitted,而后调用mmap传递MAP_JIT参数分配内存。MAP_JIT是苹果公司给mmap系统调用新增的一个参数,用来服务JIT。
xnu-xnu-8020.140.41\bsd\sys\mman.h:
对于JIT内存,xnu内核限制住每个进程仅有一块内存用来存放JIT代码。
Osfmk\vm\vm_map.c
回到initializeJITPageReservation:
#if ENABLE(SEPARATED_WX_HEAP)
如果不支持APRR,就调用initializeSeparatedWXHeaps初始化刚申请过的JIT内存。在这之前JIT内存最开始的一个PAGE保留,后面设置为只执行权限。
static ALWAYS_INLINE void initializeSeparatedWXHeaps(void* stubBase, size_t stubSize, void* jitBase, size_t jitSize)
使用mach_vm_remap函数将jit内存重映射到一块新的内存中,地址由writeableAddr指向,注意原来的jit内存仍旧是保留的,这样就会有两块虚拟内存指向了同一块物理内存。
MacroAssemblerCodeRef<JITThunkPtrTag> writeThunk = jitWriteThunkGenerator(reinterpret_cast<void*>(writableAddr), stubBase, stubSize);
调用jitWriteThunkGenerator函数用来设置一个优化过的memcpy函数:
static ALWAYS_INLINE MacroAssemblerCodeRef<JITThunkPtrTag> jitWriteThunkGenerator(void* writableAddr, void* stubBase, size_t stubSize)
这段jit code是把writableAddr传递给x7寄存器,然后与x0相加,这迫使memcpy函数的目的地址始终在writableAddr内存范围内。
jit.move(x0, x3);
上面代码生成了memcpy函数其他的代码。
auto stubBaseCodePtr = CodePtr<LinkBufferPtrTag>(tagCodePtr<LinkBufferPtrTag>(stubBase));
将这段jit code链接到原始jit内存中保留的第一个只执行PAGE内存内。
回到initializeSeparatedWXHeaps:
#if USE(EXECUTE_ONLY_JIT_WRITE_FUNCTION)
对原始jit内存的第一个PAGE设置为只执行权限。
// Prevent writing into the executable JIT mapping.
对原始jit只保留读和执行权限。
// Prevent execution in the writable JIT mapping.
对第二块jit内存设置为读和写权限,当jit要生成jit code时会调用performJITMemcpy将jit code写入这个内存区域。而jit返回给浏览器的那个jit内存是原始的jit只执行内存,这样攻击者实际就猜不出来可写的jit code内存在哪,因为那块内存是随机化生成的,并没有暴露给浏览器。
前面提到jit内存默认设置为128M,这是arm near jump的最大范围,如何让jit使用更多的内存,JSC使用了一种叫做ISLAND的技术,JSC的注释中很清晰的表达了它的架构:
通过一个个“小岛”间接的跳转到更远的地址去,使得jit能支持512M的内存。
Webkit不仅二进制本身使用了pac技术,还对JIT code也使能了pac技术。
./WTF/wtf/PtrTag.h
wekit对指针做了标签分类:
enum PtrTag : uintptr_t {
可以看到只对函数指针进行pac签名。
template<PtrTag tag, typename PtrType>
函数指针使用IA key。
JavaScriptCore/assembler/MacroAssemblerARM64E.h
定义了pac使用的bit数。
ALWAYS_INLINE void tagPtr(RegisterID tag, RegisterID target)
JSC使用了以下pac指令:pac*、aut*、xpac*、reta*、bra*/blra*。
同时苹果公司为了防止pac被绕过的问题,对webkit以及xnu内核都使用了-fptrauth-auth-traps编译选项,以下代码选自ios16 kernelcache:
AUTIA X16, X17
autia检查完毕后,会把pac去掉,然后使用xpaci和cmp指令在一次做了对比,防止autia出错。
JS语言中的变量会在JS引擎中抽象为特定的c语言对象,如果引擎出现漏洞,那么就可通过js语言操纵这些c语言对象,是常见的JS引擎漏洞来源。Webkit为了缓解这种漏洞攻击,对Webkit使用的Heap做了很多安全加固,GigaCage就是其中一个,用来将c语言对象限制在一个4G大小的cage中,cage中的对象使用32位bit的索引来引用。GigaCage依附于bmalloc内存分配器,webkit中的各种组件都可以使用。它
它对对象做了如下区分:
enum Kind {
当前只分为Primitive和JSValue两大类,未来最多会支持21个cage。
下面看下它是如何初始化建立Cage区域的:
bmalloc/bmalloc/Gigacage.cpp
初始化每个种类的cage数组。
uint64_t random;
将cage数组打乱处理,防止黑客猜测到特定种类cage的索引。
for (Kind kind : shuffledKinds) {
计算所有cage需要的内存大小,每个cage后面加一个32G的guard,因为对象采用32位大小做索引,每位共8个bit,因此最大寻址范围就位4G*8=32G, 即使利用漏洞也只能读取到guard范围的内存。
void* base = tryVMAllocate(maxAlignment, totalSize, VMTag::JSGigacage);
分配cage内存。
size_t nextCage = 0;
接着从这块大内存中继续划分每个cage的内存范围。
for (Kind kind : shuffledKinds) {
随机产生了两个64bit随机数。
size_t gigacageSize = maxSize(kind);
第一个随机数用来生成cage的大小。maximumCageSizeReductionForSlide被设置为1G或4G大小。
g_gigacageConfig.setAllocSize(kind, size);
g_gigacageConfig.basePtrs数组保存了每个cage的起始地址。
ptrdiff_t offset = roundDownToMultipleOf(vmPageSize(), random[1] % (gigacageSize - size));
第二个随机数用于计算cage的起始地址,可以看到两个cage之间除了32G的guard内存,还有一个随机化过的起始地址,这样猜测使猜测一个cage的地址更加困难。
GigaCage定义了caged接口用于访问一个指针:
bmalloc/bmalloc/Gigacage.h
根据kind提取cage基地址,在加上一个mask后的32位索引。
以下对象都使用了cage进行防护:
./JavaScriptCore/runtime/ArrayBuffer.h: using DataType = CagedPtr<Gigacage::Primitive, void, tagCagedPtr>;
苹果为了增强JIT的安全防护能力,又增加了一个叫做jitcage的防护技术,目前能google到的关于它的分析,只有synacktiv的安全研究员在2022年的一篇paper中有相对详细的介绍《attacking safari in 2022》。
类似于aprr,苹果公司又增加了新的硬件安全机制来保护特定的jit区域,按照synacktiv的paper,在此区域里的jit code不能执行以下指令:ret、br/blr/bl、svc、mrs/msr。由于笔者只通过webkit的源码和部分二进制做了静态分析,以上synacktiv给出的结论应该是动态调试的推论,笔者无法验证。
Jitcage需要苹果公司授权一个特殊的entitlement:"com.apple.private.verified-jit",这是一个未公开的entitlement,我们可以在javascriptcore的源码中看到有对它的引用:
./JavaScriptCore/runtime/Options.cpp
Jitcage的一个功能是修改了kernel中的mmap接口,当在某一条件下,它会申请一块特殊的内存,这块特殊的内存如上所述,会得到硬件的保护。
__int64 __fastcall mmap(proc *a1, __int64 a2, _QWORD *a3)
首先要判断当前进程是否有"com.apple.private.verified-jit"这个entitlement。
__int64 __fastcall sub_FFFFFFF007EC3B64(
首先调用vm_map_enter_mem_object分配一块内存,然后将地址存入current thread的0x348地址,0x350限制了这块内存的大小为1M。内核会在某一时刻调用enable_jitbox函数。
void __fastcall enable_jitbox(__int64 a1)
LDR X8, [X0,#0x350]
0x348代表jitcage内存地址,0x350代表其大小,分别写入两个不同的寄存器中。
苹果公司只公开了部分的jitcage源码,通过关键字JIT_CAGE搜寻webkit的源码,可以看到在jsc初始化bytecode操作表的时候引用了jitcage的另一个功能。
./JavaScriptCore/assembler/JITOperationList.cpp
JSC_JIT_CAGED_POINTER_REGISTRATION()在源码中被抹掉了,通过分析二进制可以看到它的实现:
__int64 __fastcall JSC::initialize(void)::$_7::operator()(WTF *a1)
为操作表分配内存。
_WriteStatusReg(ARM64_SYSREG(3, 4, 15, 15, 6), _ReadStatusReg(ARM64_SYSREG(3, 4, 15, 15, 6)) | 0x8000);
将特殊寄存器第15bit置1。
__isb(0xFu);
对操作表进行赋值。
if ( useJIT )
将特殊寄存器第15bit清0。
可以推测出这个寄存器实现了上锁与解锁的功能。这个__break(0xC471u)指令有特殊的功效,它防止jitcode中调用和修改这个寄存器,如果有以上行为就会触发一个断点操作被内核捕获。
这里的key指的是brab指令的第一个参数,在挑战到目标地址时,利用pac的b key以及这个特殊的参数做指针完整性判断。我们可以在webkit生成machine code时看到对它的一些引用:
JavaScriptCore/assembler/MacroAssemblerARM64E.h
JSC_JIT_CAGED_FAR_JUMP函数在源码中被抹掉了,通过查看反汇编代码:
_int64 __fastcall JSC::MacroAssemblerARM64E::farJump(__int64 a1, int a2, JSC::ARM64LogicalImmediate *a3)
a3代表brab的第一个参数,可以看到jitcage的意图是限制了brab这个参数的范围,根据不同的汇编指令只允许使用对应的值。通过对整个jsc binary进行搜寻可以证明:
__text:000000018B991760 ADRL X7, _g_config
JavaScriptCore/llint/LLIntData.cpp
__text:000000018C2A8F70 loc_18C2A8F70 ; CODE XREF: JSC::LLInt::initialize(void)+C0↑j
static_cast(Gate::jitCagePtr)]对应#(qword_1D9598BC0 - 0x1D9598000),它是将unk_1D95A1030内存处写入。而unk_1D95A1030保存的值是另一个函数设置的:
__text:000000018C2E03C8 __ZNSt3__117__call_once_proxyINS_5tupleIJOZN3JSC5LLInt15jitCagePtrThunkEvE4$_32EEEEEvPv
unk_1D95A1030内存处填充的是一段machine code,大致类似:
Mov xx, jitCagePtrGateAfter
0xB389使用上一小节介绍的技术限制了它的取值范围,然后校验jitCagePtrGateAfter指针并跳转到此处去执行。
__text:000000018B991A84 _jitCagePtrGateAfter ; DATA XREF: std::__call_once_proxy<std::tuple<JSC::LLInt::jitCagePtrThunk(void)::$_32 &&>>(void *)+16C↓o
使用b key验证栈中的返回地址。
接下来看下jitCagePtr
__text:000000018B991A68 EXPORT _jitCagePtr
JitCagePtr函数作为一个跳板函数,g_config[0xBC0]处的函数地址已经在前面被赋值过了,然后使用#0xE016作为brab的第一个参数,在前一小节已经介绍过了。
Jsc中有很多地方调用了jitCagePtr:
__text:000000018C839068 MOV X21, X22
X22为即将调用的某个函数指针,首先使用xpaci清除掉它的pac code,将其传入x0,x1传入#0x24AD,不同的函数可以传递不同的值,在前面讲过,brab时会用到这个值校验它的完整性。
GigaCage解决了OOB(out of bounds)的问题, 同样在bmalloc里实现了一个叫做IsoHeap(Isolate heap)的功能,它使每个类型的数据结构都在同一个类型的内存区域分配,形成一个类似隔离区的功能,这使得UAF(Use After Free)的利用变得非常困难。
在wtf中有如何宏定义:
WTF/wtf/IsoMallocInlines.h
IsoHeap模板根据不同的类型生成了一些函数,比如重载了new和delete运算符,当对这个类型的数据进行new和delete时,就会使用IsoHeap的接口。
在webkit的代码中,存在大量的使用IsoHeap的代码,比如:
WebCore/rendering/RenderTableRow.cpp
IsoHeap定义了一个名为Directory的内存块,它包含若干Page。
bmalloc/bmalloc/IsoDirectory.h
这些Directory内存块通过单链表链接起来。
bmalloc/bmalloc/IsoHeapImplInlines.h
每个Page包含若干chunk,chunk大小由Config::objectSize定义。
bmalloc/bmalloc/IsoPage.h
每个page的状态由如下定义:空、全满、半满,这跟slab的算法类似,内存分配器算法也就那么几种。
Page中的每个chunk放到一个叫做Freelist的链表中:
bmalloc/bmalloc/FreeList.h
FreeList中的每个节点指向的下一个节点地址都做了混淆保护:
struct FreeCell {
secret是一个运行时产生的随机数。
bmalloc/bmalloc/IsoPageInlines.h
产生一个随机数。
FreeCell* head = nullptr;
下一个cell的地址加密存储。
head = cell;
我们看到虽然cell的地址使用了加密存储,但是每个cell在初始化时是顺序存储的,没有像linux slub和ios zone一样使用洗牌算法将cell顺序打乱。
webkit在2019年时使用随机化技术对StructureID进行了保护:
即使使用了随机化,由于entropy bits较少,已经出现很多绕过方法,因此webkit在最新的版本中去掉了随机化功能,使用了新的保护技术。
JavaScriptCore/runtime/StructureID.h
最低位还是一个bit的Nuke Bit。
STRUCTURE_ID_WITH_SHIFT宏用在64bit地址,但是cpu仅使用了36bit有效地址的情景下,它将StructureID的地址进行移位编码存储。
#if ENABLE(STRUCTURE_ID_WITH_SHIFT)
编码时右移了4位,因此它只能编码36bit的内存地址。
ALWAYS_INLINE Structure* StructureID::decode() const
解码时在左移4位。
如果cpu使用更大的寻址能力,webkit使用以下编码方式:
#elif CPU(ADDRESS64)
编码时使用structure的地址与上structureIDMask, 然后调用StructureID构造函数对m_bits进行赋值,它是32bit。
class StructureID {
structureIDMask可以选取以下值:
#if defined(STRUCTURE_HEAP_ADDRESS_SIZE_IN_MB) && STRUCTURE_HEAP_ADDRESS_SIZE_IN_MB > 0
可以看到如果structureHeapAddressSize为4G,m_bits可以代表全部的32bit,它是一个在StructureHeap的索引,这使得猜测StructureID的值比随机化5bit更加困难。
ALWAYS_INLINE Structure* StructureID::decode() const
解码时m_bits在与上structureIDMask, 它是一个32bit的索引,因此要在加上sturctureID的基地址g_jscConfig.startOfStructureHeap。