性能文章>PC GWP-ASan方案原理 | 堆破坏问题排查实践>

PC GWP-ASan方案原理 | 堆破坏问题排查实践原创

314913

背景

众所周知,堆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的排查难度,有效缩短了研发排查、修复问题的时间。

技术方案

监控原理

检测原理概述

  1. 创建受保护内存池:

首先,我们需要保留一块连续的n*page size的受保护内存池。其中,可分配内存的page是Slot,不可分配内存的page是Guard PageSlotGuard Page间隔分布,整个内存池最前和最后都是Guard Page,所有的Slot都受到Guard Page保护,之后应用分配的堆内存将随机采样分配到这些Slot上。

  1. 采样监控内存分配行为,记录堆栈:

之后,hook应用堆内存分配行为,每次分配堆内存时,随机决定目标内存是走GWP-ASan分配——分配在一个空闲的Slot上,还是走系统原生分配。如果走GWP-ASan分配,那么目标内存会被随机左对齐/右对齐分配在一个空闲的Slot上,同时记录分配内存的堆栈信息。

而当释放内存时,会先判断目标内存是否在GWP-ASan受保护内存池上,如果是,那么释放这块内存和其所在的Slot,同时记录释放内存的堆栈。slot空闲后,可以重新被用于分配。堆栈信息记录在metadata中。

  1. 持续监测,记录异常:

    1. 首先,我们需要知道Guard Page和空闲的Slot都是不可读写的。接下来我们看看GWP-ASan是如何发现异常的:
    2. Use-after-free: Slot上未分配内存时,是不可读写的。当访问到不可读写的Slot时,应用抛出异常,此时检查该Slot是否刚释放过内存,如果释放过内存,那么可以判定此异常为Use-after-free
    3. Buffer-underflow:当内存左对齐分配在Slot上时,如果发生了underflow,应用会访问到Slot左侧不可读写的Guard Page,应用抛出异常,此异常为Buffer-underflow
    4. Buffer-overflow:当内存右对齐分配在Slot上时,如果发生了overflow,应用会访问到Slot右侧不可读写的Guard Page,应用抛出异常,此异常为Buffer-overflow
    5. Double-free:应用释放内存时,首先检查目标内存地址是否位于受保护内存池区间内,如是,由GWP-ASan释放内存,释放前检查目标内存地址所在Slot是否已经被释放,如是,那么可以判定此异常为Double-free
    6. 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-freeBuffer-underflowBuffer-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了。

00007ffb`d422e4a0 498b06          mov     rax,qword ptr [r14]
00007ffb`d422e4a3 488bd5          mov     rdx,rbp
00007ffb`d422e4a6 498bce          mov     rcx,r14
00007ffb`d422e4a9 ff10            call    qword ptr [rax]

查看问题代码:

class VENotifyListenerBase {
public:
    virtual void notify(const VENotifyData& data) = 0;
};
//辅助注册类
class VENotifyListener : public VENotifyListenerBase
{
public:
 VENotifyListener (){
VENotify:: instance (). addListener ( this );
}

 virtual ~ VENotifyListener () {
VENotify:: instance (). removeListener ( this );
}
};

void VENotify::notify(const VENotifyData& data)
{
    ++m_nested;
    std::atomic<char*> info = nullptr;
    for (size_t index = 0; index < m_listeners.size(); ++index) {
        auto listener = m_listeners[index];
        if (!listener) {
            ++m_invaildCount;
            continue;
        }
        ...

        listener-> notify (data);  // crash点
    }
    --m_nested;
    ...
}

很多类继承了VENotifyListener 这个帮助类。分析这个帮助类,我们比较容易得出结论VENotify的变量m_listeners线程不安全,当VENotify::removeListenerVENotify::notify存在竞争时,就可能会出现这个crash。这个结论是靠我们的经验得出的,我们可以加个锁,搞定这个竞争导致的crash。

那么这个问题确实解决了么?如果我们没有GWP-ASan,我们很可能会止步于此,匆匆修复crash并提交代码,拍着胸脯说,我搞定了。

细心的同学可能会发现,有人可能会不继承VENotifyListener ,而是继承VENotifyListenerBase ,直接调用VENotify::instance().addListenerVENotify::instance().removeListener,检索工程代码可能会发现一堆addListenerremoveListener,更不幸的是,可能会发现addListenerremoveListener都是成对出现的。到底是谁使用不规范导致的crash呢?接下来我们只能逐个检查代码,或者深入调试找到问题位置。这么做可能需要花费较多的时间。

