自修改代码(Self Modifying Code)详解

自修改代码的简要历史

自修改代码有很广泛的用途:

1.在10到20年前使用SMC(自保护代码)保护应用程序是很难的,即使是用它来把编译的代码放到内存里.

2.在90年代中期95/NT出现了,那时的程序员对在新的操作系统下如何保护应用程序感到迷惑.不知道该如何将保护措施移植到这个新的版本下.已经不可能再自由的访问内存,硬件,和一般的操作系统,所有以前学会的技巧不得不放弃,开始人们认为除了使用VxD外没法再写SMC,这都是因为文档没跟上而遭到各方的质疑.

然后发现要想在我们的程序中继续使用SMC,我们可以采用下面两种方式:

  • 使用从Kernell32导出的WriteMemoryProcess
  • 将代码放到堆栈中修改

 

Windows 内存的组织

在Wnidws下创建SMS并没有我想的那么直接,首先你必须面对一些特别的方式,其次要用到Windows提供给你的指南.

这也许你知道,Wondows为进程分配了4GB的虚拟内存.这个内存的地址,Windows有两个用途,其一是CS段寄存器,其二是给了DS,SS,和ES寄存器.他们使用同样的内存基地址,(等于0),而且同样限制在4GB

只有一个段即包含代码也包含数据,那就是进程的堆栈,可以使用NEAR 调用或者jump控制堆栈上的本地代码,即不需要要使用SS寄存器去访问堆栈,而且CS寄存器的值也不等于DS,ES,和SS寄存器,

MOV dest

CS:[src]

MOV dest

DS:[src]

MOV dest

SS:[src]

指令指向的是相同的本地地址

内存页包含的数据,代码和堆栈有不同的属性,事实上,代码页允许读和执行,数据页允许读和写,堆栈同时允许读,写,和执行.

尽管他们都绑定了一些安全属性,后面我们会继续讲到.

 

使用WriteProcessMemory

改变进程内字节的最简单的方式是使用WriteMemoryProcess(当然是一些安全标志没有被设置的时候)

对于我们要修改的内存中的进程第一件事情是用OpenProcess打开它,同时需要设上PROCESS_VM_OPERATION 和PROCESS_VM_WRITE属性.

这儿有一些SMC的简单例子,需要在C++中用到内联的汇编语言,

例程1:使用WritePreocessMemory创建SMC

int WriteMe(void *addr, int wb) 

      HANDLE h=OpenProcess(PROCESS_VM_OPERATION|PROCESS_VM_WRITE, 
      true, GetCurrentProcessId()); 
      return WriteProcessMemory(h, addr, &wb, 1, NULL); 


int main(int argc, char* argv[]) 

      _asm { 
               push 0x74 ; JMP --> > JZ 
               push offset Here 
               call WriteMe 
               add esp, 8 
      Here: JMP short here 
      } 
      printf("Holy Sh^& OsIX, it worked! #JMP SHORT $-2 was changed to JZ $-2n"); 
      return 0; 


正如你所看到的,程序用JZ指令实现了跳转.这样程序就可以继续,程序告诉我们,使用jump成功了.

不过WriteMemoryProcess还有一些缺点,首先它会被有经验的解密者在入口表处发现,它很可能会自这个调用处设置一个断点,然后单步找到它想要的代码,WriteProcessMemory一般的用途是编译器的编译内存中,或者可执行文件的解包中,但一定要确保不被解密者利用到.

WriteMemoryProcess 的另一个缺点是无法在内存中创建新页.它只能在已存在的页上工作.

 

将代码放到堆栈上,然后执行

在堆栈上执行代码不是不可能,但是这样会不会产生线程安全性问题呢?如果安装了不允许代码在堆栈上执行的补丁,那么程序员就无技可施了.但反过来可能的结果是你的大部分程序将无法运行,Linux就有这样一个补丁.

记得上面提到的WriteMemoryProcess的几个缺点了么?好的,这是使用堆栈执行代码的两个原因,其一是,一个攻击者在不知道的内存块上是不可能通过发送指令来修改代码,他不得不去分析保护代码,这很难成功.第二个原因是在任何时候堆栈上的可执行代码是真实存在的,应用程序可以给堆栈分配足够的内存,不需要的时候可以释放,通常情况下,系统给堆栈分配1MB的内存,如果感觉分配的内存不够时,可以在程序的配置文件中修改.

 

为什么代码重定位不好呢?

你必须明白在Windows 9X, Windows NT, and Windows 2000.中堆栈的位置是不同的.为了使你的程序在移植到其他机器中还能使用,必须进行重定位,实现起来到不难,只需要满足一些简单的规则.

 

