《0day》-5-堆溢出

第 5 章 堆溢出利用

5.1 堆的工作原理

5.1.1 Windows 堆的历史

微软操作系统堆管理机制的发展大致可以分为三个阶段。

  1. Windows 2000~Windows XP SP1:堆管理系统只考虑了完成分配任务和性能因素,丝毫没有考虑安全因素,可以比较容易发被攻击者利用。
  2. Windows XP 2~Windows 2003:加入了安全因素,比如修改了块首的格式并加入安全cookie,双向链表结点在删除时会做指针验证等。这些安全防护措施使堆溢出攻击变得非常困难,但利用一些高级的攻击技术在一定情况下还是有可能利用成功。
  3. Windows Vista~Windows 7:不论在堆分配效率上还是安全与稳定性上,都是堆管理算法的一个里程碑。

《0day》主要讨论 Windows 2000~Windows XP SP1 平台的堆管理策略。

5.1.2 堆与栈的区别

程序在执行时,栈和堆两种不同类型的内存协同配合。

栈内存堆内存
典型用例函数局部数组动态增长的链表等数据结构
申请方式在程序中直接声明,
sub esp,xx;
需要用函数申请,通过返回的指针使用。
malloc()new
释放方式函数返回时,由系统自动回收,
add esp,xx;
有可能失败
需要把指针传给专用的释放函数,
.如free()delete
否则会造成内存泄露
管理方式申请后直接使用,申请与释放由系统
自动完成,最后达到栈区平衡
需要程序员处理申请与释放
所处位置变化范围很大 0x0012XXXX
增长方向高–>低低–>高(不考虑碎片等情况)
存储内容数据+控制流信息数据
底层硬件直接支持操作系统库函数

栈只有 pop 和 push 两种操作,总是在“线性”变化,其管理机制也相对简单;堆往往显得“杂乱无章”,堆溢出不容易掌握。

5.1.3 堆的数据结构与管理策略

操作系统一般会提供一套API把复杂的堆管理机制屏蔽掉,这里先从宏观上介绍一下堆管理机制的原理。

程序员使用堆只需要做三件事情:

  • 申请一定大小的内存
  • 使用内存
  • 释放内存

堆管理系统要响应程序的内存使用申请就意味着要在杂乱的堆区中辨别出哪些内存是正在被使用的,哪些内存是空闲的,并最终“寻找”到一片恰当的空闲内存区域,以指针形式返回给程序。

  • 杂乱:堆区经过反复的申请、释放操作之后,出现碎片。
  • 辨别:找出可以返回的空闲块
  • 恰当:不能返回过大的内存造成浪费

现代操作系统的堆数据结构一般包括堆块和堆表两类。

graph LR
a[堆数据结构]-->b[堆块]
a-->c[堆表]

b-->d[块首]
b-->e[块身]

c-->f[空表]
c-->g[快表]

堆块

堆区的内存,以堆块为单位进行标识,而不是传统的按字节标识。

一个堆块包括两个部分:

  • 块首,用来标识这个堆块自身的信息,例如本块的大小、本块空闲还是占用等信息
  • 块身,最终分配给用户使用的数据区

堆管理系统所返回的指针一般指向块身的起始位置,在程序中是感觉不到块首的存在的。连续地进行内存申请时,可能会发现返回的内存之间存在“空隙”,那就是块首。

