简明、现代而优雅的破解技术笔记 第二课
需要用到的工具
正向开发:另一个密码判断程序
思来想去,还是觉得每次用到任何东西的时候都把文档搬出来给大家看,一方面是我自己比较麻烦,思路总是断,另一方面,也使得文章内容重点不清,难以阅读。我的本意并不是想给大家上课,而是想以笔记的形式记录一些自己在破解实践过程中学到的东西。所以应该还是以思路连贯为主要目标,基础部分应该尽快略过,否则我永远无法真正开始写我的笔记。请大家见谅。如果你在阅读当中感到有任何不通顺或者不理解的地方,请一定要在楼下跟帖询问,或者如果你认为你问的问题与主题的相关性比较弱,也可以另开帖询问。
我依然会将需要用到的基础知识提前标示出来,方便大家查找其他资料来学习。本期正向开发中用到的知识有:
需要实现的状态机
我们要判断用户输入的序列是否为“52pojie”,可构造以下状态机:(手头没有画图软件,手画了一个)
当这个状态机停机的时候,我们就知道,用户输入了正确的密码,否则这个状态机就会一直运行下去。
我们需要给状态机输入按键信息,来促进它的状态转移,同时我们还需要知道状态机是否停机,状态机停机后才能运行后续逻辑,否则就一直在密码状态机中运行。
所以状态机可以用C++类来实现:
class 密码状态机
{
private:
enum 状态
{
状态_初始状态,
状态_5,
状态_2,
状态_P,
状态_O,
状态_J,
状态_I,
状态_E_停机,
};
状态 当前状态;
public:
密码状态机()
{
当前状态 = 状态_初始状态;
}
~密码状态机(){}
void 状态转移(char 用户输入字符)
{
switch (当前状态)
{
case 状态_初始状态:
{
用户输入字符 == '5' ? 当前状态 = 状态_5 : 当前状态 = 状态_初始状态;
}
break;
case 状态_5:
{
用户输入字符 == '2' ? 当前状态 = 状态_2 : 当前状态 = 状态_初始状态;
}
break;
case 状态_2:
{
用户输入字符 == 'P' || 用户输入字符 == 'p' ? 当前状态 = 状态_P : 当前状态 = 状态_初始状态;
}
break;
case 状态_P:
{
用户输入字符 == 'O' || 用户输入字符 == 'o' ? 当前状态 = 状态_O : 当前状态 = 状态_初始状态;
}
break;
case 状态_O:
{
用户输入字符 == 'J' || 用户输入字符 == 'j' ? 当前状态 = 状态_J : 当前状态 = 状态_初始状态;
}
break;
case 状态_J:
{
用户输入字符 == 'I' || 用户输入字符 == 'i' ? 当前状态 = 状态_I : 当前状态 = 状态_初始状态;
}
break;
case 状态_I:
{
用户输入字符 == 'E' || 用户输入字符 == 'e' ? 当前状态 = 状态_E_停机 : 当前状态 = 状态_初始状态;
}
break;
}
}
bool 是否停机()
{
return 当前状态 == 状态_E_停机;
}
};
main函数
有了刚才构造的状态机,我们的main函数就好写了,只需要将用户的键盘输入喂给状态机做状态转移,并等待状态机停机(这代表密码输入正确),然后执行后续逻辑(打印“密码正确!”)。
所以我们最终写出来的main函数如下:
#include <iostream>
#include <conio.h>
int main()
{
密码状态机 密码状态机实例;
while (!密码状态机实例.是否停机())
{
密码状态机实例.状态转移(_getch());
}
std::cout << "密码正确!\n";
system("pause");
return 0;
}
运行一下,确实莫得问题。
逆向分析:第一次
这次我们依然使用IDA来进行逆向工程的工作。
试试IDA能把我们的程序怎么样
我们把Debug X64的程序直接拖到IDA里,加载PDB,IDA很快就完成了对程序的分析。
可以看到,左边甚至出现了我们定义好的密码状态机类的几个方法,main函数已经加载好了,我们直接按下F5。
掀桌不玩了!每次我们自己写的程序一按F5就全看光了,还有什么好玩的!
出现这种情况的原因
因为我们使用的是Debug配置文件,Visual Studio默认会在这种配置文件中开启PDB(调试符号表)的生成,并且禁用一切优化,所以生成的程序代码异常规范,可以说是与源代码严格一一对应的。当然,这主要是为了Visual Studio自己调试起来方便,但也不可避免地为破解者开了一路绿灯。当IDA加载了PDB之后,便可以轻松知道源代码中你的各种变量、函数、对象、方法的命名,然后自动把这些符号映射到逆向分析出来的代码上。
这时依然要重申我一直在说的一句话,你的逆向工程开展的层次越接近源代码的层次,你获得源代码的可能性就越高。妄言原生程序无法看到源代码是非常不负责任的,事实上,如果原生程序与其源代码之间只隔了一个编译器,那么通过相应的反编译器,你自然可以获得程序的源代码。诚然,反编译器也是一种工具,是前人经验的集合,例如IDA中附带的C语言反编译器,就是Hex-Rays公司几十年逆向工程经验的结晶。在同一层面上,不同的技术有时可以互相代替,例如拿到一个原生程序,即使它不是用C语言编写的,也依然可以使用IDA内置的C语言反编译器将它反编译成C语言源代码。拿到一个Kotlin写的APP,你依然可以用JVM反编译工具把它反编译成Java源代码。此时,所谓的“源代码”也不一定就是软件作者编写出来的那一份源代码,但逻辑必然是相同的。这逻辑就是人类的逻辑,是程序语言描述的,被编译器翻译给机器执行的人类的逻辑。当你找到这一层逻辑的时候,你就是找到了你逆向目标的终极核心。
正向开发:使用Release重新编译
我们在Visual Studio中把Debug换成Release,并重新编译。编译完成后,需要特别指出,exe文件生成的目录和Debug版本不一样,请切换到Release版本的目录,再测试一下程序的运行情况。
逆向分析:第二次
将Release版本的exe拖入IDA,Release版本默认仍然会生成PDB文件,但我们这次在IDA中选择不加载,因为真正破解时你是拿不到软件原作者的PDB文件来开挂的。
初见Release版代码
加载完成后,IDA依然停在了main函数的位置,左边的函数框里也依然存在大量已经改好名字的函数,这是因为IDA有独立查找这些函数的能力。这种查找方法叫做“模式匹配”(pattern matching),也是我们将来要介绍的一个重点内容。顺便一提,模式匹配用的数据叫做“签名”(signature),在大多数国外论坛简称sig,这是一种非常重要的逆向工程经验资源,你可以看到很多人在交流这个。本论坛常说的易语言事件代码,也是一种签名。不是只有易语言才能用那种方法破解,只要你对于别人写程序时所用的框架和库有一定了解,你自己也可以积累这种资源。
既然停在了main函数处,我们依然可以无脑F5,那么我们马上来试一下吧!
惊呆了,我的main函数完全不是这么写的呀!我的状态机类去哪了???怎么状态转移的switch case结构直接跑到main函数里来了???最后cout的时候为什么我看不懂???密码正确四个字去哪了???
“密码正确”去哪了
我们先来解决这个问题,在IDA上方的菜单栏选择Search -> Text。
在弹出的窗口中输入“密码正确”,勾选Find all occurrences。
IDA很快就可以搜索出来结果。
如果无法搜索到中文,说明你的程序内码与IDA现在使用的系统内码不一致。我强烈建议大家开启Windows 10的“使用UTF-8提供全球语言支持”功能,并且在一开始就把所有程序代码存储为UTF-8格式,并在Visual Studio中将程序字符集设置为Unicode字符集。
想要开启“使用UTF-8提供全球语言支持”功能,需要点击开始->设置->时间和语言->区域->其他日期、时间和区域设置->更改日期、时间或数字格式->“管理”选项卡->更改系统区域设置->勾选“使用UTF-8提供全球语言支持”->确定,重启。需要注意的是,重启之后易语言等过时的软件会显示乱码,但其他正常的现代软件不会受到任何影响。你的开发和逆向体验都会变得比以前更加舒适,并且再也不用使用任何中文搜索插件即可搜索现代软件里的中文。
是什么东西输出了“密码正确”
搜索到“密码正确”后,我们直接双击找到的位置,可以直接跳转到出现“密码正确”的位置。两个都点击一遍后,我们可以发现,第一个是一段程序代码,第二个是程序数据,那段程序代码引用了这个程序数据。所以我们直接跳到代码部分。
按下F5,直接看反编译的C代码。
这就是具体处理输出“密码正确”的部分代码了。鼠标滚轮滚到最上方,我们看看这是哪个函数。
选中这个函数名并按下N键,即可打开重命名对话框。将这个函数重命名为“执行输出密码正确”。
可以看到,虽然反编译窗口这边没能正常显示中文,但左边函数窗口中出现了“执行输出密码正确”函数。
再次选中这个函数的名字,按X键,即可弹出这个函数所有被调用的位置。
顺着call往前找,双击即可,会发现我们来到了main函数。看来在反编译器中不太建议使用中文重命名函数,但我们还是看到了曙光。
由此可知,只要让程序从开头就跳到LABEL_5,就可以绕过密码验证,这是暴力破解的思路,我们就这样尝试一下。
Switch-case结构跑到main函数里来,并且状态机对象不知所踪,这是因为VC的编译器非常强悍,自由度也很高。你通过C++代码完美表达了你的思想,但编译器理解了你的思想后,选择了一个最适合机器执行的方式来编写机器指令的程序,并没有严格按照你的思路去转换。这就是编译器优化的可怕之处,我们平时碰到的原生代码大多数都是经过编译器优化的,但是不同的编译器功力会有深有浅,像微软C++编译器这样功力深厚的其实并不多见(因为它不仅有编译优化,还有链接优化和全程序优化,而其他编译器限于与链接器合作不足无法进行链接优化和全程序优化)。所以我们最终需要理解的代码可能和作者原来写的完全不相干,但是他们都体现了相同的逻辑。
暴力破解方案
回到IDA-View,我们可以看到main函数运行的框图。在框图最下面,我们可以看到我们想要跳转到的位置,LABEL_5的实际地址,我们需要想办法让main函数直接跳到这里。
有许多不负责任的教程教你直接动态调试,寻找关键跳,然后直接把跳转给取消,或者让它跳到别的地方。原理是没有太大问题的,但是执行起来很不安全,在你对于一个程序没有宏观了解,胸中无成竹的时候,盲目改跳转很容易让程序栈变得不平衡,从而使得程序易于崩溃。
为了确保我们修改后的程序执行时不会崩溃,我们需要平衡程序的栈指针。先设置一下IDA,让IDA默认显示栈指针。点击Options -> General,在打开的窗口中的Disassembly选项卡中勾选Stack pointer。
点击OK后你会发现,程序指令代码的最左侧出现了一些000、008、028的绿色数字,这就是栈指针的位置。
现在可以看到,我们要跳转的目标栈指针是028,所以我们一定要从同样是028的位置上跳过去。我们发现main函数的开头就有这样的位置,所以我们在这里物色好一句指令,把它修改成jmp到我们的目标位置。
我看上了第一个框中最后的一句028 lea rsi, cs:140000000h
。根据第一节课提到的方法,我们可以把这句改掉,改成jmp loc_140001046
。
可以看到,IDA的框图几乎立刻改变了连线,我们根据第一节课提到的方法保存修改后的程序,可以发现程序已经爆破成功。
获知真正的密码
只会暴力破解有时确实足够了,但作为一个逆向工程师这并不代表任何水平,如果你能通过查看程序代码的方式获知密码,不修改程序,只是知道密码是多少,你才能够被称为一个有能力的逆向工程师。
显然,main函数中while里面的内容就是编译完成后的算法代码,很容易发现v4是用户每次键盘输入的字符。经过人脑运行,发现这个代码里还被编译出了位运算这种骚操作,一步一步走下去的话,v4需要先后等于:53、50、80或112、79或111、74或106、73或105、69或101。查ASCII码表可知,密码为52pojie不区分大小写。
到了这部分,就只能自己用脑子跟算法了,没有工具可以辅助,但好在一般需要看这种东西的时候也不是太难,难的算法有另外的分析方法。这个我也没有学习得炉火纯青,还是留待后面的日子里在笔记中慢慢整理完善吧。
尾记
其实我也是第一次如此细致地对比一个非常简单的程序的Debug和Release版本的区别,我发现这样看来,编译器这一层足够把人类的思维和最终运行的代码分隔开了。经过编译器优化的代码是精巧的,不仅完美适合机器运行,还能精确满足编写者的需求。在此向微软的编译器团队致以最崇高的敬意!
学会了如何查找关键信息,并一点一点抽丝剥茧理清程序逻辑,并找到关键修改点或者理解整个算法,接下来我们就要学习如何在没有任何信息可查的情况下尽快入手干活了。这将是下一期的内容,下一期将使用我曾经编写过的一个现成的CrackMe,我将不需要再啰嗦正向开发的内容。