幸运的是在8086里所有的short jumps和near调用是相联系的.也就是说它不使用线性地址,只是下一条指令和目标地址之间不同,这使我们的代码重定位更加的简单,但也有一些约束

 

例如,下面函数会发生什么呢?

void OSIXDemo() 

{

    printf("Hi from OSIXn");

}

函数被拷贝到了堆栈,控制传递给它了么?因为printf的地址已经改变了,这很可能会产生错误.

 

在汇编里面,我们可以使用寄存器很容易的锁定它,重定位调用printf函数就很简单,例如:

LEA EAX, 

printfNCALL EAX.

 

现在是绝对线性地址,而不是关联的.被放置到了EAX寄存器里,现在无论从哪调用它,控制都可以被传递给printf.

 

做这些事情需要你的编译器支持线性的汇编,

例程2:代码如何拷贝到堆栈并且执行

void Demo(int (*_printf) (const char *,...)) 

      _printf("Hello, OSIX!n"); 
      return; 


int main(int argc, char* argv[]) 

      char buff[1000]; 
      int (*_printf) (const char *,...); 
      int (*_main) (int, char **); 
      void (*_Demo) (int (*) (const char *,...)); 
      _printf=printf; 
     

      //加上两个赋值语句
      _main = main;
      _Demo = Demo;
      //

      int func_len = (unsigned int) _main - (unsigned int) _Demo; 
      for (int a=0; a<func_len; a++) 
      buff[a] = ((char *) _Demo)[a]; 
      _Demo = (void (*) (int (*) (const char *,...))) &buff[0]; 

      _Demo(_printf); 
      return 0; 

如果有人告诉你高级语言无法在堆栈上执行代码那么请不要相信.

 

需要的一些优化

首先你要考虑使用哪种编译器,如果你打算使用SMC或者要在堆栈上执行代码,你一定要好好的研究编译器的使用向导.很多人第一次失败的原因都是因为没有使编译器"最优化".

怎么会发生这种现象呢?因为在纯粹的高级语言中,例如C和PASCAL,曾经被谴责无法拷贝函数的代码到堆栈和其他地方,程序员可以获得一个函数的指针,但没有它的标准,我们的程序员称它为"魔数",因为只有编译器知道.

幸运的是,几乎所有的编译器使用同样的逻辑产生代码,这样程序可以假设编译的代码,程序员也可以假设.

我们返回去看看例程2.假设指向函数的指针和函数的首地址一致,主体在首地址的后面,大多数编译器使用这种"common sense compiling",许多大的编译器都使用这种规则(VC++, Borland, 等等).所以如果你没使用一些不知名的编译器,那就不要担心这些.

需要注意的是VC++,如果在Debug模式,编译器就会插入一个"适配器"并且将函数分配到其他一些地方,谴责MS,但是不要担心,只要在编译器的选项里选中"Link Incrementally"就可以了.如果你的编译器没有这样的选项,或者类似的事情,你要么不使用SMC要么改用其他的编译器.

决定函数长度是另一个问题,但这是需要技巧的.在C++里,sizeof结构不返回函数自身的长度,而是指向函数的指针的大小.但按照规则:编译器是按源代码中代码出现的顺序分配内存.所以,函数主体的长度就等于指向函数的指针和指向函数后面的指针之间的距离.容易吧!

 

优化编译器还有另一件事情去做:删除那些他们认为不再使用的变量.返回去看例程2.一些东西被写到了buff缓存.但是没有从那个地方读任何东西,大多数的编译器无法是被传递到缓存的控制.所以他们删除了正在拷贝的代码.这就是为什么控制被传递到了未被初使化的缓存.接着程序崩溃了,如果出现这种问题,请取消"Global optimization"选项.

 

如果你的程序仍然不工作,别放弃,原因很可能是编译器在每个函数的末尾插入了常规调用来监控堆栈.Microshoft' VC++就是这么做的.它在debug项目里放置了__chkesp调用.不要打算到文档里找到它,那里面没有---想一想,这种调用是相关的,没办法屏蔽它,然而,在release版本里,VC++不检查堆栈的状态.所以就不会出现这种问题.

 

在自己的APPS中使用SMC

现在你可以问你自己(或者问我)"在堆栈上执行代码的好处是什么?"回答是:堆栈上的函数的代码可以被很灵巧的改变.

即使笨人的加密代码也使解密高手变的难堪.当然,如果调试的话会变的容易一点.但还是很难.

 

这个简单的加密算法将用异或连续的处理代码的每一行,并且重新执行一遍将产生我们需要的目标代码.

这儿是个例子用来读我们的DEMO函数的内容,并把它加密,把结果写到文件.

 

例程3:怎样加密DEMO函数