幸运的是,GWP-ASan也抓到同位置的crash了,我们看下GWP-ASan的crash输出:

USE AFTER FREE
*******.dll VENotify::notify
*******.dll QMetaObject::metacall
*******.dll QQmlObjectOrGadget::metacall

GWP-Asan Info
Error type:USE AFTER FREE
Allocation address:0x1866ff827b20
Allocation size:1240
GWPASan region start:0x1866ddb10000
GWPASan region size:0x12c001000
Valid memory range:[ 0x1866ff827b20, 0x1866ff827ff8 )

GWP-ASan确切的告知我们此处crash原因是UAF,并告诉了我们很多的细节信息。那么是谁在什么时候被释放的?

GWP-ASan的Free Stack页面告知我们是MediaInfoViewModel导致的问题,我们检查MediaInfoViewModel代码发现有如下代码:

void MediaInfoViewModel::EnableNotify(bool enable) {
    if (enable) {
        VENotify::instance().addListener(this);
    } else {
        VENotify::instance().removeListener(this);
    }
}

果然,业务自己调用了 VENotify::instance().addListener,但是MediaInfoViewModel析构前并没有保证一定会调用 VENotify::instance().removeListener。这种情况下,意味着 VENotify::instance()持有了一个MediaInfoViewModel*的悬垂指针,等到下次notify调用,就会crash。

修复方案:

  1. 确保MediaInfoViewModel在析构前会调用VENotify::instance().removeListener
  2. 对存在线程间竞争的地方加锁保护。

实际案例 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的报告如下:

USE AFTER FREE
********.dll QUICollectionViewItem::clearSubitems
********.dll DraftTemplatePageControl::updateSearchCategoryViewModel
********.dll QMetaObject::invokeMethodImpl

GWP-Asan Info
Error type:USE AFTER FREE
Allocation address:0x2198e2a4bf80
Allocation size:128
GWPASan region start:0x2198300b0000
GWPASan region size:0x12c001000
Valid memory range:[ 0x2198e2a4bf80, 0x2198e2a4c000 )

GWP-ASan再次明确的的告知我们此处crash原因是UAF,此时我们只要集中精力检查UAF方可。那么是谁释放了QUICollectionViewItem

上图Free Stack页面显示QUICollectionViewItem是在QT消息循环中被析构的,虽然是QUICollectionViewItem析构的第一现场,但不是代码级别的第一现场。了解QT的同学知道,调用了deleteLater()才会有此堆栈。为了解决crash,我们还需要找到调用deleteLater()的地方,最后找到如下代码段:

void QUICollectionViewItem::slotTreeItemWillDistory()
{
    if (m_parentItem != nullptr ) { 
        m_parentItem->removeSubitem(this);
        ...
    }
    ...
    deleteLater();
}

回顾一下我们的crash以及UAF,实际上父节点持有了悬垂指针并调用clearSubitems(),程序就会挂掉。此处的代码看似从m_parentItem中移除了本节点(注:m_parentItem->removeSubitem(this)),但是如果代码不严谨(如m_parentItem在某种情况下被设置为nullptr),那么就可能存在悬垂指针。我们检查谁会修改m_parentItem,且要重点检查谁会将m_parentItem修改为nullptr

检查代码会发现只有一个函数会修改m_parentItem,代码如下:

void QUICollectionViewItem::setParentItem(QUICollectionViewItem* parentItem)
{
    IF_RETURN_VOID(m_parentItem == parentItem);
    m_parentItem = parentItem; 

    IF_RETURN_VOID(m_parentItem != nullptr);
    if (m_inVisualArea || m_collectionView->alwaysKeepItems()){
        ...
    }
   ...
}

注意上述代码没有处理m_parentItem变更的情况,此时我们找到问题位置。

修复方案:

当一个节点的父节点要变更时,需要从旧的父节点中摘除自己,避免旧的父节点持有子节点的悬垂指针。

void QUICollectionViewItem::setParentItem(QUICollectionViewItem* parentItem)
{
    IF_RETURN_VOID(m_parentItem == parentItem);
 if (m_parentItem) { 
 m_parentItem-> removeSubitem ( this ); 
 } 
 m_parentItem = parentItem; 
    ...
}

实际案例 3

首先我们看下常规的dump输出,windbg再次提示我们crash在标准库相关操作了。

void XXXXXX_class::checkRequestCompleted()
{
    if (resource_request_status_map_.empty())
        return;
    for (auto iter = resource_request_status_map_.begin(); iter != resource_request_status_map_.end(); ++iter) {
        if (!iter->second.first || !iter->second.second)
            return;
    }
    ...
}

