技术要点
之所以会输出666666,其实是因为浮点数0.9f的十六进制表示为0x3F666666。因此与0xFFFFFF取逻辑与运算后,就变成了0x666666。
本文使用纯汇编语言进行编写,使用NASM汇编器生成机器码。
由于静态依赖会导致链接器在配置的时候会变得麻烦,主要是配置各种LIB文件路径会因为SDK版本啥的产生问题,因此代码要编写为完全无静态外部依赖的形式来减少麻烦。
不配置LIB,就意味着没有CRT可用。如果不使用CRT,就无法使用printf函数。但是可以用WriteConsoleA实现对控制台的输出。
使用WriteConsoleA函数时,需要填stdout的句柄。一般来说,获取stdout句柄的方式是通过GetStdHandle函数,但是其实有个歪门邪道,使得我们不需要使用API来获取句柄。
不配置LIB其实连WriteConsoleA函数都调用不了。因此我们还需要定位WriteConsoleA函数。
这里用到的套路和我之前写过的只导入ntdll.dll的Hello World差不多。就是通过访问fs/gs段的方式去读TEB结构体,获取PEB从而获取各种需要的东西。
获取控制台句柄
StdOut的句柄位于TEB->PEB->ProcessParameters。写汇编时无非就是代入各种偏移量,代码如下:
search_stdout:
; Look for Process Parameters
mov rax,qword gs:[0x60] ; Load PEB to rax
mov rax,qword [rax+0x20] ; Load ProcParam to rax
mov rax,qword [rax+0x28] ; Load StdOut to rax
mov [rel StdOutHandle],rax
ret
获取模块基地址
这里我们需要用到sprintf和WriteConsoleA函数。sprintf函数在ntdll.dll里有导出,而WriteConsoleA函数在kernel32.dll里有导出。
要找这俩模块的基地址,可以通过TEB->PEB->Ldr的方式,然后遍历LDR双向链表来实现。
虽然说标准的做法应该是要判断LDR->BaseDllName的,但是按照模块加载顺序,ntdll.dll是最先加载的,其次就是kernel32.dll,因此我们只需要在LDR->InLoadOrderModuleList链表里走第一第二节点即可。代码如下:
search_modules:
; Look for LDR
mov rax,qword gs:[0x60] ; Load PEB to rax
mov rax,qword [rax+0x18] ; Load LDR to rax
mov rax,qword [rax+0x10] ; InLoadOrderModuleList
; NTDLL is one link ahead.
mov rax,qword [rax]
; Now, rax is pointing to LDR entry of NTDLL.
mov rcx,qword [rax+0x30] ; Load NTDLL Base Address
mov [rel ntdll_base],rcx
; KERNEL32 is one link ahead.
mov rax,qword [rax]
mov rcx,qword [rax+0x30] ; Load KERNEL32 Base Address
mov [rel kernel32_base],rcx
ret
获取函数地址
拿到模块基址后,就可以通过走导出表来获取函数地址了,本质上就是自行实现GetProcAddress函数。
这里方便起见,代码就不对模块的DOS头和NT头进行校验了。
值得注意的是,导出表里的函数名是排好序的,因此我们可以使用二分搜索法来优化搜索速度。
通常比较字符串我们会用strcmp函数,不过没有CRT的关系,只能自己比较字符串了。
x86提供了cmpsb指令,十分方便,用repz cmpsb指令就可以达到字符串比较的效果。
当cmpsb指令判断出es:[rdi]和ds:[rsi]的两个字节相等时,会将rflags.zf置位,因此加上repz前缀(REPeat if ZF is set)就会重复执行这条指令,并且对rcx寄存器进行-1。
由于我们的字符串比较是从前向后的,因此要用cld指令对rflags.df进行复位,确保cmpsb会对rdi和rsi进行+1的操作,而不是-1。
当repz cmpsb指令判断到不相同的字节,或者rcx变成零了之后,便不再重复执行。接下来就是根据rflags的状态就行跳转了。
如果rflags.zf置位,就意味着repz cmpsb在判断了rcx个字节后仍未遇到不同的字节,这表明字符串是相等的。因此我们用jz或je指令跳转至对应的条件处理。
如果rflags.af置位,就意味着repz cmpsb时,es:[rdi]比ds:[rsi]的字节大了。因此用ja指令跳转至对应的条件处理。
如果上述均不满足,则意味着es:[rdi]比ds:[rsi]的字节小了。无需跳转,直接处理这个条件就行。
对于二分搜索法而言,如果当前的数大了,就要压制上限来缩小范围。如果当前的数小了,就要压制下限来缩小范围。通过一半一半地压缩范围来搜索便是二分搜索法的原理。
当我们搜索到指定函数后,将索引代入到NameOrdinal里获取一个16位数,这个数就是获取函数RVA的索引了。最后将RVA与映像基地址相加便是函数的绝对地址。说了这么多,代码如下:
; Input parameters:
; rdi: Function name
; rbx: Image base
; rcx: String Length, including null-terminator.
search_exported_symbol:
; Warning: this function do not check validity of the image.
mov ax,word [rbx+0x3C] ; Load e_lfanew
movzx rax,ax
mov eax,dword [rbx+rax+0x88] ; Load Export Directory
push rsi
mov r8d,dword [rbx+rax+0x1C] ; Functions
mov r9d,dword [rbx+rax+0x20] ; Names
mov r10d,dword [rbx+rax+0x24] ; NameOrdinals
mov r11d,dword [rbx+rax+0x10] ; Base
; Preparing for binary-search
push r13
push r14
push r15
xor r14,r14
mov r15d,dword [rbx+rax+0x18] ; Number of Names
lea r9,qword [rbx+r9] ; Name Rva Array Base
mov rdx,rax
cld
; Start binary-searching
.bs_loop:
push rcx
push rdi
lea r13,[r14+r15] ; mid=lo+hi
shr r13,1 ; mid/=2
mov esi,[r9+r13*4] ; Load the name
add rsi,rbx
; Compare string
repz cmpsb
pop rdi
pop rcx
ja .bigger_name
jz .function_found
; Iterated Name is smaller, so reduce the lower-boundary
lea r14,[r13+1]
jmp .bs_loop
.bigger_name:
; Iterated Name is bigger, so reduce the upper-boundary
lea r15,[r13-1]
jmp .bs_loop
.function_found:
; Function is found, locate the RVA.
lea rdx,[rbx+r10] ; NameOrdinal Array Base
mov ax,[rdx+r13*2] ; ax=NameOrdinal[Mid]
movzx rax,ax
lea rdx,[rbx+r8] ; Function Rva Array Base
mov eax,[rdx+rax*4]
add rax,rbx
pop r15
pop r14
pop r13
pop rdi
ret
可以看到我这里并没有使用标准的调用约定。这没关系,毕竟是自己写的函数。
输出控制台
接下来由于我们需要调用外部函数了,因此必须服从调用约定。
微软规定在x64下,前四个参数放在rcx,rdx,r8,r9四个寄存器里,并且要在栈上为这四个参数预留shadow space,之后的所有参数要放在栈上。
我们调用sprintf时,只有三个参数,第一个是字符串缓冲区,第二个是字符串格式,第三个则是打印的数据。
而WriteConsoleA则有五个参数,因此第五个参数lpReserved必须放在内存里。
字符串缓冲区可以从栈上分配,因此和调用函数所需的shadow space一并分配即可。
由于参数最多的函数WriteConsoleA有五个参数,因此直接为shadow space分配0x28个字节即可。而我们的字符串缓冲区预留16个字节就足够大了。
因此,用sub rsp,0x38指令分配栈空间即可。
sprintf返回值是打印出来的字符串的长度,不含\0,因此直接传给WriteConsoleA函数使用即可。
而lpNumberOfCharsWritten参数可选,因此直接把r9填零即可,同时把这个寄存器赋值到[rsp+20h]来表示lpReserved参数也是零。
说了这么多,代码如下:
main:
; Locate StdOut Handle.
call search_stdout
; Locate modules...
call search_modules
push rbx
; Locate sprintf function.
mov rbx,[rel ntdll_base]
lea rdi,[rel sprintf_name]
mov rcx,8
call search_exported_symbol
mov [rel sprintf],rax
; Locate WriteConsoleA function.
mov rbx,[rel kernel32_base]
lea rdi,[rel WriteConsoleA_name]
mov rcx,14
call search_exported_symbol
mov [rel WriteConsoleA],rax
pop rbx
; len=sprintf(buff,"%X",0.9f & 0xffffff);
sub rsp,38h
lea rcx,[rsp+28h]
lea rdx,[rel fmt]
mov r8,[rel x]
and r8,0xFFFFFF
call [rel sprintf]
; WriteConsoleA(StdOutHandle,buff,len,NULL,NULL);
mov rcx,[rel StdOutHandle]
lea rdx,[rsp+28h]
mov r8,rax ; Return value of sprintf is number of characters.
xor r9,r9
mov [rsp+20h],r9
call [rel WriteConsoleA]
add rsp,38h
ret
全局变量
为了方便起见,使用了一些全局变量来缩短代码量并简化了逻辑,代码如下:
x:
dd 0.9
fmt:
db "%X",10,0
sprintf_name:
db "sprintf",0
WriteConsoleA_name:
db "WriteConsoleA",0
ntdll_base:
dq 0
kernel32_base:
dq 0
sprintf:
dq 0
WriteConsoleA:
dq 0
StdOutHandle:
dq 0
编译
由于本文用NASM汇编编写,因此要安装NASM汇编器。
给NASM喂一个-fwin64参数确保其输出一个win64的.obj文件。
最后是调用链接器,由于我们没有使用导入函数,因此无需配置LIB路径,但是需要配置/ENTRY参数,否则链接器找不到入口点。
如果嫌找msvc的链接器太麻烦,可以安装LLVM,然后用lld-link.exe。
别忘了把函数声明为global,否则汇编器不会生成全局符号,链接器就会找不到符号:
global main
global search_ntdll
global search_exported_symbol
如图所示,能输出666666,并且导入表是空的,完全没有静态依赖(这一点弹幕流根本做不到):