首页
社区
课程
招聘
[原创]向WM程序添加新节的研究心得[1]对WM环境下ARM汇编的深入认识
发表于: 2009-3-20 13:37 7475

[原创]向WM程序添加新节的研究心得[1]对WM环境下ARM汇编的深入认识

2009-3-20 13:37
7475

WM程序仍然是PE格式, 只要是PE格式, 添加新节的原理都是一样的. 本文就不介绍其原理了, 网上资料很多, <<软件加密技术内幕>>和<<加密与解密>>中也有介绍.

但具体到ARM平台, 具体处理方式与MASM32有很大区别. 本文立足于ARM汇编, 准确说, 是立足于Xarm开发环境, 介绍实现过程中遇到的困难和问题.

自己是在这个研究过程中, 大大加深了对ARM汇编的理解, 在这里和大家分享一下.
下面是我在研究过程中, 依次遇到的一些问题, 是在自己当时作的笔记基础上修改后发出来的.

写在前面:
ARM指令的特点:
写本段时, 手边没有现成的资料, 仅凭记忆和推断, 可能有误. 不过没关系, 提示出思路就行了
ARM指令是标准的RISC(精简指令集), 它的最本质的特点就是: 减轻硬件设计的负担, 加重编译器的负担. 一切设计都是为了机器, 不是为了人, 所以完全不像MASM32一样人性化(连.IF都有,好羡慕... 连有什么offset, assume... 都极大的方便了汇编程序员).
其他的特点是由这个本质特点决定的.
指令长度固定为4字节, 32bit. 那么可以算一笔帐了:
在形如
        add        r0        ,        r1        ,        r2        ,        lsl # 3
的指令中, 每个寄存器号需要4bit来表示(r0 - r15), 移位值需要5+1bit表示(可左右分别移32位)也就是说, 除了操作码以外, 已经占据了32bit中的18bit, 前面留给操作码和s后缀的长度有14bit
再来看看立即数的传送:
        mov        r0        ,        # 0xFF00
按前面的结论, 操作码部分有14bit, 操作数部分, 寄存器有4bit, 立即数总共还剩14bit(指令总长固定为32bit). 为了让立即数的取值范围更宽一点, 引入移位的方法, 即在剩下的14bit中, 抽出5+1bit作为移位的值, 剩给立即数的恰恰剩下8bit. 这就是为什么"二进制宽度"大于8的数不能用作立即数, 超过了指令中给立即数留的8bit范围了. (当然如果不用移位, 指令中表示移位的5+1位为0就行了)
最后来看看跳转指令:
        b        Label
没有寄存器号, 除了开头操作码部分的14bit以外, 剩下的18bit可以全部留给Label使用!但是地址是32位啊, 够把整条指令填满了! 真的需要在指令中指出32位地址吗? 不!
b是用来跳转到一条指令处, 而指令都是按4字节对齐的, 也就是说b的目的地, 最后2bit必定是0.再加上如果用pc作为一个基准, 在pc上作加法来移动pc, 所需要在指令中指定的地址宽度就更少了.如果再加上移位, 也许就更节约了.

确实, b实现的编码机制不同, 我承认目前我还没搞懂怎么回事...
不好意思, 但它的执行效果, 确实是将一个"大立即数"以某种方式放到了pc. 以后再研究这个内部机制.

这样说明, 是为了说清楚下面的关于"大立即数"的概念.

01.........................................
b的实质:
        b        Label
实质就是将一个标号对应的VA传送到pc, 可认为实现了一个大立即数的传送Label在编译或装载的过程中,被替换成了一个具体的VA值, 在Debug中看来, 该语句实际上是:
        b        00011200
这样的形式
在编译完成后, 这个常数(大立即数)就确定下来了, 并以某种方式保存在编译后的指令中.(如上所述, 具体的方式未知, 但是确实是一个常数)

如果该VA值已经存放在寄存器中, 则可以用:
        mov        pc        ,        r0
代替.
但是将VA值装载到寄存器中, 又涉及到另外的问题. 稍后分析.

02.........................................
bl的实质:
        bl        Label
与b完全类似, 只是要多做一个大立即数的传送工作:
先将当前指令的下一条指令的VA, 传送到lr, 再将Label在编译过程中转换得到的VA, 传送到pc相当于是两次大立即数的传送.
传递下条指令的VA到lr的过程是一个"动态"过程, 与bl的实际位置有关, 即与当前的pc值有关.并未在编译过程中像Label一样被固定下来.

在MASM32中, 要添加新节的话, 新节中为了确定自己的实际位置, 往往要用到以下两行:
        call        Label
Label
        pop        eax