到底是什么问题导致的crash?这代码看着也很简单,普通的dump没有再提供更多的信息~

iter空指针?XXXXXX_class被析构?多线程竞争?UAF?溢出?我们不得不猜测,并查看代码,或者进一步分析dump来验证我们的想法。

我们再看下GWP-ASan提供的信息,GWP-ASan报告如下:

USE AFTER FREE
******.dll XXXXXX_class::responseToGetEffectListByResourceIds
******.dll davinci::effectplatform::loki::FetchEffectsByIdsTask::onFailed
******.dll VECORE::NetClient::request

GWP-Asan Info
Error type:USE AFTER FREE
Allocation address:0x1f662391bfc0
Allocation size:56
GWPASan region start:0x1f6510c40000
GWPASan region size:0x12c001000
Valid memory range:[ 0x1f662391bfc0, 0x1f662391bff8 )

可以看到对于同一个标准库的数据结构,同时有三个线程在访问。此时我们明确的知道,此crash是因为多线程竞争导致的。而且GWP-ASan明确输出了数据结构的释放堆栈,我们不用再去猜测及思考问题是如何导致的。

修复方案:

非常简单,对存在竞争的数据结构加锁方可。

Buffer-overflow:内存溢出

实际案例 1

我们还是看下常规dump提供的信息:

dump指示崩溃在了share_ptr增加引用计数的地方。 大家都知道share_ptr的引用计数是保存在堆里面的,我们又遇到堆问题了。

static std::vector< int64_t > getKeyframeTrimDeltaList (std::shared_ptr<SegmentT> video_segment)   { 
    std::vector<int64_t> trimDeltaList;
    ...
    return trimDeltaList;
}
    
//crash的函数
std::vector< int64_t > ExecutorHelper::getKeyframeSeqDeltaList ( const std::shared_ptr<SegmentVideo>& segment)  {
    ...
    auto trimDeltaList = getKeyframeTrimDeltaList(segment);
    ...
}

template<typename SegmentT>
std::vector<int64_t> get_keyframe_seq_delta_list(const std::shared_ptr<Draft>& draft,
                                          const std::shared_ptr<SegmentT> &segment) const {
        ...
        auto ret = ExecutorHelper::getKeyframeSeqDeltaList(segment);
        ...
}

const std::vector<int64_t> VideoSettingsData::updateKeyframeSeqTimeList(size_t index, bool force)
{
    if (index >= m_segmentPtrs.size() || index >= m_keyframeSeqOffsetTimelists.size()) {
        assert(false);
    }
    auto & seg = m_segmentPtrs[index];
    assert(seg);
    ...
}

void VideoSettingsData::setSegmentIds(const std::vector<std::string>& segIds)
{
    ...
    if (auto query_utils = LvveQueryUtils) {
        for (size_t i = 0; i < m_segmentIds.size(); ++i) {
            ...
            auto segmentPtr = ....;
            ...
            IF_CONTINUE(segmentPtr == nullptr)
 m_segmentPtrs. push_back (segmentPtr); 
 updateKeyframeSeqTimeList (i, true ); 
        }
    }
}

如果没有GWP-ASan的帮助,大家看下问题在什么地方?没有排查经验的话,同学们可能就折在崩溃点的附近的代码了,然后百思不得其解。即便有排查经验的,同学们亦需要逐帧去检查代码实现,还得理解代码实现,最后定位问题位置。

我们看下GWP-ASan的输出:

BUFFER OVERFLOW
*******.dll VideoSettingsData::updateKeyframeSeqTimeList
*******.dll QMetaCallEvent::placeMetaCall
*******.dll QApplicationPrivate::notify_helper

GWP-Asan Info
Error type:BUFFER OVERFLOW
Allocation address:0x3a3d230a3fe0
Allocation size:32
GWPASan region start:0x3a3ca3fd0000
GWPASan region size:0x12c001000
Valid memory range:[ 0x3a3d230a3fe0, 0x3a3d230a4000 )

可见GWP-ASan告知我们是堆溢出,并且替我们定位到了第一现场。 我们只要查看ViedoSettingsData.cpp803行周围的代码,就能迅速定位问题。也就是上述代码的 auto& seg = m_segmentPtrs[index];这段代码导致了溢出。再查看上一层函数,发现当IF_CONTINUE(segmentPtr == nullptr) 时,必然会出现堆越界

