找回密码
 立即注册→加入我们

QQ登录

只需一步,快速开始

搜索
热搜: 下载 VB C 实现 编写
查看: 634|回复: 9

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

[复制链接]

65

主题

117

回帖

1万

积分

用户组: 超级版主

OS与VM研究学者

UID
1043
精华
35
威望
789 点
宅币
8316 个
贡献
1094 次
宅之契约
0 份
在线时间
2074 小时
注册时间
2015-8-15
发表于 2022-11-4 09:10:56 | 显示全部楼层 |阅读模式

欢迎访问技术宅的结界,请注册或者登录吧。

您需要 登录 才可以下载或查看,没有账号?立即注册→加入我们

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

前言

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


QQ图片20221104061423.png

技术要点

之所以会输出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

获取模块基地址

这里我们需要用到sprintfWriteConsoleA函数。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会对rdirsi进行+1的操作,而不是-1
repz cmpsb指令判断到不相同的字节,或者rcx变成零了之后,便不再重复执行。接下来就是根据rflags的状态就行跳转了。
如果rflags.zf置位,就意味着repz cmpsb在判断了rcx个字节后仍未遇到不同的字节,这表明字符串是相等的。因此我们用jzje指令跳转至对应的条件处理。
如果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,并且导入表是空的,完全没有静态依赖(这一点弹幕流根本做不到):



QQ图片20221104075805.png
Capture.PNG

调试

如果需要调试,则要在给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导出表的符号名表是已经排好序的,故可以直接用二分搜索法快速搜索。



源码回帖后可见。不过文中贴出的代码足够完整了,足够拼凑出可编译运行的源码了。
游客,如果您要查看本帖隐藏内容请回复
回复

使用道具 举报

9

主题

179

回帖

1万

积分

用户组: 真·技术宅

UID
4293
精华
6
威望
441 点
宅币
8689 个
贡献
850 次
宅之契约
0 份
在线时间
340 小时
注册时间
2018-9-19
发表于 2022-11-4 09:25:16 | 显示全部楼层
要不改成我的新版吧,用0.9f异或0.5f或者整数减法来实现666666。
回复 赞! 靠!

使用道具 举报

1112

主题

1653

回帖

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
245
威望
744 点
宅币
24263 个
贡献
46222 次
宅之契约
0 份
在线时间
2298 小时
注册时间
2014-1-26
发表于 2022-11-4 09:36:01 | 显示全部楼层
用字符串方式找 sprintf 或者 WriteConsoleA 用二分查找嘛?如果用函数序号来找,应该会更快一些(
回复 赞! 靠!

使用道具 举报

30

主题

211

回帖

2798

积分

用户组: 版主

UID
1821
精华
7
威望
69 点
宅币
2178 个
贡献
206 次
宅之契约
0 份
在线时间
483 小时
注册时间
2016-7-12
发表于 2022-11-4 23:09:52 | 显示全部楼层
本帖最后由 Ayala 于 2022-11-4 23:20 编辑
0xAA55 发表于 2022-11-4 09:36
用字符串方式找 sprintf 或者 WriteConsoleA 用二分查找嘛?如果用函数序号来找,应该会更快一些( ...


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

使用道具 举报

30

主题

211

回帖

2798

积分

用户组: 版主

UID
1821
精华
7
威望
69 点
宅币
2178 个
贡献
206 次
宅之契约
0 份
在线时间
483 小时
注册时间
2016-7-12
发表于 2022-11-4 23:21:49 | 显示全部楼层
本帖最后由 Ayala 于 2022-11-4 23:27 编辑

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

使用道具 举报

1112

主题

1653

回帖

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
245
威望
744 点
宅币
24263 个
贡献
46222 次
宅之契约
0 份
在线时间
2298 小时
注册时间
2014-1-26
发表于 2022-11-6 21:14:53 | 显示全部楼层
Ayala 发表于 2022-11-4 23:21
硬编码看着头大可以参考一下这个
试试比较常规的写法吧,汇编这玩意重复的代码写一次就好了
https://www.0x ...

确实。
回复 赞! 靠!

使用道具 举报

55

主题

275

回帖

9358

积分

用户组: 管理员

UID
77
精华
16
威望
237 点
宅币
8223 个
贡献
251 次
宅之契约
0 份
在线时间
255 小时
注册时间
2014-2-22
发表于 2022-11-7 23:49:54 | 显示全部楼层
那个(int&)是啥意思?不像是取X的地址啊?
回复 赞! 靠!

使用道具 举报

0

主题

3

回帖

21

积分

用户组: 初·技术宅

UID
8124
精华
0
威望
2 点
宅币
14 个
贡献
0 次
宅之契约
0 份
在线时间
1 小时
注册时间
2022-11-8
发表于 2022-11-8 10:33:24 | 显示全部楼层
我记得好像是win7之前和之后的LDR->InLoadOrderModuleList会有变化,所以假定第二个是ntdll不一定好
回复 赞! 靠!

使用道具 举报

1

主题

159

回帖

634

积分

用户组: 大·技术宅

UID
7535
精华
0
威望
0 点
宅币
474 个
贡献
0 次
宅之契约
0 份
在线时间
72 小时
注册时间
2021-10-16
发表于 2022-11-11 09:08:36 | 显示全部楼层
感谢楼主分享~~~
回复 赞! 靠!

使用道具 举报

1112

主题

1653

回帖

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
245
威望
744 点
宅币
24263 个
贡献
46222 次
宅之契约
0 份
在线时间
2298 小时
注册时间
2014-1-26
发表于 2022-11-11 18:57:20 | 显示全部楼层
Golden Blonde 发表于 2022-11-7 23:49
那个(int&)是啥意思?不像是取X的地址啊?

是取,但语法上则是“引用”
回复 赞! 靠!

使用道具 举报

QQ|Archiver|小黑屋|技术宅的结界 ( 滇ICP备16008837号 )|网站地图

GMT+8, 2024-4-30 11:41 , Processed in 0.051954 second(s), 34 queries , Gzip On.

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表