windows程序防狼术入门
当初由于一些原因以及兴趣,学习了一段时间软件逆向,对于软件加密解密有了点粗略的了解。而后看到某些同学辛辛苦苦的搞出个软件,自己费心费力去加密,但搞出来后往往能被秒破,实不忍心。今天大概总结下一些基本的软件加密手段,以供参考,高手勿喷。
关于解密
软件解密主要有2个层次,一个俗称爆破,就是不分析加密算法,只修改一些与验证相关的跳转指令来使得软件正常运行,另一个就是能真正破解加密算法,进而写出注册机。破解手段通常有静态分析和动态分析两种方式,目前二者的代表工具是IDA和OllyDbg(OD)。
加密算法与代码
加密首先必须设计一套加密算法,这个可以用现成的如MD5,SHA之类的算法,也可以自己设计个稍微简单点的算法。一般情况,作为一个开发者,设计一个简单的加密算法应该问题不大的,但是算法设计必须要严密,不能出现漏网之鱼。比如一个时间限制的算法,如果只记录开始结束时间,然后用当前时间去判断,这样的算法通过修改系统时间就给绕过去了,就不够严密,需要改善;例如可再记录一个最近一次运行时间,这样就可以处理修改系统时间的漏洞了。
有了一个完善的加密算法,最直接也最容易想到的做法就是把用户输入的密码用算法转换后与保存的密钥对比,一致则验证通过,不一致则验证失败。这样的加密程序估计新手也能快速爆破了。那么在代码编写时,需要注意下面几点
首先,加密算法尽量不出现在程序中。比如你的加密算法是\(f\),用户输入密码\(x\),程序保存的秘钥为\(y\),那么只有在\(y==f(x)\)时才能验证通过。避免\(f\)的具体实现出现在程序中,可以防止破解者分析你的加密算法从而写出注册机,那么可以设计另外一组算法\(g\)和\(h\)使得\(y==f(x)\;\Leftrightarrow \; g(y)==h(f(x))\),记\(s=hf\),这样在程序里就只会出现\(g\)和\(s\)而不会出现\(f\)了。例如下面代码:
1 #define MAX_LEN 256 2 int Validation(char *py, char *px) 3 { 4 char azy[MAX_LEN] = {0}; 5 char azx[MAX_LEN] = {0}; 6 char *ptmp = NULL; 7 8 //这里加密算法实质是将数字转换为小写字母 9 //但此处分别直接将待匹配密钥py和用户密码px转大写字符后对比 10 //而不是将px转小写字母后与py比较 11 ptmp = azy; 12 while(*py != '\0') 13 { 14 *ptmp++ = (*py++) & (~0x20); //这里是把所有字母转为大写 15 } 16 17 ptmp = azx; 18 while(*px != '\0') 19 { 20 *ptmp++ = (*px++) + 0x10;//这里把所有数字转大写字母 21 } 22 23 return strcmp(azy, azx); 24 25 }
加密算法\(f\)是把数字映射为小写字母,但验证过程中,直接把用户输入密码映射到大写字母(即为\(s\)函数),同时将保存密码也转换到大写字母(\(g\)函数),再进行比较,这样就避免了加密算法\(f\)出现在程序中。当然这里算法很简单,也许能推导出\(f\),但随着算法复杂性增加就会非常难了。
第二,尽量别用if…else判断验证结果。用了if…else结构判断,必然会有一个jmp指令,别人只要定位到该指令修改jmp条件,就彻底被爆破了。可以将验证结果作为索引去达到目的,比如上面的加密算法,若用户输入12345打印验证成功,否则失败。如下代码:
1 int main() 2 { 3 char aKey[] = "abcde"; 4 char aPassword[MAX_LEN] = {0}; 5 printf("input password:\n"); 6 gets(aPassword); 7 8 int nRes = Validation(aKey, aPassword); 9 10 //这里直接使用if...else判断 11 if(nRes != 0) 12 { 13 printf("validation failed!\n"); 14 return 1; 15 } 16 printf("validation success!\n"); 17 18 //这里讲验证结果作为索引 19 char aaPrintInfo[][MAX_LEN] = {"validation success!", "validation failed!"}; 20 printf("%s\n",aaPrintInfo[nRes]); 21 22 23 return 0; 24 }
如果不得不用if…else结构,可将if语句与验证函数分散开,对于静态分析代码的难度会有所增加。
第三,就是一些关键提示信息不要放在堆内而放在栈内。OD有个查找字符串功能可以把程序堆内的字符串列出来,新手最喜欢用这个来定位跳转点爆破了。
1 void main() 2 { 3 char a[]= "this is in stack"; 4 char *b = "this is in heap"; 5 6 printf("%s\n%s\n", a, b, "also in heap"); 7 }
这段代码有3个字符串(a,b和“also in heap”),编译后通过OD加载并查找字符串,如下图:
可以看到存放在堆中的字符串被搜索出来了,从而可以快速定位到对应代码位置:
上图中选中行的edx存放的就是字符串a,但却不会被搜索出来。
加壳
不得不说,虽然上面做了那么些工作,对于破解来说也仅仅增加了一点点的难度,一般的新手努力点也不难搞定。那么通过软件加壳的方式可以把那些不会脱壳的新手们挡在门外。
对于普通的PE文件,将其按二进制打开可以直接解析其内部的数据或者指令,壳就相当于一个加锁的箱子,让人不能直接看到PE文件的真正内容而只能看到加密后的内容,在程序运行时在将其解密到内存从而运行。也就是说,对于加壳的程序,静态分析是不可行的,必须要脱壳后才能分析,即便是动态调试也可能会很所难度。
软件壳有压缩壳和加密壳,一般压缩壳主要是减小PE文件的大小,而加密壳则是为了防止PE文件被反编译、调试和修改等。常用的一些壳如UPX,ASP等等都有专门的加壳与脱壳工具,目前据说最难搞定的还是vmprotect,在看雪网站有多种加壳工具,大家可自行参考。
加壳后PE文件的大小以及程序入口点都会发生变化,可以使用PEID来查看相关加壳信息。下图是程序加壳前后的信息,可以看到PE文件的很多信息都不一样了:
反调试
如果通过加壳保护了程序,固然不错。但目前大多数的壳都有了脱壳机,有很大风险被脱掉,那我们还得要加强防范,这就是程序反调试。反调试的基本思想是检测程序当前是否在被调试,若是则做一些保护措施,如退出、崩溃等手段。
运行一个程序,其进程内有很多地方会标识当前进程是否在被调试,通过检测这些变量就可以简单地判断出来从而进行处理。Windows系统还提供了一个IsDebuggerPresent的API来供调用,不过该函数名声太大,很多调试器都会绕过它。这个博客列得比较详细,值得参考。
另外,若在程序某个位置打了软件断点,此处会被调试器修改为0xCC,当执行到该处时才会修改回去,因此还有一类方法就是程序校验,如CRC校验或MD5校验。基本做法是将当前PE文件做为输入,生成一个字符串,通过判断字符串是否改变可判断程序是否被调试或修改。
还有一种比较粗暴的做法。目前windows程序调试器用得较多时OD和SoftIce,可以通过枚举系统当前进程来判断这两款调试器是否在运行,若运行则认为程序在被调试。也许别人在调试别的程序呢,不管那么多了,为了安全起见,不得不“宁肯错杀三千也绝不漏网一人”。判断系统是否有OD在运行的代码如下:
1 #include "tlhelp32.h" 2 bool IsODRuning() 3 { 4 HANDLE hwnd; 5 PROCESSENTRY32 tp32; //结构体 6 tp32.dwSize = sizeof(PROCESSENTRY32); 7 TCHAR *str= _TEXT("OLLYDBG.EXE"); 8 bool bFindOD=false; 9 hwnd=::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS,NULL); 10 if(INVALID_HANDLE_VALUE!=hwnd) 11 { 12 Process32First(hwnd,&tp32); 13 do{ 14 15 if(0==wcsicmp(str,tp32.szExeFile)) 16 { 17 bFindOD=true; 18 break; 19 } 20 }while(Process32Next(hwnd,&tp32)); 21 } 22 CloseHandle(hwnd); 23 24 return bFindOD; 25 }
最后,还有利用异常处理的方法。比如下面代码,通过人为故意产生一个中断异常,然后在异常处理中去验证,这样在调试的时候中断异常就是一个断点,从而程序不会进入异常处理。代码如下:
1 long g_label = 0; 2 LONG Handle(EXCEPTION_POINTERS *pExceptionInfo ) 3 { 4 if(EXCEPTION_BREAKPOINT == pExceptionInfo->ExceptionRecord->ExceptionCode) 5 { 6 //validation 7 8 if(/*success*/) 9 { 10 pExceptionInfo->ContextRecord->Eip = g_label; 11 12 return EXCEPTION_CONTINUE_EXECUTION; 13 } 14 } 15 return EXCEPTION_EXECUTE_HANDLER; 16 } 17 18 void main() 19 { 20 21 //===exception validation begin 22 LPTOP_LEVEL_EXCEPTION_FILTER lpOld; 23 lpOld = SetUnhandledExceptionFilter((LPTOP_LEVEL_EXCEPTION_FILTER)Handle); 24 25 __asm 26 { 27 push label_ok; 28 pop g_label; 29 int 3; 30 } 31 32 label_ok: 33 SetUnhandledExceptionFilter(lpOld); 34 //===exception validation end 35 //do your things...... 36 }
当然也可以采用其他异常(如除0异常),但用异常的一个缺点就是自己写代码调试的时候也很不方便。
以上就是我大概了解的反调试技术,不过加解密是具有强烈对抗性的,现在一些调试器都增加了反反调试手段,让程序的反调试失效。
驱动保护与硬件加密狗
程序做好上面的保护,基本上已经具有一定的自我保护能力了,一般个人写的软件已经足够。如果你写的是商业软件,需要高度防范破解,那可以采用驱动保护或硬件加密狗。具体采用何种根据软件来定。
如果软件是一些专业性较强的,可以采用硬件加密狗来保护;如果软件是像网络游戏那样面向广泛大众群体的,采用加密狗就不现实了,一般都采用驱动保护,企鹅的游戏基本都有TenProtect的驱动保护,盛大的GPK保护等都是比较典型的例子。
加密狗我没仔细研究过,就不好多说了。上面那些反调试的手段都运行在ring3级别,而驱动则运行ring0级别,驱动保护的做法主要是hook系统底层的一些API,通过检验调用者来区分外部调试修改还是程序自己的操作。比如打开进程的操作,所有调试器都需要调用,通过驱动层hook该函数来防止调试器打开或附加到程序进程。
结语
自从接触了这些东西,才知道“涉密不上网,上网不涉密”的真正意义。要知道这一行高手很多,即便用尽各种手段,也不可能保证软件绝对安全,只要软件运行就会留下痕迹,就有被破解的可能。
现在已一年多没搞这些了,以后估计也没时间去搞,当初学习的时候虽然很累,但却感觉很充实很有兴趣,甚至还想换那方向的工作,谨以此作为对那段时间学习的总结。
虽然写了这么些加密的东西,我个人还是更崇尚开源,如果不是那么必要,还是希望大家能多把源码与人分享,共同进步。