唐凌 发表于 2022-11-4 09:10:56

【汇编】无导入DLL的Win64汇编程序

本帖最后由 tangptr@126.com 于 2022-11-4 09:12 编辑


# 前言
本文部分遵循弹幕流思想,毫无静态依赖,造了很多轮子。不过弹幕流本人肯定是造不出完全无依赖的轮子了哈哈哈哈哈哈哈哈。
用C艹来描述本文的要写的程序的话,其实就是一个表情包。



# 技术要点
之所以会输出`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](https://www.0xaa55.com/thread-25988-1-1.html)差不多。就是通过访问`fs/gs`段的方式去读`TEB`结构体,获取PEB从而获取各种需要的东西。

## 获取控制台句柄
`StdOut`的句柄位于`TEB->PEB->ProcessParameters`。写汇编时无非就是代入各种偏移量,代码如下:
```Assembly
search_stdout:
        ; Look for Process Parameters
        mov rax,qword gs:                ; Load PEB to rax
        mov rax,qword         ; Load ProcParam to rax
        mov rax,qword         ; Load StdOut to rax
        mov ,rax
        ret
```

## 获取模块基地址
这里我们需要用到`sprintf`和`WriteConsoleA`函数。`sprintf`函数在`ntdll.dll`里有导出,而`WriteConsoleA`函数在`kernel32.dll`里有导出。
要找这俩模块的基地址,可以通过`TEB->PEB->Ldr`的方式,然后遍历LDR双向链表来实现。
虽然说标准的做法应该是要判断`LDR->BaseDllName`的,但是按照模块加载顺序,`ntdll.dll`是最先加载的,其次就是`kernel32.dll`,因此我们只需要在`LDR->InLoadOrderModuleList`链表里走第一第二节点即可。代码如下:
```Assembly
search_modules:
        ; Look for LDR
        mov rax,qword gs:                ; Load PEB to rax
        mov rax,qword         ; Load LDR to rax
        mov rax,qword         ; InLoadOrderModuleList
        ; NTDLL is one link ahead.
        mov rax,qword
        ; Now, rax is pointing to LDR entry of NTDLL.
        mov rcx,qword         ; Load NTDLL Base Address
        mov ,rcx
        ; KERNEL32 is one link ahead.
        mov rax,qword
        mov rcx,qword         ; Load KERNEL32 Base Address
        mov ,rcx
        ret
```

## 获取函数地址
拿到模块基址后,就可以通过走导出表来获取函数地址了,本质上就是自行实现`GetProcAddress`函数。
这里方便起见,代码就不对模块的DOS头和NT头进行校验了。
值得注意的是,导出表里的函数名是排好序的,因此我们可以使用二分搜索法来优化搜索速度。
通常比较字符串我们会用`strcmp`函数,不过没有CRT的关系,只能自己比较字符串了。
x86提供了`cmpsb`指令,十分方便,用`repz cmpsb`指令就可以达到字符串比较的效果。
当`cmpsb`指令判断出`es:`和`ds:`的两个字节相等时,会将`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:`比`ds:`的字节大了。因此用`ja`指令跳转至对应的条件处理。
如果上述均不满足,则意味着`es:`比`ds:`的字节小了。无需跳转,直接处理这个条件就行。
对于二分搜索法而言,如果当前的数大了,就要压制上限来缩小范围。如果当前的数小了,就要压制下限来缩小范围。通过一半一半地压缩范围来搜索便是二分搜索法的原理。
当我们搜索到指定函数后,将索引代入到`NameOrdinal`里获取一个16位数,这个数就是获取函数RVA的索引了。最后将RVA与映像基地址相加便是函数的绝对地址。说了这么多,代码如下:
```Assembly
; 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                                 ; Load e_lfanew
        movzx rax,ax
        mov eax,dword                 ; Load Export Directory
        push rsi
        mov r8d,dword                 ; Functions
        mov r9d,dword                 ; Names
        mov r10d,dword                 ; NameOrdinals
        mov r11d,dword                 ; Base
        ; Preparing for binary-search
        push r13
        push r14
        push r15
        xor r14,r14
        mov r15d,dword                 ; Number of Names
        lea r9,qword                                 ; Name Rva Array Base
        mov rdx,rax
        cld
        ; Start binary-searching
.bs_loop:
        push rcx
        push rdi
        lea r13,        ; mid=lo+hi
        shr r13,1                        ; mid/=2
        mov esi,        ; 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,
        jmp .bs_loop
.bigger_name:
        ; Iterated Name is bigger, so reduce the upper-boundary
        lea r15,
        jmp .bs_loop
