我的知识记录

digSelf

C++逆向:循环结构的分析、识别与还原

2021-09-01
C++逆向:循环结构的分析、识别与还原

循环结构是程序设计的三大结构之一,搞清楚它在反汇编下的代码特征对于逆向工程来说是非常重要的。对于C/C++来说,循环结构常见的有三种类型:forwhiledo...while。本文会分析这三种情况分别在VS2019MSVCVC6MSVC采用速度最快的优化选项后的表现形式及其还原方法,当VC6中的反汇编结果与VS2019中的一致时,则不再赘述。

...

各种循环语句的分析

do...while循环的分析

示例代码:

int main(int argc, char **argv)
{
    int nSum = 0;
    int i = 1;
    do 
    {
        nSum = nSum + i;
        i++;
    } while (i <= 100);
    
    printf("%d\r\n", nSum);
	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+F5↓p
.text:00401040
.text:00401040 argc            = dword ptr  8
.text:00401040 argv            = dword ptr  0Ch
.text:00401040
.text:00401040                 xor     ecx, ecx
.text:00401042                 mov     eax, 1
.text:00401047
.text:00401047 LOOP_BEGIN:                             ; CODE XREF: _main+D↓j
.text:00401047                 add     ecx, eax
.text:00401049                 inc     eax
.text:0040104A                 cmp     eax, 64h ; 'd'
.text:0040104D                 jle     short LOOP_BEGIN
.text:0040104F                 push    ecx
.text:00401050                 push    offset _Format  ; "%d\r\n"
.text:00401055                 call    _printf
.text:0040105A                 add     esp, 8
.text:0040105D                 xor     eax, eax
.text:0040105F                 retn
.text:0040105F _main           endp

观察其循环部分的特征:

.text:00401047 LOOP_BEGIN:                             ; CODE XREF: _main+D↓j
.text:00401047                 add     ecx, eax
.text:00401049                 inc     eax
.text:0040104A                 cmp     eax, 64h ; 'd'
.text:0040104D                 jle     short LOOP_BEGIN

可以发现,其逻辑上是当eax <= 100时就会继续执行循环体内部的代码,其逻辑与源代码的逻辑是一致的,无需对jcc的条件进行取反。

对永真条件的测试代码:

int main(int argc, char **argv)
{
	do 
    {
		printf("always true\n");
	} while (true);
	return 0;
}

反汇编结果如下:

.text:00401060 loc_401060:                             ; CODE XREF: _main+2D↓j
.text:00401060                 push    offset aAlwaysTrue ; "always true\n"
.text:00401065                 call    _printf
.text:0040106A                 add     esp, 4
.text:0040106D                 jmp     short loc_401060

可以总结do...while代码特征:只有一个跳转,且当比较后,条件跳转往上走的就是do...while循环结构。如果不是条件跳转,而是无条件跳转jmp的话,则表明是永真的循环。

还原时,只需要找到do...while循环结构的边界:

  • jcc跳转所在地址处是do...while循环的下界,即:高级代码的右花括号}的位置。
  • jcc跳转后面接的地址是do...while循环的上界,即:高级代码的左花括号{的位置

找到这两个位置后,将循环条件取正作为do...while的循环条件;将标号和jcc跳转所包裹的代码作为do...while循环体内部的代码进行还原即可。

while循环的分析

对于while的分析,需要注意,此时需要临时关闭优化选项,否则编译器会将下述示例代码优化为同do...while相同的结果。而对于优化为和do...while相同结果的,则不再赘述。

while的代码实例:

int main(int argc, char **argv)
{
    int nSum = 0;
    int i = 1;
    while (i <= 100)
    {
        nSum = nSum + i;
        i++;
    };
    
    printf("%d\r\n", nSum);
	return 0;   
}

反汇编结果:

.text:00401080 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401080 _main           proc near               ; CODE XREF: __scrt_common_main_seh+F5↓p
.text:00401080
.text:00401080 nSum            = dword ptr -8
.text:00401080 i               = dword ptr -4
.text:00401080 argc            = dword ptr  8
.text:00401080 argv            = dword ptr  0Ch
.text:00401080 envp            = dword ptr  10h
.text:00401080
.text:00401080                 push    ebp
.text:00401081                 mov     ebp, esp
.text:00401083                 sub     esp, 8
.text:00401086                 mov     [ebp+nSum], 0
.text:0040108D                 mov     [ebp+i], 1
.text:00401094
.text:00401094 LOOP_BEGIN:                             ; CODE XREF: _main+2C↓j
.text:00401094                 cmp     [ebp+i], 64h ; 'd'
.text:00401098                 jg      short LOOP_END
.text:0040109A                 mov     eax, [ebp+nSum]
.text:0040109D                 add     eax, [ebp+i]
.text:004010A0                 mov     [ebp+nSum], eax
.text:004010A3                 mov     ecx, [ebp+i]
.text:004010A6                 add     ecx, 1
.text:004010A9                 mov     [ebp+i], ecx
.text:004010AC                 jmp     short LOOP_BEGIN
.text:004010AE ; ---------------------------------------------------------------------------
.text:004010AE
.text:004010AE LOOP_END:                               ; CODE XREF: _main+18↑j
.text:004010AE                 mov     edx, [ebp+nSum]
.text:004010B1                 push    edx
.text:004010B2                 push    offset _Format  ; "%d\r\n"
.text:004010B7                 call    _printf
.text:004010BC                 add     esp, 8
.text:004010BF                 xor     eax, eax
.text:004010C1                 mov     esp, ebp
.text:004010C3                 pop     ebp
.text:004010C4                 retn
.text:004010C4 _main           endp

可以总结while循环结构的代码特征:

  • 有两处跳转,一处为条件跳转,另一处为绝对跳转。
  • 条件跳转为下跳,绝对跳转为上跳
  • 条件跳转处的条件为源代码中的循环条件的相反条件

上述特征符合高级语法while的逻辑和汇编代码自身的逻辑。而在还原时,只需要找到其边界:

  • jcc条件跳转为下跳,它是代码的上界,即:高级代码的左花括号{的位置
  • jmp无条件跳转为上跳,它是代码的下界,即:高级代码的右花括号}的位置

还原时,只需要找到这两个边界后,将jcc条件取反后作为循环条件,两个边界中间的汇编代码还原为相对应的高级代码即可。

for循环的分析

在对for循环的分析时,也需要暂时关闭优化选项,理由同while的分析。

示例代码:

int main(int argc, char **argv)
{
    int nSum = 0;
    int i = 1;
    for (i = 1; i <= 100; ++i)
    {
		nSum = nSum + i;
    }
    
    printf("%d\r\n", nSum);
	return 0;   
}

反汇编结果:

.text:00401080 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401080 _main           proc near               ; CODE XREF: __scrt_common_main_seh+F5↓p
.text:00401080
.text:00401080 nSum            = dword ptr -8
.text:00401080 i               = dword ptr -4
.text:00401080 argc            = dword ptr  8
.text:00401080 argv            = dword ptr  0Ch
.text:00401080 envp            = dword ptr  10h
.text:00401080
.text:00401080                 push    ebp
.text:00401081                 mov     ebp, esp
.text:00401083                 sub     esp, 8
.text:00401086                 mov     [ebp+nSum], 0
.text:0040108D
.text:0040108D LOOP_BEGIN:
.text:0040108D                 mov     [ebp+i], 1
.text:00401094                 jmp     short LOOP_CMP
.text:00401096 ; ---------------------------------------------------------------------------
.text:00401096
.text:00401096 LOOP_STEP:                              ; CODE XREF: _main+2E↓j
.text:00401096                 mov     eax, [ebp+i]
.text:00401099                 add     eax, 1
.text:0040109C                 mov     [ebp+i], eax
.text:0040109F
.text:0040109F LOOP_CMP:                               ; CODE XREF: _main+14↑j
.text:0040109F                 cmp     [ebp+i], 64h ; 'd'
.text:004010A3                 jg      short LOOP_END
.text:004010A5                 mov     ecx, [ebp+nSum]
.text:004010A8                 add     ecx, [ebp+i]
.text:004010AB                 mov     [ebp+nSum], ecx
.text:004010AE                 jmp     short LOOP_STEP
.text:004010B0 ; ---------------------------------------------------------------------------
.text:004010B0
.text:004010B0 LOOP_END:                               ; CODE XREF: _main+23↑j
.text:004010B0                 mov     edx, [ebp+nSum]
.text:004010B3                 push    edx
.text:004010B4                 push    offset _Format  ; "%d\r\n"
.text:004010B9                 call    _printf
.text:004010BE                 add     esp, 8
.text:004010C1                 xor     eax, eax
.text:004010C3                 mov     esp, ebp
.text:004010C5                 pop     ebp
.text:004010C6                 retn
.text:004010C6 _main           endp

可以观察到其有三个跳转,因此它的速度是会比whiledo...while循环要慢的,因为它打断了流水线优化。还可以观察到,其明显的具有三个部分:

  • LOOP_INIT部分
  • LOOP_CMP部分
  • LOOP_STEP部分

由于要符合高级语言for的逻辑要求:

  • 初始化部分只执行一次;
  • 在执行循环体之前需要先判断一下循环条件;
  • 在执行完循环体后再执行步长部分。

故可以总结代码特征为:

  • 有三处跳转,一处条件跳转,两处绝对跳转。
  • 条件跳转为下跳,跳转到循环体外部
  • 第一处绝对跳转为下跳,跳转到for循环的比较部分
  • 第二处绝对跳转为上跳,跳转到for循环的步长部分。

掌握了for循环的代码特征后,还原也就简单了,只需要分别找到对应的几个部分进行还原即可。

关键字breakcontinue的分析

对于这两个部分的分析,我是打开O2了的优化。

循环体内包含break关键字

示例代码:

int main(int argc, char** argv)
{
	for (int i = 1; i <= 100; ++i)
	{
		if (i == argc)
		{
			break;
		}
		printf("%d\r\n", i);
	}
	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+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                 push    esi
.text:00401044                 push    edi
.text:00401045                 mov     edi, [ebp+argc]
.text:00401048                 mov     esi, 1
.text:0040104D                 nop     dword ptr [eax]
.text:00401050
.text:00401050 LOOP_BEGIN:                             ; CODE XREF: _main+26↓j
.text:00401050                 cmp     esi, edi
.text:00401052                 jz      short LOOP_END
.text:00401054                 push    esi
.text:00401055                 push    offset _Format  ; "%d\r\n"
.text:0040105A                 call    _printf
.text:0040105F                 inc     esi
.text:00401060                 add     esp, 8
.text:00401063                 cmp     esi, 64h ; 'd'
.text:00401066                 jle     short LOOP_BEGIN
.text:00401068
.text:00401068 LOOP_END:                               ; CODE XREF: _main+12↑j
.text:00401068                 pop     edi
.text:00401069                 xor     eax, eax
.text:0040106B                 pop     esi
.text:0040106C                 pop     ebp
.text:0040106D                 retn
.text:0040106D _main           endp

可以发现,其已经将for循环,优化成了do...while的循环了。而在这个循环体内部,还有一个条件跳转:

.text:00401050                 cmp     esi, edi
.text:00401052                 jz      short LOOP_END

经过分析后,可以得出,其是跳转到循环体外部的,也就是符合了break关键字的逻辑要求:结束循环,转到执行循环体外部的代码

在还原时,可以根据出现在循环体内部的情况,来具体根据关键字的语义来还原。

循环体内包含continue关键字

示例代码:

int main(int argc, char** argv)
{
	for (int i = 1; i <= 100; ++i)
	{
		if (i == argc)
		{
			continue;
		}
		printf("%d\r\n", i) :
	}
	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+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                 push    esi
.text:00401044                 push    edi
.text:00401045                 mov     edi, [ebp+argc]
.text:00401048                 mov     esi, 1
.text:0040104D                 nop     dword ptr [eax]
.text:00401050
.text:00401050 LOOP_BEGIN:                             ; CODE XREF: _main+26↓j
.text:00401050                 cmp     esi, edi
.text:00401052                 jz      short LOOP_STEP
.text:00401054                 push    esi
.text:00401055                 push    offset _Format  ; "%d\r\n"
.text:0040105A                 call    _printf
.text:0040105F                 add     esp, 8
.text:00401062
.text:00401062 LOOP_STEP:                              ; CODE XREF: _main+12↑j
.text:00401062                 inc     esi
.text:00401063
.text:00401063 LOOP_CMP:
.text:00401063                 cmp     esi, 64h ; 'd'
.text:00401066                 jle     short LOOP_BEGIN
.text:00401068
.text:00401068 LOOP_END:
.text:00401068                 pop     edi
.text:00401069                 xor     eax, eax
.text:0040106B                 pop     esi
.text:0040106C                 pop     ebp
.text:0040106D                 retn
.text:0040106D _main           endp

可以发现,其也是将其优化成了do...while了。再进一步分析,可以得出一个类似于满足for循环的语义的几个部分。在还原时可以按照自己的喜好和代码可读性,还原为任意一种循环即可。

在分析上述反汇编时,可以看到一处条件跳转:

.text:00401050 LOOP_BEGIN:                             ; CODE XREF: _main+26↓j
.text:00401050                 cmp     esi, edi
.text:00401052                 jz      short LOOP_STEP

其就是关键字continue的语义:提前结束本次循环,进入下一次循环。

循环体内包含break关键字造成的特殊优化现象

示例代码:

int main(int argc, char** argv)
{
	for (int i = 1; i <= 100; ++i)
	{
		printf("%d", i);
		break;
	}
	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+F5↓p
.text:00401040
.text:00401040 argc            = dword ptr  8
.text:00401040 argv            = dword ptr  0Ch
.text:00401040
.text:00401040                 push    1
.text:00401042                 push    offset _Format  ; "%d"
.text:00401047                 call    _printf
.text:0040104C                 add     esp, 8
.text:0040104F                 xor     eax, eax
.text:00401051                 retn
.text:00401051 _main           endp

可以发现,编译器并没有生成循环代码。因为编译器在编译阶段判断出了这个循环只需要执行一次,故可以将循环体内部的代码外提,然后省略掉不必要的循环。

关键字continuebreak的还原方法总结

根据上述分析,如果一个条件跳转跳转到了循环体的外部,则它就是break;如果一个条件跳转跳转到了循环体的循环起始位置,则它就是continue

总的优化原则

对于循环结构来说,最好是能尽量不打断流水线优化。所以常见的对于循环优化的方法有:

  • 强度削弱,用指令周期少指令代替指令周期多的指令

  • 减少分支,能优化成do...while就优化成do...while的形式

  • 代码外提

对于强度削弱不需要解释,那么为什么要能优化成do...while的循环就优化成do...while的形式呢?由于do...while的特征是先执行一次循环体内的代码后,再进行判断,它与汇编上条件跳转成立的条件逻辑上是一致的,且根据上面分析,它只有一处向上的跳转,能够减少分支,这样就可以尽量少的影响到流水线优化,提高程序的执行效率。

而对于代码外提其实是有条件的,对于循环条件的检查部分,不能是循环体内被修改的值,只有满足这个条件的时候才会被外提。即:当比较的是固定值时,编译器就会将其自动代码外提;但是当它是一个黑盒的时候,就不能做到自动的代码外提了。

  • 0