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

动手点关注

9ec5a0c4679a3c0e57a7831b19148213.gif

干货不迷路

1. 背景

众所周知,堆 crash dump 是最难分析的 dump 类型之一。此类 crash 最大的问题在于,造成错误的代码无法在发生堆破坏时被发现。线上采集到的 minidump,仅能提供十分有限的信息。当调试工具报告了堆破坏、堆内存访问违例后,即便是有经验的开发人员也会觉得头疼。剪映专业版及其依赖的音视频编辑 SDK、特效模块均采用 MD 的方式链接标准库,这意味着任何一个模块出现了堆损坏都会互相影响。从 crash 的位置回溯堆破坏的源头,是一个非常有挑战性的工作。剪映业务模块较常见的是Use-after-free,而音视频编辑 SDK和特效模块这类底层算法特效模块更多的是Buffer-overflow,不同团队模块间的堆错误互相影响,导致问题难以定位。

GWP-ASan 是 Google 主导开发的用于检测堆内存问题的调试工具。它基于经典的 Electric Fence Malloc 调试器 (https://linux.die.net/man/3/efence)原理,概率采样内存分配行为,抓取内存问题并生成上传崩溃报告。说到这里,也许你会好奇它和 ASan(Address Sanitizer)的区别。ASan 是一种编译器调试工具,监控所有内存分配行为,可以发现栈、堆和全局内存问题,但它性能开销很高(2-3倍),不适合线上使用。GWP-ASan 相较于 ASan,虽然无法发现栈内存和全局内存问题,但因为它是采样监控,性能消耗可以忽略不计,更适用于线上场景。目前,GWP-ASan 可检测的错误有: Use-after-free Buffer-underflow Buffer-overflow Double-free free-invalid-address

GWP-ASan 有多种实现方案,本方案基于 Windows 平台说明,字节内部 APM-PC 平台相较于市面上其他方案的亮点有:

  • 无侵入式接入,可以检测特定类型三方库的内存分配。

  • 支持无感知监测,发现异常后进程可继续运行。

  • 支持调整检测所用的堆页面个数配置和采样率配置,灵活调整性能消耗。

剪映专业版接入字节内部 APM-PC 平台的 GWP-ASan 功能后,帮助业务、音视频编辑 SDK、特效模块解决 30 余例疑难堆 crash。GWP-ASan dump 比原生 dump 提供了更丰富的信息,并指出了堆 crash 关联的信息细节,降低了疑难 crash 的排查难度,有效缩短了研发排查、修复问题的时间。

2. 技术方案

2.1 监控原理

2.1.1 检测原理概述

1. 创建受保护内存池:

扫描二维码关注公众号,回复: 14620332 查看本文章

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

c8cb8455b2c842035bbea338a9a6bc99.png

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

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

c7e42e8a255745a36af8d6045133c724.png

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

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

首先,我们需要知道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

2.1.2 堆内存分配 API

前面已经提到,GWP-ASan 用于检测堆内存问题,为了检测堆内存问题,必须先感知应用内存分配行为。很自然的,我们会想到 hook 内存分配方法,但是该 hook 哪个方法呢?

下图描述了 Windows 应用分配堆内存的可用方法:

50d703c6d5611065f057882346dc4c3f.png

GlobalAlloc/LocalAlloc是为了兼容 Windows 旧版本的 API,现在基本不适用,所以不监控。HeapAlloc/HeapFree一般用于进程分配内存,不监控。VirtualAlloc是应用层内存分配的底层实现,开发一般不直接用此 API 分配内存,它离应用分配堆内存行为太远,堆栈参考意义不大;且 Windows GWP-ASan 需要基于此实现,因此,也不监控。

最终选定 Hook malloc/free等系列方法,Hook malloc/free后,能感知到用户分配的堆内存。

ce645d09f0d76614e91562f87be2d0bf.png

2.1.3 Hook方案

下面的方案都是应用层的 Hook 方案,内核层 Hook 仅适用于 x86 平台。

665770722156fdfe0a31c089e91dd120.png

Detours 库作为微软官方出品的 Hook 库,兼容性佳,稳定性好,是最佳选择。但是还需要注意的是,Windows 下,运行时库配置会影响 Hook 结果,Detours 只能无侵入式 Hook/MD 库的内存分配行为,/MT 库需要提供自身内存分配的函数指针才能 Hook。

2.1.4 堆栈记录

首先要说明的是,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 原本的信息,基本就能定位问题。

2.2 监控流程:

fd12c6fd9108e151cef5852172af86f5.png

2.3 拓展场景:

2.3.1 无崩溃方案:

GWP-ASan 检测到异常后,会主动崩溃导致客户端进程退出,给用户带来了不良体验。无崩溃的 GWP-ASan 检测到异常后,再将对应内存页标注为可读写的(如为 use-after-free/buffer-underflow/buffer-overflow),仅生成上传崩溃报告,不主动终结进程,客户端标注异常已解决。用户无感知,程序继续运行。需要注意的是,客户端在 UEF 里标记访问区域内存页为可读写内存页可能影响后续的 GWP-ASan 检测。

9fdaaf4be7b39d49d2cd6fdc15eda7b1.png

3. 实战分享

3.1 Use-After-Free:释放后使用

实际案例 1

我们看下常规的 dump 输出,windbg 告知我们程序 crash 在 25 行。

f46863034bc862ffa537d1157428b7b7.png

因为 12 行有空指针检查,可以排除空指针问题。

执行.ecxr恢复异常现场也可以证明,crash 和空指针无关。只是一个简单的访问违例。

0f2e2ef32b6070736016e7b8ee32359e.png

汇编指定地址,可以知道这个动作是在读取类的虚指针,读取内存的过程中 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失效的问题
        auto pName = m_infos[index];
        if (!pName) {
            pName = const_cast<char*>(typeid(*listener).name());
            m_infos[index] = pName;
        }

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

void VENotify::removeListener(VENotifyListenerBase* listener)
{
    for (size_t index = 0; index < m_listeners.size(); ++index) {
        if (m_listeners[index] == listener) {
            if (m_nested > 0) {
                m_listeners[index] = nullptr ; 
            }
            else {
                m_listeners.erase(m_listeners.begin() + index);
                m_infos.erase(m_infos.begin() + index);
            }
            return;
        }
    }
}

很多类继承了VENotifyListener 这个帮助类。分析这个帮助类,我们比较容易得出结论VENotify线程不安全,当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
VECreator.dll VENotify::notify
Qt6Core.dll QMetaObject::metacall
Qt6Qml.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,并告诉了我们很多的细节信息。那么是谁在什么时候被释放的?

9ff343e2c1786885ed015e292393d9a5.png

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。

67f7ac093869ae8fa9a54e41f7ba2713.png

切换栈帧到 08 查看代码,发现QUICollectionViewItem是一个多叉树的数据结构。

03137d533f77c3d3532148cac2401e8d.png

调试器告知我们,此 crash 确实是一个堆 crash,在枚举成员变量的时候挂掉了。此时的 this 指针指向的位置已经出现了问题,已经不再是正常的地址了。查看this指针指向的地址可以证明这一点。

25c621447515d574c632fd8d615a0d10.png

因为不是第一现场,我们需要考虑什么情况,会导致此问题。首先堆溢出,内存踩踏,UAF 都可以导致此问题。

不过根据经验来看,针对这种指针比较多的数据结构,UAF 的概率比较高,但是没人敢拍着胸脯说这个 crash 一定是 UAF 导致的。

GWP-ASan 再次抓到了此问题,GWP-ASan 的报告如下:

USE AFTER FREE
FusionUI.dll QUICollectionViewItem::clearSubitems
VECreator.dll DraftTemplatePageControl::updateSearchCategoryViewModel
Qt6Core.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

ae9a9c419d64a4aa0a88e2f8c75ac4fc.png

上图显示QUICollectionViewItem是在 QT 消息循环中被析构的,虽然是析构的第一现场,但不是代码级别的第一现场。了解 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()){
        ...
    }
   ...
}

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

