天天看點

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

作者:閃念基因

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. 建立受保護記憶體池:

首先,我們需要保留一塊連續的n*page size的受保護記憶體池。其中,可配置設定記憶體的page是Slot,不可配置設定記憶體的 page 是Guard Page。Slot和Guard Page間隔分布,整個記憶體池最前和最後都是Guard Page,所有的Slot都受到Guard Page保護,之後應用配置設定的堆記憶體将随機采樣配置設定到這些Slot上。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

2. 采樣監控記憶體配置設定行為,記錄堆棧:

之後,hook 應用堆記憶體配置設定行為,每次配置設定堆記憶體時,随機決定目标記憶體是走 GWP-ASan 配置設定——配置設定在一個空閑的Slot上,還是走系統原生配置設定。如果走 GWP-ASan 配置設定,那麼目标記憶體會被随機左對齊/右對齊配置設定在一個空閑的Slot上,同時記錄配置設定記憶體的堆棧資訊。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

而當釋放記憶體時,會先判斷目标記憶體是否在 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 應用配置設定堆記憶體的可用方法:

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

GlobalAlloc/LocalAlloc是為了相容 Windows 舊版本的 API,現在基本不适用,是以不監控。HeapAlloc/HeapFree一般用于程序配置設定記憶體,不監控。VirtualAlloc是應用層記憶體配置設定的底層實作,開發一般不直接用此 API 配置設定記憶體,它離應用配置設定堆記憶體行為太遠,堆棧參考意義不大;且 Windows GWP-ASan 需要基于此實作,是以,也不監控。

最終標明 Hook malloc/free等系列方法,Hook malloc/free後,能感覺到使用者配置設定的堆記憶體。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

2.1.3 Hook方案

下面的方案都是應用層的 Hook 方案,核心層 Hook 僅适用于 x86 平台。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

Detours 庫作為微軟官方出品的 Hook 庫,相容性佳,穩定性好,是最佳選擇。但是還需要注意的是,Windows 下,運作時庫配置會影響 Hook 結果,Detours 隻能無侵入式 Hook/MD 庫的記憶體配置設定行為,/MT 庫需要提供自身記憶體配置設定的函數指針才能 Hook。

2.1.4 堆棧記錄

首先要說明的是,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 原本的資訊,基本就能定位問題。

2.2 監控流程:

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

2.3 拓展場景:

2.3.1 無崩潰方案:

GWP-ASan 檢測到異常後,會主動崩潰導緻用戶端程序退出,給使用者帶來了不良體驗。無崩潰的 GWP-ASan 檢測到異常後,再将對應記憶體頁标注為可讀寫的(如為 use-after-free/buffer-underflow/buffer-overflow),僅生成上傳崩潰報告,不主動終結程序,用戶端标注異常已解決。使用者無感覺,程式繼續運作。需要注意的是,用戶端在 UEF 裡标記通路區域記憶體頁為可讀寫記憶體頁可能影響後續的 GWP-ASan 檢測。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

3. 實戰分享

3.1 Use-After-Free:釋放後使用

實際案例 1

我們看下正常的 dump 輸出,windbg 告知我們程式 crash 在 25 行。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

因為 12 行有空指針檢查,可以排除空指針問題。

執行.ecxr恢複異常現場也可以證明,crash 和空指針無關。隻是一個簡單的通路違例。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

彙編指定位址,可以知道這個動作是在讀取類的虛指針,讀取記憶體的過程中 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::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 輸出:

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,并告訴了我們很多的細節資訊。那麼是誰在什麼時候被釋放的?

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

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。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

切換棧幀到 08 檢視代碼,發現QUICollectionViewItem是一個多叉樹的資料結構。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

調試器告知我們,此 crash 确實是一個堆 crash,在枚舉成員變量的時候挂掉了。此時的 this 指針指向的位置已經出現了問題,已經不再是正常的位址了。檢視this指針指向的位址可以證明這一點。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

因為不是第一現場,我們需要考慮什麼情況,會導緻此問題。首先堆溢出,記憶體踩踏,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。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

上圖顯示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()){
        ...
    }
   ...
}
           

注意上述代碼沒有處理parentItem為nullptr的情況,此時我們找到問題位置。

修複方案:

當一個節點的父節點要變更時,需要從父節的子 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 在标準庫相關操作了。

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐
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 )           
PC GWP-ASan 方案原理 | 堆破壞問題排查實踐
PC GWP-ASan 方案原理 | 堆破壞問題排查實踐
PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

可以看到對于同一個标準庫的資料結構,同時有三個線程在通路。此時我們明确的知道,此 crash 是因為多線程競争導緻的。而且 GWP-ASan 明确輸出了資料結構的釋放堆棧,我們不用再去猜測及思考問題是如何導緻的。

修複方案:

非常簡單,對存在競争的資料結構加鎖方可。

3.2 Buffer-overflow:記憶體溢出

實際案例 1

我們還是看下正常 dump 提供的資訊:

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

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 )
           
PC GWP-ASan 方案原理 | 堆破壞問題排查實踐
PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

可見 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 檢視輸出:

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

普通 dump 顯示的資訊,dump 顯示 crash 在函數末尾的 memcpy 中,真的很幸運,雖然是堆問題,當時我們 crash 在了第一現場。

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

粗略的看這個代碼也沒什麼問題,排查問題的時候,我們認為隻要能擷取局部如果能檢視到局部變量的值,就可以知道為什麼 crash 了。

檢查目前棧幀的局部變量,如下圖:

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

非常不幸,我們沒法看到 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 解析輸出的内容:

PC GWP-ASan 方案原理 | 堆破壞問題排查實踐

注意:我們一共申請了 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

作者:張迅、劉鵬華

來源:微信公衆号:位元組跳動技術團隊

出處:https://mp.weixin.qq.com/s/EcHvSv3yuEwljppg3g0I7w

繼續閱讀