代码混淆之我见(一)
前言:第一篇认真写的文章,还是有点仓促(下午还要去学校上晚自习,见谅,见谅……)。文章中附带的那个源代码,我用的PEFile库好像有问题,据朋友反映说在WIN7下被该库操作过的文件一直无法打开,汗……如果确实运行不起来,文章后面给了一种解决方案。如果你有好用的操作PE的VC库,不妨发我一份?Warning:我只是一个高中生,和计算机有关的东西都是自学的,代码混淆是一个学术性很强的东西,不是我这种渣渣能驾驭的,文章中如有错误,请使劲打我脸!打得不够响就会误人子弟!
一.混淆技术概论
究竟是什么是混淆?如果仔细思考一下这个问题,就会发现混淆其实是打破思维惯性的一种方式。不可否认惯性思维带来的方便,但是同样有其局限性。我们一般对反汇编代码进行还原时,默认CALL就是对一个函数的调用,碰到RET就是函数返回,条件分支两侧的代码都有可能被执行。而代码混淆就是打破了这种思维惯性,让逆向工程变得更加复杂。
说到混淆,就不得不提到编译原理。编译器在把中间代码翻译为目标程序时,会先经过一个代码优化器来处理。
其过程可以表示为:
源程序 - > 前端 - > 中间代码 - > 代码优化器 - > 中间代码 - > 代码生成器 - > 目标程序
而混淆,就是代码优化器的逆过程。
相应的,混淆技术也可以分为两类,基于控制流的混淆,基于数据流的混淆。这种分类其实有武断的成分(概念有交叉)在里面,就像要问苏轼是豪放派词人还是婉约派词人一样。实际应用中,这两种技术都会被紧密结合在一起。
所以如果一个人要进行解混淆操作,而这个人恰好对编译优化技术一窍不通,可以说,他能做到的最好的解混淆也就是搜一搜特征码,做模式替换。
基于数据流的混淆技术
(1)常量展开(常量合并的逆操作)
编译器在编译时,会把那些在每次运行时总是得到相同常量值的表达式替换为该常量值。
int a;
a = 5 * 7 + 10;
比如上面这段代码,在现代编译器中是不可能把"5 * 7 + 10"这个计算过程编译进目标程序的,因为这个值在编译时就可以推算出来。
对代码进行混淆时,我们可以提取出一些指令的立即数,对其进行展开。
push 2
push 1
inc dword ptr,1
这两段代码是完全等价的,如果忽略标志位的变化的话。在VMProtect 1.X(早期版本)中,该混淆技术被大量运用。
(2)恒等运算
x - 0 == x
x - 1 == ~-x
x + 1 == -x
ror x,y == x >> y | (x << (lenbite(x) - y))
rol x,y == x << y | (x >> (lenbite(x) - y))
//lenbite指取位数,比如DWORD取位数是32
(3)模式替换(窥孔优化的逆操作)
1. push x
2. lea esp,
mov ,x
3. lea esp,
4. push reg32
mov reg32,esp
xchg ,esp
pop esp
1与2是完全等价的,3与4也是完全等价的。
这项技术的可怕在于,这个过程是可以不断重复的。混淆了一次代码,可能又会有新的可以混淆的代码出现。
这项技术在Themiad的虚拟机里可以说是运用地非常成熟。
当然,尽管其变形后的代码往往让人读了之后有想吐的感觉,但其实去混淆非常简单。你放心,我绝不会编这么一个无聊的小孩子谎话来骗你,只要搜集到足够多的模板,对其替换回来就好了。唯一要考虑的难题就是,你的编码能力能不能支撑你写完一个高阶的模式匹配器。
基于控制流的混淆技术
(1)插入死代码(消除死代码的逆操作)
如果一个变量在程序的某一点上的值以后可能会被用到,则该变量在改点上是活的,否则,它在该点上就是死的。与此相关的一个概念就是死代码,即永远不会被使用的语句。
基于该项技术,还可以延伸出伪造基本块等混淆技术。
其思路就是在两个基本块之间,或者我们可以在一个基本块里强行再随机选择首指令划出两个基本块,这两个新基本块本应该有一条无条件转移指令连接,我们可以伪造一条条件跳转指令,而有效的,永远是两侧分支中的一支。
如何不让逆向者轻易的发现这是伪造的基本块呢?
我们可以引入一些必然成立的数学公式,或者在某个范围上必然成立的数学公式。
比如,贯穿整个高中函数题的基本不等式。a^2 + b^2 >= 2(ab)^(1/2)
或者柯西不等式。(a^2+b^2)(c^2 + d^2)≥(ac+bd)^2
再阴险一点的话,可以考虑去数论里面找一些灵感,比如费马小定理。
(2)流图展平
流图是用来表示中间代码的一种方式。流图就是图,而基本块构成图的节点,基本块之间以转移指令构造出来的联系就是图的边。
借助IDA等工具,我们可以清楚地看到一段代码的流图,而这对我们逆向工程的进行是很有益处的。有了控制流,我们就知道往哪里入手,如果走控制流行不通,我们就只能走数据流,比如常见的我们对内存中的关键信息下内存断点。如果数据流和控制流都走不通,那么唯有设计一个解混淆工具,这是背水一战。
(3)虚拟机
没什么多说的,大名鼎鼎的一项技术。
简单来说就是将部分代码重新编译一次,并打包解释器进程序,运行的时候动态解释执行。
但这种技术会带来很多额外的CPU开销,不能用来,或者说不会有人用来保护算法性质的代码。
(4)打乱代码顺序局部性
同一基本块中的指令基本都是按顺序排列的,而不同基本块之间也基本上是按照一定顺序排列的,因为这可以减少跳转指令的数量。
在十年前,或者说更早的时候,用JMP IMM32来打乱代码顺序这种技术的运用可以说是数不胜数。
但以现在的眼光来看,这种技术完全是幼儿园五岁小孩的把戏。有太多办法可以把它KILL掉,这里不多赘述。
(5)特殊的控制流模糊化
我们可以采取异常处理机制,设置程序的下一步状态。但这也是基于系统的,你不能指望在Linux上面还能使用VEH。
实现思路就很简单了,现在也还有一些壳会用到这种技术。
如果说阴险的话,我首推PESpin壳,该壳的v1.33版本会使用DebugAPI接管程序,并转移一部分指令进虚拟机,异常触发时由调试程序来执行。可以说是异常机制运用的典范了。
二.尝试实现一个混淆器 —— 流图展平
对于PE操作库,反汇编引擎,汇编引擎,我都是直接在Github上找的。它们分别是,PEFile、BeaEngine、XDEParser。
尝试对问题分解,我们首先得知道流图展平究竟是个什么东西。
概论里面没有详细说明,现在详细解说一下。
因为找不到好用的画图的工具,所以只能手绘代替了。
可以看到,程序的流图原来是非常清晰明朗的,而经过混淆后,程序各基本块之间的联系被隐藏在Dispatch中了。
实现思路很简单,我们首先需要切割出基本块,然后设计一个Dispatch,并在每个基本块(把转移指令排除在外)尾部附上对上下文的设置,由Dispatch对上下文进行解码,并设置程序状态。
1.基本块如何划分?
基本块是满足下列条件的连续汇编指令(在编译原理中是中间代码指令)
(1)控制流只能从基本块的第一条指令进入该块。也就是说,没有跳转到该基本块中间的指令
(2)除了基本块尾指令,否则不会有机会离开基本块。
如何划分基本块呢?
首先我们要确定究竟那些指令是首指令,选择首指令的规则如下。
(1)待混淆的所有代码的第一条指令是首指令。
(2)任意一条转移指令的目标指令是一个首指令。
(3)紧跟在一条转移指令后的指令是一个首指令。
每个首指令对应的基本块包括了从它开始,直到下一个首指令(不含)或者结尾指令的所有指令。
2.如何设置Dispatch?
本来应该是引入一个标志,来控制程序的转移的。
比如以下代码。请细细领悟一下。
int code = 0;
while (1)
{
switch (code)
{
case 0:
i = 50;
code = 1;
break;
case 1:
if (i < 20)
code = 2;
else
code = 3;
break;
case 2:
if (i >= 0)
code = 4;
else
code = 5;
break;
case 3:
if (i <= 100)
code = 6;
else
code = 5;
break;
case 4:
i -= 3;
code = 2;
break;
case 5:
return 0;
case 6:
i += 3;
code = 3;
}
}
我将其简化,使用堆栈传递信息。
push BranchType
push Branch2 //跳转成立的目标代码块
push Branch1 //跳转不成立的目标代码块
jmp Dispatch //跳转到Dispatch
Dispatch的代码如下。
pushad
pushfd
mov edx,dword ptr ;edx - > eflags
mov ecx,dword ptr ;ecx - > BranchType
cmp ecx,0 ;JO
jnz @F
push edx
popfd
mov edx,0
mov eax,1
cmovo edx,eax
jmp end5
@@:
cmp ecx,1 ;JC | JB
jnz @F
push edx
popfd
mov edx,0
mov eax,1
cmovc edx,eax
jmp end5
@@:
cmp ecx,2 ;JE
jnz @F
push edx
popfd
mov edx,0
mov eax,1
cmove edx,eax
jmp end5
@@:
cmp ecx,3 ;JS
jnz @F
push edx
popfd
mov edx,0
mov eax,1
cmovs eax,edx
jmp end5
@@:
cmp ecx,4 ;JP
jnz @F
push edx
popfd
mov edx,0
mov eax,1
cmovp eax,edx
jmp end5
@@:
cmp ecx,5 ;JL
jnz @F
mov eax,1
push edx
popfd
mov edx,0
cmovl edx,eax
jmp end5
@@:
cmp ecx,7 ;JG
jnz @F
mov eax,1
push edx
popfd
mov edx,0
cmovg edx,eax
jmp end5
@@:
cmp ecx,6 ;JA
jnz @F
mov eax,1
push edx
popfd
mov edx,0
cmova edx,eax
@@:
mov eax,dword ptr ;JECXZ
cmp eax,0
mov ebx,1
cmovz edx,ebx
jmp end5
end5:
push dword ptr
add esp,4
popfd
popad
sub esp,28h
ret 30h
一些汇编指令可能需要解说一下,CMOVXX,是条件赋值指令,是在686 CPU以后引入的,对于从罗云斌的汇编书入门的朋友,可能会习惯性地指明使用.386指令集,要注意使用.686才能在MASM编译器里编译,对于一些调用MASM做汇编与机器码转换的软件,也有部分BUG,朋友告诉我说,很多工具包里装的转换软件其实是使用486指令集的,不能编译成功。
对于JXX和JNXX指令,我只实现了JXX指令。
JXX Label2
Label1:
其实可以转换为
JNXX Label1:
Label2
这种形式。
本来限于代码排序问题,这种转换实现是很费功夫的,但我们设计的Dispatch,天然支持设置两处分支目标。
上一段切割基本块的代码。
for (unsigned int i = 0; i < vAsm.size(); i++) //对所有跳转指令的下一条指令,跳转指令目的地,以及最后一条指令做标记
{
if (IsBranch(vAsm.stAsm.Instruction.BranchType) && vAsm.stAsm.Instruction.AddrValue != 0)
{
vAsm.states = true;
for (unsigned int x = 0; x < vAsm.size(); x++)
{
if (vAsm.stAsm.VirtualAddr == vAsm.stAsm.Instruction.AddrValue)
{
vAsm.states = true;
}
}
}
}
vAsm.states = true;
CodeBlock stBlock;
for (unsigned int i = 0; i < vAsm.size(); i++)
{
if (vAsm.states == true)
{
stBlock.Entry = (int)stBlock.vAsm.VirtualAddr;
if (!IsBranch(vAsm.stAsm.Instruction.BranchType) || vAsm.stAsm.Instruction.AddrValue == 0) //如果尾指令不是跳转指令
{
stBlock.iBranch1 = (int)vAsm.stAsm.VirtualAddr;
stBlock.iBranch2 = (int)vAsm.stAsm.VirtualAddr;
srand(unsigned int(time(NULL)));
stBlock.nBrType = rand() % 8; //当成JMP指令来处理
}
else if (IsBranch(vAsm.stAsm.Instruction.BranchType) && vAsm.stAsm.Instruction.AddrValue != 0) //如果尾指令是跳转指令
{
stBlock.nBrType = tranbr(vAsm.stAsm.Instruction.BranchType);
stBlock.iBranch1 = (int)vAsm.stAsm.VirtualAddr;
stBlock.iBranch2 = (int)vAsm.stAsm.Instruction.AddrValue;
if (stBlock.nBrType == 10) //如果是JMP指令,随便找一条跳转指令模拟JMP
{
srand(unsigned int(time(NULL)));
stBlock.nBrType = rand() % 8;
stBlock.iBranch1 = stBlock.iBranch2;
}
if (stBlock.nBrType < 0) //对于JNC Label \ Label2: 指令,将其转换为JC Label2 \ Label1:
{
stBlock.nBrType = -stBlock.nBrType;
int k;
k = stBlock.iBranch1;
stBlock.iBranch1 = stBlock.iBranch2;
stBlock.iBranch2 = k;
}
stBlock.vAsm.pop_back(); //删除尾指令
}
vBlocks.push_back(stBlock);
stBlock.vAsm.clear();
stBlock.vAsm.push_back(vAsm.stAsm);
}
else
{
stBlock.vAsm.push_back(vAsm.stAsm);
}
}
vBlocks.vAsm.push_back(vAsm.stAsm); //将代码切割成基本块
接下来我们要做的就是抹除原来的指令,以及打乱一下代码的空间局部性。
for (unsigned int i = 0; i < vBlocks.size() - 1; i++)
{
srand(unsigned int(time(NULL)));
chanblock(vBlocks, i, rand() % (unsigned int)vBlocks.size());
}
//先将基本块随机交换位置,打乱代码空间局部性
int pBase = (int)pe.getaddr(pCopy, vt_offset, vt_va);
vector<int> vOldEntry;
const int jmpsize = 17; //每个基本块后都会添加一段指令,以跳转到Dispatch,需要用到该字段计算新的Entry
for (unsigned int i = 0; i < vBlocks.size(); i++)
{
vOldEntry.push_back(vBlocks.Entry);
}
//记录所有基本块的原入口
vBlocks.Entry = pBase;
for (unsigned int i = 1; i < vBlocks.size(); i++)
{
static int len = 0;
for (unsigned int x = 0; x < vBlocks.vAsm.size(); x++)
{
memcpy(xde.instr, vBlocks.vAsm.CompleteInstr, 64);
xde.cip = 0;
XEDParseAssemble(&xde);
len += xde.dest_size; //这里出现设计失误了,本来应该记录一下指令长度的
}
len += jmpsize; //基本块尾部跳转到Dispatch的指令长度
vBlocks.Entry = pBase + len;
}
//更新所有基本块的入口
for (unsigned int i = 0; i < vBlocks.size(); i++)
{
for (unsigned int x = 0; x < vOldEntry.size(); x++)
{
if (vBlocks.iBranch1 == vOldEntry)
{
vBlocks.iBranch1 = vBlocks.Entry;
}
if (vBlocks.iBranch2 == vOldEntry)
{
vBlocks.iBranch2 = vBlocks.Entry;
}
}
}
//修正所有区块的后继区块
for (unsigned int i = 0; i < vOldEntry.size(); i++)
{
if (vOldEntry == pStart)
{
PBYTE p;
unsigned char jm32 = 0xE9;
int k;
p = pe.get(pe.getaddr(pStart, vt_va, vt_offset));
memcpy(p, &jm32, 1);
p++;
k = vBlocks.Entry - pStart - 5;
memcpy(p, &k, 4);
p += 4;
memset(p, 0xCC, pEnd - pStart - 5);
break;
}
}
//修改原有代码,指向被混淆后的代码,同时抹除原有代码
接下来要做的事就是把各个基本块的指令按新顺序写入,再在尾部附加跳转到Dispatch以及设置上下文的代码就好了。
三.如何解决PE不能运行的问题。
我们可以写段代码来测试一下。
#include "stdafx.h"
int _tmain(int argc, _TCHAR* argv[])
{
int i = 20;
scanf("%d", &i);
if (i > 50)
{
printf("i > 50\n");
}
else
{
printf("i < 50\n");
}
for (i = 0; i < 20; i++)
{
printf("i = %d\n", i);
}
getchar();
getchar();
return 0;
}
混淆后,如果出现这种情况。
请在studype从,把.lcz区段导出,然后找到未混淆的原文件,手动在lordpe中导入.lcz区段,注意区段一定要在最末尾。
然后跳转到待混淆代码的开始,将其手工修改跳转到被混淆后的代码。
注意,混淆后的代码基本块顺序被打乱了,要手动定位一下。
以下是我的手工修改。
JMP跳转到了已经被处理过的混淆代码,这个区块是我们自己手动加的。
注意寻找第一个基本块,切记啊。
这是我朋友花了半个小时还不知道的东西,简直无语……
目录里的I.EXE是测试程序。
源代码下载:
链接: https://pan.baidu.com/s/1mqeJGTYaM2jOrPA-HGdzwg 提取码: jqd9
非常棒,沉舟童鞋的作品一直很认真很仔细! 这个帖子满满的干货!
代码混淆,论坛几乎没有!
感谢作者,希望大佬有空出个视频。 沉舟兄已经可以了 我高中还在玩蛇 你高中就在玩这些了 高中童靴不错,有点悟性! 感谢楼主的分享。
看到楼主的年龄,我是实在汗颜{:5_117:} 果断收藏了。 教程对新手而言简直太有用了 多谢分享多谢分享 多谢分享多谢分享