技术要点
之所以会输出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
,并且导入表是空的,完全没有静态依赖(这一点弹幕流根本做不到):