关于原理:PC-GWPASan方案原理-堆破坏问题排查实践

背景

家喻户晓,堆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

  • Windows常见内存治理办法
  • Comparing Memory Allocation Method
  • [https://chromium.googlesource.com/chromium/src.git/+/HEAD/doc…](https://chromium.googlesource.com/chromium/src.git/+/HEAD/doc…
    )
  • https://sites.google.com/a/chromium.org/dev/Home/chromium-security/articles/gwp-asan

理解更多

无关PC端监控的能力,咱们已将局部性能在火山引擎利用性能监控全链路版中对外提供,你可增加下方小助手或点击“申请链接”申请收费试用。

  • 增加小助手,回复【APM】退出技术交换群
  • 申请链接:https://wenjuan.feishu.cn/m/cfm?t=s6RJBByEbWvi-fmpz*

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理