修复方案:

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

void QUICollectionViewItem::setParentItem(QUICollectionViewItem* parentItem)
{
    IF_RETURN_VOID(m_parentItem == parentItem);
 if (m_parentItem) { 
 m_parentItem-> removeSubitem ( this ); 
 } 
 m_parentItem = parentItem; 
    
    IF_RETURN_VOID(m_parentItem != nullptr);
    if (m_inVisualArea || m_collectionView->alwaysKeepItems()){
        ...
    }
    ...
}

实际案例 3

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

ad8d1a6e6b6cb30ee4c138a19da32cbb.png

void VipManager::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空指针?VipManager被析构?多线程竞争?UAF?溢出?我们不得不猜测,并查看代码,或者进一步分析 dump 来验证我们的想法。

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

USE AFTER FREE
VECreator.dll VipManager::responseToGetEffectListByResourceIds
EffectPlatform.dll davinci::effectplatform::loki::FetchEffectsByIdsTask::onFailed
VECreator.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 )

e4afd2b69f62073a891954ef38db5444.png

5ecc46509f109902a7d341d4e32c3de3.png

d1a1cc6fbad7d861dcfd625bb0c59e8e.png

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

修复方案:

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

3.2 Buffer-overflow:内存溢出

实际案例 1

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

b7a3117421c6776b9ebcd9c2fdbc8c5c.png

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);
    IF_RETURN_VALUE(seg == nullptr, {});
    if (force || m_keyframeSeqOffsetTimelists[index].size() != seg->get_keyframes().size()) {
        auto session = LvveCoreInstance->session();
        auto queryUtils = LvveCoreInstance->queryUtils();
        m_keyframeSeqOffsetTimelists[index] =
         queryUtils->get_keyframe_seq_delta_list(lvveDraft, seg);
    }
    return m_keyframeSeqOffsetTimelists[index];
}

