本节书摘来自异步社区《C++ 黑客编程揭秘与防范(第2版)》一书中的第6章6.5节破解基础知识及调试API函数的应用,作者冀云,更多章节内容可以访问云栖社区“异步社区”公众号查看。
6.5 破解基础知识及调试API函数的应用
C++ 黑客编程揭秘与防范(第2版)
在介绍完PE文件结构以后,接下来介绍调试API。调试API是系统留给用户进行程序调试的接口,其功能非常强大。在介绍调试API以前,先来回顾一下OD的使用。OD是用来调试应用程序的强大的工具。第5章中对其进行了简单的介绍,本章中将通过实例来回顾其强大功能。同样,为了后续的部分较容易理解,这里写一个简单程序,用OD来进行调试。除了介绍调试API以外,还会介绍一些简单的与破解相关的内容。当然,破解是一个技术的积累,也是要靠多方面技术的综合应用,希望这些简单的基础知识能给读者起到一个抛砖引玉的作用。
6.5.1 CrackMe程序的编写
下面将写一个CrackMe程序,CrackMe的意思是“来破解我”。这里提到的破解是针对软件方面来说的,不是网络中的破解。对于软件的破解来说,主要是解除软件中的各种限制,比如时间限制、序列号限制……对于破解来说,无疑与逆向工程有着密切的关系,想要突破任何一种限制都要去了解该种限制的保护方式或保护机制。
破解别人的软件属于侵权行为,尽管有很多人在做这样的事情,但是大多人数是为了进行学习研究,而非用于牟取商业利益。因此,为了尊重他人的劳动成果,也为了避免给自己带来不必要的麻烦,读者尽可能找一些CrackMe来进行学习和研究。
下面来写一个非常简单的CrackMe程序,并进行“破解”。自己写CrackMe并破解,虽然这样省去了很多问题的思考,但是对于初学者来说仍然是一件非常有趣的事情。
这个程序使用MFC来编写,界面如图6-48所示。
从图6-48中可以看出,整个程序只有两个可以输入内容的文本编辑框和两个可以单击的按钮,除此之外什么都没有,更不会有什么提示。基本上这就是一个CrackMe的样子。不过有的人习惯在CrackMe中添加一个美女的照片,让界面显得美观诱惑,有的人喜欢给CrackMe加层壳来增加破解的难度,不过这些不重要,关键是要进行学习。在界面上输入一个账号和一个密码,当单击“确定”按钮后,该按钮会执行以下代码:
void CEasyCrackMeDlg::OnBtnReg()
{
// TODO: Add your control notification handler code here
char szUser[MAXBYTE] = { 0 };
char szPassword[MAXBYTE] = { 0 };
char szTmpPassword[MAXBYTE] = { 0 };
// 获取输入的账号和密码
GetDlgItemText(IDC_EDIT_USER, szUser, MAXBYTE);
GetDlgItemText(IDC_EDIT_PASSWORD, szPassword, MAXBYTE);
// 判断账号是否为空
if ( strlen(szUser) == 0 )
{
return ;
}
// 判断密码是否为空
if ( strlen(szPassword) == 0 )
{
return ;
}
// 判断账号长度是否小于7
if ( strlen(szUser) < 7 )
{
return ;
}
// 根据账号生成密码
for ( int i = 0; i < strlen(szUser); i ++ )
{
if ( szUser[i] == 'Z'
|| szUser[i] == 'z'
|| szUser[i] == '9' )
{
szTmpPassword[i] = szUser[i];
}
else
{
szTmpPassword[i] = szUser[i] + 1;
}
}
// 把生成的密码和输入的密码进行匹配
if ( strcmp(szTmpPassword, szPassword) == 0 )
{
MessageBox("密码正确");
}
else
{
MessageBox("密码错误");
}
}
整个代码非常简单。这段代码是通过输入的账号来生成密码的,而不是有固定的账号和固定的密码进行一一对应。生成密码的算法非常简单,把输入的账号的每一个ASCII码进行加1运算,但是有几个ASCII值除外。如果该ASCII码是字符大写“Z”、小写“z”或者是数字“9”,就不会进行加1运算。除了这点外,要求账号的长度必须大于7位,这也算是一个小小的要求了。
测试一下。输入一个小于7位的账号,再随便输入一个密码,单击“确定”按钮,这时程序不会有任何反应。那么,这次输入一个超过7位的账号,单击“确定”按钮来试试,如图6-49所示。
CrackMe提示“密码错误”。当然,密码是根据账号算出来的,而且跟账号的长度是相等的。那么,该如何获得这个CrackMe的密码呢?如果这个CrackMe不是自己写的,那该怎么办?想必读者都知道该怎么办,接下来的工作就交给OD来完成。
6.5.2 用OD破解CrackMe
对于破解来说,总是要找到一个突破点。而对于这个简单的CrackMe来说,突破点是非常多的。下面会以不同的方式来开始这次破解之旅,不需要有太多的汇编知识,毕竟这里是基础性的知识。要想深入学习破解,对破解有所了解的话,那么学习和掌握汇编是必修课。
1.破解方法一
现在用OD打开所编写的CrackMe,如图6-50所示。
还记得OD中各个窗口的作用吗?如果忘记,请参考第5章的内容。用OD打开CrackMe以后会看到很多汇编代码,这部分内容可以通过前面学习的汇编语言和逆向知识来进行阅读。这里会利用前面介绍的一些基本的破解技巧,通过这些技巧来完成破解工作。
首先来梳理一下思路,梳理思路的时候可以参考上面写的代码。输入“账号”及“密码”后,首先程序会从编辑框处获得“账号”的字符串及“密码”的字符串,然后进行一系列的比较验证,再通过“账号”来计算出正确的“密码”,最后来匹配正确的“密码”与输入的“密码”是否一致,根据匹配结果给出相应的提示。
上面是编写代码的流程和思路,也可根据这个思路合理地设置断点(设置断点也叫下断点)。“断点”就是产生中断的位置。通过下断点,可以让程序中断在需要调试或分析的地方。下断点在调试中起着非常大的作用,学会在合理的地方下断点也是一个技巧性的知识,合理地下断点有助于对软件进行分析和调试,读者应该学着掌握它。断点的分类很多,有内存断点、硬件断点、INT 3断点……关于断点的知识和原理,将在稍后的内容中进行介绍,这里就不介绍了。
现在就可以选择合适的地方设置断点了。可以在API函数上设置断点,比如在GetDlgItemText()行设置断点,也可以选择在strlen()上设置断点,还可以在strcmp()上设置断点,甚至可以在MessageBox()上设置断点。上面的这些API函数都是可以设置断点的,但是对于GetDlgItemText()和MessageBox()I函数来说,需要下断点的时候指定是ANSI版本还是UNICODE版本。也就是说,系统中是没有这两个函数的,根据版本的不同存在系统的函数只有GetDlgItemTextA()、GetDlgItemTextW()、MessageBoxA()和MessageBoxW()。通常使用ANSI版本的即可。
上面有如此多的API函数可供设置断点,那么要选择哪个进行设置呢?最好的选择是strcmp()函数,因为在比较函数处肯定会出现正确的“密码”。而在GetDlgItemTextA()和strlen()上设置断点,需要使用F8进行跟踪。如果在MessageBoxA()上设置断点,那么就不容易找到正确的“密码”存放的位置。所以选择在strcmp()上设置断点。在“命令”窗口中输入“bp strcmp”,然后按回车键,如图6-51所示。
如何知道断点是否设置成功呢?按下Alt + B组合键,打开断点窗口可以查看,如图6-52所示。
断点已经设置好了,那么就按F9键来运行程序。CrackMe启动了,输入一个长度大于等于7位的“账号”:“testtest”,然后随便输入一个“密码”:“123456”,单击“确定”按钮,OD中断在断点处,如图6-53所示。
从图6-53中可以看到,OD断在了strcmp函数的首地址处,地址为10217570。当OD被断下后,在菜单栏的下方会看到“暂停”字样的状态。断在这里如何找到真正的密码呢?其实在提示的地方已经显示出了正确的密码,也可以通过查看栈窗口来找到正确的密码。函数参数的传递是依赖于栈的。对于C语言来说,函数的参数是从右往左依次入栈的。strcmp()函数有两个参数,分别是要进行比较的字符串。在栈窗口中可以看到输入的密码及正确的密码,如图6-54所示。
可以看到,在调用strcmp()时,传递的两个参数的值分别是“123456”和“uftuuftu”两个字符串。前面的字符串肯定是输入的密码,那么后面的字符串肯定就是正确的密码了。按F9键运行程序,会出现对话框提示密码错误。现在关闭OD,直接打开CrackMe。仍然用刚才的账号“testtest”,然后输入密码“uftuuftu”,单击“确定”按钮,会提示“密码正确”,如图6-55所示。
这样就完成了破解。这种方法比较简单,只要在strcmp()函数处设置断点即可。读者可以试着在其他几个API函数处设置断点,然后试着找到正确的注册码。接下来,尝试使用另外的方法来对CrackMe进行破解。
2.破解方法二
在上一种方法中,通过对API函数设置断点找到了正确的密码。现在通过提示字符串来完成破解。在不知道正确密码的情况下输入密码,通常会得到的提示字符串是“密码错误”。只要在程序中寻找该字符串,并且查看是何处使用了该字符串,那么就可以对破解起到提示性的作用。
用OD打开CrackMe程序,然后在反汇编界面处单击鼠标右键,在弹出的菜单中依次选择“Ultra String Reference”->“Find ASCII”命令,会出现“Ultra String Reference”窗口,如图6-56所示。
在“Ultra String Reference”窗口中可以看到两个非常熟悉的字符串,双击“密码正确”字符串,来到00401EAE地址处,该地址内容如图6-57所示。
从图6-57中可以看到3处比较关键性的内容,第一个是strcmp()函数,第二个是字符串“密码正确”,第三个是字符串“密码”错误。由这3个内容可以联想到,这和C代码基本上是对应的。根据strcmp()的比较结果,if…else…会选择不同的流程执行。也就是说,只要改变比较的结果或者更换比较的条件,都可以改变程序的流程。下面主要讲述修改比较条件的方法,拿具体的例子来解释,具体代码如下:
`
javascript
// 把生成的密码和输入的密码进行匹配
if ( strcmp(szTmpPassword, szPassword) == 0 )
{
MessageBox("密码正确");
}
else
{
MessageBox("密码错误");
}
strcmp()是字符串比较函数。如果两个字符串相等,也就是说,输入的密码与正确的密码匹配,则执行“密码正确”流程;否则反之。修改一下比较的条件,也就是说,两个密码匹配不成功,使其执行“密码成功”的流程。这样,输入错误的密码也会提示“密码正确”。在C语言中的修改很简单,只要修改为如下代码即可:
if ( strcmp(szTmpPassword, szPassword) != 0 )
但是对于反汇编应该如何做呢?其实非常简单,再看一下图6-57中的那几条反汇编代码。想要修改其判断条件,只要修改00401EA8处的指令代码JNZ SHORT 00401EBD即可。该指令的意思是如果比较结果不为0,则跳转到00401EBD处执行。JNZ指令是结果不为0则跳转,只要把JNZ修改为JZ即可,JZ的意思刚好与JNZ相反。修改方法很简单,选中00401EA8地址所在的行,按下空格键即可进行编辑,如图6-58所示。
<div style="text-align: center">
<img src="https://yqfile.alicdn.com/8689e49fbebb3ad024cd396e572b26f818ad36c4.png" >
</div>
单击窗口上的“汇编”按钮,然后按F9键运行,随便输入一个长度大于7位的账号,再输入一个密码,然后单击“确定”按钮,会提示“密码正确”,如图6-59所示。
<div style="text-align: center">
<img src="https://yqfile.alicdn.com/bf1ace763d2a290c0a8437994b5d52aec9673e1d.png" >
</div>
关掉OD和CrackMe,然后直接运行CrackMe,随便输入账号和密码,单击“确定”按钮后提示密码错误。为什么呢?因为刚才只是在内存中进行了修改,需要对修改后的文件进行存盘,这样在以后运行时,该修改才有效。修改后的存盘方法为:选中修改的反汇编代码(可以多选几行,只要修改的那行被选中即可),然后单击右键,在弹出的菜单中选择“复制到可执行文件”->“选定内容”命令,会出现“文件”对话框,如图6-60所示。
<div style="text-align: center">
<img src="https://yqfile.alicdn.com/af43985c8648ea35e0d7355f79a5e60516f7c89f.png" >
</div>
在这个对话框中单击鼠标右键,在弹出的菜单中选择“保存文件”命令,然后进行保存。这样修改就存盘了。下次在执行该程序时,随便输入大于7位的账号和密码,都会提示“密码正确”。如果输入了正确的密码,那么会提示“密码错误”。
上面就是两种破解CrackMe的方法,这两种方法都是极其简单的方法,现在可能已经很不实用了。这里是为了学习,提高动手能力,而采用了这两种方法。
**6.5.3 文件补丁及内存补丁**
有时破解一个程序后可能会将其发布,而往往被破解的程序只是修改了其中一个程序而已,无须将整个软件都进行打包再次发布,只需要发布一个补丁程序即可。发布补丁常见的有三种情况,第一种情况是直接把修改后的文件发布出去,第二种情况是发布一个文件补丁,它去修改原始的待破解的程序,最后一种情况是发布一个内存补丁,它不修改原始的文件,而是修改内存中的指定部分。
3种情况各有好处。第一种情况将已经修改后的程序发布出去,使用者只需要简单进行替换就可以了。但是有个问题,如果程序的版本较多,直接替换可能就会导致替换后的程序无法使用。第二种方法是发布文件补丁,该方法需要编写一个简单的程序去修改待破解的程序,在破解以前可以先对文件的版本进行判断,如果补丁和待破解程序的版本相同则进行破解,否则不进行破解。但是有时候修改了文件以后,程序可能无法运行,因为有的程序会对自身进行校验和比较,当校验和发生变化后,程序则无法运行。最后一种方式是内存补丁,也需要自己动手写程序,并且写好的补丁程序需要和待破解的程序放在同一个目录下,执行待破解的程序时,需要执行内存补丁程序,内存补丁程序会运行待破解的程序,然后比较补丁与程序的版本,最后进行破解。同样,如果有内存校验的话,也会导致程序无法运行。不过,无论是文件校验还是内存校验,都可以继续对被校验的部分进行打补丁来突破程序校验的部分。不过这不是本部分的重点,这里的重点是编写一个针对上一节程序的文件补丁程序和内存补丁程序。
1.文件补丁
用OD修改CrackMe是比较容易的,如果脱离OD该如何修改呢?其实在OD中修改反汇编的指令以后,对应地,在文件中修改的是机器码。只要在文件中能定位到指令对应的机器码的位置,那么直接修改机器码就可以了。JNZ对应的机器码指令为0x75,JZ对应的机器码指令为0x74。也就是说,只要在文件中找到这个要修改的位置,用十六进制编辑器把0x75修改为0x74即可。如何能把这个内存中的地址定位到文件地址呢?这就是前面介绍的PE文件结构中把VA转换为FileOffset的知识了。
具体的手动步骤,请读者自己尝试,这里直接通过写代码进行修改。为了简单起见,这里使用控制台来编写,而且直接对文件进行操作,省略中间的步骤。想必有了思路以后,对于读者来说就不是难事。
关于文件补丁的代码如下:
include
include
int main(int argc, char* argv[])
{
// VA = 00401EA8
// FileOffset = 00001EA8
DWORD dwFileOffset = 0x00001EA8;
BYTE bCode = 0;
DWORD dwReadNum = 0;
// 判断参数
if ( argc != 2 )
{
printf("Please input two argument rn");
return -1;
}
// 打开文件
HANDLE hFile = CreateFile(argv[1],
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
if ( hFile == INVALID_HANDLE_VALUE )
{
return -1;
}
SetFilePointer(hFile, dwFileOffset, 0, FILE_BEGIN);
ReadFile(hFile, (LPVOID)&bCode, sizeof(BYTE), &dwReadNum, NULL);
// 比较当前位置是否为JNZ
if ( bCode != 'x75' )
{
printf("%02X rn", bCode);
CloseHandle(hFile);
return -1;
}
// 修改为JZ
bCode = 'x74';
SetFilePointer(hFile, dwFileOffset, 0, FILE_BEGIN);
WriteFile(hFile, (LPVOID)&bCode, sizeof(BYTE), &dwReadNum, NULL);
printf("Write JZ is Successfully ! rn");
CloseHandle(hFile);
// 运行
WinExec(argv[1], SW_SHOW);
getchar();
return 0;
}
代码给出了详细的注释,只需要把CrackMe文件拖放到文件补丁上或者在命令行下输入命令即可,如图6-61所示。
<div style="text-align: center">
<img src="https://yqfile.alicdn.com/367b4215419240d64f185362eb98700cd7468f16.png" >
</div>
通常,在做文件补丁以前一定要对打算进行修改的位置进行比较,以免产生错误的修改。程序使用的方法是将要修改的部分读出来,看是否与用OD调试时的值相同,如果相同则打补丁。由于这里只是介绍编程知识,针对的是一个CrackMe。如果对某个软件进行了破解,自己做了一个文件补丁发布出去给别人使用,不进行相应的判断就直接进行修改,很有可能导致软件不能使用,因为对外发布以后不能确认别人所使用的软件的版本等因素。因此,在进行文件补丁时最好判断一下,或者是用CopyFile()对文件进行备份。
2.内存补丁
相对文件补丁来说,还有一种补丁是内存补丁。这种补丁是把程序加载到内存中以后对其进行修改,也就是说,本身是不对文件进行修改的。要将CrackMe载入内存中,载入内存可以调用CreateProcess()函数来完成,这个函数参数众多,功能强大。使用CreateProcess()创建一个子进程,并且在创建的过程中将该子进程暂停,那么就可以安全地使用WriteProcessMemory()函数来对CrackMe进行修改了。整个过程也比较简单,下面直接来阅读源代码:
include
include
int main(int argc, char* argv[])
{
// VA = 004024D8
DWORD dwVAddress = 0x00401EA8;
BYTE bCode = 0;
DWORD dwReadNum = 0;
// 判断参数数量
if ( argc != 2 )
{
printf("Please input two argument rn");
return -1;
}
STARTUPINFO si = { 0 };
si.cb = sizeof(STARTUPINFO);
si.wShowWindow = SW_SHOW;
si.dwFlags = STARTF_USESHOWWINDOW;
PROCESS_INFORMATION pi = { 0 };
BOOL bRet = CreateProcess(argv[1],
NULL,
NULL,
NULL,
FALSE,
CREATE_SUSPENDED, // 将子进程暂停
NULL,
NULL,
&si,
π);
if ( bRet == FALSE )
{
printf("CreateProcess Error ! rn");
return -1;
}
ReadProcessMemory(pi.hProcess,
(LPVOID)dwVAddress,
(LPVOID)&bCode,
sizeof(BYTE),
&dwReadNum);
// 判断是否为JNZ
if ( bCode != 'x75' )
{
printf("%02X rn", bCode);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return -1;
}
// 将JNZ修改为JZ
bCode = 'x74';
WriteProcessMemory(pi.hProcess,
(LPVOID)dwVAddress,
(LPVOID)&bCode,
sizeof(BYTE),
&dwReadNum);
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
printf("Write JZ is Successfully ! rn");
getchar();
return 0;
}
代码中的注释也比较详细,代码的关键是要进行比较,否则会造成程序的运行崩溃。在进行内存补丁前需要将线程暂停,这样做的好处是有些情况下可能没有机会进行补丁就已经执行完需要打补丁的地方了。当打完补丁以后,再恢复线程继续运行就可以了。
文件补丁与内存补丁已经介绍完了。这两种补丁,都是通过前面学到的知识来完成的,可见前面的基础知识的用处还是非常广泛的。用了这么多的篇幅来介绍使用OD破解CrackMe,也介绍了文件补丁和内存补丁,那么,接下来就开始学习调试API。掌握调试API以后,就可以打造一个类似于OD的应用程序调试器,下面来一步一步学习。