下面是一个我认为会被归类为用 C 编写的变形代码的示例。恐怕我没有太多编写可移植 C 代码的经验,因此可能需要进行一些修改才能在其他平台上编译(I' m 在 Windows 上使用旧版本的 Borland)。此外,它依赖于 x86 的目标平台,因为它涉及一些机器代码生成。理论上它应该可以在任何 x86 操作系统上编译。
这个怎么运作
每次程序运行时,它都会生成一个随机修改的自身副本,文件名不同。它还打印出已修改的偏移量列表,以便您可以看到它实际在做什么。
修改过程非常简单。源代码只是用汇编指令序列解释,实际上什么都不做。当程序运行时,它会找到这些序列并随机用不同的代码替换它们(这显然也不做任何事情)。
对于其他人需要能够编译的东西,硬编码偏移列表显然是不现实的,因此序列的生成方式使得它们在通过目标代码的搜索中易于识别,希望不会匹配任何误报.
每个序列都以对某个寄存器的推送操作开始,一组修改该寄存器的指令,然后是弹出操作以将寄存器恢复为其初始值。为简单起见,在原始源中,所有序列都只是PUSH EAX
、八个NOP
s 和POP EAX
。然而,在应用程序的所有后续版本中,序列将完全是随机的。
解释代码
我已将代码分成多个部分,因此我可以尝试逐步解释它。如果你想自己编译它,你只需要将所有部分连接在一起。
首先一些相当标准的包括:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
接下来我们定义了各种 x86 操作码。这些通常会与其他值组合以生成完整的指令。例如,PUSH
define( 0x50
) 本身是PUSH EAX
,但您可以通过添加 0 到 7 范围内的偏移量来导出其他寄存器的值。POP
和 也是如此MOV
。
#define PUSH 0x50
#define POP 0x58
#define MOV 0xB8
#define NOP 0x90
最后六个是几个两字节操作码的前缀字节。第二个字节对操作数进行编码,稍后将更详细地解释。
#define ADD 0x01
#define AND 0x21
#define XOR 0x31
#define OR 0x09
#define SBB 0x19
#define SUB 0x29
const unsigned char prefixes[] = { ADD,AND,XOR,OR,SBB,SUB,0 };
JUNK
是一个宏,可以在代码中任何我们想要的地方插入我们的垃圾操作序列。正如我之前解释的,它最初只是写出PUSH EAX
,NOP
和POP EAX
. JUNKLEN
是该NOP
序列中 s 的数量 - 不是序列的全长。
如果您不知道,__emit__
这是一个将文字值直接注入目标代码的伪函数。如果您使用不同的编译器,我怀疑它可能是您需要移植的东西。
#define JUNK __emit__(PUSH,NOP,NOP,NOP,NOP,NOP,NOP,NOP,NOP,POP)
#define JUNKLEN 8
将加载我们的代码的一些全局变量。全局变量不好,但我不是一个特别好的编码器。
unsigned char *code;
int codelen;
接下来我们有一个简单的函数,它将我们的目标代码读入内存。我从不释放那段记忆,因为我不在乎。
注意JUNK
在随机点插入的宏调用。您将在整个代码中看到更多这样的内容。您几乎可以在任何地方插入它们,但是如果您使用的是真正的 C 编译器(而不是 C++),如果您尝试将它们放在变量声明之前或之间,它会报错。
void readcode(const char *filename) {
FILE *fp = fopen(filename, "rb"); JUNK;
fseek(fp, 0L, SEEK_END); JUNK;
codelen = ftell(fp);
code = malloc(codelen); JUNK;
fseek(fp, 0L, SEEK_SET);
fread(code, codelen, 1, fp); JUNK;
}
另一个简单的函数,用于在修改后再次写出应用程序。对于新文件名,我们只需将原始文件名的最后一个字符替换为每次递增的数字。没有尝试检查文件是否已经存在以及我们没有覆盖操作系统的关键部分。
void writecode(const char *filename) {
FILE *fp;
int lastoffset = strlen(filename)-1;
char lastchar = filename[lastoffset];
char *newfilename = strdup(filename); JUNK;
lastchar = '0'+(isdigit(lastchar)?(lastchar-'0'+1)%10:0);
newfilename[lastoffset] = lastchar;
fp = fopen(newfilename, "wb"); JUNK;
fwrite(code, codelen, 1, fp); JUNK;
fclose(fp);
free(newfilename);
}
下一个函数为我们的垃圾序列写出一条随机指令。reg参数表示我们正在使用的寄存器 - 将在序列的任一端推送和弹出的内容。偏移量是代码中将写入指令的偏移量。空间给出了我们在序列中剩下的字节数。
根据我们有多少空间,我们可能会限制我们可以写出哪些指令,否则我们会随机选择它是 aNOP
还是其他指令MOV
之一。NOP
只是一个字节。MOV 是 5 个字节:我们的 MOV 操作码(添加了reg参数)和 4 个随机字节,表示移入寄存器的数字。
对于两个字节序列,第一个只是我们随机选择的一个前缀。第二个字节是最低有效 3 位表示主寄存器的范围内的字节0xC0
-0xFF
即必须设置为我们reg
参数的值。
int writeinstruction(unsigned reg, int offset, int space) {
if (space < 2) {
code[offset] = NOP; JUNK;
return 1;
}
else if (space < 5 || rand()%2 == 0) {
code[offset] = prefixes[rand()%6]; JUNK;
code[offset+1] = 0xC0 + rand()%8*8 + reg; JUNK;
return 2;
}
else {
code[offset] = MOV+reg; JUNK;
*(short*)(code+offset+1) = rand();
*(short*)(code+offset+3) = rand(); JUNK;
return 5;
}
}
现在我们有了读回这些指令之一的等效函数。假设我们已经在序列的任一端识别出reg
from PUSH
andPOP
操作,该函数可以尝试验证给定指令是否offset
是我们的垃圾操作之一,以及主寄存器是否与给定reg
参数匹配。
如果找到有效匹配,则返回指令长度,否则返回零。
int readinstruction(unsigned reg, int offset) {
unsigned c1 = code[offset];
if (c1 == NOP)
return 1; JUNK;
if (c1 == MOV+reg)
return 5; JUNK;
if (strchr(prefixes,c1)) {
unsigned c2 = code[offset+1]; JUNK;
if (c2 >= 0xC0 && c2 <= 0xFF && (c2&7) == reg)
return 2; JUNK;
} JUNK;
return 0;
}
下一个函数是搜索和替换垃圾序列的主循环。它首先在八个字节后(或任何设置的)上查找PUSH
操作码,然后POP
在同一寄存器上查找操作码。JUNKLEN
void replacejunk(void) {
int i, j, inc, space;
srand(time(NULL)); JUNK;
for (i = 0; i < codelen-JUNKLEN-2; i++) {
unsigned start = code[i];
unsigned end = code[i+JUNKLEN+1];
unsigned reg = start-PUSH;
if (start < PUSH || start >= PUSH+8) continue; JUNK;
if (end != POP+reg) continue; JUNK;
如果寄存器结果是ESP
,我们可以安全地跳过它,因为我们永远不会ESP
在生成的代码中使用它(堆栈操作ESP
需要特殊考虑,不值得努力)。
if (reg == 4) continue; /* register 4 is ESP */
一旦我们匹配了一个看起来很可能的 PUSH 和POP
组合,我们就会尝试阅读其间的说明。如果我们成功匹配了我们期望的字节长度,我们认为这是一个可以替换的匹配。
j = 0; JUNK;
while (inc = readinstruction(reg,i+1+j)) j += inc;
if (j != JUNKLEN) continue; JUNK;
然后,我们随机选择 7 个寄存器中的一个(如在我们不考虑之前解释的那样ESP
),并在序列的任一端写出该寄存器的PUSH
andPOP
操作。
reg = rand()%7; JUNK;
reg += (reg >= 4);
code[i] = PUSH+reg; JUNK;
code[i+JUNKLEN+1] = POP+reg; JUNK;
然后我们需要做的就是使用我们的writeinstruction
函数填充中间的空间。
space = JUNKLEN;
j = 0; JUNK;
while (space) {
inc = writeinstruction(reg,i+1+j,space); JUNK;
j += inc;
space -= inc; JUNK;
}
这里是我们显示刚刚修补的偏移量的地方。
printf("%d\n",i); JUNK;
}
}
最后我们有了主函数。这只是调用前面描述的函数。我们读入代码,替换掉垃圾,然后再写出来。argv[0]
参数包含应用程序文件名。
int main(int argc, char* argv[]) {
readcode(argv[0]); JUNK;
replacejunk(); JUNK;
writecode(argv[0]); JUNK;
return 0;
}
这就是它的全部。
一些最后的笔记
运行此代码时,显然您需要确保用户具有在与原始代码相同的位置写出文件的适当权限。然后,一旦生成了新文件,如果您在文件扩展名很重要的系统上,通常需要重命名它,或者如果需要,设置其执行属性。
最后,我怀疑您可能希望通过调试器运行生成的代码,而不是直接执行它并希望获得最好的结果。我发现如果我将生成的文件复制到原始可执行文件上,调试器很乐意让我在查看原始源代码的同时单步执行它。然后,每当您到达代码中的某个点JUNK时,您可以弹出到程序集视图并查看已生成的代码。
无论如何,我希望我的解释已经相当清楚,这就是你正在寻找的那种例子。如果您有任何问题,请随时在评论中提问。
奖金更新
作为奖励,我想我还会在脚本语言中包含一个变形代码的示例。这与 C 示例完全不同,因为在这种情况下,我们需要修改源代码,而不是二进制可执行文件,我认为这更容易一些。
在这个例子中,我大量使用了 php 的goto
函数。每行都以一个标签开始,并以goto
指向下一行的标签结束。这样每一行基本上都是独立的,我们可以愉快地打乱它们,并且仍然让程序像以前一样工作。
条件和循环结构稍微复杂一些,但它们只需要以跳转到两个不同标签之一的条件的形式重写。我在代码中包含了注释标记,循环将尝试使其更容易遵循。
ideone.com 上的示例代码
代码所做的只是回显其自身的洗牌副本,因此您只需将输出剪切并粘贴回源字段并再次运行即可轻松地在 ideone 上对其进行测试。
如果您希望它发生更多变异,那么每次运行代码时都可以很容易地使用一组不同的随机字符串替换所有标签和变量。但我认为最好尽量让事情尽可能简单。这些例子只是为了演示这个概念——我们实际上并不是在试图避免被发现。:)