在本人第一篇博客UPX代码buildloader函数分析(https://bbs.kanxue.com/thread-283702.htm),对加载器初始化过程阐明了不同条件下的加载逻辑,本文根据其加载逻辑进行更细致的分析其操作细节。
首先在文件src\stub\src\amd64-win64.pe.S中发现PE文件加壳入口点:
.intel_syntax noprefix
是用于汇编语言的编译器指令,主要用于告诉汇编器使用Intel 语法来解析接下来的代码,并且在操作数之前不加任何前缀(noprefix
)。
接下来按照加载逻辑的添加顺序分别分析其处理细节:
✦
PEISDLL0与PEISEFI0
✦
section PEISDLL0
mov [rsp + 8], rcx
mov [rsp + 0x10], rdx
mov [rsp + 0x18], r8
在 Windows的 x64调用约定中,rcx是第一个参数,rdx是第二个参数,r8是第三个参数。因此在函数调用前保存参数。
section PEISEFI0
push rcx
push rdx
此处处理过程与 PEISDLL0类似,保存了 rcx和 rdx 的内容,但这里的压栈方式不同,是直接将它们压入栈顶。可能是为了适应 EFI 系统环境的不同调用约定。
✦
主程序解密逻辑
✦
section PEISDLL1
cmp dl, 1
jnz reloc_end_jmp
如果是dll文件,在主程序入口前还需要添加初始化逻辑,判断是否是dll文件的卸载操作。在 Windows 系统中,dl 在 DLL 入口点(DllMain 函数)中传递给 DLL 的 fdwReason 参数,值为 1 时表示 DLL 正在被卸载(DLL_PROCESS_DETACH)
。在此处如果值并不为1(不是卸载操作),则程序将跳转到reloc_end_jmp
标签,继续正常的初始化流程。
section PEMAIN01
//; remember to keep stack aligned!
push rbx
push rsi
push rdi
push rbp
lea rsi, [rip + start_of_compressed]
lea rdi, [rsi + start_of_uncompressed]
这一部分代码是为压缩数据解压作准备的主要逻辑。将压缩后的数据解压到内存中的指定位置,然后继续执行原始程序代码。
a) 首先保存寄存器rbx、rsi、rdi 和 rbp的值。
b)lea rsi, [rip + start_of_compressed]
计算压缩数据的起始地址,并存入 rsi 寄存器,其中rip 是当前指令地址。
c)lea rdi, [rsi + start_of_uncompressed]
则计算未压缩数据的起始地址,存入 rdi 寄存器。
最后rsi 指向压缩数据,而 rdi 指向解压后的数据。
section PEICONS1
incw [rdi + icon_offset]
section PEICONS2
add [rdi + icon_offset], IMM16(icon_delta)
1.将内存地址[rdi + icon_offset]
处的 16 位字增加 1,更新图标的索引。
2.将立即数icon_delta
加到内存地址[rdi + icon_offset]
上,过增量更新图标的偏移。
section PETLSHAK
lea rax, [rdi + tls_address]
push [rax] // save the TLS index
mov [rax], IMM32(tls_value) // restore compressed data overwritten by the TLS index
push rax
处理TLS 相关的初始化操作,包括保存和恢复被 TLS 索引覆盖的数据,确保数据正确性。
1.将rdi + tls_address
的有效地址加载到 rax 寄存器中,指向 TLS(线程本地存储)地址。
2.将 rax 寄存器指向的内存值(即 TLS 索引)压入栈中保存。
3.将 tls_value 存入 rax 指向的内存位置,恢复之前被 TLS 索引覆盖的数据。
4.将 rax 寄存器的值压入栈中保存。
.intel_syntax noprefix
section LZMA_HEAD
mov eax, IMM32(lzma_u_len)
push rax
mov rcx, rsp
mov rdx, rdi
mov rdi, rsi
mov esi, IMM32(lzma_c_len).att_syntax
#define NO_RED_ZONE
#include "arch/amd64/regs.h"
#include "arch/amd64/lzma_d.S"
.intel_syntax noprefix
section LZMA_TAIL
leave
pop rax
LZMA_HEAD初始化了解压缩参数,如压缩和解压数据的长度、内存位置等。
LZMA_TAIL 则是清理操作,负责恢复栈并弹出数据,标志着解压过程的结束。
其中的regs.h头文件保存了对寄存器的相关信息:
lzma_d.S文件则是其中解压缩的算法实现文件,主要涉及解码和处理 LZMA 压缩数据的逻辑,对应添加指令LZMA_ELF00,LZMA_DEC20
在调用解密函数前,进行了调用约定、压缩类型检查、解码器初始化以及堆栈分配对齐。最后根据条件编译,选择不同的文件引入解码函数。
这里其中lzma_d_cs.S、lzma_d_cf.S以及lzma_d_cn.S都是由汇编机器码组成的汇编文件。
NRV算法对应有2B、2D以及2E,这里以2E为例,即执行指令NRV2E。可见指令同样引入文件nrv2e_d.S,这里对解压缩主要流程进行分析。
1. 字节处理
top_n2e:
movb (%rsi),%dl # speculate: literal, or bottom 8 bits of offset
jnextb1yp lit_n2e
从 rsi(源指针)处获取下一个字节,并存储到 %dl 中。这一步推测该字节是字面值(literal)还是偏移值的一部分。然后根据寄存器的数据,跳转到 lit_n2e 处理字面值数据。
lit_n2e:
incq %rsi; movb %dl,(%rdi)
incq %rdi
如果判定当前字节是字面值数据,则将其存储到目标位置 %rdi,并递增源和目标地址指针,准备处理下一个字节。
2. 偏移与长度计算
off_n2e:
dec off
getnextbp(off)
getoff_n2e:
getnextbp(off)
jnextb0np off_n2e
处理偏移值(off),首先从输入数据中获取偏移值的高位字节。
subl $ 3,off; jc offprev_n2e
shll $ 8,off; movzbl %dl,%edx
orl %edx,off; incq %rsi
xorl $~0,off; jz eof
sarl off # Carry= original low bit
调整偏移值,判断是否需要跨越多个字节计算。如果偏移值满足某些条件(例如低位较小),会跳转到 offprev_n2e。
off 最终得到的是需要从目标位置向后移动的距离,它决定了从哪里复制数据。
len_n2e:
getnextb(len)
jnextb0n len_n2e
addl $6-2-2,len
从源数据中获取解压数据块的长度,利用 getnextb 函数从输入流中读取长度值。
3. 数据复制
gotlen_n2e:
cmpq $-0x500,dispq
adcl $2,len # len += 2+ (disp < -0x500);
call copy
根据前面解析出来的偏移值和长度,调用 copy 函数,复制解压出来的数据块到目标位置。这里的 adcl 指令根据偏移的大小,调整解压出的数据块长度。
NRV2E 压缩格式的解压流程:通过多次从压缩数据中读取字节或位,代码能够逐步解析出偏移和长度信息,随后将数据块从先前的位置复制到新的位置,完成解压缩过程。
✦
导入表处理
✦
sub rsp, 0x28
lea rdi, [rsi + compressed_imports]
分配栈空间,并将 compressed_imports 加载到 rdi 中,准备开始解析导入表。
next_dll:
mov eax, [rdi]
or eax, eax
jz SHORT(imports_done)
mov ebx, [rdi + 4] // iat
lea rcx, [rax + rsi + start_of_imports]
add rbx, rsi
add rdi, 8
call [rip + LoadLibraryA]
xchg rax, rbp
读取dll的名称地址,如果名称为空(eax=0),则跳转到imports_done结束导入表的修复。否则,继续准备加载dll的导入表,调用系统API函数LoadLibraryA加载dll,并将结果保存在rbp中。
next_func:
mov al, [rdi]
inc rdi
or al, al
jz next_dll
从 rdi 读取当前导入函数的标识符。如果标识符为 0,说明所有函数已处理完毕,跳转到 next_dll 处理下一个 DLL。
section PEK32ORD
jpe not_kernel32
mov eax, [rdi]
add rdi, 4
mov rax, [rax + rsi + kernel32_ordinals]
jmp SHORT(next_imp)
如果当前导入函数属于 kernel32.dll,则根据序号查找该函数的地址。
byname:
mov rcx, rdi
mov rdx, rdi
dec eax
repne
scasb
first_imp:
mov rcx, rbp
call [rip + GetProcAddress]
如果函数按名称导入,使用 GetProcAddress 来获取函数地址。这里先通过 repne scasb 搜索字符串(函数名称),然后调用 GetProcAddress 获取函数的内存地址。
next_imp:
mov [rbx], rax
add rbx, 8
jmp SHORT(next_func)
将获取到的函数地址存储到 IAT 中,并继续处理下一个函数。
imp_failed:
add rsp, 0x28
pop rbp
pop rdi
pop rsi
pop rbx
xor eax, eax
ret
如果导入失败,则清理栈空间,返回 eax = 0 表示失败。
这段代码动态解析并加载 PE 文件的导入表,加载所需的 DLL 并获取函数地址,完成导入表修复的任务。
✦
重定位表处理
✦
section PERELOC1
lea rdi, [rsi + start_of_relocs]section PERELOC2
add rdi, 4
section PERELOC3
lea rbx, [rsi - 4]
reloc_main:
xor eax, eax
mov al, [rdi]
inc rdi
or eax, eax
jz SHORT(reloc_endx)
cmp al, 0xEF
ja reloc_fx
首先将重定位表地址初始化。然后每次从重定位表读取一个字节并检查其值。如果为 0x00,表示重定位表处理完毕,跳转到 reloc_endx 结束处理。如果字节值大于 0xEF,则跳转到 reloc_fx 处理其他类型的重定位项。
reloc_add:
add rbx, rax
mov rax, [rbx]
bswap rax
add rax, rsi
mov [rbx], rax
jmp reloc_main
将偏移量加到 rbx,然后从 rbx 地址加载目标地址(目标地址是反向字节序,因此使用 bswap 交换字节顺序)。接着,将目标地址加上基址 rsi,以便修正地址引用,并将结果存回原位置。
reloc_fx:
and al, 0x0F
shl eax, 16
mov ax, [rdi]
add rdi, 2
对于特殊的重定位项,使用低 4 位并移位来计算偏移。然后从 rdi 中读取额外的 2 个字节作为偏移量,进行地址修正。
section PERLOHI0
xchg rdi, rsi
lea rcx, [rdi + reloc_delt]
section PERELLO0
jmp 1f
rello0:
add [rdi + rax], cx
1:
lodsd
or eax, eax
jnz rello0
处理低位重定位,将 reloc_delt 加到目标地址中,并使用循环结构逐个应用。
section PERELHI0
shr ecx, 16
jmp 1f
relhi0:
add [rdi + rax], cx
1:
lodsd
or eax, eax
jnz relhi0
类似低位重定位的处理逻辑,不过这里处理的是高 16 位的重定位,修正高位地址。
这段代码实现 PE 文件中的重定位表修复,遍历重定位表中的各个项,并对内存中的地址进行修正。这可以确保当程序被加载到不同的内存地址时,所有地址引用都能被正确调整。
看雪ID:Bogger
https://bbs.kanxue.com/user-home-1006242.htm
# 往期推荐
球分享
球点赞
球在看
点击阅读原文查看更多