void VideoSettingsData::setSegmentIds(const std::vector<std::string>& segIds)
{
    auto video_face_helper = player::PlayerFactory::getFactory()->getInstance<player::IPlayerVideoFace>();
    MODULE_HOST_LOCK_VOID(video_face_helper);

    lock_video_face_helper->SetCurrentSelSegment(segIds);
    m_segmentIds = segIds;
    m_segmentPtrs.clear();
    m_keyframeSeqOffsetTimelists.clear();
    m_keyframeSeqOffsetTimelists.resize(m_segmentIds.size());
    if (auto query_utils = LvveQueryUtils) {
        for (size_t i = 0; i < m_segmentIds.size(); ++i) {
            const auto& id = m_segmentIds[i];
            auto segmentPtr = std::dynamic_pointer_cast<lvve::SegmentVideo>(query_utils->get_segment(id));
            assert(segmentPtr);
            IF_CONTINUE(segmentPtr == nullptr)
 m_segmentPtrs. push_back (segmentPtr); 
 updateKeyframeSeqTimeList (i, true ); 
        }
    }
}

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

我们看下 GWP-ASan 的输出:

BUFFER OVERFLOW
VECreator.dll VideoSettingsData::updateKeyframeSeqTimeList
Qt6Core.dll QMetaCallEvent::placeMetaCall
Qt6Widgets.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 )

34083149ac22b4f615c325b6ad153036.png

f89b254e5c19aa8939f1075a5974d467.png

可见 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 我们自己还没法复现。但是内部 APM-PC 平台监控到的 crash 还不少。

const static uint32_t KEY_LENGTH = 32;
const static uint32_t IV_LENGTH  = 16;
const static int32_t KEY_BLOCK_SIZE = 4; //key和IV的公约数

static const std::vector<int32_t> DRAFT_JSON_HEDLEY_NO_ESCAPE
    split_location = {0, 7, 20, 33, 40, 47, 59, 66, 76, 89, 99, 127};
    