块首的具体内容可在[这里](#5.2.3 识别堆表 )查看。

堆表

堆表一般位于堆区的起始位置,用于索引堆区中所有堆块的重要信息,包括堆块的位置、堆块的大小、空闲还是占用等。

堆表的数据结构决定了整个堆区的组织方式,是快速检索空闲块、保证堆分配效率的关键。

堆表可能会考虑采用平衡二叉树等高级数据结构用于优化查找效率。现代操作系统的堆表往往不止一种数据结构。

在 Windows 中,占用态的堆块被使用它的程序索引,而堆表只索引所有空闲态的堆块。

最重要的堆表有两种:

  • 空闲双向链表Freelist,简称空表
  • 快速单向链表Lookaside,简称快表
空表

空闲堆块的块首中包含一对重要的指针,这对指针用于将空闲堆块组织成双向链表。

按照堆块的大小不同,空表总共被分为 128 条。

堆区一开始的堆表区中有一个 128 项的指针数组,被称做空表索引Freelist array。该数组的每一项包括两个指针,用于标识一条空表。

空闲双向链表Freelist

0号空表链入了所有大于等于1024字节,小于 512KB的堆块,并且升序地依次排列下去。

1-127项指示的空闲堆块都按照索引递增8字节。
空 闲 堆 块 的 大 小 = 索 引 项 ( I D ) × 8 ( 字 节 ) 空闲堆块的大小=索引项(ID)×8(字节) ID×8

快表

快表是 Windows 用来加速堆块分配而采用的一种堆表。

之所以把它叫做“快表”是因为这类单向链表中从来不会发生堆块合并.

空闲块块首被设置为占用态,用来防止堆块合并

快表也有 128 条,组织结构与空表类似,只是其中的堆块按照单链表组织。

快速单向链表Lookaside

快表总是被初始化为空,而且每条快表最多只有 4 个结点,故很快就会被填满。


堆的管理策略

堆中的操作有3种:

  • 堆块分配
  • 堆块释放
  • 堆块合并Coalesce

分配和释放是在程序提交申请和执行的,而堆块合并则是由堆管理系统自动完成的。

堆块分配

堆块分配可以分为三类:

  • 快表分配

  • 普通空表分配:

  • 零号空表分配

从快表中分配堆块:

  1. 寻找到大小匹配的空闲堆块
  2. 状态修改为占用态
  3. 从堆表中卸下
  4. 返回一个指向堆块块身的指针

普通空表分配,寻找最优的空闲块分配,若失败,则寻找次优的空闲块分配。总之就是寻找最小的能够满足要求的空闲块。

零号空表中按照大小升序链着大小不同的空闲块,故在分配时先从free[0]反向查找最后一个块(即表中最大块),,如果能满足要求,再正向搜索最小能够满足要求的空闲堆块进行分配。

空表分配存在“找零钱”现象,次优分配发生时,会先从大块中按请求的大小精确地“割”出一块进行分配,然后给剩下的部分重新标注块首,链入空表。这就是堆管理系统的“节约”原则。

于快表只有在精确匹配时才会分配,故不存在“找钱”现象。

这里暂不讨论堆缓存heap cache、低碎片堆LFH和虚分配。

堆块释放

释放堆块的操作:

  1. 将堆块状态改为空闲
  2. 链入相应的堆表

所有的释放块都链入堆表的末尾,分配的时候也先从堆表末尾拿。

再次强调,快表最多只有 4 项。

在堆块分配和释放时,根据操作内存大小的不同,Windows 采取的策略也会有所不同。可以把内存块按照大小分为3类:

  • 小块:SIZE < 1KB
  • 大块:1KB ≤ SIZE < 512KB
  • 巨块:SIZE ≥ 512KB

对应的分配和释放算法也有三类,我们可以通过下表来理解 Windows 的堆管理策略。

分 配释 放
小块分配要考虑的优先级:
快表–>普通空表–>堆缓存–>0号空表–>内存紧缩后再次尝试
若无法分配,则返回NULL
优先链入快表(只能链入4个空闲块);
如果快表满,则将其链入相应的空表.
大块优先级:
堆缓存–>0号空表
优先将其放入堆缓存
若堆缓存满,将链入0号空表
巨块巨块申请非常罕见,要用到虚分配方法(实际上并不是从堆区分配的)。暂不讨论。直接释放,没有堆表操作
堆块合并

经过反复的申请与释放操作,堆区很可能产生很多内存碎片。为了合理有效地利用内存,堆管理系统还要能够进行堆块合并操作。

当堆管理系统发现两个空闲堆块彼此相邻的时候,就会进行堆块合并操作。

内存紧缩

堆块合并过程:

  1. 将几个块从空闲链表中卸下
  2. 合并堆块
  3. 调整合并后大块的块首信息
  4. 将新块重新链入空闲链表

堆区还有一种操作叫做内存紧缩shrink the compact,由RtlCompactHeap执行,这个操作的效果与磁盘碎片整理差不多,会对整个堆进行调整,尽量合并可用的碎片。


小结

总结调一下 Windows 堆管理的几个要点:

  • 快表中的空闲块被设置为占用态,故不会发生堆块合并操作。
  • 快表只有精确匹配时才会分配,不存在“搜索次优解”和“找零钱”现象。
  • 快表是单链表,操作比双链表简单,插入删除都少用很多指令。
  • 综上所述,快表很“快”,故在分配和释放时总是优先使用快表,失败时才用空表。
  • 快表只有 4 项,很容易被填满,因此空表也是被频繁使用的。

Windows 的堆管理策略兼顾了内存合理使用、分配效率等多方面的因素。

5.2 在堆中漫游

5.2.1 堆分配函数之间的调用关系

5.2.1  Windows堆分配体系架构

Windows的堆分配函数可以在MSDN中找到详细说明。它们之间的关系如下图所示。

5.2.2 Windows堆分配 API 调用关系

所有的堆分配函数最终都将使用位于ntdll.dll中的RtlAllocateHeap()函数进行分配,这个函
数也是在用户态能够看到的最底层的堆分配函数。研究Windows堆只要研究这个函数即可。

5.2.2 堆的调试方法

想写出堆溢出exploit,需要对堆中的重要数据结构掌握到字节级别。

调试堆与调试栈不同,不能直接用调试器 Ollydbg、Windbg 来加载程序,否则堆管理函数会检测到当前进程处于调试状态,而使用调试态堆管理策略。

调试态堆管理策略:

  • 不使用快表,只用空表分配;
  • 所有堆块都被加上了多余的 16 字节尾部用来防止溢出,这包括 8 个字节的0xAB和 8 个字节的0x00
  • 块首的标志位不同。

调试态的堆和常态堆就好像 debug 版本的 PE 和 release 版本的 PE 一样。做堆溢出实验如果发现在调试器中能够正常执行 shellcode,但单独运行程序却发生错误,那很可能就是因为调试堆和常态堆之间的差异造成的。

为了避免程序检测出调试器而使用调试堆管理策略,我们可以在创建堆之后加入一个人工断点:_asm int 3,然后让程序单独执行。当程序把堆初始化完后,断点会中断程序,这时再用调试器 attach 进程,就能看到真实的堆了。

#include <windows.h>
main()
{
    HLOCAL h1, h2, h3, h4, h5, h6;
    HANDLE hp;
    hp = HeapCreate(0, 0x1000, 0x10000);
    __asm int 3

    h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 3);
    h2 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 5);
    h3 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 6);
    h4 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 8);
    h5 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 19);
    h6 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 24);

    //free block and prevent coaleses
    HeapFree(hp, 0, h1); //free to freelist[2]
    HeapFree(hp, 0, h3); //free to freelist[2]
    HeapFree(hp, 0, h5); //free to freelist[4]

    HeapFree(hp, 0, h4); //coalese h3,h4,h5,link the large block to
    //freelist[8]

    return 0;
} 