.function_found:
        ; Function is found, locate the RVA.
        lea rdx,        ; NameOrdinal Array Base
        mov ax,        ; ax=NameOrdinal
        movzx rax,ax
        lea rdx,        ; Function Rva Array Base
        mov eax,
        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`填零即可,同时把这个寄存器赋值到``来表示`lpReserved`参数也是零。
说了这么多,代码如下:
```Assembly
main:
        ; Locate StdOut Handle.
        call search_stdout
        ; Locate modules...
        call search_modules
        push rbx
        ; Locate sprintf function.
        mov rbx,
        lea rdi,
        mov rcx,8
        call search_exported_symbol
        mov ,rax
        ; Locate WriteConsoleA function.
        mov rbx,
        lea rdi,
        mov rcx,14
        call search_exported_symbol
        mov ,rax
        pop rbx
        ; len=sprintf(buff,"%X",0.9f & 0xffffff);
        sub rsp,38h
        lea rcx,
        lea rdx,
        mov r8,
        and r8,0xFFFFFF
        call
        ; WriteConsoleA(StdOutHandle,buff,len,NULL,NULL);
        mov rcx,
        lea rdx,
        mov r8,rax                ; Return value of sprintf is number of characters.
        xor r9,r9
        mov ,r9
        call
        add rsp,38h
        ret
```

## 全局变量
为了方便起见,使用了一些全局变量来缩短代码量并简化了逻辑,代码如下:
```Assembly
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
```

## 编译
由于本文用(https://nasm.us/)汇编编写,因此要[安装NASM汇编器](https://www.nasm.us/pub/nasm/releasebuilds/2.15.05/win64/)。
给NASM喂一个`-fwin64`参数确保其输出一个win64的.obj文件。
最后是调用链接器,由于我们没有使用导入函数,因此无需配置LIB路径,但是需要配置`/ENTRY`参数,否则链接器找不到入口点。
如果嫌找msvc的链接器太麻烦,可以安装(https://llvm.org/),然后用`lld-link.exe`。
别忘了把函数声明为`global`,否则汇编器不会生成全局符号,链接器就会找不到符号:
```Assembly
global main
global search_ntdll
global search_exported_symbol
```
如图所示,能输出`666666`,并且导入表是空的,完全没有静态依赖(这一点弹幕流根本做不到):






## 调试
如果需要调试,则要在给nasm加一个`-g`参数来生成调试符号,并且链接器要带`/DEBUG`参数和`/PDB`参数生成PDB文件。
下断点可以通过在源码里加一个`int3`指令。
但NASM令人无语,`int 3`会生成出`CD 03`两个字节,要写成`int3`才会生成`CC`一个字节。调试器有个弱智行为:遇到断点指令时,继续执行会只advance一个字节,因此`CD 03`会使得调试器无法正确执行断点后的指令。

# 总结
最后总结一下:
在没有API可以直接使用的情况下,仍然可以通过访问`gs`段的方式获取很多有用的信息,比如模块列表,控制台句柄等等。
用`repz cmpsb`指令可以直接模拟`strncmp`函数,甚至还拥有比较字符串大小的能力,但是它不会判断null-terminator,因此要务必用`rcx`寄存器限制其比较字符串的长度。
PE导出表的符号名表是已经排好序的,故可以直接用二分搜索法快速搜索。


源码回帖后可见。不过文中贴出的代码足够完整了,足够拼凑出可编译运行的源码了。
**** Hidden Message *****

系统消息 发表于 2022-11-4 09:25:16

要不改成我的新版吧,用0.9f异或0.5f或者整数减法来实现666666。:lol

0xAA55 发表于 2022-11-4 09:36:01

用字符串方式找 sprintf 或者 WriteConsoleA 用二分查找嘛?如果用函数序号来找,应该会更快一些(

Ayala 发表于 2022-11-4 23:09:52

本帖最后由 Ayala 于 2022-11-4 23:20 编辑

0xAA55 发表于 2022-11-4 09:36
用字符串方式找 sprintf 或者 WriteConsoleA 用二分查找嘛?如果用函数序号来找,应该会更快一些( ...

序号查找兼容不好,首字母顺序查找就很好,不比2分查找慢,主要由于cpu缓存问题,数据量小,2分优势体现不出来

Ayala 发表于 2022-11-4 23:21:49

本帖最后由 Ayala 于 2022-11-4 23:27 编辑

硬编码看着头大可以参考一下这个
试试比较常规的写法吧,汇编这玩意重复的代码写一次就好了
https://www.0xaa55.com/thread-1875-1-1.html

0xAA55 发表于 2022-11-6 21:14:53

Ayala 发表于 2022-11-4 23:21
硬编码看着头大可以参考一下这个
试试比较常规的写法吧,汇编这玩意重复的代码写一次就好了
https://www.0x ...

确实。

Golden Blonde 发表于 2022-11-7 23:49:54

那个(int&)是啥意思?不像是取X的地址啊?

0x5F3759DF 发表于 2022-11-8 10:33:24

我记得好像是win7之前和之后的LDR->InLoadOrderModuleList会有变化,所以假定第二个是ntdll不一定好

xiawan 发表于 2022-11-11 09:08:36

感谢楼主分享~~~

0xAA55 发表于 2022-11-11 18:57:20

Golden Blonde 发表于 2022-11-7 23:49
那个(int&)是啥意思?不像是取X的地址啊?

是取,但语法上则是“引用”
页: [1]
查看完整版本: 【汇编】无导入DLL的Win64汇编程序