void VideoSettingsData::setSegmentIds(const std::vector<std::string>& segIds)
{
    ...
    m_segmentIds = segIds;
    ...
        for (size_t i = 0; i < m_segmentIds.size(); ++i) {
            ...
 IF_CONTINUE (segmentPtr == nullptr ) 
 m_segmentPtrs. push_back (segmentPtr); 
 updateKeyframeSeqTimeList (i, true ); 
        }
    
}

修复方案:

解除updateKeyframeSeqTimeList的越界操作。

实际案例 2

此处代码看起来比较复杂,为了方便理解,此处只保留分析crash相关的代码。本crash我们内部无法复现。但内部APM-PC平台监控到的crash还不少。


bool EncryptUtilsImpl::getOriginEncryptText(const char *encryptText,
                                           ...) {
    ...
    int length = strlen(encryptText);
    ...
    *withOutKeyEncryptText = ( char *) malloc (length - LENGTH1 - LENGTH2 + 1 );
    ...
    int32_t pre_length = 0;
    int32_t pre_location = 0;
    ...
    for (int it = 0; it < XXXXXXXX.size(); it++) {
        ...
        if (XXXXXX) {
            ...
            pre_length += XXXXXX;
            pre_location = XXXXXX;
        }
       ...
    }
 memcpy (*withOutKeyEncryptText + pre_length, 
 xxxxxx+ pre_length   + xxxxxx, 
 xxxxxx- pre_location + xxxxxx); 

    return true;
}

打开常规dump查看输出:

dump显示crash在函数末尾的memcpy中,真的很幸运,虽然是堆相关的问题,但是我们crash在了第一现场。

ExceptionAddress: 00007ffa16b715f0 (VCRUNTIME140!memcpy+0x0000000000000300)
ExceptionCode: c0000005 (Access violation)

粗略的看这个代码也没什么问题,排查问题的时候,我们如果能得到局部变量pre_length pre_location length 的值,就可以知道为什么crash了。

检查当前栈帧的局部变量,如下图:

非常不幸,我们没法看到length的值,release版本已经将这个局部变量给优化掉了。

如果我们不是作者的话,不了解程序逻辑,当观察到char * ``encryptText`` = 0x00000254a6a6e870 "U???" ,我们很可能会怀疑是堆破坏了(后面了解了代码逻辑后知道,这个地方内存是正确的)。我们针对问题用户单独开启了GWP-ASan,很快GWP-ASan捕获到同位置的crash。

GWP-ASan输出如下:

BUFFER OVERFLOW
*******.dll EncryptUtilsImpl::getOriginEncryptText
*******.dll EncryptUtilsImpl::decrypt
*******.exe VELauncher::exec

GWP-Asan Info
Error type:BUFFER OVERFLOW
Allocation address:0x293a8fd5000
Allocation size: 68  #关键信息,缺失的length信息
GWPASan region start:0x293a5500000
GWPASan region size:0x12c001000
Valid memory range:[ 0x293a8fd5000, 0x293a8fd5044 )

下图是GWP-ASan捕获的dump,windbg解析输出的内容:

注意:我们一共申请了Allocation size:68 个字节的内存:

// length - LENGTH1 - LENGTH2 + 1 = 68
*withOutKeyEncryptText = ( char *) malloc (length - LENGTH1 - LENGTH2 + 1 ); 

然而现在int pre_length = 0n83:

    memcpy(*withOutKeyEncryptText + pre_length,
           xxxxxx+ pre_length   + xxxxxx,
           xxxxxx- pre_location + xxxxxx);

显然,*withOutKeyEncryptText + pre_length现在越界了。

void EncryptUtilsImpl::decrypt(const char *encryptText, char **outEncryptText, const std::string& from) {
   ...
   getOriginEncryptText(encryptText, ...);
   ...
}

std::string EncryptUtilsImpl::decrypt(const std::string& encryptStr, ...) {
    ...
    const char *input_str = encryptStr. data (); 
    if (strlen(input_str) > 0) {
        EncryptUtilsImpl:: decrypt (input_str,...); 
    }
    ...    
}

我们回溯代码,最终发现,原来是实现方式上有点问题。我们将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

了解更多

有关PC端监控的能力,我们已将部分功能在火山引擎应用性能监控全链路版中对外提供,你可添加下方小助手或点击“申请链接”申请免费试用。

image.png

点赞收藏
火山引擎开发者服务

火山引擎应用性能监控全链路版,经字节内部众多APP实践验证、提供APP、Web、小程序、服务端、PC、OS端在内的APM服务,通过先进的数据采集技术,为用户优化应用性能助力。

请先登录,查看1条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
3
1