void _bild() 

      FILE *f; 
      char buff[1000]; 
      void (*_Demo) (int (*) (const char *,...)); 
      void (*_Bild) (); 
      _Demo=Demo; 
      _Bild=_bild; 

      int func_len = (unsigned int) _Bild - (unsigned int) _Demo; 
      f=fopen("Demo32.bin", "wb"); 
      for (int a=0; a<func_len; a++) 
      fputc(((int) buff[a]) ^ 0x77, f); 
      fclose(f); 

在加密完成之后,内容被放到了字符串中,现在Demo函数可以从初始化代码中被移除了,然后当我们需要的时候,它可以被加密,可以被拷贝到本地缓存,可以被执行调用.

这是我们如何实现的例子:

 

例程4:加密程序

int main(int argc, char* argv[]) 

      char buff[1000]; 
      int (*_printf) (const char *,...); 
      void (*_Demo) (int (*) (const char *,...)); 
      char code[]="x22xFCx9BxF4x9Bx67xB1x32x87 
x3FxB1x32x86x12xB1x32x85x1BxB1 
x32x84x1BxB1x32x83x18xB1x32x82 
x5BxB1x32x81x57xB1x32x80x20xB1 
x32x8Fx18xB1x32x8Ex05xB1x32x8D 
x1BxB1x32x8Cx13xB1x32x8Bx56xB1 
x32x8Ax7DxB1x32x89x77xFAx32x87 
x27x88x22x7FxF4xB3x73xFCx92x2A 
xB4"; 

      _printf=printf; 
      int code_size=strlen(&code[0]); 
      strcpy(&buff[0], &code[0]); 

      for (int a=0; a<code_size; a++) 
      buff[a] = buff[a] ^ 0x77; 
      _Demo = (void (*) (int (*) (const char *,...))) &buff[0]; 
      _Demo(_printf); 
      return 0; 

注意printf函数显示一个祝贺.第一眼你可能注意不到什么是没用的,但是如果找一下字符串"Hello, OSIX!" 所在的位置,它不应当在代码段(borland把它放到那是有原因的)--因此检查数据段,你会发现它原来因该在那.

现在,即使攻击者查看源代码,也会迷惑不解的,我用这种方法隐藏所有的信息(一串数字,我的程序的发生器,等等).

如果你想用这种方法检验序列号,检验方法要有组织,以便解压的时候还能用到,下一个例子我会讲这些.

记住,实现SMC的时候需要知道你要改变的字节的确切的位置.因此,需要用汇编来代替高级语言.

 

用汇编语言来实现有一个问题要注意,MOV指令需要通过传递绝对的线性地址来改变确切的字节.在程序运行期间我们能找到这些信息.调用 $+5POP REGMOV [reg+relative_address], xx状态已经取得.可以工作了,插入下面声明,它执行CALL指令,并且从堆栈弹出返回地址(或者这个指令的绝对地址).这被用来作为堆栈函数代码的基地址.

下面是序列好代码的例子:

例程5:产生一个序列号,并在堆栈运行.

MyFunc: 
push esi ; Saving the esi register on the stack 
mov esi, [esp+8] ; ESI = &username[0] 
push ebx ; Saving other registers on the stack 
push ecx 
push edx 
xor eax, eax ; Zeroing working registers 
xor edx, edx 
RepeatString: ; Byte-by-byte string-processing loop 

lodsb ; Reading the next byte into AL 
test al, al ; Has the end of the string been reached? 
jz short Exit 

; The value of the counter that processes 1 byte of the string 
; must be choosen so that all bits are intermixed, but parity 
; (oddness) is provided for the result of transformations 
; performed by the XOR operation. 

mov ecx, 21h 
RepeatChar: 
xor edx, eax ; Repeatedly replacing XOR with ADC 
ror eax, 3 
rol edx, 5 
call $+5 ; EBX = EIP 
pop ebx ; / 
xor byte ptr [ebx-0Dh], 26h; 
; This instruction provides for the loop. 
; The XOR instruction is replaced with ADC. 
loop RepeatChar 
jmp short RepeatString 

Exit: 

xchg eax, edx ; The result of work (ser.num) in EAX 
pop edx ; Restoring the registers 
pop ecx 
pop ebx 
pop esi 
retn ; Returning from the function 

这个算法有点怪异--

因为不断的调用一个函数,并且给它传递同样的参数有可能产生或者一样,或者完全不同的结果!这在于用户名的长度,当函数结束的时候,如果他是odd,xor被ADC替换,如果是偶数,看起来什么都没发生.

好的,这些是全部了,我希望你最少了解一件事情,这花了我整整两小时,欢迎反馈.

 

唉!我翻译它竟然用了三个小时,汗啊!!!

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值