5.2.3 识别堆表

HeapCreate()成功地创建了堆区之后,会把整个堆区的起始地址返回给EAX,堆表中包含的信息依次是:

  1. 段表索引(Segment List)
  2. 虚表索引(Virtual Allocation list)
  3. 空表使用标识(freelist usage bitmap)
  4. 空表索引区。

5.2.7 在内存浏览器中查看堆块

当一个堆刚刚被初始化时,它的堆块状况是非常简单的:

  • 只有一个空闲态的大块,这个块被称做“尾块”。
  • 位于堆偏移0x0688处(启用快表后这个位置将是快表)
  • Freelist[0]指向“尾块”
  • 除零号空表索引外,其余各项索引都指向自己,这意味着其余所有的空闲链表中都没有空闲块。

先介绍一下堆块的块首中数据的含义。

占用态堆块的结构如下图

5.2.8   占用态堆块的数据结构

self size计算单位是 8 个字节,即0x0130表示堆块大小为0x980字节。

该大小包含块首在内,如果请求 32 字节,实际会分配的堆块为 40 字节。

按照堆表数据结构的规定,指向快表的指针位于偏移 0x584 字节处,在本章所有的实验中,这个指针均为 NULL

因为只有堆是可扩展的时候快表才会启用,要想启用快表我们在创建堆的时候就不能使用HeapCreate (0,0x1000,0x10000)来创建堆了,而要使用HeapCreate(0,0,0创建一个可扩展的堆。

空闲态堆块和占用态堆块的块首结构基本一致,只是数据区的前 8 个字节移到块首用于存放空表指针了,下面是我从原书修改过的图。

5.2.9   空闲态堆块的数据结构

5.2.4 堆块的分配

堆块的单位是 8 字节,不足 8 字节的部分按 8 字节分配。

初始状态下,快表和空表都为空,不存在精确分配。请求将使用“次优块”进行分配。这个“次优块”就是位于偏移 0x0688 处的尾块。

由于次优分配的发生,分配函数会陆续从尾块中切走一些小块,并修改尾块块首中的size信息,最后把freelist[0]指向新的尾块位置。

上面的测试代码内存请求分配情况如下。

堆句柄请求字节数实际分配(堆单位)实际分配(字节)
h13216
h25216
h36216
h48216
h519432
h624432

“找零钱”现象会使得尾块块首的size减小2+2+2+2+4+4=0x10,并且flink in freelist,即freelist[0],它的空表指针会指向新尾块的块身位置。

5.2.5 堆块的释放

由于前三次释放的堆块在内存中不连续,因此不会发生合并。按照其大小,h1h3所指向的堆块应该被链入freelist[2]的空表,h5则被链入freelist[4]

可以到flink in freelist查看freelist

5.2.6 堆块的合并

释放h4后,h3、h4、h5这 3 个空闲块彼此相邻,这时会发生按照步骤进行堆块合并操作。

它们的大小一共2+2+4=8,所以会将被链入freelist[8]

合并只修改了块首的数据,原块的块身基本没有发生变化。注意合并后的新块大小已经被修改为0x0008,其空表指针指向freelist[8]

freelist[2]现在只剩下h1freelist[4]现在指向自身;freelist[8]原来指向自身,现在则指向合并后的新空闲块。

5.2.7 快表的使用

#include <stdio.h> 
#include <windows.h> 
void main() 
{ 
  HLOCAL h1,h2,h3,h4; 
  HANDLE hp; 
  hp = HeapCreate(0,0,0); 	//extensible heap
  __asm int 3 
  h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 
  h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 
  h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16); 
  h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,24); 
  HeapFree(hp,0,h1); 
  HeapFree(hp,0,h2); 
  HeapFree(hp,0,h3); 
  HeapFree(hp,0,h4); 
  h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16); 
  HeapFree(hp,0,h2); 
} 

使用快表之后堆结构也会发生一些变化,其中最为主要的变化是空表使用标识的“尾块”不再位于堆0x0688偏移处了,这个位置被快表霸占。

堆刚初始化后快表是空的,这也是为什么代码中我们要反复的申请释放空间。首先我们从FreeList[0]中依次申请8、16、24 个字节的空间,然后再通过HeapFree()操作将其释放到快表中。

快表未满时优先释放到快表中

8字节的会被插入到Lookaside[1]中、16 字节的会被插入到Lookaside[2]中、24 字节的会被插入到Lokkaside[3]中。

快表中的堆块与空表中的堆块有着两个明显的区别:

  • 块首中的标识位为0x01,也就是这个堆块是 Busy 状态,这也是为什么快表中的堆块不进行合并操作的原因.
  • 块首只存指向下一堆块的指针,不存在指向前一堆块的指针.

此时如果我们再申请 8、16 或 24 字节大小空间时系统会从快表中给我们分配,同时修改Lookaside[i]表头。

5.3 堆溢出利用(上)——DWORD SHOOT

5.3.1 链表“拆卸”中的问题

堆管理系统的三类操作都是对链表的修改。

如果我们能伪造链表结点的指针,在“卸下”和“链入”堆块的过程中就有可能获得一次读写内存的机会。

