在逆向分析中,switch
语句会被编译器转化为不同的底层实现方式,这取决于编译器优化和具体的场景。常见的实现方式包括以下几种:
①顺序判断(if-else链):
编译器将switch
语句转化为一系列的if-else
语句。这种方式适用于case
值较少的情况。
反汇编代码表现为一系列的比较和条件跳转指令(如CMP和JNE/JE)。 每个case的值会与目标变量进行比较,匹配时跳转到对应的代码块执行。
②跳转表(jump table):
当switch
语句中的case
值是连续或接近连续的整数时,编译器可能会生成一个跳转表。跳转表是一个包含多个指针的数组,根据目标变量的值选择跳转到不同的代码块。
反汇编时表现为读取跳转表并跳转,例如使用JMP指令配合计算偏移。 通常会看到LEA(加载有效地址)指令和JMP指令配合使用,或者使用INDIRECT JMP,根据变量值计算偏移量并跳转。
③二分查找(binary search)
当case
值的范围较大且稀疏时,编译器可能会生成二分查找结构。编译器会按case
值排序,并生成类似于二分查找树的结构来进行高效的判断。
反汇编时表现为多个比较和有条件的跳转,通常是递归式的分支跳转。
假设有如下C代码:
switch(x) {
case 1:
do_case1();
break;
case 2:
do_case2();
break;
default:
do_default();
break;
}
其汇编代码可能会有以下几种表现形式:
①顺序判断:
CMP EAX, 1
JE case1
CMP EAX, 2
JE case2
JMP default
②跳转表:
MOV EAX, [EBP+var_x] ; 获取x的值
CMP EAX, 2 ; 检查x的范围
JA default
JMP [jump_table + EAX*4] ; 通过x的值在跳转表中选择跳转地址
jump_table
中保存了各个case
的代码地址。
③二分查找:
CMP EAX, 2
JE case2
CMP EAX, 1
JE case1
JMP default
这种实现常见于case
值不连续且较大的情况。在该文中说的三种表现方式中二分查找和顺序判断这两种方式相对应容易理解,接下去的内容就蜻蜓点水大概过一下,重点会将跳转表的表现形式做说明。
逆向分析示例
假设此时有一个简单的switch
代码如下:
#include <stdio.h>
#include <stdlib.h>
int main() {
int nFlag = 0;
scanf("%d", &nFlag);
switch (nFlag)
{
case 10:
printf("nFlag = 10");
break;
case 11:
printf("nFlag = 11");
break;
case 12:
printf("nFlag = 12");
break;
case 254:
printf("nFlag = 254");
break;
case 255:
printf("nFlag = 255");
break;
case 256:ewZ3ews
printf("nFlag = 256");
break;
case 454:
printf("nFlag = 454");
break;
case 455:
printf("nFlag = 455");
break;
case 456:
printf("nFlag = 456");
break;
default:
break;
}
system("pause");
return 0;
}
此时使用Visual Studio
对该代码进行编译,生成exe
文件,对应的编译配置为Debug-x86
;
Debug-x86程序分析
静态分析:
将生成的程序载入IDA中进行静态分析
在Function Window
中定位到main
函数:
(关于定位main函数的各种方法请查看前面的文章),接下去开始逐步对进行代码分析:
mov [ebp+var_C], 0
lea eax, [ebp+var_C]
push eax
push offset Format ; "%d"
call j__scanf
add esp, 8
上述这个汇编代码片段的作用是调用scanf
函数从用户那里读取一个整数,并将其存储在局部变量中。我们逐步分析这个代码:
mov [ebp+var_C], 0
:这条指令将值 0
存储到局部变量 [ebp+var_C]
中,通常用于初始化变量 var_C
,确保它在使用前被清零。[ebp+var_C]
是 x86
汇编中的一种内存寻址方式,用于访问栈上的局部变量。为了理解它的含义,需要了解栈帧(stack frame)和ebp
寄存器在函数调用中的作用。
①当函数被调用时,系统会为这个函数在栈上分配一段内存,称为栈帧。栈帧中存储着局部变量、函数参数以及保存的寄存器值等信息。 ②ebp(Base Pointer,基指针)寄存器用于标记栈帧的基地址,即函数栈帧的一个固定参考点。 ③在函数调用期间,ebp 通常不会改变,函数中的局部变量和参数都可以相对于 ebp 寄存器通过偏移量进行访问。
ebp+var_C
实际上表示函数栈帧中的一个局部变量的内存地址。在函数执行时,ebp
指向栈帧的底部,局部变量通常存储在 ebp
的下方,var_C
是这个局部变量的偏移量。[ebp+var_C]
:表示访问存储在ebp
基地址加上 var_C
偏移量处的内存位置的值。接着我们继续解释代码:
lea eax, [ebp+var_C]
:这条指令将局部变量 var_C
的地址加载到寄存器 eax
中。lea
指令用于获取某个变量的地址,而不是变量的值,因此 eax
现在保存的是变量 var_C
的地址。
push eax
:将 eax
(也就是变量 var_C
的地址)压入栈中。这是为了给后续的 scanf
函数准备参数,scanf
需要知道存储输入结果的变量地址。
push offset Format
:将格式字符串的地址(这里是 "%d"
,表示整数格式)压入栈中。这是 scanf
函数的第二个参数,用来指定输入的数据类型。
call j__scanf
:调用 scanf
函数,它会根据传入的格式字符串和变量地址,从用户那里读取输入并将值存储在 var_C
中。
add esp, 8
:由于之前在栈中压入了两个 4 字节的参数(格式字符串的地址和变量的地址),scanf
调用后需要通过这条指令恢复栈平衡,将栈指针 esp
增加 8,清理掉这两个参数。
这个汇编代码大致等价于以下 C 代码:
int var_C = 0;
scanf("%d", &var_C);
scanf
函数调用完毕,后续代码如下:
mov eax, [ebp+var_C]
mov [ebp+var_D4], eax
cmp [ebp+var_D4], 0FFh
jg short loc_4119C2
cmp [ebp+var_D4], 0FFh
jz loc_411A34
mov ecx, [ebp+var_D4]
sub ecx, 0Ah ; switch 245 cases
mov [ebp+var_D4], ecx
cmp [ebp+var_D4], 0F4h
ja def_4119BB ; jumptable 004119BB default case, cases 13-253
; jumptable 004119F1 default case, cases 257-453
mov edx, [ebp+var_D4]
movzx eax, ds:byte_411AF8[edx]
jmp ds:jpt_4119BB[eax*4] ; switch jump
这段汇编代码的主要作用是对局部变量 var_D4
进行一系列的操作和比较,并根据比较结果跳转到不同的代码位置。我们逐步解释代码的含义:
mov eax, [ebp+var_C]
:将位于 [ebp+var_C]
的值(即局部变量 var_C
的值)加载到 eax
寄存器中。
mov [ebp+var_D4], eax
:将 eax
中的值(即用户输入的 var_C
的值)存储到 [ebp+var_D4]
,即局部变量 var_D4
中。
cmp [ebp+var_D4], 0FFh
:将 var_D4
的值与 0xFF
(255 十进制)进行比较。
jg short loc_4119C2
:如果比较结果为 "大于"(jg
,Jump if Greater),则跳转到 loc_4119C2
代码如下:
loc_4119C2: ; CODE XREF: _main+5D↑j
mov ecx, [ebp+var_D4]
sub ecx, 100h ; switch 201 cases
mov [ebp+var_D4], ecx
cmp [ebp+var_D4], 0C8h
ja def_4119BB ; jumptable 004119BB default case, cases 13-253
; jumptable 004119F1 default case, cases 257-453
mov edx, [ebp+var_D4]
movzx eax, ds:byte_411C04[edx]
jmp ds:jpt_4119F1[eax*4] ; switch jump
cmp [ebp+var_D4], 0FFh
:再次比较 var_D4
的值与 0xFF
,这与前面相同,继续进行比较。
jz loc_411A34
:如果比较结果为 "相等"(jz
,Jump if Zero),则跳转到 loc_411A34
,代码如下。
loc_411A34: ; CODE XREF: _main+69↑j
push offset aNflag255 ; "nFlag = 255"
call j__printf
add esp, 4
jmp short def_4119BB ; jumptable 004119BB default case, cases 13-253
; jumptable 004119F1 default case, cases 257-453
mov ecx, [ebp+var_D4]
:将局部变量 var_D4
的值加载到 ecx
寄存器中。
sub ecx, 0Ah
:将 ecx
中的值减去 0xA
(十进制 10)。那么在这边为什么要减去10呢?其实在反汇编代码中,var_D4
的值被减去 0xA
(10),这是与 switch-case
语句的基准值对齐的一部分优化。为了更清楚地解释这个过程,我们需要理解以下几个概念:
在高级语言中,switch-case 语句通常用于根据不同的值跳转到不同的代码分支。当代码被编译时,编译器会尝试优化 switch-case 的实现,特别是当 case 的值范围较密集时,编译器可能会选择使用 跳转表(jump table) 来提高效率。 跳转表允许程序通过索引快速跳转到对应的分支,而不是通过一系列的 if-else 或 cmp 指令逐个比较所有 case 值。跳转表的索引通常是 case 值的偏移量,因此编译器会对 switch-case 的 case 值进行某种形式的调整,使得 case 的最小值成为跳转表的起始索引。
在此处的反汇编代码中,var_D4
被减去 0xA(10)
,这很可能是因为 switch-case
中的 case
值范围不是从 0 开始的;很有可能此时 switch-case
语句中 case
的最小值是 10,那么减去 0xA
就将输入值与 case
的最小值对齐,使其从 0 开始索引跳转表。接着回到汇编代码:
mov [ebp+var_D4], ecx
:将减法操作后的结果(ecx
中的值)存储回局部变量 var_D4
。
cmp [ebp+var_D4], 0F4h
:比较 var_D4
的新值和 0xF4
(十进制 244)。
ja def_4119BB
:ja
是无符号大于跳转指令(Jump if Above),即如果 var_D4
的值大于 0xF4
,则跳转到 def_4119BB
处执行。(这个事实上就是默认分支比较简单有兴趣可以看一下,我们的重点在下面的跳转表)
def_4119BB: ; CODE XREF: _main+88↑j
; _main+9B↑j ...
mov esi, esp ; jumptable 004119BB default case, cases 13-253
; jumptable 004119F1 default case, cases 257-453
push offset Command ; "pause"
call ds:__imp__system
add esp, 4
cmp esi, esp
call j___RTC_CheckEsp
xor eax, eax
push edx
mov ecx, ebp
push eax
lea edx, dword_411AC8
call j_@_RTC_CheckStackVars@8 ; _RTC_CheckStackVars(x,x)
pop eax
pop edx
pop edi
pop esi
pop ebx
mov ecx, [ebp+var_4]
xor ecx, ebp
call j_@__security_check_cookie@4 ; __security_check_cookie(x)
add esp, 0D4h
cmp ebp, esp
call j___RTC_CheckEsp
mov esp, ebp
pop ebp
retn
_main endp
mov edx, [ebp+var_D4]
:将局部变量 var_D4
的值加载到寄存器 edx
中。
!!movzx eax, ds:byte_411AF8[edx]
:这条指令从地址 ds:byte_411AF8[edx]
处取出一个字节,并将其零扩展到 eax
寄存器中。
①movzx 是 "移动并零扩展"(move with zero extension),它从内存中读取一个字节并将其扩展为 32 位存储在 eax 中,确保高位被清零。 ②ds:byte_411AF8[edx] 表示从数据段 ds 的偏移地址 0x411AF8 开始,通过 edx 的值作为偏移量来访问一个字节数据。
ds:byte_411AF8[edx]解释:
这个部分指的是一个基于段寄存器和偏移量的内存访问。让我们逐步解析它:
①ds
:这是数据段寄存器(Data Segment)。
②byte_411AF8
:这是一个内存地址,表示一个位于 411AF8
地址处的字节变量。此时411AF8
地址存放的数据如下:
byte_411AF8 db 0, 1, 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
; DATA XREF: _main+94↑r
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4 ; indirect table for switch statement
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4
db 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3
这是一个查找表用于处理 switch-case
语句。表中大多数值是 4
,这可能是查找表的默认值,用于表示跳转到 switch-case
的默认分支(default case
)。特定位置上,例如第一个位置是 0
,第二个位置是 1
,第三个位置是 2
,最后一个位置是 3
,这些值对应于 switch-case
语句中的有效分支。
③[edx]
:表示用 edx
中的值作为索引来访问该数组中的具体字节。
到此我们就可以清楚的知道ds:byte_411AF8[edx]
表达的含义:表示对 byte_411AF8
数组的访问,其中 edx
寄存器中的值用作数组的索引。在获取数组的对应索引后,执行下一条指令:
!!jmp ds:jpt_4119BB[eax*4]
:
这条指令是一个基于跳转表的间接跳转,结合跳转表的内容来看,它是通过 eax
中的值从跳转表中选择一个跳转目标,然后执行相应的代码分支(case
)。
以下为跳转表jpt_4119BB
的内容,在跳转表中存储了各个 switch-case
语句的跳转地址。每一行表示一个 dd
数据(dd
表示定义双字节,4
个字节),即一个内存地址。正因为跳转表中存储的case
语句的跳转地址为dd
类型,所以在索引需要乘以 4
来计算跳转表的偏移量。
jpt_4119BB dd offset loc_4119F8 ; DATA XREF: _main+9B↑r
dd offset loc_411A07 ; jump table for switch statement
dd offset loc_411A16
dd offset loc_411A25
dd offset def_4119BB
jmp ds:jpt_4119BB[eax*4]
指令通过 eax
的值(在上条指令的查询表中获取)计算出的偏移量,跳转到跳转表中指定的地址。
例如,此时程序接收到的用户输入为254,此时我们根据代码做一个解析(解析放在注释~~~
部分):
mov eax, [ebp+var_C] ~~~程序接收到254~~~
mov [ebp+var_D4], eax
cmp [ebp+var_D4], 0FFh
jg short loc_4119C2
cmp [ebp+var_D4], 0FFh
jz loc_411A34
mov ecx, [ebp+var_D4]
sub ecx, 0Ah ; switch 245 cases ~~~254-10 = 244~~~
mov [ebp+var_D4], ecx
cmp [ebp+var_D4], 0F4h
ja def_4119BB ; jumptable 004119BB default case, cases 13-253
; jumptable 004119F1 default case, cases 257-453
mov edx, [ebp+var_D4] ~~~244 => edx~~~~
movzx eax, ds:byte_411AF8[edx] ~~~以edx(244)为索引在查询表byte_411AF8中查询~~~
jmp ds:jpt_4119BB[eax*4] ; switch jump ~~~根据查询到的值在跳转表中做跳转~~~
这个时候我们来说以下程序以edx(244)
为索引在查询表byte_411AF8
中查询的结果:
查询到的结果为3;接着执行jmp ds:jpt_4119BB[eax*4]
进行跳转(间接跳转),此时的跳转指令为jmp ds:jpt_4119BB[12]
,对照跳转表:
我们最后会跳转进入loc_411A25
分支,此时该分支代码如下:
loc_411A25: ; CODE XREF: _main+9B↑j
; DATA XREF: .text:jpt_4119BB↓o
push offset aNflag254 ; jumptable 004119BB case 254
call j__printf
add esp, 4
jmp short def_4119BB ; jumptable 004119BB default case, cases 13-253
; jumptable 004119F1 default case, cases 257-453
成功定位跳转至case 254
分支并执行代码;其他根据查询表、跳转表进行跳转的分支跳转也是按照这个流程,这边就不再做其他赘述了。
动态分析
将x86-Debug
程序载入x86dbg中,进行动态分析:
右击汇编代码窗口,选择搜索->所有模块->字符串
输入特征字符串(nFlag)定位到main
函数,并开始分析:
此时持续按F8
进行步过运行程序,直到运行到如下函数后就无法继续运行:
此时打开程序窗口可以看到此时程序正在等待用户输入,这个时候基本上可以断定switchre_x86-debug.4D10A0
函数为scanf
函数;存放用户输入的变量地址存放在eax
寄存器中,此时输入254
为例子查看程序的具体执行。
回车后程序继续执行;在接收到用户输入的数据后先与0xFF
(255)进行比较:若大于255则跳转至switchre_x86-debug.4D19C2
位置,若等于0xFF
则跳转至switchre_x86-debug.4D1A34
位置。(这些都比较简单,如果感觉看着吃力的话可以再去看看笔者之前的文章这边我们的重点还是放在Switch
分支结构的跳转表表现形式)
因为此时我们输入的值为254
所以既不大于也不等于0xFF
(x64dbg可以在程序运行过程中修改内存中变量的值),接着运行以下指令。
将用输入的值传入ecx
寄存器,接着减去A(10);接着与0xF4(244)进行比较,大于244则转入switchre_x86-debug.4D1A7D
(默认分支),若不大于则将被减去10的用户输入数据转入edx;此时我们可以在寄存器窗口查看当前寄存器中的值。
接着进行地址索引(间接跳转),索引得到的值:0xF4+4D1AF8 = 4D1BEC此时我们地址索引的代码如下:
这个时候我们可以直接在内存窗口,输入ctrl + G
进行值的查看;
可以看到此时内存中的值为03,其实从代码中我们也可以发现,程序会将地址索引到的值放入eax
寄存器中:此时也可以查看寄存器窗口eax寄存器中的值为3:
接着查看下面的代码:最后执行jmp
跳转指令,此时会进行地址索引(其实就是间接跳转),获取最后case
的执行地址。
这个时候我们接着计算地址:(eax)3*4 + 4D1AE4 = 4D 1AF0(该地址是存储case代码地址的内存地址),我们接着在内存窗口中查看地址为4D 1AF0的值,因为是小端序存储,所以得到的值应该是:004D1A25。
这个地址就是case
对应的地址位置,我们在汇编窗口进行定位,可以看到此时已经跳转至case 254
分支。
其他的分支也是如此,此处就不做过多说明。
在本篇文章中,我们深入探讨了 switch-case
语句在汇编代码中的表现形式,特别是如何通过跳转表来优化分支跳转逻辑。通过分析具体的反汇编代码,我们能够清晰地理解编译器如何通过查找表和间接跳转提高执行效率。
间接跳转是一种程序控制流机制,它通过一个变量(通常是寄存器或内存地址)中的值来决定程序的跳转目标,而不是直接跳转到一个明确指定的地址。与直接跳转不同,间接跳转的目标地址是在程序运行时动态确定的。
这不仅帮助我们更好地理解反汇编代码中的结构,也为我们逆向分析复杂程序提供了重要的工具和思路。希望通过这些案例,你能够对 switch-case
语句的反汇编分析有更加深入的理解。此外x64-release程序的逆向过程也与x86-debug相似,只不过release代码中有对程序进行优化,有兴趣也可以动手做做。
另外,请多多关注笔者的VX-公ZH-【风铃Sec】,你们的支持就是我更新的动力!