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的排查難度,有效縮短了研發排查、修復問題的時間。

技術方案

監控原理

檢測原理概述

  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端監控的能力,我們已將部分功能在火山引擎應用性能監控全鏈路版中對外提供,你可添加下方小助手或點擊“申請鏈接”申請免費試用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章