之前的分析文章中对 CVE-2016-7202 漏洞进行过分析,这个漏洞是由于 Array.prototype.reverse
在处理时对 side-effect 情况控制不严导致的,而这个漏洞前后一共修补过两次。在今年年初的 Pwn2Own 上,来自腾讯的科恩实验室同样使用了 Array.prototype.reverse
中的一个漏洞,更为奇特的是这个漏洞前后一共修复了四次。本文将详细分析这个这个修补了四次的漏洞以及前三次修复的前因后果。
漏洞概述
该漏洞是由于 Array 功能函数在设计时考虑不完全,引起的 UAF
漏洞样本
漏洞样本根据 ChakraCore 的源码补丁自行构造
漏洞分析
该漏洞的成因主要有以下三点。设计者与代码的编写者沟通不到位导致了这个问题
- InlineSegment
- Array.prototype.reverse
- Array.prototype.splice
InlineSegment
js 创建 Array 有几种常见的方法 arr = [1,2,3,4,]
、arr = new Array(10)
、arr = new Array(1,2,3,4,5)
当以 new Array
形式创建 Array 时,会调用函数 JavascriptNativeIntArray::NewInstance |-> JavascriptLibrary::CreateNativeIntArray
来完成 Array 的创建。其最终调用的函数逻辑如下
if(length > SparseArraySegmentBase::HEAD_CHUNK_SIZE)
{
return RecyclerNew(recycler, className, length, arrayType);
}
array = RecyclerNewPlusZ(recycler, allocationPlusSize, className, length, arrayType);
SparseArraySegment *head =
InitArrayAndHeadSegment(array, 0, alignedInlineElementSlots, true);
head->FillSegmentBuffer(0, alignedInlineElementSlots);
若申请的长度不超过 HEAD_CHUNK_SIZE
即 0x10,首先通过 RecyclerNewPlusZ
分配带有冗余空间的 JavascriptArrayObject,再将冗余空间设置为该 JavascriptArray 的 head segment。通过这种方式创建的 Array ,其 head segment 与 ArrayObject 实际上处于同一个内存块中,拥有相同的生命周期。
若申请的长度超过了 HEAD_CHUNK_SIZE
则仅申请固定大小的 ArrayObject 空间,其所需的 segment 将在以后的赋值操作 DirectSetItem
中进行申请和初始化。
当以 arr = [1,2,3,4,]
形式创建 Array 时, 会调用函数 JavascriptLibrary::CreateCopyOnAccessNativeIntArrayLiteral
来完成 Array 的创建。其最终创建逻辑为
className* array = RecyclerNewZ(recycler, JavascriptCopyOnAccessNativeIntArray, ints->count, arrayType);
JavascriptLibrary *lib = functionBody->GetScriptContext()->GetLibrary();
SparseArraySegment *seg;
if (JavascriptLibrary::IsCachedCopyOnAccessArrayCallSite(functionBody->GetScriptContext()->GetLibrary() , arrayInfo))
{
seg = lib->cacheForCopyOnAccessArraySegments->GetSegmentByIndex(arrayInfo->copyOnAccessArrayCacheIndex);
}
else
{
seg = SparseArraySegment::AllocateLiteralHeadSegment(recycler, ints->count);
}
这种方式仅仅申请固定大小的 ArrayObject 空间,其 segment 将单独进行申请。
Array.prototype.reverse
Array.prototype.reverse 操作负责将 Array 中所有数据进行翻转,由于 Array 的成员均存储在 Segment 中,因此 reverse 操作也会对 Segment 进行翻转。其具体代码如下
/*
ChakraCore v1.4.2
*/
SparseArraySegmentBase* seg = pArr->head;
SparseArraySegmentBase *prevSeg = nullptr;
SparseArraySegmentBase *nextSeg = nullptr;
SparseArraySegmentBase *pinPrevSeg = nullptr;
while (seg)
{
nextSeg = seg->next;
// If seg.length == 0, it is possible that (seg.left + seg.length == prev.left + prev.length),
// resulting in 2 segments sharing the same "left".
if (seg->length > 0)
{
if (isIntArray)
{
((SparseArraySegment*)seg)->ReverseSegment(recycler);
}
else if (isFloatArray)
{
((SparseArraySegment*)seg)->ReverseSegment(recycler);
}
else
{
((SparseArraySegment*)seg)->ReverseSegment(recycler);
}
seg->left = ((uint32)length) > (seg->left + seg->length) ? ((uint32)length) - (seg->left + seg->length) : 0;
seg->next = prevSeg;
// Make sure size doesn't overlap with next segment.
// An easy fix is to just truncate the size...
seg->EnsureSizeInBound();
// If the last segment is a leaf, then we may be losing our last scanned pointer to its previous
// segment. Hold onto it with pinPrevSeg until we reallocate below.
pinPrevSeg = prevSeg;
prevSeg = seg;
}
seg = nextSeg;
}
pArr->head = prevSeg;
举例来说一个 Array 其 segment 状态为
---------------- ---------------- ---------------- ----------------
|Array | head | -> | seg1 | -> | seg2 | -> | seg3 |
---------------- ---------------- ---------------- ----------------
将其 Reverse 之后其 segment 状态变为
---------------- ---------------- ---------------- ----------------
|Array | head | <- | seg1 | <- | seg2 | <- | seg3 |
---------------- ---------------- ---------------- -------^--------
\_____________________________________________________________________|
Array.prototype.splice
Array.prototype.splice 操作会删除 Array 中的部分数据并在删除的位置添加新的数据,最后返回被删除的部分。由于需要将删除的数据返回,因此还会新建一 Array 并以 删除的数据为其赋值。为了节省效率对于那些整个 segment 都被删除的情况,splice 函数会将这个 segment 直接移动到那个新 Array 中。具体代码如下
/*
ChakraCore v1.4.2
*/
// Step (1) -- WOOB 1116297: When left >= start, step (1) is skipped, resulting in pNewArr->head->left != 0. We need to touch up pNewArr.
if (startSeg->left < start)
{
if (start < startSeg->left + startSeg->length)
{
uint32 headDeleteLen = startSeg->left + startSeg->length - start;
if (startSeg->next)
{
// We know the new segment will have a next segment, so allocate it as non-leaf.
newHeadSeg = SparseArraySegment::template AllocateSegmentImpl(recycler, 0, headDeleteLen, headDeleteLen, nullptr);
}
else
{
newHeadSeg = SparseArraySegment::AllocateSegment(recycler, 0, headDeleteLen, headDeleteLen, nullptr);
}
newHeadSeg = SparseArraySegment::CopySegment(recycler, newHeadSeg, 0, startSeg, start, headDeleteLen);
newHeadSeg->next = nullptr;
*prevNewHeadSeg = newHeadSeg;
prevNewHeadSeg = &newHeadSeg->next;
startSeg->Truncate(start);
}
savePrev = startSeg;
prevPrevSeg = prevSeg;
prevSeg = &startSeg->next;
startSeg = (SparseArraySegment*)startSeg->next;
}
// Step (2) first we should do a hard copy if we have an inline head Segment
else if (hasInlineSegment && nullptr != startSeg)
{
// start should be in between left and left + length
if (startSeg->left <= start && start < startSeg->left + startSeg->length)
{
uint32 headDeleteLen = startSeg->left + startSeg->length - start;
if (startSeg->next)
{
// We know the new segment will have a next segment, so allocate it as non-leaf.
newHeadSeg = SparseArraySegment::template AllocateSegmentImpl(recycler, 0, headDeleteLen, headDeleteLen, nullptr);
}
else
{
newHeadSeg = SparseArraySegment::AllocateSegment(recycler, 0, headDeleteLen, headDeleteLen, nullptr);
}
newHeadSeg = SparseArraySegment::CopySegment(recycler, newHeadSeg, 0, startSeg, start, headDeleteLen);
*prevNewHeadSeg = newHeadSeg;
prevNewHeadSeg = &newHeadSeg->next;
// Remove the entire segment from the original array
*prevSeg = startSeg->next;
startSeg = (SparseArraySegment*)startSeg->next;
}
// if we have an inline head segment with 0 elements, remove it
else if (startSeg->left == 0 && startSeg->length == 0)
{
Assert(startSeg->size != 0);
*prevSeg = startSeg->next;
startSeg = (SparseArraySegment*)startSeg->next;
}
}
// Step (2) proper
SparseArraySegmentBase *temp = nullptr;
while (startSeg && (startSeg->left + startSeg->length) <= limit)
{
temp = startSeg->next;
// move that entire segment to new array
startSeg->left = startSeg->left - start;
startSeg->next = nullptr;
*prevNewHeadSeg = startSeg;
prevNewHeadSeg = &startSeg->next;
// Remove the entire segment from the original array
*prevSeg = temp;
startSeg = (SparseArraySegment*)temp;
}
可以看出,如果 segment 不是 startSeg 且其中树数据均处于删除的范围之内,则直接将该 segment 移动到返回的 Array 中。
漏洞成因
结合以上三个条件,便有可能产生漏洞。
假设 splice 移动的 segment 是一个 InlineSegment ,那么该 InlineSegment 将被认为是新 Array 的segment ,然而 InlineSegment 的生命周期却和其创建者一致。当其创建者 Array 被释放后,InlineSegment将同时被释放。于是新Array 将链接一个已经被释放的 segemnt 空间!!!
然而一般情况下 InlineSegment 一定会是 Array 的第一个 segment,即若出现在 splice 的删除列表中,则一定是 startSeg。然而通过 Reverse 操作,可以将 InlineSegment 变成 Array 的最后一个 segment 从而绕过了这种限制。
举例来说:
一个 Array 其 segment 状态为
---------------- ---------------- ---------------- ----------------
|Array | head | -> | seg1 | -> | seg2 | -> | seg3 |
---------------- ---------------- ---------------- ----------------
将其 Reverse 之后其 segment 状态变为
---------------- ---------------- ---------------- ----------------
|Array | head | <- | seg1 | <- | seg2 | <- | seg3 |
---------------- ---------------- ---------------- -------^--------
\_____________________________________________________________________|
对其调用 splice 删除最后几个seg, 返回值为 New Array
---------------- ---------------- ---------------- ----------------
|Array | head | <- | seg1 | | seg2 | <- | seg3 |
---------------- -------^-------- ---------------- -------^--------
\_____________________|_______________________________________________|
---------------- |
| new Array | _____________|
----------------
接着释放 Array
---------------- ----------------
| FREE | <- | seg1 |
---------------- -------^--------
|
---------------- |
| new Array | _____________|
----------------
至此 new_Array 便可以访问到已经 free 的一段空间
补丁分析
这个漏洞前后修补了四次,由于笔者原因这里只能对其中三次补丁情况进行分析。
补丁一
补丁在 ReverseHelper
函数中添加了函数判断,如果当前操作的 arr 的第一个 segment 是一个 InlineSegment ,为了避免漏洞的出现,首先将其替换成为一个非 Inline 的 segment
/*
ChakraCore v1.4.4
*/
// https://github.com/Microsoft/ChakraCore/pull/2959/commits/0cdbf2fe68f452c163ca5307cb9c57b118e966cc
+ // During the loop below we are going to reverse the segments list. The head segment will become the last segment.
+ // We have to verify that the current head segment is not the inilined segement, otherwise due to shuffling below, the inlined segment will no longer
+ // be the head and that can create issue down the line. Create new segment if it is an inilined segment.
+ if (pArr->head && pArr->head->next)
+ {
+ if (isIntArray)
+ {
+ CopyHeadIfInlinedHeadSegment(pArr, recycler);
+ }
+ else if (isFloatArray)
+ {
+ CopyHeadIfInlinedHeadSegment(pArr, recycler);
+ }
+ else
+ {
+ CopyHeadIfInlinedHeadSegment(pArr, recycler);
+ }
+ }
+
+ SparseArraySegmentBase* seg = pArr->head;
+
while (seg)
{
nextSeg = seg->next;
该补丁之后之前的样本将不能再触发漏洞。但是由于其判断 if (pArr->head && pArr->head->next)
,使得操作忽略了单 segment 的情。
考虑如下样本
var arr = new Array(100)
for (i=0;i
a1 原本只有两个成员,也处于 inlinesegment 中,接着将其 length 修改为 4,length 的长度增加并不会影响segment。继续进入 Reverse 函数,可以完美的绕过补丁代码,进行segment 的转化。
此时 Array length 为4,head segment 的 length 为 2。查看转化部分代码可以看出,segment->left 是根据 length 计算而来。
if (seg->length > 0)
{
if (isIntArray)
{
((SparseArraySegment*)seg)->ReverseSegment(recycler);
}
else if (isFloatArray)
{
((SparseArraySegment*)seg)->ReverseSegment(recycler);
}
else
{
((SparseArraySegment*)seg)->ReverseSegment(recycler);
}
seg->left = ((uint32)length) > (seg->left + seg->length) ? ((uint32)length) - (seg->left + seg->length) : 0;
seg->next = prevSeg;
// Make sure size doesn't overlap with next segment.
// An easy fix is to just truncate the size...
seg->EnsureSizeInBound();
// If the last segment is a leaf, then we may be losing our last scanned pointer to its previous
// segment. Hold onto it with pinPrevSeg until we reallocate below.
pinPrevSeg = prevSeg;
prevSeg = seg;
}
回到样本中,array 经转化之后其 head segment 变为 seg->left = 2; seg.length = 2;。Array 的起始下标规定应该从 0 开始,这样显然不合理,因此 Reverse 会调用函数 EnsureHeadStartsFromZero
在当前的 head 之前再添加一段 起始为 0 的 segment,从而使得原先的 inline head 又被移动到了 array 的末尾。漏洞依然存在~~~~~
补丁二
因此在 v1.7.1 版本中对这里再次进行修补,完善了检测机制
/*
ChakraCore v1.7.1
*/
// https://github.com/Microsoft/ChakraCore/pull/3509/files
- if (pArr->head && pArr->head->next)
+ if (pArr->head && (pArr->head->next || (pArr->head->left + pArr->head->length) < length))
补丁三
除没有完全修补好的问题外,Reverse 函数中还存在另一个问题。
为了加快垃圾收集阶段的效率,在 Segment 的分配阶段有这样的判断,对于纯数据的 Segment 如果其没有 next 节点,则使用 leaf HeapBlock 完成内存的分配请求,此时 Chakra 认为其内存区域内不会有对象指针出现,Leaf Segment 在 Mark 阶段将不会进行逐字节的扫描。
inline SparseArraySegment * SparseArraySegment::AllocateSegment(Recycler* recycler, uint32 left, uint32 length, SparseArraySegmentBase *nextSeg)
{
if (DoNativeArrayLeafSegment() && nextSeg == nullptr)
{
return AllocateSegmentImpl(recycler, left, length, nextSeg);
}
return AllocateSegmentImpl(recycler, left, length, nextSeg);
}
但是这个设定在 Array.prototype.reverse 函数中将不会得到满足。按照设定 NativeIntArray 的最后一个 segment 中不会有需要标记的对象指针存在,因此使用 Leafblock 分配,但调用 Array.prototype.reverse 之后最后一个 Segment 会变成 Head segment。如果该 Array 是一个稀疏数组那么 Head segment 一定会有 next 指针。但是此时的 segment 处于 Leafblock 中,在Mark 阶段 next 指针将不会被标记,从而有可能在 sweep 阶段被释放,产生 UAF!
Reverse 函数的编写者显然也考虑到了这个问题,于是在 Reverse 函数交换 segment 完毕之后会对 head segment 进行判断,如果 head segment 有 next 成员,并且 head segment 在 leaf HeapBlock 中那么就 Realloc 这个 head。
查看函数的总体逻辑,可以发现 ReallocLeaf 操作是发生在segment 交换完毕之后的,此时 Array 中的segment 已经变成如下所示。此时再进行 Realloc 操作,那么如果 Realloc 操作中触发了 GC ,在这次 GC 中由于 seg3 处于 LeafHeapBlock 中因此其指向 seg2 的指针将不会被标记。从而在 Sweep 阶段 seg2 将会被释放。
---------------- ---------------- ---------------- ----------------
| Array | <- | seg1 | <- | seg2 | <- | seg3(leaf) |
---------------- ---------------- ---------------- -------^--------
\_____________________________________________________________________|
对于这个问题,新的补丁中将 Realloc 操作提前,一旦判定 Array 拥有 head segment,并且 head 拥有 next 字段且 Array 是一个数字 Array 就调用 Realloc 操作重新分配 Array 最后一个 segment
if (pArr->head && pArr->head->next)
{
if (isIntArray)
{
CopyHeadIfInlinedHeadSegment(pArr, recycler);
+ ReallocateNonLeafLastSegmentIfLeaf(pArr, recycler);
}
else if (isFloatArray)
{
CopyHeadIfInlinedHeadSegment(pArr, recycler);
+ ReallocateNonLeafLastSegmentIfLeaf(pArr, recycler);
}
else
{
CopyHeadIfInlinedHeadSegment(pArr, recycler);
}
}
补丁四
最新的补丁中又对 reverse 函数进行了修补,修补理由是 ReallocateNonLeafLastSegmentIfLeaf
函数有可能触发 OOM 异常从而会让 Array 的 lastUsedSegment 字段指向未知的位置。
经笔者分析,并没有能够发现在 OOM 之前修改 lastUsedSegment 的情况,因此未能构造样本
对于这个问题的补丁也十分简单,就是在 Reverse 中加入 AutoDisableInterrupt failFastOnError(scriptContext->GetThreadContext());
检测异常。
总结
这个漏洞涉及到的情况其实并不算很复杂,但是在漏洞修复方面却出现了如此多的问题,思考起来是由于 Array.prototype.reverse
函数本身的特点,这个函数会造成 Array 自身的结构变动,从而影响到所有与 Array 结构相关的功能。如果在编写代码时没有对这些功能的整体有一个大体的了解就可能造成这样那样的问题,同时在补丁上也难以一步到位。
文章在撰写的过程中难免会有一些疏漏,也有可能出现由于对引擎本身理解不够导致的错误,还请大家予以指正。
Reference
- patch#1
- patch#2
- patch#3
- patch#4