PC GWP-ASan 方案原理 | 堆破坏问题排查实践
背景
众所周知,堆 crash dump 是最难分析的 dump 类型之一。此类 crash 最大的问题在于,造成错误的代码无法在发生堆破坏时被发现。线上采集到的 minidump,仅能提供十分有限的信息。当调试工具报告了堆破坏、堆内存访问违例后,即便是有经验的开发人员也会觉得头疼。 剪映专业版及其依赖的音视频编辑 SDK、特效模块均采用 MD 的方式链接标准库,这意味着任何一个模块出现了堆损坏都会互相影响。从 crash 的位置回溯堆破坏的源头,是一个非常有挑战性的工作。剪映业务模块较常见的是Use-after-free
,而音视频编辑 SDK 和特效模块这类底层算法特效模块更多的是Buffer-overflow
,不同团队模块间的堆错误互相影响,导致问题难以定位。
GWP-ASan 是 Google 主导开发的用于检测堆内存问题的调试工具。它基于经典的 Electric Fence Malloc 调试器原理,概率采样内存分配行为,抓取内存问题并生成上传崩溃报告。说到这里,也许你会好奇它和 ASan(Address Sanitizer)的区别。ASan 是一种编译器调试工具,监控所有内存分配行为,可以发现栈、堆和全局内存问题,但它性能开销很高(2-3 倍),不适合线上使用。GWP-ASan 相较于 ASan,虽然无法发现栈内存和全局内存问题,但因为它是采样监控,性能消耗可以忽略不计,更适用于线上场景。目前,GWP-ASan 可检测的错误有:
Use-after-free
Buffer-underflow
Buffer-overflow
Double-free
free-invalid-address
Electric Fence Malloc 调试器:https://linux.die.net/man/3/efence
GWP-ASan 有多种实现方案,本方案基于 Windows 平台说明,字节内部 APM-PC 平台相较于市面上其他方案的亮点有:
无侵入式接入,可以检测特定类型三方库的内存分配。
支持无感知监测,发现异常后进程可继续运行。
支持调整检测所用的堆页面个数配置和采样率配置,灵活调整性能消耗。
剪映专业版接入字节内部 APM-PC 平台的 GWP-ASan 功能后,帮助业务、音视频编辑 SDK、特效模块解决 30 余例疑难堆 crash。GWP-ASan dump 比原生 dump 提供了更丰富的信息,并指出了堆 crash 关联的信息细节,降低了疑难 crash 的排查难度,有效缩短了研发排查、修复问题的时间。
技术方案
监控原理
检测原理概述
创建受保护内存池:
首先,我们需要保留一块连续的n*page size
的受保护内存池。其中,可分配内存的 page 是Slot
,不可分配内存的 page 是Guard Page
。Slot
和Guard Page
间隔分布,整个内存池最前和最后都是Guard Page
,所有的Slot
都受到Guard Page
保护,之后应用分配的堆内存将随机采样分配到这些Slot
上。
采样监控内存分配行为,记录堆栈:
之后,hook 应用堆内存分配行为,每次分配堆内存时,随机决定目标内存是走 GWP-ASan 分配——分配在一个空闲的Slot
上,还是走系统原生分配。如果走 GWP-ASan 分配,那么目标内存会被随机左对齐/右对齐分配在一个空闲的Slot
上,同时记录分配内存的堆栈信息。
而当释放内存时,会先判断目标内存是否在 GWP-ASan 受保护内存池上,如果是,那么释放这块内存和其所在的 Slot,同时记录释放内存的堆栈。slot 空闲后,可以重新被用于分配。堆栈信息记录在 metadata 中。
持续监测,记录异常:
首先,我们需要知道
Guard Page
和空闲的Slot
都是不可读写的。接下来我们看看 GWP-ASan 是如何发现异常的:Use-after-free
:Slot
上未分配内存时,是不可读写的。当访问到不可读写的Slot
时,应用抛出异常,此时检查该Slot
是否刚释放过内存,如果释放过内存,那么可以判定此异常为Use-after-free
。Buffer-underflow
:当内存左对齐分配在Slot
上时,如果发生了 underflow,应用会访问到Slot
左侧不可读写的Guard Page
,应用抛出异常,此异常为Buffer-underflow
。Buffer-overflow
:当内存右对齐分配在Slot
上时,如果发生了 overflow,应用会访问到Slot
右侧不可读写的Guard Page
,应用抛出异常,此异常为Buffer-overflow
。Double-free
:应用释放内存时,首先检查目标内存地址是否位于受保护内存池区间内,如是,由 GWP-ASan 释放内存,释放前检查目标内存地址所在Slot
是否已经被释放,如是,那么可以判定此异常为Double-free
。Free-invalid-address
: 应用释放内存时,首先检查目标内存地址是否位于受保护内存池区间内,如是,由 GWP-ASan 释放内存,释放前先检查要释放的内存地址和之前分配返回的内存地址是否相等,如果不相等,那说明目标释放地址是非法地址。此异常为Free-invalid-address
。
堆内存分配 API
前面已经提到,GWP-ASan 用于检测堆内存问题,为了检测堆内存问题,必须先感知应用内存分配行为。很自然的,我们会想到 hook 内存分配方法,但是该 hook 哪个方法呢?
下图描述了 Windows 应用分配堆内存的可用方法:
GlobalAlloc/LocalAlloc
是为了兼容 Windows 旧版本的 API,现在基本不适用,所以不监控。HeapAlloc/HeapFree
一般用于进程分配内存,不监控。VirtualAlloc
是应用层内存分配的底层实现,开发一般不直接用此 API 分配内存,它离应用分配堆内存行为太远,堆栈参考意义不大;且 Windows GWP-ASan 需要基于此实现,因此,也不监控。
最终选定 Hook malloc/free
等系列方法,hook malloc/free
后,能感知到用户分配的堆内存。
Hook 方案
下面的方案都是应用层的 Hook 方案,内核层 Hook 仅适用于 x86 平台。
Detours 库作为微软官方出品的 hook 库,兼容性佳,稳定性好,是最佳选择。但是还需要注意的是,Windows 下,运行时库配置会影响 hook 结果,Detours 只能无侵入式 hook/MD 库的内存分配行为,/MT 库需要提供自身内存分配的函数指针才能 hook。
堆栈记录
首先要说明的是,GWP-ASan 监控依赖崩溃监控。Use-after-free
、Buffer-underflow
、Buffer-overflow
都是在客户端发生异常后,结合 GWP-ASan 的 metadata 去判定的。目前字节内部 APM-PC 平台的崩溃报告格式为 minidump。一个 minidump 文件由多种 streams 组成,如 thread_list_stream、module_list_stream 和 exception_stream 等等。不同 stream 记录了不同信息,我们可以将 GWP-ASan 采集到的异常信息视为单独的 gwpasan_stream,附加到 minidump 文件中。
GWP-ASan 采集的信息主要包括:错误类型、分配地址和大小、分配堆栈、释放堆栈(如有)、受保护内存池起止地址。这些信息基于 Protobuf 协议序列化后,被添加到 minidump 文件中。GWP-ASan 通过 Windows native API CaptureStackBackTrace
API 在客户端回溯 “释放/分配” 堆栈。minidump 上传到平台后,平台抽取出 GWP-ASan 信息,结合 minidump 中 loaded module list,结合相关模块的符号表,符号化 GWP-ASan 分配/释放堆栈。GWP-ASan 信息结合 minidump 原本的信息,基本就能定位问题。
监控流程
拓展场景
无崩溃方案
GWP-ASan 检测到异常后,会主动崩溃导致客户端进程退出,给用户带来了不良体验。无崩溃的 GWP-ASan 检测到异常后,再将对应内存页标注为可读写的(如为 use-after-free/buffer-underflow/buffer-overflow),仅生成上传崩溃报告,不主动终结进程,客户端标注异常已解决。用户无感知,程序继续运行。需要注意的是,客户端在 UEF 里标记访问区域内存页为可读写内存页可能影响后续的 GWP-ASan 检测。
实战分享
Use-After-Free:释放后使用
实际案例 1
我们看下常规的 dump 输出,windbg 告知我们程序 crash 在 25 行。
因为 12 行有空指针检查,可以排除空指针问题。
执行.ecxr
恢复异常现场也可以证明,此 crash 和空指针无关。只是一个内存访问违例。
汇编指定地址,可以知道这个 crash 动作是在读取类的虚指针,读取内存的过程中 crash 了。
查看问题代码:
很多类继承了VENotifyListener
这个帮助类。分析这个帮助类,我们比较容易得出结论VENotify
的变量m_listeners
线程不安全,当VENotify::removeListener
和VENotify::notify
存在竞争时,就可能会出现这个 crash。这个结论是靠我们的经验得出的,我们可以加个锁,搞定这个竞争导致的 crash。
那么这个问题确实解决了么?如果我们没有 GWP-ASan,我们很可能会止步于此,匆匆修复 crash 并提交代码,拍着胸脯说,我搞定了。
细心的同学可能会发现,有人可能会不继承VENotifyListener
,而是继承VENotifyListenerBase
,直接调用VENotify::instance().addListener
和VENotify::instance().removeListener
,检索工程代码可能会发现一堆addListener
和removeListener
,更不幸的是,可能会发现addListener
和removeListener
都是成对出现的。到底是谁使用不规范导致的 crash 呢?接下来我们只能逐个检查代码,或者深入调试找到问题位置。这么做可能需要花费较多的时间。
幸运的是,GWP-ASan 也抓到同位置的 crash 了,我们看下 GWP-ASan 的 crash 输出:
GWP-ASan 确切的告知我们此处 crash 原因是 UAF,并告诉了我们很多的细节信息。那么是谁在什么时候被释放的?
GWP-ASan 的 Free Stack 页面告知我们是MediaInfoViewModel
导致的问题,我们检查MediaInfoViewModel
代码发现有如下代码:
果然,业务自己调用了 VENotify::instance().addListener
,但是MediaInfoViewModel
析构前并没有保证一定会调用 VENotify::instance().removeListener
。这种情况下,意味着 VENotify::instance()
持有了一个MediaInfoViewModel*
的悬垂指针,等到下次notify
调用,就会 crash。
修复方案:
确保
MediaInfoViewModel
在析构前会调用VENotify::instance().removeListener
;对存在线程间竞争的地方加锁保护。
实际案例 2
首先我们看下常规的 dump 输出,windbg 告知我们 crash 在 QT 和 std 标准库中,std 标准库鲜有 bug,此处肯定不是第一现场,QT 虽然潜在的有 bug,但实际上 bug 也是比较少的。这应该又是一个堆 crash。
切换栈帧到 08 查看代码,发现QUICollectionViewItem
是一个多叉树的数据结构。
调试器告知我们,此 crash 确实是一个堆 crash,在枚举成员变量的时候挂掉了。此时的 this 指针指向的位置已经出现了问题,已经不再是正常的地址了。查看 this 指针指向的地址可以证明这一点。
因为不是第一现场,我们需要考虑什么情况,会导致此问题。堆溢出,内存踩踏,UAF 都可以导致此问题。
不过根据经验来看,针对这种指针比较多的数据结构,UAF 的概率比较高,但是没人敢拍着胸脯说这个 crash 一定是 UAF 导致的。
GWP-ASan 再次抓到了此问题,GWP-ASan 的报告如下:
GWP-ASan 再次明确的的告知我们此处 crash 原因是 UAF,此时我们只要集中精力检查 UAF 方可。那么是谁释放了QUICollectionViewItem
?
上图 Free Stack 页面显示QUICollectionViewItem
是在 QT 消息循环中被析构的,虽然是QUICollectionViewItem
析构的第一现场,但不是代码级别的第一现场。了解 QT 的同学知道,调用了deleteLater()
才会有此堆栈。为了解决 crash,我们还需要找到调用deleteLater()
的地方,最后找到如下代码段:
回顾一下我们的 crash 以及 UAF,实际上父节点持有了悬垂指针并调用clearSubitems()
,程序就会挂掉。此处的代码看似从m_parentItem
中移除了本节点(注:m_parentItem->removeSubitem(this)
),但是如果代码不严谨(如m_parentItem
在某种情况下被设置为nullptr
),那么就可能存在悬垂指针。我们检查谁会修改m_parentItem
,且要重点检查谁会将m_parentItem
修改为nullptr
。
检查代码会发现只有一个函数会修改m_parentItem
,代码如下:
注意上述代码没有处理m_parentItem
变更的情况,此时我们找到问题位置。
修复方案:
当一个节点的父节点要变更时,需要从旧的父节点中摘除自己,避免旧的父节点持有子节点的悬垂指针。
实际案例 3
首先我们看下常规的 dump 输出,windbg 再次提示我们 crash 在标准库相关操作了。
到底是什么问题导致的 crash?这代码看着也很简单,普通的 dump 没有再提供更多的信息~
iter
空指针?XXXXXX_class
被析构?多线程竞争?UAF?溢出?我们不得不猜测,并查看代码,或者进一步分析 dump 来验证我们的想法。
我们再看下 GWP-ASan 提供的信息,GWP-ASan 报告如下:
可以看到对于同一个标准库的数据结构,同时有三个线程在访问。此时我们明确的知道,此 crash 是因为多线程竞争导致的。而且 GWP-ASan 明确输出了数据结构的释放堆栈,我们不用再去猜测及思考问题是如何导致的。
修复方案:
非常简单,对存在竞争的数据结构加锁方可。
Buffer-overflow:内存溢出
实际案例 1
我们还是看下常规 dump 提供的信息:
dump 指示崩溃在了 share_ptr 增加引用计数的地方。 大家都知道 share_ptr 的引用计数是保存在堆里面的,我们又遇到堆问题了。
如果没有 GWP-ASan 的帮助,大家看下问题在什么地方?没有排查经验的话,同学们可能就折在崩溃点的附近的代码了,然后百思不得其解。即便有排查经验的,同学们亦需要逐帧去检查代码实现,还得理解代码实现,最后定位问题位置。
我们看下 GWP-ASan 的输出:
可见 GWP-ASan 告知我们是堆溢出,并且替我们定位到了第一现场。 我们只要查看ViedoSettingsData.cpp
803 行周围的代码,就能迅速定位问题。也就是上述代码的 auto& seg = m_segmentPtrs[index];
这段代码导致了溢出。再查看上一层函数,发现当IF_CONTINUE(segmentPtr == nullptr)
时,必然会出现堆越界。
修复方案:
解除updateKeyframeSeqTimeList
的越界操作。
实际案例 2
此处代码看起来比较复杂,为了方便理解,此处只保留分析 crash 相关的代码。本 crash 我们内部无法复现。但内部 APM-PC 平台监控到的 crash 还不少。
打开常规 dump 查看输出:
dump 显示 crash 在函数末尾的 memcpy 中,真的很幸运,虽然是堆相关的问题,但是我们 crash 在了第一现场。
粗略的看这个代码也没什么问题,排查问题的时候,我们如果能得到局部变量pre_length
pre_location
length
的值,就可以知道为什么 crash 了。
检查当前栈帧的局部变量,如下图:
非常不幸,我们没法看到 length 的值,release 版本已经将这个局部变量给优化掉了。
如果我们不是作者的话,不了解程序逻辑,当观察到char * ``encryptText`` = 0x00000254a6a6e870 "U???"
,我们很可能会怀疑是堆破坏了(后面了解了代码逻辑后知道,这个地方内存是正确的)。我们针对问题用户单独开启了 GWP-ASan,很快 GWP-ASan 捕获到同位置的 crash。
GWP-ASan 输出如下:
下图是 GWP-ASan 捕获的 dump,windbg 解析输出的内容:
注意:我们一共申请了 Allocation size:68 个字节的内存:
然而现在int pre_length = 0n83
:
显然,*withOutKeyEncryptText + pre_length
现在越界了。
我们回溯代码,最终发现,原来是实现方式上有点问题。我们将encryptStr
当作一个 buffer 使用,encryptStr
内部保存的不一定是字符串。换句话说本函数的第一个参数const char *encryptText
并不是个字符串,而是个二进制流 。但是EncryptUtilsImpl::getOriginEncryptText()
内部却对encryptText
进行了int length = strlen(encryptText)
操作。此时,如果encryptText
二进制数据流中很不幸提前出现了 0,那么这个地方就会出现堆溢出 crash。
修复方案:
不再使用const char *input_str = encryptStr.data();
的形式传裸指针给函数。 而是选择直接传const std::string& encryptStr
,此时 std::string 会携带了正确的数据长度,问题得以解决。
Reference
https://chromium.googlesource.com/chromium/src.git/+/HEAD/docs/gwp_asan.md
https://sites.google.com/a/chromium.org/dev/Home/chromium-security/articles/gwp-asan
了解更多
有关 PC 端监控的能力,我们已将部分功能在火山引擎应用性能监控全链路版中对外提供,你可添加下方小助手或点击“申请链接”申请免费试用。
添加小助手,回复【APM】加入技术交流群
版权声明: 本文为 InfoQ 作者【字节跳动终端技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/a281dd008b205ddee106cd11a】。文章转载请联系作者。
评论