上篇我用C写了一个简单的密码验证程序,把它输出为exe后就可以直接运行了,大概长这个样子:
正确的密码是1234567,输入正确密码就会自动结束程序跳出来,错误就会让我们再次输入。那么在我们只有exe程序的时候怎么去修改它,让我们可以不用输密码或者随便乱输密码就直接结束程序呢。我们直接打开exe看看:
这完全没法看懂,没有程序源码,自然也就不可能按照我们的思路去修改程序。程序当然是可以修改的,但是只能蒙着眼睛乱改,全世界最厉害的程序员都不可能看懂这个东西。但是一个程序要在计算机上运行,一定要通过CPU和内存。就好比一个方案想要实行必须要通过各种人来执行一样;如果说我们想要进一个小区,不需要让我们真的成为小区业主和访客,而只需要让保安相信我们是就行。所以我们破解程序就要搞定CPU和内存,而可以管理这两个的就是汇编和机器码。
在开始之前,首先我们需要两个工具来帮助我们看程序的具体流程:
一个是IDA,可以之间看到程序的各个分支结构,方便我们立刻找到程序在内存中的对应位置,它会直接打开到程序所在位置,并显示流程图,非常适合做静态分析,一般病毒这种不能运行的程序就可以用IDA来查看分析并修改。
另一个是OD,或者叫dbg,都可以,它可以通过添加断点直接让我们查看程序如何运行,非常适合做动态调试以及复杂程序的破解。
要注意对应的32和64程序要用x32和x64工具打开。破解不需要我们对汇编有多么深刻的理解,也不需要我们能用汇编进行多么高深的编程。网安学习只有一点要求,能看懂就行,看不懂百度。
源码长这个样子:
#include <stdio.h>
#include <string.h>
#define PASSWORD "1234567"
int verif_password(char *password)
{
int authenticated;
authenticated = strcmp(password, PASSWORD);
return authenticated;
}
int main()
{
int valid_flag=0;
char password[1024];
while (1)
{
printf("please input password: " );
scanf("%s", password);
valid_flag = verif_password(password);
if (valid_flag){
printf("incorrect password, try again\n");
}
else
{
printf("Congratulations, you have entered the correct password!\n");
break;
}
}
}
为方便后面查找,编译结果也一起放出来:
0000000000401560 | 55 | push rbp |
0000000000401561 | 48:89E5 | mov rbp,rsp |
0000000000401564 | 48:83EC 30 | sub rsp,30 |
0000000000401568 | 48:894D 10 | mov qword ptr ss:[rbp+10],rcx | rcx:NtQueryInformationThread+14
000000000040156C | 48:8D15 8D2A0000 | lea rdx,qword ptr ds:[404000] | 0000000000404000:"1234567"
0000000000401573 | 48:8B4D 10 | mov rcx,qword ptr ss:[rbp+10] | rcx:NtQueryInformationThread+14
0000000000401577 | E8 5C150000 | call <JMP.&strcmp> |
000000000040157C | 8945 FC | mov dword ptr ss:[rbp-4],eax |
000000000040157F | 8B45 FC | mov eax,dword ptr ss:[rbp-4] |
0000000000401582 | 48:83C4 30 | add rsp,30 |
0000000000401586 | 5D | pop rbp |
0000000000401587 | C3 | ret |
0000000000401588 | 55 | push rbp |
0000000000401589 | 48:81EC 30040000 | sub rsp,430 |
0000000000401590 | 48:8DAC24 80000000 | lea rbp,qword ptr ss:[rsp+80] | [rsp+80]:NlsAnsiCodePage+1B24
0000000000401598 | E8 13010000 | call c1.4016B0 |
000000000040159D | C785 AC030000 00000000 | mov dword ptr ss:[rbp+3AC],0 |
00000000004015A7 | 48:8D0D 5A2A0000 | lea rcx,qword ptr ds:[404008] | rcx:NtQueryInformationThread+14, 0000000000404008:"please input password: "
00000000004015AE | E8 45150000 | call <JMP.&printf> |
00000000004015B3 | 48:8D45 A0 | lea rax,qword ptr ss:[rbp-60] |
00000000004015B7 | 48:89C2 | mov rdx,rax |
00000000004015BA | 48:8D0D 5F2A0000 | lea rcx,qword ptr ds:[404020] | rcx:NtQueryInformationThread+14, 0000000000404020:"%s"
00000000004015C1 | E8 22150000 | call <JMP.&scanf> |
00000000004015C6 | 48:8D45 A0 | lea rax,qword ptr ss:[rbp-60] |
00000000004015CA | 48:89C1 | mov rcx,rax | rcx:NtQueryInformationThread+14
00000000004015CD | E8 8EFFFFFF | call c1.401560 |
00000000004015D2 | 8985 AC030000 | mov dword ptr ss:[rbp+3AC],eax |
00000000004015D8 | 83BD AC030000 00 | cmp dword ptr ss:[rbp+3AC],0 |
00000000004015DF | 74 0E | je c1.4015EF |
00000000004015E1 | 48:8D0D 3B2A0000 | lea rcx,qword ptr ds:[404023] | rcx:NtQueryInformationThread+14, 0000000000404023:"incorrect password, try again"
00000000004015E8 | E8 03150000 | call <JMP.&puts> |
00000000004015ED | EB B8 | jmp c1.4015A7 |
00000000004015EF | 48:8D0D 522A0000 | lea rcx,qword ptr ds:[404048] | rcx:NtQueryInformationThread+14, 0000000000404048:"Congratulations, you have entered the correct password!"
00000000004015F6 | E8 F5140000 | call <JMP.&puts> |
00000000004015FB | EB AA | jmp c1.4015A7 |
找关键点
我们用IDA打开程序后长这个样子:
之所以保留左边的进度条,是为了说明我们的程序在内存里就是很小的一部分,要在这么多东西里去捞一个小程序极其麻烦,所以我们需要工具辅助。我们能发现,这个程序有一个分支,一个分支是返回去不断循环的,另一个没有任何的返回,而是以main endp结尾。所以我们能猜到,程序的if else判断一定是在这两个分支的上一行,也就是jz short loc_4015EF。那么我们点一下这行让它被选中,然后按空格看这行的位置在哪。这里显示的4015DF。
为什么要看位置?因为汇编语言没有固定语法,它就是机器语言的直接翻译,不同工具不同设备打开的语言可能会不一样,所以我们很可能没办法通过搜索语句直接找到正确的位置,只能通过内存地址来找,这是唯一的。
那么我们来看一下这个跳转函数
call verif_password #引用了这个函数
mov [rbp+3B0h+var_4], eax #把eax中的值添加到rbp+2B0+var_4位置储存
cmp [rbp+3B0h+var_4], 0 #验证这个添加的值是否为0
jz short loc_4015EF #为0就跳转到4015EF,否则就继续执行
这里面涉及到三个命令,两个寄存器。通过百度可知call表示调取函数;mov表示将后面的值移到前面的位置上储存;eax就是extended ax,是通用寄存器ax的扩展。rbp是re-extended base pointer,也就是栈基寄存器的再扩展,我们前面说了,8086CPU寻址是通过代码栈头CS加上一个偏移地址IP来获得的,栈基寄存器的作用和CS近似,目的是为了找到内存用来运行程序的位置,后面两个是偏移量,偏移量源自于调取C中main函数之前的代码:
var_410= byte ptr -410h
var_4= dword ptr -4
push rbp #压栈,以rbp值为基准
sub rsp, 430h #rsp-430,也就是分配430内存空间
lea rbp, [rsp+80h] #写入实际有效的地址80-430=3B0
为了方便理解,我们把这个过程用图来表示:
需要注意的是,IDA优先显示主函数,所以并没有在主函数结构中展示调用函数的结构,因此,我们需要进入到列表状态去看上下的函数来确定密码是如何被验证的,于是我们有了代码:
public verif_password
verif_password proc near
var_4= dword ptr -4
Str1= qword ptr 10h
#创建栈空间
push rbp
mov rbp, rsp
sub rsp, 30h
#输入正确密码Str2和我们手动的密码对象Str1
mov [rbp+Str1], rcx
lea rdx, Str2 ; "1234567"
mov rcx, [rbp+Str1] ; Str1
#调用函数比较,结果进入eax
call strcmp
#验证结果从eax放入指定位置,使eax指向结果位置
mov [rbp+var_4], eax
mov eax, [rbp+var_4]
#取栈释放
add rsp, 30h
pop rbp
#返回结果并结束
retn
verif_password endp
这里面提到了一个函数strcmp,我们看看这个函数的官方解释是什么:
int my_strcmp(const char* str1, const char* str2)
{
#断言
assert(str1 && str2);
#循环对比各位字符直到指针到\0时返回0结束
while (*str1 == *str2)
{
if (*str1 == '\0')
{
return 0;
}
str1++;
str2++;
}
#返回结果相等为0
return(*str1 - *str2);
}
从上面的结果来看,我们可以得出一些结论:
跳转部分使用的jz指令,jz指定了真假的跳转路径。由于真值唯一,假值无数,所以我们其实只要反转跳转逻辑,让真值变成错误,让假值变为正确的,就可以通过随意输入值来结束程序。除非运气真的那么好乱输都输入了真值,这样再乱输一个就好了嘛,或者直接让它无论如何都跳转到正确结果。
程序验证的值来自eax,储存位置在主函数rbp+3B0-4处,且eax需要为0,这个过程通过strcmp来实现,但是strcmp不能被更改,所以我们不能通过直接修改底层函数的流程来更改结果。但是,我们可以在函数执行之后储存eax值之前使eax值恒等于0来使验证直接失效。
另外,主函数中还需要比对rbp+3B0-4位置和0的大小,那么我们是否可以直接修改比对方法使得无论此位置的值是否为0都可以通过验证。
开始破解
有了思路后我们就可以开始着手修改了,首先我们试试第一个方法,由于在做的过程中我发现break会导致退出循环让整个程序彻底结束从而退出cmd,这个不好展示,所以我删除了break让它无论如何都会返回到循环头,区别只在于显示结果。同时,由于IDA不能做动态调试,且修改会直接影响到程序本身,我们就需要使用OD,那么我们开始:
思路一:修改跳转命令
打开OD加载程序,从IDA中找到跳转位置的VA码,复制下来,进入OD按ctrl+G输入进去并回车就跳转到指定命令了,从这里我们就能看出来,不同工具打开的同一程序的指令都是不完全相同的,所以汇编没有必要去硬记,关键是知道意思,必要时百度即可。
同样的,这个是在验证是否为0,此处使用了je,表示等于0就跳转到指定位置,否则就继续按顺序执行。百度一下je指令,就知道它反过来的指令是jne,不等于0才跳转。je的机器码是74,jne是75,所以我们点击这个地方,然后ctrl+e就可以修改下面的机器码:
然后我们用f9运行一下看看:成功。
但是这并不够,因为我在百度过程中还看到了额外的跳转指令,那么这些跳转指令有没有可以直接给我们用的呢?当然有,jmp可以无条件跳转到任何地方,它的机器码是EB。同样的,我们修改命令得到如下结果:此时无论我们输什么东西都只会得到正确结果。
而假设密码正确的函数段是紧接着这个跳转结果的,我们还可以通过nop指令来掠过这个跳转命令使得它不跳转只执行下面的程序,但是因为这里下面接的是不正确,所以一旦我们修改为nop就只会出现不正确结果。需要注意的是,nop指令不能作为一个跳过指令使用,不能占半截,否则程序逻辑会出问题导致卡死,必须要在完整的函数前面将整个指令段全部用nop占据,不能多了,也不能少了。
思路二:修改验证数据
随意在程序中添加代码会导致整个程序结构发生变化,从而导致添加的这个指令之后所有的程序都无法执行,因为地址变了。所以我们只能找程序中的空位来添加值,比如nop,但是我们可以发现,从验证函数开始,整个程序中间可以说没有半点缝隙能让我们插入指令。但是我们可以修改现有指令,只要逻辑正确,就没有问题。在上面讲到的验证函数部分,已知验证的核心寄存器是eax。如果eax初始值就是0,那么我们可以在call strcmp这地方用nop直接替换,但假设eax初始不为0呢,这样后面无论如何进行验证的值都是0。假设eax不为0呢,那我们需要手动在strcmp后添加mov eax,0来使他强制为0。由于mov eax,0的机器码是B8 00 00 00 00,我们观察一下就能发现引用strcmp函数部分的机器码是:
0000000000401577 | E8 5C150000 | call <JMP.&strcmp> |
刚刚好,所以我们直接替换掉这个引用,就得到如下结果:eax恒等于0,所以无论如何验证,最终都是为真。
同样的,我们还可以进入到主函数中,查看验证部分:
00000000004015CD | E8 8EFFFFFF | call c1.401560 |
00000000004015D2 | 8985 AC030000 | mov dword ptr ss:[rbp+3AC],eax |
00000000004015D8 | 83BD AC030000 00 | cmp dword ptr ss:[rbp+3AC],0 |
在这个点地方,我们发现验证的关键是引用了位置在401560的函数,也就是上面返回了eax值的验证函数。通过观察发现这个也可以被修改为B8 00 00 00 00,或者,我们让它指向一个新的位置,比如在整个程序之前的nop位置,然后把那一堆nop改成B8 00 00 00 00也是一样的。总之就是保证在这个之前让eax=0。不知道机器码可以通过空格打开选择行数对应的汇编编辑,了解机器码主要是为了符合占位不改变程序结构。
思路三:修改验证逻辑
当我们遇到一个逻辑语句,比如sql中的select * from a where x=1 and y=1这种限定条件时,假设我们想要结果变得更多,我们可以把等于变成大小于或者将and变成or。同理,我们只要修改验证逻辑既可以逐步扩大我们可输入的数据范围。首先我们看看cmp指令的基本逻辑:x和y两个数,进行x-y计算;等于0时记录为1为真;不等于0时记录为0为假。当然cmp还有符号和进位大小比较,这里可以不用管。由于我们已经知道了eax=0时为真,eax=1或-1时为假,那么假设我们把比较数字变成1,只要我们输入的值开头大于密码开头就可以成功,字母衡大于数字,所以结果就是下图:这个和文件名排序规则相同。
原本程序逻辑是只针对我们输入的值进行一次比较,也就是说逻辑恒定,但是,假设我们让程序和我们输入的结果来进行比较呢?此处,我们将比较上面的指令从搬运变成了异或:
由于异或并不存在比较过程,所以cmp仍然要保留。如果将cmp修改为test会导致后面进位造成程序混乱,因为cmp需要的比较位数是00,而test是00 00。单纯从表象上来看,好像是我们连续输入两次就会得到正确结果,但事实并不是这样。异或运算的规则是1+1=0、1+0=1、0+0=0,也就是说,第一次的错误输入会导致比较函数返回eax=1,当前rbp+3AC和eax比较不等时,会得出1并存入rbp+3AC,此时用rbp+3AC和0比较就会得出错误结果;然后,我们再输入一个任意错误值时,验证函数一样会返回eax=1,此时rbp+3AC已经储存了1,和eax比较会得到0并存入rbp+3AC,所以rbp+3AC中的0和0比较即得到正确结果。如果我们持续输入正确密码,因为验证函数持续返回0,会因为rbp+3AC和eax会一直不同而持续进行1和0比较,所以会一直错下去,除非再这之前有个正确结果使得rbp+3AC中储存了一次0,此时1234567会一直正确下去。具体运行结果如下:
我们可以通过and和or来达到类似的效果,and是二极管,经过任何一次正误变化就一直保持下去,本质上和直接要求正确密码没有区别;or恒等于错。
最后,无论哪种方法,保存结果到原本程序或者另存为新程序就好,一个破解程序就完成了。
OK,以上就是第一次破解一个超简单程序的记录,极其小白那种,我不说了嘛,我正经学计算机才几个月。这个其实我是借的0day安全这本书里的例子来做的,但人家没讲这么细,就给了个je换jne,而且连汇编的结果都不一样,基本靠自己琢磨。所以形式并不重要,关键点是要自己琢磨和学习相应的思路,毕竟汇编都是活的,人就更不能是死的。
版权归原作者 爆肝的麻薯 所有, 如有侵权,请联系我们删除。