bool EncryptUtilsImpl::getOriginEncryptText(const char *encryptText,
                                            char **withOutKeyEncryptText,
                                            char **aesKey,
                                            char **aesIv) {
    if (*withOutKeyEncryptText) {
        free(*withOutKeyEncryptText);
    }
    if (*aesKey) {
        free(*aesKey);
    }
    if (*aesIv) {
        free(*aesIv);
    }
    int length = strlen(encryptText);
    if (length < KEY_LENGTH + IV_LENGTH) {
        return false;
    }
    *withOutKeyEncryptText = ( char *) malloc (length - KEY_LENGTH - IV_LENGTH + 1 );
    memset(*withOutKeyEncryptText, '\0', length - KEY_LENGTH - IV_LENGTH + 1);
    *aesKey = (char *) malloc(KEY_LENGTH + 1);
    memset(*aesKey, '\0', KEY_LENGTH + 1);
    *aesIv = (char *) malloc(IV_LENGTH + 1);
    memset(*aesIv, '\0', IV_LENGTH + 1);
    int32_t pre_length = 0;
    int32_t pre_location = 0;
    int32_t count = 0;
    for (int it = 0; it < split_location.size(); it++) {
        if ((split_location[it] - pre_location - KEY_BLOCK_SIZE) > 0) {
            memcpy(*withOutKeyEncryptText + pre_length,
                   encryptText + pre_length + KEY_BLOCK_SIZE * count,
                   split_location[it] - pre_location - KEY_BLOCK_SIZE);
            pre_length += (split_location[it] - pre_location - KEY_BLOCK_SIZE);
            pre_location = split_location[it];
        }
        if (count * KEY_BLOCK_SIZE < KEY_LENGTH) {
            memcpy(*aesKey + count * KEY_BLOCK_SIZE,
                   encryptText + split_location[it],
                   KEY_BLOCK_SIZE);
        } else {
            memcpy(*aesIv + count * KEY_BLOCK_SIZE - KEY_LENGTH,
                   encryptText + split_location[it],
                   KEY_BLOCK_SIZE);
        }
        count++;
    }
 memcpy (*withOutKeyEncryptText + pre_length, 
 encryptText + pre_length + KEY_BLOCK_SIZE * count, 
 length - pre_location - KEY_BLOCK_SIZE); 

    return true;
}

打开常规 dump 查看输出:

1d350fdf17e444df353ac8e76aebc48d.png

普通 dump 显示的信息,dump 显示 crash 在函数末尾的 memcpy 中,真的很幸运,虽然是堆问题,当时我们 crash 在了第一现场。

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

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

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

7eb7907adf13f2edea5e6e69fd07cbd0.png

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

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

GWP-ASan 输出如下:

BUFFER OVERFLOW
VECreator.dll lvve::EncryptUtilsImpl::getOriginEncryptText
VECreator.dll lvve::EncryptUtilsImpl::decrypt
JianyingPro.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 解析输出的内容:

917c52ee2569b55ef8383126793d074d.png

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

// length - KEY_LENGTH - IV_LENGTH + 1 == 68
*withOutKeyEncryptText = (char *) malloc(length - KEY_LENGTH - IV_LENGTH + 1);

然而现在int pre_length = 0n83:

memcpy(*withOutKeyEncryptText + pre_length,
           encryptText + pre_length + KEY_BLOCK_SIZE * count,
           length - pre_location - KEY_BLOCK_SIZE);

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

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

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

我们回溯代码,最终发现,原来是实现方式上有点问题。我们将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 会携带了正确的数据长度,问题得以解决。

4. Reference

[Windows 常见内存管理方法] 

https://docs.microsoft.com/en-us/windows/win32/memory/memory-management-functions#general-memory-functions

[Comparing  Memory Allocation Method]

https://docs.microsoft.com/en-us/windows/win32/memory/comparing-memory-allocation-methods

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

5. 了解更多

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

10aac5b0f2b1aa370ce2fe34e3cae794.png

扫码添加小助手,回复【APM】加入技术交流群

6. 关于我们

字节跳动终端技术团队(Client Infrastructure)是大前端基础技术的全球化研发团队(分别在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队)。负责整个字节跳动的大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、懂车帝等,在移动端、Web、Desktop 等各终端都有深入研究。

字节跳动剪映研发团队,主要支持剪映、醒图、Faceu 等多款国内外产品的研发工作,业务覆盖多元化影像创作场景,截止 2021 年 12 月,相关产品多次登顶国内外 App Store 免费应用榜第一,并继续保持高速增长。加入我们,一起打造全球最受用户欢迎的影像创作产品。

剪映专业版(Windows& Mac ) :2021 年上线,采用了 QT6 框架,并且是国内首家使用 QT6 框架并且支持Win7用户的桌面软件。目前在剪辑能力上与行业标杆产品 PR、Final Cut Pro 等几乎追齐,是抖西头部创作者的首选桌面编辑软件。剪映专业版致力于为PC用户提供专业剪辑能力,降低复杂视频的创作门槛,提高创作者的工作效率。

扫描下方二维码进行简历投递,加入我们,让我们一起做影像创作的领军者!

2a56d48ccd10f8a6e3bbd49f9c778634.png

桌面端 C++ 研发工程师-剪映

f4dbc6137969a1dc9b2353b64d1285b5.png 点击「阅读原文」申请免费试用,立即体验!

猜你喜欢

转载自blog.csdn.net/ByteDanceTech/article/details/129943104
PC: