C++逆向:选择结构的分析、识别与还原
编辑结构化程序设计的三大结构,分别是:顺序结构、选择结构和循环结构。这三种基本结构可以任意组合,形成多姿多彩的程序世界。在这其中的顺序结构是按照代码编写的顺序逐步执行;而选择结构打断了这种顺序,让程序支持了逻辑条件。因此,可以识别与还原选择结构对于C/C++逆向工程是非常重要的。本文只进行识别和还原if-else
结构,而switch-case
结构已经阐述完毕,本文不再进行赘述。实验环境采用的是VS2019
的MSVC
和VC6
的MSVC
进行编译,优化选项为速度最快优化。
...
条件转移的分类
条件转移指令大致上分为三类:
if-else
结构switch-case
结构- 三目运算
其中:switch-case
的优化策略较多,因此对于它在反汇编中的识别和还原另开辟了一篇文章进行了讨论与分析。本文注重分析if-else
分支结构的分析与还原以及对三目运算的简要分析,在分析时需要注意各种优化策略(如:传播、折叠、公共表达式等)优化策略对分析的影响。
各类选择结构的分析
if...else
单分支结构的分析
常量做判断条件
测试代码:
int main(int argc, char** argv) {
if (1) {
printf("constant\n");
}
return 0;
}
反汇编的结果:
.text:00401040 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401040 _main proc near ; CODE XREF: __scrt_common_main_seh(void)+F5↓p
.text:00401040
.text:00401040 argc = dword ptr 4
.text:00401040 argv = dword ptr 8
.text:00401040 envp = dword ptr 0Ch
.text:00401040
.text:00401040 push offset Format ; "constant\n"
.text:00401045 call sub_401010
.text:0040104A add esp, 4
.text:0040104D xor eax, eax
.text:0040104F retn
.text:0040104F _main endp
可以发现,没有生成任何的条件转移指令。为什么呢?因为编译器在编译过程中就可以确定执行的是哪一个分支,故编译器在编译阶段就将其优化掉了,不会产生任何的条件转移指令。对于这样的代码,在底层是还原不出来上述测试代码的,但是可以还原出其等价代码。
if
单分支结构
测试代码:
int main(int argc, char** argv) {
if (argc > 2) {
printf("argc > 2\n");
}
return 0;
}
反汇编的完整结果:
.text:00401040 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401040 _main proc near ; CODE XREF: __scrt_common_main_seh(void)+F5↓p
.text:00401040
.text:00401040 argc = dword ptr 8
.text:00401040 argv = dword ptr 0Ch
.text:00401040 envp = dword ptr 10h
.text:00401040
.text:00401040 push ebp ; ArgList
.text:00401041 mov ebp, esp
.text:00401043 cmp [ebp+argc], 2
.text:00401047 jle short IF_END
.text:00401049 push offset Format ; "argc > 2\n"
.text:0040104E call sub_401010
.text:00401053 add esp, 4
.text:00401056
.text:00401056 IF_END: ; CODE XREF: _main+7↑j
.text:00401056 xor eax, eax
.text:00401058 pop ebp
.text:00401059 retn
.text:00401059 _main endp
经过整理可以发现,其产生了一条条件转移指令jle short IF_END
。但是好像与我们源代码中写的条件argc > 2
是不同的,为什么是这样的?编译器生成的汇编代码还是正确的吗?答案是生成的汇编代码是正确的。原因是对于高级代码(如C语言的代码)而言,如果条件成立则执行条件体内部的代码;而对于汇编指令来说,如果条件成立则跳转成立,就会将执行流程转移了。所以在生成汇编代码的时候,需要对原条件进行取反,才能保证其逻辑上与高级代码的逻辑是一致的,即:条件成立,执行条件体内的代码。
根据上述特点,可以总结单分支转移结构的特点:
- 有一条
jcc
指令 jcc
指令后面会紧跟一个标号- 标号上面没有其他的跳转
- 这个跳转跳过一段代码
根据这些特点就可以将单分支结构给识别出来了,还原时,只需要将jcc
指令和标号中间的代码还原到条件体内部,而判断条件为jcc
指令条件取反即可还原。
if...else
结构
测试代码:
int main(int argc, char** argv) {
if (argc > 2) {
printf("argc > 2\n");
}
else {
printf("default\n");
}
return 0;
}
VS2019
的反汇编结果
.text:00401040 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401040 _main proc near ; CODE XREF: __scrt_common_main_seh+F5↓p
.text:00401040
.text:00401040 argc = dword ptr 8
.text:00401040 argv = dword ptr 0Ch
.text:00401040 envp = dword ptr 10h
.text:00401040
.text:00401040 push ebp
.text:00401041 mov ebp, esp
.text:00401043 cmp [ebp+argc], 2
.text:00401047 mov ecx, offset _Format ; "argc > 2\n"
.text:0040104C mov eax, offset aDefault ; "default\n"
.text:00401051 cmovg eax, ecx
.text:00401054 push eax ; _Format
.text:00401055 call _printf
.text:0040105A add esp, 4
.text:0040105D xor eax, eax
.text:0040105F pop ebp
.text:00401060 retn
.text:00401060 _main endp
可以发现没有使用jcc
指令,而是使用了新的指令cmovg
,它们是一系列的条件赋值指令,指令功能为:当条件成立时,则执行赋值操作。对于本例来说,其优化后的反汇编代码还原后为:
int __cdecl main(int argc, const char **argv, const char **envp) {
const char *str = "default\n";
if (argc > 2) {
str = "argc > 2\n";
}
printf(str);
return 0;
}
即:编译器首先假设是default
的情况,然后再进行判断其是否大于2,编译器这么做是与源代码等价的。
MSVC
的高版本编译器使用了条件赋值指令来实现了无分支的操作,那么在不支持条件赋值指令的CPU上是如何操作的呢?现在用VC6
的MSVC
进行编译测试一下(因为VC6
的那个年代,没有这条指令,所以可以观察VC6
下的编译结果),观察其反汇编结果:
.text:00401000 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401000 _main proc near ; CODE XREF: start+AFp
.text:00401000
.text:00401000 arg_0 = dword ptr 4
.text:00401000
.text:00401000 cmp [esp+arg_0], 2
.text:00401005 jle short ELSE_BEGIN
.text:00401005
.text:00401007 push offset s->Argc>2 ; "argc > 2\n"
.text:0040100C call printf
.text:0040100C
.text:00401011 add esp, 4
.text:00401014 xor eax, eax
.text:00401016 retn
.text:00401016
.text:00401017 ; ---------------------------------------------------------------------------
.text:00401017
.text:00401017 ELSE_BEGIN: ; CODE XREF: _main+5j
.text:00401017 push offset s->Default ; "default\n"
.text:0040101C call printf
.text:0040101C
.text:00401021 add esp, 4
.text:00401024
.text:00401024 ELSE_END:
.text:00401024 xor eax, eax
.text:00401026 retn
.text:00401026
.text:00401026 _main endp
可以发现,其还是使用的条件转移指令进行实现的。由于采用的是速度最快的优化,其优化方式如图所示:
所以没有使用无条件跳转转移指令来连接公共的代码块,而是利用公共表达式优化,将公共代码块复制一份,抵消了一次跳转,从而没有打断CPU的流水线优化,提高了运行速度。
if...else
的代码特征:
- 有多个
jcc
指令,每个jcc
指令后都有一个标号 - 标号之间为各个条件块内的代码
而对于还原方法,其还原的方法与if
单分支结构的还原方法一致。
if...else if
多分支结构的分析
if...else if
结构
测试代码:
int main(int argc, char** argv) {
if (argc > 2) {
printf("argc > 2\n");
}
else if (argc < 4) {
printf("argc < 4\n");
}
return 0;
}
VS2019
版本的完整的反汇编结果如下:
.text:00401040 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401040 _main proc near ; CODE XREF: __scrt_common_main_seh+F5↓p
.text:00401040
.text:00401040 argc = dword ptr 8
.text:00401040 argv = dword ptr 0Ch
.text:00401040 envp = dword ptr 10h
.text:00401040
.text:00401040 push ebp
.text:00401041 mov ebp, esp
.text:00401043 cmp [ebp+argc], 2
.text:00401047 mov ecx, offset _Format ; "argc > 2\n"
.text:0040104C mov eax, offset aArgc4 ; "argc < 4\n"
.text:00401051 cmovg eax, ecx
.text:00401054 push eax ; _Format
.text:00401055 call _printf
.text:0040105A add esp, 4
.text:0040105D xor eax, eax
.text:0040105F pop ebp
.text:00401060 retn
.text:00401060 _main endp
可以发现,其也是使用的comvg
来实现的无分支转移。对于低版本的编译器,如VC6
的MSVC
的情况,反汇编结果如下:
.text:00401000 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401000 _main proc near ; CODE XREF: start+AFp
.text:00401000
.text:00401000 arg_0 = dword ptr 4
.text:00401000
.text:00401000 mov eax, [esp+arg_0]
.text:00401004 cmp eax, 2
.text:00401007 jle short ELSE_IF
.text:00401007
.text:00401009 push offset s->Argc>2 ; "argc > 2\n"
.text:0040100E call printf
.text:0040100E
.text:00401013 add esp, 4
.text:00401016 xor eax, eax
.text:00401018 retn
.text:00401018
.text:00401019 ; ---------------------------------------------------------------------------
.text:00401019
.text:00401019 ELSE_IF: ; CODE XREF: _main+7j
.text:00401019 cmp eax, 4
.text:0040101C jge short EXIT_PROGRAM
.text:0040101C
.text:0040101E push offset s->Argc<4 ; "argc < 4\n"
.text:00401023 call printf
.text:00401023
.text:00401028 add esp, 4
.text:00401028
.text:0040102B
.text:0040102B EXIT_PROGRAM: ; CODE XREF: _main+1Cj
.text:0040102B xor eax, eax
.text:0040102D retn
.text:0040102D
.text:0040102D _main endp
使用的还是公共代码块提取出来,以减少一次跳转,提高运行速度。
if...else if...else
结构
测试代码:
int main(int argc, char** argv) {
if (argc > 2) {
printf("argc > 2\n");
}
else if (argc < 0) {
printf("argc < 0\n");
}
else {
printf("argc >= 0 and argc <= 2\n");
}
return 0;
}
VS2019
的MSVC
的反汇编结果:
.text:00401040 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401040 _main proc near ; CODE XREF: __scrt_common_main_seh+F5↓p
.text:00401040
.text:00401040 argc = dword ptr 8
.text:00401040 argv = dword ptr 0Ch
.text:00401040 envp = dword ptr 10h
.text:00401040
.text:00401040 push ebp
.text:00401041 mov ebp, esp
.text:00401043 mov ecx, [ebp+argc]
.text:00401046 cmp ecx, 2
.text:00401049 jle short ELSE_IF
.text:0040104B mov eax, offset _Format ; "argc > 2\n"
.text:00401050 push eax ; _Format
.text:00401051 call _printf
.text:00401056 add esp, 4
.text:00401059 xor eax, eax
.text:0040105B pop ebp
.text:0040105C retn
.text:0040105D ; ---------------------------------------------------------------------------
.text:0040105D
.text:0040105D ELSE_IF: ; CODE XREF: _main+9↑j
.text:0040105D test ecx, ecx
.text:0040105F mov edx, offset aArgc0AndArgc2 ; "argc >= 0 and argc <= 2\n"
.text:00401064 mov eax, offset aArgc0 ; "argc < 0\n"
.text:00401069 cmovns eax, edx
.text:0040106C push eax ; _Format
.text:0040106D call _printf
.text:00401072 add esp, 4
.text:00401075 xor eax, eax
.text:00401077 pop ebp
.text:00401078 retn
.text:00401078 _main endp
.text:00401078
其首先判断是否是大于2的,如果不是再使用条件赋值语句来减少一次跳转。对于其高版本的还原是比较好做等价代码的还原的。对于低版本的MSVC
的反汇编结果如下:
.text:00401000 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401000 _main proc near ; CODE XREF: start+AFp
.text:00401000
.text:00401000 arg_0 = dword ptr 4
.text:00401000
.text:00401000 mov eax, [esp+arg_0]
.text:00401004 cmp eax, 2
.text:00401007 jle short ELSE_IF
.text:00401007
.text:00401009 push offset s->Argc>2 ; "argc > 2\n"
.text:0040100E call printf
.text:0040100E
.text:00401013 add esp, 4
.text:00401016 xor eax, eax
.text:00401018 retn
.text:00401018
.text:00401019 ; ---------------------------------------------------------------------------
.text:00401019
.text:00401019 ELSE_IF: ; CODE XREF: _main+7j
.text:00401019 test eax, eax
.text:0040101B jge short ELSE
.text:0040101B
.text:0040101D push offset s->Argc<0 ; "argc < 0\n"
.text:00401022 call printf
.text:00401022
.text:00401027 add esp, 4
.text:0040102A xor eax, eax
.text:0040102C retn
.text:0040102C
.text:0040102D ; ---------------------------------------------------------------------------
.text:0040102D
.text:0040102D ELSE: ; CODE XREF: _main+1Bj
.text:0040102D push offset s->Argc>0AndArgc<2 ; "argc >= 0 and argc <= 2\n"
.text:00401032 call printf
.text:00401032
.text:00401037 add esp, 4
.text:0040103A xor eax, eax
.text:0040103C retn
.text:0040103C
.text:0040103C _main endp
使用的是公共代码优化和jcc
指令来实现的多分支结果,其还原方法还是对jcc
的条件取反再进行还原即可。
三目运算的分析
测试代码:
int main(int argc, char** argv) {
printf("%d\n", argc > 2 ? argc - 1 : argc + 10);
return 0;
}
VS2019
的MSVC
的反汇编结果:
.text:00401040 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401040 _main proc near ; CODE XREF: __scrt_common_main_seh+F5↓p
.text:00401040
.text:00401040 argc = dword ptr 8
.text:00401040 argv = dword ptr 0Ch
.text:00401040 envp = dword ptr 10h
.text:00401040
.text:00401040 push ebp
.text:00401041 mov ebp, esp
.text:00401043 or ecx, 0FFFFFFFFh
.text:00401046 mov edx, 0Ah
.text:0040104B cmp [ebp+argc], 2
.text:0040104F cmovle ecx, edx
.text:00401052 add ecx, [ebp+argc]
.text:00401055 push ecx
.text:00401056 push offset _Format ; "%d\n"
.text:0040105B call _printf
.text:00401060 add esp, 8
.text:00401063 xor eax, eax
.text:00401065 pop ebp
.text:00401066 retn
.text:00401066 _main endp
使用的还是条件赋值指令,提高运行速度。对于其还原只需要关注条件赋值指令,它本质上的逻辑还是先假设一个条件预先成立,然后再判断其余剩下的条件,根据条件成立与否来进行值的替换。
补充:对于复杂条件的分支结构的识别与还原
在写代码的过程中,最常见的就是经过逻辑与和逻辑或来进行条件组合。那么对于这些复杂的条件如何识别和还原呢?先对与条件、或条件的单独情况进行观察分析,然后再对复杂的组合条件来进行分析。
与条件
测试代码:
int main(int argc, char** argv) {
if (argv != nullptr && argc > 2) {
printf("argv != nullptr && argc > 2\n");
}
return 0;
}
VS2019
的MSVC
下的反汇编结果:
.text:00401040 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401040 _main proc near ; CODE XREF: __scrt_common_main_seh+F5↓p
.text:00401040
.text:00401040 argc = dword ptr 8
.text:00401040 argv = dword ptr 0Ch
.text:00401040 envp = dword ptr 10h
.text:00401040
.text:00401040 push ebp
.text:00401041 mov ebp, esp
.text:00401043 cmp [ebp+argv], 0
.text:00401047 jz short EXIT_PROGRAM
.text:00401049 cmp [ebp+argc], 2
.text:0040104D jle short EXIT_PROGRAM
.text:0040104F push offset _Format ; "argv != nullptr && argc > 2\n"
.text:00401054 call _printf
.text:00401059 add esp, 4
.text:0040105C
.text:0040105C EXIT_PROGRAM: ; CODE XREF: _main+7↑j
.text:0040105C ; _main+D↑j
.text:0040105C xor eax, eax
.text:0040105E pop ebp
.text:0040105F retn
.text:0040105F _main endp
可以观察到,对于多个jcc
指令后面的标号都有一个统一的标号。这个特点符合逻辑运算的性质,即:对于用与运算连接的逻辑表达式而言,如果一个条件为假,则整个表达式为假。因此jcc
后面的标号是退出条件体的标号,而退出标号和最后一条jcc
指令之间的代码则是条件体内的代码。
还原方法则是:
- 将
jcc
的各个条件取反 - 将各个条件使用
&&
进行组合 - 还原条件体内的代码即可
或条件
测试代码:
int main(int argc, char** argv) {
if (argv != nullptr || argc > 2) {
printf("argv != nullptr || argc > 2\n");
}
return 0;
}
VS2019
的MSVC
下的反汇编结果:
.text:00401040 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401040 _main proc near ; CODE XREF: __scrt_common_main_seh+F5↓p
.text:00401040
.text:00401040 argc = dword ptr 8
.text:00401040 argv = dword ptr 0Ch
.text:00401040 envp = dword ptr 10h
.text:00401040
.text:00401040 push ebp
.text:00401041 mov ebp, esp
.text:00401043
.text:00401043 IF_BEGIN:
.text:00401043 cmp [ebp+argv], 0
.text:00401047 jnz short IF_BODY
.text:00401049 cmp [ebp+argc], 2
.text:0040104D jle short EXIT_PROGRAM
.text:0040104F
.text:0040104F IF_BODY: ; CODE XREF: _main+7↑j
.text:0040104F push offset _Format ; "argv != nullptr || argc > 2\n"
.text:00401054 call _printf
.text:00401059 add esp, 4
.text:0040105C
.text:0040105C EXIT_PROGRAM: ; CODE XREF: _main+D↑j
.text:0040105C xor eax, eax
.text:0040105E pop ebp
.text:0040105F retn
.text:0040105F _main endp
可以发现,其代码特征是:
- 除了最后一个
jcc
外,后面的标号都是指向同一个条件体内的代码块的起始位置 - 最后一个
jcc
后的标号为退出条件体的代码块的起始位置
上述代码特征符合或运算的逻辑表达式性质:对于用或连接的逻辑表达式而言,如果一个条件成立,整个表达式为真。
还原时,只需要:
- 除了最后一个
jcc
的条件需要取反以外,其余的jcc
条件都不取反 - 使用或运算连接各个条件
- 还原条件体内的代码
与运算和或运算的组合
对于组合的情况,其会将各种优化手段用上,我们需要做的就是去掉这些优化,来还原代码。
测试代码:
int main(int argc, char **argv) {
if (argv != nullptr && argc != 1) {
printf("%d\n", argc + 1);
}
else if (argc == 2 || argc == *(int*)(*argv)) {
printf("argv is not nullptr and argc == argv");
}
else {
printf("%p\n", argv);
}
return 0;
}
VS2019
的MSVC
下的反汇编结果:
.text:00401040 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401040 _main proc near ; CODE XREF: __scrt_common_main_seh+F5↓p
.text:00401040
.text:00401040 argc = dword ptr 8
.text:00401040 argv = dword ptr 0Ch
.text:00401040 envp = dword ptr 10h
.text:00401040
.text:00401040 push ebp
.text:00401041 mov ebp, esp
.text:00401043 mov edx, [ebp+argv]
.text:00401046 mov ecx, [ebp+argc]
.text:00401049 test edx, edx
.text:0040104B jz short loc_401067
.text:0040104D cmp ecx, 1
.text:00401050 jz short loc_40106C
.text:00401052 lea eax, [ecx+1]
.text:00401055 push eax
.text:00401056 push offset _Format ; "%d\n"
.text:0040105B call _printf
.text:00401060 add esp, 8
.text:00401063 xor eax, eax
.text:00401065 pop ebp
.text:00401066 retn
.text:00401067 ; ---------------------------------------------------------------------------
.text:00401067
.text:00401067 loc_401067: ; CODE XREF: _main+B↑j
.text:00401067 cmp ecx, 2
.text:0040106A jz short loc_401084
.text:0040106C
.text:0040106C loc_40106C: ; CODE XREF: _main+10↑j
.text:0040106C mov eax, [edx]
.text:0040106E cmp ecx, [eax]
.text:00401070 jz short loc_401084
.text:00401072 push edx
.text:00401073 push offset aP ; "%p\n"
.text:00401078 call _printf
.text:0040107D add esp, 8
.text:00401080 xor eax, eax
.text:00401082 pop ebp
.text:00401083 retn
.text:00401084 ; ---------------------------------------------------------------------------
.text:00401084
.text:00401084 loc_401084: ; CODE XREF: _main+2A↑j
.text:00401084 ; _main+30↑j
.text:00401084 push offset aArgvIsNotNullp ; "argv is not nullptr and argc == argv"
.text:00401089 call _printf
.text:0040108E add esp, 4
.text:00401091 xor eax, eax
.text:00401093 pop ebp
.text:00401094 retn
.text:00401094 _main endp
可以观察到上面的反汇编结果好像与上面分析的没有几个是一样的,怎么办呢?一点点的看。首先观察第一个部分:
.text:00401043 mov edx, [ebp+argv]
.text:00401046 mov ecx, [ebp+argc]
.text:00401049 test edx, edx
.text:0040104B jz short loc_401067
.text:0040104D cmp ecx, 1
.text:00401050 jz short loc_40106C
.text:00401052 lea eax, [ecx+1]
.text:00401055 push eax
.text:00401056 push offset _Format ; "%d\n"
.text:0040105B call _printf
.text:00401060 add esp, 8
.text:00401063 xor eax, eax
.text:00401065 pop ebp
.text:00401066 retn
可以发现其既不像与运算连接的条件(没有统一的退出标号)也不像或运算连接的条件(没有统一的入口标号),所以需要模拟执行。模拟执行后可以发现test edx, edx
和cmp ecx, 1
是与条件,因为只有他们两个都不成立的情况下,才能执行条件体内的代码。继续向下查看:
.text:00401067 loc_401067: ; test edx, edx jz short loc_401067 edx == argv
.text:00401067 cmp ecx, 2
.text:0040106A jz short ELSE_BEGIN
.text:0040106C
.text:0040106C loc_40106C: ; cmp ecx, 1 jz short loc_40106C ecx == argc
.text:0040106C mov eax, [edx]
.text:0040106E cmp ecx, [eax]
.text:00401070 jz short ELSE_BEGIN
.text:00401072 push edx
.text:00401073 push offset aP ; "%p\n"
.text:00401078 call _printf
.text:0040107D add esp, 8
.text:00401080 xor eax, eax
.text:00401082 pop ebp
.text:00401083 retn
.text:00401084 ; ---------------------------------------------------------------------------
.text:00401084
.text:00401084 ELSE_BEGIN: ; CODE XREF: _main+2A↑j
.text:00401084 ; _main+30↑j
.text:00401084 push offset aArgvIsNotNullp ; "argv is not nullptr and argc == argv"
.text:00401089 call _printf
.text:0040108E add esp, 4
.text:00401091 xor eax, eax
.text:00401093 pop ebp
.text:00401094 retn
这块代码块好像与条件的特征,但是它真的是吗?如果是与条件相连接的话,它们就应该是一个整体,条件判断的入口应该都是标号loc_401067
的位置。虽然它们都有统一的退出出口,但是进入这个条件的入口却不是相同的,因此这两个条件判断不是与运算连接的。那么它们是什么呢?需要模拟执行一下。当两个条件各自成立时,发现有共同的入口,符合用或条件连接的代码特征,且与上面的判断条件有多个入口逻辑上可以解释的通顺,则可以判定其为或条件连接的代码特征。
综上,可以还原出代码:
int __cdecl main(int argc, const char **argv, const char **envp) {
if (argv != nullptr && argc != 1) {
printf("%d\n", argc + 1);
return 0;
}
if (argc == 2 || argc == *(int*)argv) {
printf("argv is not nullptr and argc == argv");
return 0;
}
printf("%p\n", argv);
return 0;
}
分析还原出来的代码和源代码进行比较,发现其在逻辑上是等价的,故还原的没有问题。
- 0
-
分享