来得到Label处的VA, 新节中的其他代码在寻址时, 都要用到这个VA和目标地址相对于Label的偏移量来作加法, 以得到目标地址的VA, 如下句得到Dest处的实际VA.
        add        ecx        ,        eax        ,        offset Dest - offset Label
关于MASM32的这方面文章很多, 如果不清楚, 可以去搜一下. 本文不怎么介绍MASM32, 本人也不会MASM32, 只是看得懂而已, 没写过程序.

是不是在ARM汇编中, 也要用类似的方法, 以lr中存放的值(VA), 作为基准定位目标位置的VA呢?
        bl        Start
Start       
        mov        r0        ,        lr
        ldr        r3        ,        = Dest
        ldr        r2        ,        = Start
        sub        r1        ,        r3        ,        r2
        add        r0        ,        r0        ,        r1
用这种方法来得到一个实际的VA!
错! 这种方法带来的只有目标程序崩溃!

也许有什么办法也可以实现这种模型, 但是由于ARM汇编能够自由的操作pc, 就有更简单的实现方法, 下文叙述.

03.........................................
关于Label:
Label在编译的过程中, 会被替换成常数
如上所述, 这个常数可以被b或bl传送到pc或lr中
如果此常数已经在寄存器中, 则可以用mov指令对pc或lr进行操作

如果已经编译好的代码, 实际位置发生变化, Label不会跟着变化, 一经编译, Label就成为了一个固定的常数. 也就是说, 如果代码在编译后, 被移动到另一个地址执行(新节附着在目标程序之后), Label不会与时俱进, 如果想控制b或bl跳转的目的地, 必须想好Label这种大立即数传送到寄存器的方法.

04.........................................
关于大立即数:
本文指的大立即数, 是指在任意移位的条件下, 宽度仍然超过8bit的立即数.
如上文所述, 这种数不可能放在32bit的指令中, 只能通过非常手段加载到寄存器.
LDR伪指令可以做到这一点!(由于关键字不区分大小写, 容易把LDR伪指令与ldr数据加载指令相混淆, 下一篇文章将专门关注伪指令!)

一般加载大立即数用的方法是:
        ldr        r0        ,        = 0xFFF