堆溢出利用的精髓就是用精心构造的数据去溢出下一个堆块的块首,改写块首中的前向指针flink和后向指针blink,然后在分配、释放、合并等操作发生时伺机获得一次向内存任意地址写入任意数据的机会。

我们把这种能够向内存任意位置写入任意数据的机会称为DWORD SHOOTarbitrary DWORD reset

int remove (ListNode * node)      
{ 
    node -> blink -> flink = node -> flink; 
    node -> flink -> blink = node -> blink; 
    return 0; 
} 

当堆溢出发生时,非法数据可以淹没下一个堆块的块首。这时,块首是可以被攻击者控制的,即块首中存放的前向指针flink和后向指针blink是可以被攻击者伪造的。当这个堆块被从双向链表中“卸下”时,node -> blink -> flink = node -> flink将把伪造的flink指针值写入伪造的blink所指的地址中去,从而发生DWORD SHOOT

5.3.2  DWORD  SHOOT 发生的原理

5.3.2 在调试中体会“DWORD SHOOT”

#include <windows.h> 
main() 
{
    HLOCAL h1, h2,h3,h4,h5,h6; 
    HANDLE hp; 
    hp = HeapCreate(0,0x1000,0x10000); 
    h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 
    h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 
    h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 
    h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 
    h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 
    h6 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 
    _asm int 3//used to break the process 
    //free the odd blocks to prevent coalesing 
    HeapFree(hp,0,h1);  
    HeapFree(hp,0,h3);  
    HeapFree(hp,0,h5); //now freelist[2] got 3 entries 
    
    //will allocate from freelist[2] which means unlink the last entry  
    //(h5) 
    h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);  

    return 0; 
}

程序步骤:

  1. 程序首先创建了一个大小为0x1000的堆区,并从其中连续申请了 6 个大小为 8 字节的堆块(实际上是 16 字节),除了freelist[0]freelist[2]之外,所有的空表索引都为空。再次强调,初始状态下,快表和空表都为空。
  2. 释放奇数次申请的堆块,为了防止堆块合并的发生;
  3. 三次释放结束后,freelist[2]所标识的空表中应该链入了 3 个空闲堆块;
  4. 再次申请 8 字节的堆块,h5被从freelist[2]拆下;
  5. 如果我们直接在内存中修改h5块首中的指针,应该能够观察到DWORD SHOOT的发生。

h5前向指针改为0x44444444,后向指针改为0x00000000,当最后一个分配函数被调用后,调试器被异常中断,原因是无法将0x44444444写入0x00000000

5.3.5  空闲双向链表示意图

5.4 堆溢出利用(下)——代码植入

5.4.1 DWORD SHOOT 的利用方法

与栈溢出中的“地毯式轰炸”不同,堆溢出更加精准,往往直接狙击重要目标。精准是DWORD SHOOT的优点,但“火力不足”有时也会限制堆溢出的利用,这样就需要选择最重要的目标用来“狙击”。

DWORD SHOOT的常用目标(Windows XP SP1 之前的平台)大概可以概括为以下几类:

  • 内存变量:修改能够改变程序流程的重要标志变量
  • 代码逻辑:如分支处的判断逻辑
  • 函数返回地址
  • 攻击异常处理机制:包括
    • S.E.H(structure exception handler)
    • F.V.E.H(First Vectored Exception Handler)
    • 进程环境块(P.E.B)中的 U.E.F (Unhandled Exception Filter)
    • 线程环境块(T.E.B)中存放的第一个S.E.H 指针(T.E.H)
  • 函数指针
  • P.E.B中线程同步函数的入口地址:下一部分介绍

5.4.2 狙击 P.E.B 中 RtlEnterCritical-Section()的函数指针

从这里开始就有点懵了?

多线程有一些同步机制,如锁机制lock、信号量semaphore、临界区critical section等。ExitProcess()函数要做很多善后工作,其中必然需要用到临界区函数RtlEnterCriticalSection()RtlLeaveCriticalSection()来同步线程防止“脏数据”的产生。

每个进程的P.E.B中都存放着一对同步函数指针,指向RtlEnterCriticalSection()RtlLeaveCriticalSection(),并且在进程退出时会被ExitProcess()调用。如果能够通过DWORD SHOOT修改这对指针中的其中一个,那么在程序退出时ExitProcess()将会被骗去调用我们的shellcode

ExitProcess()调用临界区函数,是通过进程环境块P.E.B中偏移0x20处存放的函数指针来间接完成的。例如,0x7FFDF020存放指向 RtlEnterCriticalSection()的指针, 0x7FFDF024处存放着指向RtlLeaveCriticalSection()的指针。

从 Windows 2003 Server 开始,微软已经修改了这里的实现。

如果把0x7FFDF020修改为shellcode的位置,ExitProcess()在结束进程时需要调用临界区函数来同步线程,但却从 P.E.B中拿出了指向shellcode的指针,因此shellcode被执行。

缓冲区布置如下:

  1. 0x90覆盖该块
  2. 直接将8字节块首从内存中复制使用
  3. 前向指针使用 shellcode 的起始地址
  4. 后向指针是P.E.B中的函数指针地址0x7FFDF020

但是,被我们修改的P.E.B里的函数指针不光会被ExitProcess()调用,当shellcode的函数使用临界区时,会像 ExitProcess()一样被骗。所以,对shellcode稍加修改,在一开始就把我们DWORD SHOOT的指针用汇编指令对应的机器码修复回去,以防出错。

P.E.B 中存放RtlEnterCriticalSection()函数指针的位置0x7FFDF020是固定的,但是,RtlEnterCriticalSection()的地址也就是这个指针的值0x77F8AA4C有可能会因为补丁和操作系统而不一样.

5.4.3 堆溢出利用的注意事项

没有经验的初学者在做堆溢出实验时往往会被误导去研究调试态的堆。

调试堆与常态堆的区别很大

书中使用了int 3中断指令,在堆分配之后暂停程序,然后attach进程的方法。,但大多数时候我们是无法修改源码的。另一种办法是直接修改用于检测调试器的函数的返回值,这种方法在调试异常处理机制时会经常用到,第 6 章会举例介绍。

在 shellcode 中修复环境

在劫持进程后需要立刻修复P.E.B中的函数指针,否则会引起很多其他异常。一般说来,在大多数堆溢出中都需要做一些修复环境的工作。

shellcode中的第一条指令CDF也是用来修复环境的。如果您把这条指令去掉,会发现shellcode自身发生内存读写异常。这是因为在ExitProcess()调用时,这种特殊的上下文会把通常状态为 0 的 DF 标志位修改为 1。这会导致 shellcodeLODS DWORD PTR DS:[ESI]指令在向 EAX 装入第一个hash后将ESI减 4,而不是通常的加 4,从而在下一个函数名 hash读取时发生错误。


有时还需要修复被我们折腾得乱七八糟的堆区,比较简单修复堆区的做法包括如下步骤:

  1. 在堆区偏移0x28的地方存放着堆区所有空闲块的总和TotalFreeSize
  2. 把一个较大块(或干脆直接找个暂时不用的区域伪造一个块首)块首中标识自身大小的两个字节self size修改成堆区空闲块总容量的大小TotalFreeSize
  3. 把该块的 flag 位设置为 0x10(last entry 尾块)。
  4. freelist[0]的前向指针和后向指针都指向这个堆块。

定位 shellcode 的跳板

DWORD SHOOT 后的“指针反射”现象

双向链表拆除时的第二次链表操作node -> flink -> blink = node -> blink也能导致DWORD SHOOT。这次,DWORD SHOOT将把目标地址写回shellcode起始位置偏移 4 个字节的地方。

类似这样的第二次DWORD SHOOT称为“指针反射”。

糟糕的是,它会把4个字节的目标地址刚好写进shellcode中;幸运的是,很多情况下这4个字节不会影响shellcode的效果。

若这4个字节影响最终效果,可以使用别的目标,或者使用跳板技术

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值