但编译器处理的方法非常奇特, 这是ARM汇编与MASM32的一个显著区别:
这是编译器干的事情:
编译器会在该段代码的末尾的某个地址处, 添加数据, 将0xFFF添加到那里!
然后将本句改成:
        ldr        r0        ,        [ pc, # 0x285 ]
当然不会刚好是0x285, 这个值取决于代码段的长度, 和这类语句的数量!
总之, 改成了基于当前地址的间接寻址, 然后取值, 变成了一个读取内存, 从内存中取值的操作!
注意pc实际上不是当前地址, 由于流水线, 进行运算的pc值要加上n-1条指令宽度.

这个值是编译器自动添加到代码段的.
而且, 编译完成后, []中的内容也成了一个常数
这样的后果是: 如果写好的代码被移动到另一个地址(新节附加在目标程序之后)执行, 形如上面的ldr语句还能再在pc以后的同样相对位置取到大立即数吗?
除非连那个单元一起复制到新的位置, 那就得自己重新确定要拷贝的新节的实际大小! 很麻烦.标号对应的地址(Label在编译后也就是一个大立即数)也得这样处理.

再举个例子:
最常用的取地址的方法就是:
        ldr        r0        ,        = Label
假设Label标示的地址是:0x12345678,
则编译完成后, 虚地址空间会呈现如下形态:

00011000        ldr        r0        ,        [ pc , # 4 ]
00011004        ......
00011008        ......
0001100C        dcb        0x78
0001100D        dcb        0x56
0001100E        dcb        0x34        ;这4个字节是编译器自动添加的, 就是为了能让这个数据
0001100F        dcb        0x12        ;能够进入r0

05.....................................
解决标号问题的福音:
用adr伪指令
        adr        r0        ,        Label

本指令是根据目标地址与本指令的偏移来寻址的, 会根据偏移的情况, 被编译器替换成: add或sub
        add        r0        ,        pc        ,        # 0x4
        sub        r0        ,        pc        ,        # 0x8
pc的值同样是当前地址+(n-1)*4, n是流水线级数.

也就是说, 一段代码被移动到其他地址执行, 不用像MASM32那样, 设法得到当前地址, 以及相对于
当前地址的偏移了.

用这个方法成功解决了标号问题, 其基础就在于可对pc作极其灵活的直接操作.
甚至如果不用adr也可以, 直接用add或sub, 只是这样就要考虑到不同流水线级数的具体CPU, 失去了通用性.

06.....................................
API函数调用的问题:
API如果在静态编译时被输入, 则会在程序地址空间中的一个地址存放API的实际地址, bl跳转到该地址时, 再用给pc赋值的方式, 实际跳转到API函数在内存中的实际地址(以下例子中的数字只是为了说明问题瞎编的)
        比如:        bl        MessageBoxW
函数输入后, MessageBoxW被编译成一个地址(MessageBoxW只是一个标号), 相当于一个大立即数.
比如编译成:        bl        0x00011380
则0x00011380地址处会有两行代码: (只要是调用API, 跳转后都会遇到这两条指令, 完全一样)
        ldr        r12        ,        = 0x00012590        .......(1)
(调试时, 实际显示的应该是:        ldr r12 , [pc]        ;上文已经分析过了, 下文还将进行分析)
        ldr        pc        ,        [ r12 ]                .......(2)
这两段代码不是用户自己编写的, 而是编译器或者加载器自动添加的(多半是加载器), 下面分析:
(1). 为什么使用r12 ?
        r12是约定俗成, 不用来传递参数, 也无需保存的寄存器, 对pc赋值后, 程序跳转, 用其他寄存器无法恢复. 而用r0 - r3会影响到传递给API的参数
(2). 为什么在VS的调试器中, 会显示成括号中所示的形式?
        这是在编译器对ldr加载命令进行的特殊处理引起的, 第4点已经分析过这类"大立即数"的        加载方法.
        这里的玄机是: pc实际的值同样是当前地址+(n-1)*4, n是流水线级数. 对于PPC使用的是        ARM7芯片, 三级流水线. 那么在这里, [pc]刚好取出了存放在(2)号指令之后紧跟的一个四字节数据 -- 0x00012590 !
        在VS调试器中, 这个数据会显示为:
        andeq       r3, r1, r4, lsl r0
        之类莫名其妙的指令, 事实上, 这根本就不是指令! 只是一个数据!(以前被这类"指令"困扰了很久...)
(3). DLL映射到本进程后, API的地址到底是哪个?
        如本例中, 映射到进程的地址空间后, API的地址是0x00012590
        值得注意的是, 编译完成后0x00011380就确定下来了, 而此处的两条指令也确定下来了.
        但是这两条指令之后的4B空间应该是由加载器填写的.
        加载器会以某种方式改写代码段属性, 在这些单元中填入API的实际地址

07.....................................
在一个可移动执行的代码段中使用API:
其实就是在附加到目标程序的新节中如何使用API的问题.
根据06中的分析, 如果想要简单的使用bl会遇到两个问题:
        一.函数名编译成的常数的位置, 如上文中的0x00011380处, 多半不再是自己的地盘        遇不到那两条固定的指令和一个由加载器填写的数据
        二.加载器根本不知道这段"移动代码"的存在, 不会提供"准备地址"的服务

也就是说, 只能构造一个自己的"专属"输入表, 然后在这段代码中添加查找自己的输入表, 查询想要的API的地址.
bl是不行了, 但是构造自己的输入表是可行的, 只是比较麻烦, 稍后再分析.

另一种方法, 是动态加载API
一些必然会加载的DLL, 如coredll.dll中的API使用起来是比较方便的.
通过固定代码, 向移动代码中写入用GetProcAddress函数取得的地址, 移动代码中直接跳转即可.不常用的DLL中的API, 就需要得到LoadLibrary和GetProcAddress的地址, 在移动代码中, 动态取得.
这种方法比较讨巧, 偷懒. 但是不通用. 因为如果在其他版本的OS中运行, 或者调用其他版本的
DLL时, 函数地址可能就不再是之前设定的那个了.

08.....................................
在调试器里正常运行, 直接运行失败的问题
这是在实践07中的思想的过程中(直接在主程序中向新节提供API地址的), 遇到的问题
之前实验的时候, 遇到个麻烦问题: 在VS Debugger中, 代码执行完全正常, 但是直接运行时就崩溃. 到网上找找看有不有什么说法, 结果超级失望. 只能自己探索, 谁让我选择了没人走过的路呢?

既然调试器中运行正常, 那就不可能在调试器中查错了, 只能将代码的功能模块全部注释掉, 一个一个的添加, 再重新编译, 尝试运行...... 真是笨办法, 不过没想到更好的办法了...

后来定位到一行向移动代码中的一个变量中写入数据的代码. 原来是向代码段写入数据引起的程序崩溃......
发现后, 当然解决起来就很容易了, VirtualProtect改变该段代码的属性为可写就OK了, 或者用WriteProcessMemory函数向代码中写入数据, 也没问题.像上面07点提到的, 写入API的固定地址时, 如果目标代码段没有可写属性, 直接写入就会失败.

我想说的是: 为什么会出现这种调试器检查不出来的错误!
那是因为, 调试器会将代码设置为可写状态, 这是调试器的特性! 在调试器中, 代码段本来就是可写的, 向里面写入数据当然不会有什么问题!

以后如果遇到这类调试正常, 运行失败的现象, 可以想想是否犯了这种小错误.
至于VirtualProtect函数本身, 第4个参数不能为NULL, 否则会失败.

09.....................................
添加新的IID:
在移动代码段(新节)中使用API, 有必要使得要使用的API的地址在加载时, 被加载器装载到相对于新节节头的固定位置.这样一来, 只要用adr定位到那个位置, 就可以取出API的实际地址了.

这就很容易想到IID, 想到IAT.
为自己要用的API添加IID数组, IID结构中的FirstThunk指针指向移动代码段中的一个地址, 然后在那个地址中, 手工添加一个IAT. 当代码移动到其他位置, 并被加载执行时, 手工IAT就会被加载器改写成API的实际地址了, 多棒的想法!

按这种想法, 设计添加IID的步骤如下:
PE头->DataDirectory->ImportTable->IID数组的RVA和Size
将RVA转换成Offset, 用Size定位到IID数组末尾, 按上述的想法添加新的IID.
然后测试->失败!

为什么? 又仔细分析PE结构, 发现了问题所在!(要研究这种东西, 不可能不反复研究PE结构的)
其原因在于: IID数组之后, 紧跟着就是IID中指针指向的实际内容.
比如, 如果只输入了一个Dll, 则其IID之后有20字节的NULL表示IID结束, 总的IID大小(加上NULL)为40字节, 在这40字节的数组之后, 紧跟着就是IID中OriginalFirstThunk指向的地址! 再后面紧跟着OriginalFirstThunk所指的数组(大小不确定)之后, 就是IID中Name域指向的地址!也就是说, 根本就没有留出添加新IID的空间.
强行添加IID的结果是, 破坏了宿主程序的输入表(当然只是OriginalFirstThunk那部分)! 甚至可能将宿主程序IID的Name都给覆盖了.(这才是最严重的) 如果目标程序输入的函数很少, 原IID中Name域指向的地址, 离IID就会近, 距离完全可能少于20字节. 添加一个20字节的新IID, 原来的Name就被覆盖了.

自己想出的解决方案之一, 就是将整个IID数组搬离到新节的"间隙"中,
然后修改DataDirectory中的RVA.
新节的"间隙"很好找, 定位到新节表, PointerToRawData + VirtualSize 得到的值,
按4字节对齐一下就可以了.
这样做的好处:
        定位容易.
        新节的大小比较容易控制, 存放IID数组的空间不可能不够.
        将新节与宿主程序更加紧密的结合, 如果将旧的IID数组清空, 则直接除去新节以后,
        宿主程序不能运行.(除非重建IID)
这样做也就更加要求: 新节中绝不允许出现:
        ldr        r0        ,        = Label
        ldr        r0        ,        = 0xFFF
一类的语句! 且要控制好新节的大小.

..........全文完..............

介绍得有点乱, 这是按照我在实验过程中依次遇到的问题的解决过程. 不太好分类. 有ARM汇编
的相关东西, 也有PE的知识, 还有API...

也许我应该把这个帖子分成两三个来发.
不过, 为了记录我自己的研究轨迹, 还是一起吧. 一点私心...


[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)

收藏
免费 7
支持
分享
最新回复 (6)
雪    币: 270
活跃值: (25)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
2
难道是传说中的沙发?
2009-3-20 18:00
0
雪    币: 270
活跃值: (25)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
3
很详细,不怎么了解ARM汇编,学习了。
2009-3-20 18:02
0
雪    币: 214
活跃值: (10)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
4
貌似我说得太难了一点, 主要是自己太兴奋了, 一次说得太急太多.
没关系, 后面陆续出几个帖子, 把这些道理分别说清楚.
2009-3-20 20:07
0
雪    币: 2604
活跃值: (64)
能力值: (RANK:510 )
在线值:
发帖
回帖
粉丝
5
很高兴看到你的进展这么明显!

发帖同大家交流要注意控制难度和数量,如果大多数朋友都不知道你在说什么就无法达到交流的目的了。由于嵌入式平台同传统平台有很大差距,刚转换过来需要补充一些基础知识。所以建议你在写这个系列的同时也注意介绍一些基础的知识和原理,帮助后来的朋友快速入门、共同进步。
2009-3-20 20:31
0
雪    币: 200
活跃值: (10)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
前面关于汇编的能看懂,我也终于知道为啥PC+8了,后面的就看不懂了
2009-3-20 22:46
0
雪    币: 2604
活跃值: (64)
能力值: (RANK:510 )
在线值:
发帖
回帖
粉丝
7
不用担心,技术水平是逐步积累起来的。哪怕每天有一点收获,坚持下去会逐步变成高手!
2009-3-21 09:14
0
游客
登录 | 注册 方可回帖
返回
//