AliCTF 2026 线上赛 Web - staircase
这题最终落地利用点是 MapDB 的 linked record:把 payload 分散种在多段 icon 记录里,再通过极少量元数据 patch 把 “icon” 指向链表头,最后 GET /files/icon 触发 Serializer.JAVA 反序列化完成 RCE。
题目信息
- 题目:staircase
- 分值:500
- 目标:拿到
flag{...}(flag 文件位于容器根目录/flag-<random>.txt) - 远程:题面端口会变更,需要以最新为准(本文最终打通的是
tcp *.*.*.*:*,HTTP 服务) - hint:
- 远程环境不出网
- MapDB 支持“链表式”记录(linked record)存储/读取,用于巨型记录
最终结论 本地已打通
本题最终落地利用链不再依赖“用受限写原语合成任意字节串”,而是:
- 用
POST /files/upload多次上传不同长度的icon,让 MapDB 把这些icon字节以Serializer.JAVA正常序列化落盘。 - 利用 MapDB 的
linked record机制,把若干个落盘的 icon 内容区拼成一个完整 record 字节流(几千字节也可以)。 - 通过
/files/modify的 path traversal 写/proc/self/fd/<N>,对 MapDB 临时文件做极少量元数据 patch:- 新建一个
new_recid的 index entry 指向我们构造的 linked record 链表头 - patch
recid=18leaf,把"icon"的valueRecid指向new_recid
- 新建一个
- 访问
GET /files/icon触发 MapDBSerializer.JAVA反序列化,执行命令把/flag-*写入/app/uploads/out.txt,再GET /files/download取回 flag。
对应脚本:solution/solution.py。
附件与本地复现
附件位于 task/:
task/chal.jar:Spring Boot fat jartask/Dockerfile:openjdk:17.0.2-jdk-slimtask/run.sh:启动时把$FLAG写入/flag-<random>.txt,然后java -jar chal.jar
启动逻辑(关键信息):
- 容器启动时会写入 flag 文件:
- 有环境变量
$FLAG:写入真实 flag - 否则写入
flag{testflag}
- 有环境变量
- Web 服务进程以
ctf用户运行,uploads 目录为/app/uploads。
本地测试容器推荐流程(便于后续脚本复现):
- 构建镜像:
docker build -t staircase_local -f task/Dockerfile task - 运行:
docker run -d --name staircase_test -p 18081:8080 staircase_local - 重要:为保证 MapDB 的 recordOff 分配“可复现”,每次调试尽量从 fresh 容器开始:
docker rm -f staircase_test || true- 再重新
docker run ...
- 探活:
curl -sS http://127.0.0.1:18081/files/info
服务端接口与关键逻辑
Web UI 位于 BOOT-INF/classes/static/index.html,可看到端点:
POST /files/upload:上传文件并设置“当前文件名”GET /files/icon:返回 icon(实际会从 MapDB 取"icon"值)PUT /files/modify:对“指定文件名”进行随机写GET /files/download:下载“当前文件名”GET /files/info:返回当前文件名信息
1. 任意路径写入 Path Traversal
FileStorageService.loadFile() 会做:
uploadDir.resolve(fileName).normalize()- 只检查
Files.exists(...),没有做startsWith(uploadDir)的 traversal 校验
而 downloadFile() 会额外调用 check(path)(包含 startsWith(uploadDir)),所以:
download无法直接读取任意路径modify可以写入任意已存在路径(包含/proc/self/fd/<N>)
核心写原语
FileStorageService.modifyFile(Path, byte[], long offset) 的行为:
- 每次写入都会把数据包裹成:
- 前缀:
"<start>"(7 字节) - 中间:
data(base64 解码后的原始字节,长度必须<= 8) - 后缀:
"<end>"(5 字节)
- 前缀:
- 实际写入长度:
7 + len(data) + 5,范围12..20 - offset 校验:
0 <= offset且offset + writeLen <= file.length()- 不能扩展文件,只能在现有长度内覆盖写
这意味着我们拥有一个“非常受限的随机写”,且写入内容会被固定前后缀污染。
为了后续描述“受限写如何 patch MapDB 元数据”,把一次写展开成更具体的“内存布局视角”:
- 调用
PUT /files/modify,传入offset = O、data = D($0 \le |D| \le 8$) - 实际写入字节序列为:
<start> || D || <end>,总长度 $W = 7 + |D| + 5$ - 对应文件中的覆盖区间是:
[O .. O+6]被覆盖为"<start>"[O+7 .. O+6+|D|]被覆盖为D[O+7+|D| .. O+7+|D|+4]被覆盖为"<end>"
一个常用技巧是 offset-7 trick:如果我们希望把 8 个任意字节写到某个“目标地址” T..T+7,可以让 O=T-7 且 |D|=8,这样 D 会正好落在 [T..T+7]。代价是同时会破坏:
- 前面的 7 字节:
[T-7 .. T-1]写成"<start>" - 后面的 5 字节:
[T+8 .. T+12]写成"<end>"
所以 offset-7 trick 只能用于“目标周围字节允许被污染”的位置。本题最终用它 patch 的位置都经过了避让设计:
- index table 的某个“空槽位”(邻居也为空)
recid=18leaf record(与某段 icon 内容区末尾 7 字节存在冲突,需要预留)
写原语的组合限制与可达性观察
这一节是最近的关键进展:用 Z3 把写原语当作“字符串重写系统”去做可达性分析,发现它并不是一个“任意字节可写”的原语,而是存在非常强的结构性限制。这也解释了为什么朴素地“把 Java 序列化 payload 写进空洞区”会频繁卡死。
0. 新的离线建模工具
为了避免“按字节模拟 K 次写操作”导致的 Z3 爆炸,我新增了一个更稳的建模方式:对每个位置只关心最后一次触碰它的写操作(last-writer model)。
对应脚本:
analysis/staircase_lastwriter.py:给定init window与target bytes(可指定放置位置),求解一组写操作使目标区间最终字节匹配。
这个模型的优点是:只对被约束的位置建模最终值,不需要在求解过程中展开整个窗口的逐步状态转移,通常更容易得到 sat/unsat 的明确结论。
1. 16 字节窗口的最大 data 覆盖率
我们考虑一个抽象模型:窗口长度为 $n=16$,允许做任意多次写操作;每次写等价于覆盖:
"<start>"(7 字节)data($0 \le \mathrm{len(data)} \le 8$)"<end>"(5 字节)
定义“某个位置最终的字节是否来自 data 区间(而不是固定前后缀)”。用 Z3 做最大化后得到一个非常稳定的结论:
- 在 $n=16$ 的窗口内,最多只能让 11 个字节最终来自 data
- 必然至少有 5 个字节最终来自固定前后缀
而且最优解的形态非常直观:中间会出现一段 强制的 "<end>" 5 字节块。Z3 给出的一个最小示例是:
- 先做一次
off=+1, ln=8(把 data 落在窗口末尾 8 字节) - 再做一次
off=-7, ln=3(把 data 落在窗口开头 3 字节,同时把"<end>"强制落在窗口中间 5 字节)
窗口最终“可控/不可控”模式大致是:
DDD.....DDDDDDDD
其中 ..... 的 5 字节会被 "<end>" 填满(无法通过 data 覆盖掉)。
这条观察对后续 payload 设计非常重要:我们不能假设“任意长度的连续字节串都可以被完全 data 覆盖写出”。相反,我们必须让 payload 的某些区段能容忍固定字节块(例如把这些不可控字节尽量安排到“可任意取值”的 padding 区、或其他对反序列化无关紧要的数据块中)。
2. 重要观察:从全零空洞区难以合成 Java 序列化头
基于 analysis/staircase_synth.py / analysis/staircase_synth_mask.py 的实验(初始窗口字节设为全 0):
- 无论是 PriorityQueue payload 的头(
ac ed 00 05 73 72 ...),还是现有 icon 记录的 byte[] 序列化头(ac ed 00 05 75 72 ...) - 在合理的操作次数范围内,都很难在“窗口起点”直接合成出来(经常直接
unsat)
这暗示了一个方向:payload 的起始位置如果位于“我们自己挑选的空洞区窗口起点”,很可能天然不可达。更现实的策略可能是:
- 利用 MapDB 文件中已经存在的 Java 序列化流(本题只有一处:
0x100250的 icon byte[] 记录),把它当成“引导/种子” - 或者允许在 payload 起点之前留出足够长的“垃圾区”,通过把
indexVal.offset指向垃圾区之后的位置,规避“窗口起点不可达”的约束
目前对第 2 点的 Z3 结果仍以 unknown 为主(说明可能可行但很难在给定超时内找到具体 schedule),这也是后续需要继续攻克的方向之一。
3. 更具体的前缀可达性结论
在 analysis/staircase_lastwriter.py 的 last-writer 模型下,我做了一个非常“硬”的实验:从全 0 的空洞区开始(init 全 0),尝试把某个 Java 序列化流的前缀精确写出来。
以 PriorityQueue gadget payload(analysis/payload_pq.bin)为例,它的序列化流起始字节为:
ac ed 00 05 73 72 00 17 6a 61 76 61 ...
结论(本地离线):
- 前 $8$ 字节
ac ed 00 05 73 72 00 17可以被合成(sat) - 试图再多约束 1 字节(第 9 字节为
0x6a,也就是'j')就变成unsat
同样地,现有 icon 记录(byte[])的序列化流前缀:
ac ed 00 05 75 72 00 02 5b ...
也呈现相同现象:前 8 字节可写,第 9 字节开始 unsat。
这说明:受限写原语在“空洞区构造长前缀”的能力非常差。想要落地一个可反序列化的复杂对象流(通常需要几十到上百字节的严格结构),基本不可能靠“从全 0 空洞区直接合成目标序列化流”这条思路完成。
这一结论反过来指导策略选择:必须依赖“已有字节作为种子”(例如利用 DB 文件里原本存在的数据块),或者让那些不可控的 marker 字节进入“对反序列化结构无害”的区域(例如某些字符串/数组内容区),再通过对齐把结构性字节(typecode/length 等)放到可控区域。
4. 不能用顶层 blockdata 吞掉 marker
一个很自然的想法是:让 marker 字节落入 Java 序列化的 TC_BLOCKDATA(0x77)块中,以便把它们当作“普通数据”吞掉。
但在 JDK 17.0.2 上测试(构造 ac ed 00 05 77 05 'hello' 70 并调用 ObjectInputStream.readObject()),会抛出:
java.io.OptionalDataException
也就是说:顶层直接出现 blockdata 并不能被 readObject() 当作“合法对象”读取。
因此 marker 的容纳位置不能是“随便塞在 stream 顶层”,而必须进入某个对象的字段内容区(例如字符串内容、数组内容等),并且仍需保证序列化结构字节(typecode、长度等)不会被 marker 覆盖破坏。
反序列化触发点与利用链
1. 触发点
IconStorageService 使用 MapDB:
iconMap = db.hashMap("icons")
.keySerializer(Serializer.STRING)
.valueSerializer(Serializer.JAVA)
.createOrOpen();
GET /files/icon 内部会执行:
Object v = iconMap.get("icon"); // Serializer.JAVA 反序列化发生在这里
return (byte[]) v; // cast 失败也无所谓,反序列化已完成
因此,只要能把 MapDB 里 "icon" 对应的 value 记录替换成恶意 Java 序列化流,就能在访问 /files/icon 时 RCE。
MapDB 文件布局 内存布局视角
这里把 MapDB 文件当作“内存布局”来描述:我们最终要修改的是 index 表项与 record 数据区。
1. DB 创建方式
MapDBConfig.mapDB():
DBMaker.tempFileDB()fileMmapEnable()transactionEnable()
所以 MapDB 数据落盘在临时文件(Linux 下通常 /tmp/mapdb<random>temp),并通过 mmap 映射到进程地址空间。
特别注意:transactionEnable() 意味着底层 store 是 StoreWAL(Write-Ahead Log)。
这在利用上可能有两层影响:
- 数据结构读取路径会优先命中内存 cache(例如
cacheRecords/cacheIndexVals),不一定每次都读磁盘文件。 commit()会把 WAL 内容写回主文件并清理 WAL(源码里可见destroyWalFiles()),因此落盘目录里不一定能稳定看到单独的.wal.*文件;但逻辑上它依旧是 WAL 模式。
2. 关键偏移 本地样本稳定复现
本地样本(例如 analysis/icon_live.db,从运行中的容器 /tmp/mapdb<random>temp 拷贝)中:
先强调一个容易踩坑的点:index table 的“槽位序号”不等于你脑补的 recid 数字。在这个题里我们更关心“槽位在文件内的固定偏移”。
- index table 的首个 index page 位于
0x8000,每个槽位 8 字节 - 目标槽位偏移
0x80f0对应的槽位序号为 $(0x80f0-0x8000)/8=30$ - 该槽位(去掉 parity1 校验位后)的
indexVal解码为:size = 161 (0x00a1)offset = 0x100250flags含archive位(+2)
- 紧邻槽位
0x80f8(槽位序号 31)为一个 8 字节小记录:size = 8offset = 0x100300
record 内容处的首字节为 Java 序列化流 header:
ac ed 00 05 ...
为了避免“口头描述偏移”带来的歧义,下面给出一份来自 analysis/mapdb_dump.py 的实际输出(big-endian 8 字节,带 parity1):
slot= 30 file_off=0x0080f0 raw=0x00a1000000100253 val=0x00a1000000100252 size= 161 off=0x100250 flags(linked=False,unused=False,archive=True)
slot= 31 file_off=0x0080f8 raw=0x0008000000100302 val=0x0008000000100302 size= 8 off=0x100300 flags(linked=False,unused=False,archive=True)
其中:
raw是文件中的 8 字节值val是去掉 parity1 的值(最低 bit 清零)size = val >> 48off = val & 0x0000FFFFFFFFFFF0(保证 16 对齐)
2.1. MapDB 文件内存布局示意
把 MapDB 临时文件当作一个“2MB 的线性内存”,本题会用到的关键区域可以画成:
0x000000 文件头/各种 stack/元信息
...
0x008000 index page 0(每 8 字节一个 slot)
0x0080f0 slot30: 指向 icon value record(size=161 off=0x100250)
0x0080f8 slot31: 指向 recid18 leaf record(size=8 off=0x100300)
...
0x100250 icon value record(Serializer.JAVA 的 byte[] 序列化流起点)
0x100300 recid18 leaf record(序列化后的 ["icon", valueRecid, 0],总计 8 字节)
...
0x101ea0 某次上传产生的 icon value record(长度 324 时)
...
0x200000 文件尾
补充:MapDB 临时文件大小稳定为 0x200000 = 2MB,因此我们理论上可以选择一个 16 字节对齐、且不与现有记录重叠的空洞偏移(例如 0x1a0000)来放置更大的 payload(前提是能解决受限写合成问题)。
3. MapDB 的 linked record 机制
题目 hint2 指出:MapDB 支持“链表式”记录,用于存储/读取巨型记录。结合对 mapdb-3.0.1 的逆向(StoreDirect.linkedRecordGet / StoreDirect.linkedRecordPut),其核心要点如下:
- MapDB 的 record 在 index table 里用一个 8 字节
indexVal描述,逻辑结构可理解为:indexVal = (size << 48) + offset + flags- 其中
size是 16-bit,offset16 字节对齐(低 4 bit 为 0),flags在低位(常见:linked=8,unused=4,archive=2;另外还有 parity 校验位)。
- 当
flags含 linked 位(indexVal & 8 != 0)时,这条记录被视为“linked record chunk”:- chunk 的起始 8 字节不是 payload,而是“指向下一段”的指针(准确说是下一段 chunk 的
indexVal,并带有 parity3 校验) - chunk 真正会被拼接进最终“记录内容”的数据,从
offset+8开始
- chunk 的起始 8 字节不是 payload,而是“指向下一段”的指针(准确说是下一段 chunk 的
- 读取时会循环:
读本段数据 -> 从 offset 取下一段 indexVal -> 跳转 - 这意味着:如果我们能把
"icon"对应的 record 改成 linked record,那么 value 的字节流可以跨多个 offset 拼接出来,而不一定要把所有数据连续写进原来的那一段 record 中。
更贴近实现的伪代码(省略 cache/WAL 分支):
buf = []
v = headIndexVal
while (v has linked flag):
off = indexValToOffset(v)
size = indexValToSize(v)
next = parity3Get( getLong(off) ) # off..off+7
buf += read(off+8, size-8) # 拼接数据段
v = next
# tail
off = indexValToOffset(v)
size = indexValToSize(v)
buf += read(off, size)
return buf
补充一个非常关键但容易误解的点(与 parity3 相关):
linkedRecordGet()取下一段指针时会对 8 字节 long 做parity3Get(v),其实现等价于:masked = v & ~7(清掉低 3 位)- 然后校验
v & 7 == (bitcount(masked)+1)%8 - 返回
masked
- 这意味着:下一段 indexVal 的低 3 位必须为 0,否则会在
parity3Set/parity3Get阶段直接抛异常。 - 对我们来说最重要的实际影响是:在 linked record 的“下一段 indexVal”里,
archive(2)/unused(4)这类落在低 3 位的 flags 无法通过指针传递(会被&~7清掉),只有linked(8)这种更高位的 flag 才能保留。
这也解释了为什么在脚本里构造“下一段 indexVal”时需要强制 archive=False, unused=False(否则无法通过 parity3 校验)。
最新进展:这一条机制不仅是方向,而且可以直接落地。本题的关键是绕开 /files/modify 带来的 "<start>"/"<end>" 污染:我们不再试图用受限写把 payload 一字节不差地“合成”进空洞区,而是用正常的 /files/upload 让 MapDB 自己把 icon 字节原封不动落盘,然后把这些落盘的字节区域当作 linked record 的各个 chunk,用 linkedRecordGet() 在读取时“链表式”拼接成完整的 Java 序列化流,最终在 GET /files/icon 触发反序列化执行命令。
反序列化 RCE payload 已在 Java 17.0.2 验证成功
1. 最终链路
使用 “toString 触发” 的 gadget 链(已在 openjdk:17.0.2-jdk-slim 中验证):
PriorityQueue.readObject()- comparator 触发
UsingToStringOrdering.compare() - element.toString() 触发
Jackson POJONode.toString() - 反射访问
Templates.getOutputProperties TemplatesImpl.newTransformer()执行恶意 translet<clinit>中的Runtime.exec(...)
2. Java 17 模块系统关键点
在 JDK 9+ 里 TemplatesImpl 的访问存在模块限制,需要保证 translet 的包名满足 Xalan 内部检查(例如 die.verwandlung)。
3. 生成与本地验证
相关工具在 analysis/:
analysis/JacksonPQTemplatesPatchedGen.java:payload 生成器(配套 class 已存在)analysis/payload_pq.bin:示例 payload
为了让 payload 能装进本题的 linked record 容量上限,我把生成器里的类名改成固定短名(避免 System.nanoTime() 带来的长后缀),从而把 payload 压到约 3.2KB:
- 生成器:
analysis/JacksonPQTemplatesPatchedGen.java- translet / 辅助类名固定为
die.verwandlung.P与die.verwandlung.F
- translet / 辅助类名固定为
- 产物:
analysis/tmp/payload_pq_small.bin(约3166字节,纯测试)analysis/tmp/payload_pq_catflag.bin(约3193字节,执行cat /flag-* > /app/uploads/out.txt)
在本地 docker 中验证方式(概要说明):
- 反序列化
analysis/payload_pq.bin能在容器内生成/tmp/pwned等副作用文件,说明 RCE 链成立。
远程利用计划 历史卡点
说明:这一节是历史记录。最终落地方案见「本地端到端利用 已完成」,已经不需要在空洞区用受限写原语合成完整 payload;此处保留用来说明当时的思考路径与不可行点。
完整利用链(计划):
POST /files/upload上传占位文件out.txt,后续用于/files/download拉回结果- 利用
/files/modify的 path traversal 写/proc/self/fd/<N>来定位 MapDB 临时文件 fd - 不再尝试直接 patch slot30/slot31(这是最容易把 DB 打炸的地方),改为利用
HTreeMap的 leaf 记录把"icon"的valueRecid改指向一个“新建的 recid”(例如50) - 在 index table 的空槽位(远离 slot30/slot31)写入
recid=50的 indexVal,让 MapDB 读到我们放在空洞区的 payload bytes - 访问
GET /files/icon触发反序列化执行命令:sh -c 'cat /flag-* > /app/uploads/out.txt'
GET /files/download下载 out.txt 获得 flag
当前卡点:
/files/modify的写入会强制插入"<start>"/"<end>",且data<=8,需要设计“合成任意字节写”的序列,才能把 3KB+ payload 精确写入 MapDB 文件空洞区。
补充进展:
- 近期通过更系统的可达性分析发现:写原语在组合意义上“无法覆盖所有字节为 data”,会出现不可避免的固定块(典型为
"<end>")。 - 因此 payload 写入不应继续沿用“把任意序列化流直接写到全零空洞区”的假设,而要转向:
- 选择/构造“marker 友好”的 payload(允许在某些位置出现固定字节块),或
- 利用现有序列化流作为种子,再通过更大范围的重写把它变形为目标流,或
- 利用 MapDB linked record,把不可控字节块塞进对反序列化无关紧要的 padding 区。
为了更系统地评估“合成能力”,我新增了几个离线辅助脚本(都在 analysis/):
analysis/staircase_synth.py:给定一个小窗口的 完整目标字节串,用 Z3 尝试合成有限次写操作(允许data长度0..8)。analysis/staircase_synth_mask.py:只约束窗口内的一个子区间(允许前后垃圾),更贴近真实利用:我们可以把 MapDB 的indexVal.offset指到子区间的起点。analysis/staircase_schedule_synth.py:只求“最后一次写到某位置的是 data 而不是固定前后缀”的组合可行性,用来判断是否存在“对任意字节串都成立”的通用写入 schedule(目前观察到:当目标区间长度达到 16 时,这种“全 data 覆盖”的 schedule 会变得不可满足,说明原语非常弱,必须依赖 payload 本身的字节分布/重叠写策略)。
补充一个更具体的观察(本地离线验证):
- 以 index page
0x80e0..0x8110这段为窗口,目标是“只修改槽位0x80f0..0x80f7(slot 30)为新的 indexVal,其余字节完全不变”。 - 在写原语为
"<start>"+data(<=8)+"<end>"且每次只能覆盖写(不能扩容)的前提下,用 Z3 对“有限次写操作”的可满足性做过实验:在合理的操作次数范围内,始终找不到同时保持相邻槽位字节完全不变的解。 - 这与直觉一致:任意一次把 8 字节落到
0x80f0的写法,要么会把0x80f8前缀覆盖成"<end>",要么会把0x80e8后缀覆盖成"<start>"。要想修复邻居,又会引入新的覆盖。
因此更可能的正确方向是:不要试图“只改一个槽位”,而是把一小段 index table 当作整体来重建(同时重定位相关 record),或者走不依赖 index table 的 MapDB 覆盖路径(例如事务层 / linked record / 其他结构)。
补充:本地验证了一个“看似简单但会炸”的思路
- 直接用 “offset-7 trick” 写
0x80f0处的 8 字节 index entry(例如在0x80f0-7写入 8 字节)虽然能把目标 8 字节落到正确位置,但会把紧邻槽位0x80f8的前 5 字节覆盖为"<end>",导致 MapDB 在HTreeMap.get("icon")时抛出DBException$VolumeEOF(offset 被解析成一个巨大的值,明显越界)。 - 这说明:想改
"icon"对应槽位的indexVal,基本必须同时保证相邻槽位仍然可用(或者找到能让 map 不再依赖它们的替代路径)。
关键突破:recid18 的真实含义
之前把 slot31/recid18 当作“神秘依赖”,只知道一旦破坏就 404。
通过对 org.mapdb.HTreeMap$leafValueExternalSerializer$1 的 javap 以及对本地 DB 的反射解码,确认:
leafValueExternalSerializer记录的内容是一个Object[],以三元组存储:[key, valueRecid, expireRecid](本题未启用 expire,第三个恒为 0)
- 本题初始状态下,
recid=18解码结果为:
["icon", 17, 0]
这意味着:
recid=17才是真正的 value record(里面是 Java 序列化的byte[]icon)recid=18是 leaf 节点,用来“指向 valueRecid”
因此利用策略可以改为:
- 在 index table 的空槽位新建一个 recid(例如
50),让它先指向原 value(用于验证) - 把
recid=18里记录的valueRecid改成50 /files/icon仍能正常返回 icon,说明 valueRecid 重定向成功- 下一步再把
recid=50的 indexVal 改成指向 payload 区域,从而触发反序列化 RCE
该验证已经在本地 docker 环境通过(见 solution/solution.py 的 --redirect-via-recid18)。
本地端到端利用 已完成
这一节记录最新落地方案:利用 MapDB linked record,把 3KB 级别的 Java 序列化 payload 分散种在多段 icon 记录里,再通过一条小的 indexVal patch 把 "icon" 指到我们构造的“链表头”,最终在 GET /files/icon 触发反序列化。
1. 绕过点
核心矛盾是:
/files/modify的写入会强制插入"<start>"/"<end>",且data<=8,很难在全零空洞区精确合成长序列化流。/files/upload写入icon时,会调用IconStorageService.storeIcon(byte[]),最终由 MapDB 的Serializer.JAVA把这个byte[]正常序列化落盘。这条路径落盘的字节不带"<start>"/"<end>"污染。
因此我们把目标从“用受限写制造 payload 字节”改成“用上传制造 payload 字节”,受限写只负责改少量元数据。
2. chunk 布局
以某次上传产生的 icon value record 为例,设其在 MapDB 文件中的 record 起始偏移为 recordOff:
- Java 序列化流起点:
recordOff - icon 原始字节数组内容起点:
recordOff+27 - 选择 chunk 头:
chunkHeaderOff = recordOff+32(16 对齐,且位于 icon 内容区的第 5 个字节)
把 chunkHeaderOff 这段内存当作 MapDB 的一个 record chunk:
- linked chunk:
chunkHeaderOff .. +7:下一段 chunk 的indexVal指针(需要 parity3Set,且必须满足低 3 位为 0)chunkHeaderOff+8 ..:payload 分片字节
- tail chunk:
- 直接把最后一段 payload 放在
chunkHeaderOff ..
- 直接把最后一段 payload 放在
对应的 indexVal:
- linked chunk:
size = 8 + len(fragment),offset = chunkHeaderOff,flaglinked=1 - tail chunk:
size = len(fragment),offset = chunkHeaderOff,flaglinked=0
2.1. icon 字节如何构造
服务端对 icon 的校验只有两点:
- 不能为空
- 长度不超过 324(
18x18)
它并不解析 PNG/JPEG 等格式,因此我们可以把 icon 当作“任意字节数组”使用。
脚本的构造策略(见 solution/solution.py):
- 令
icon[0..4] = b"AAAAA"(纯占位,不参与 chunk) - 令 chunk 头对齐到
icon[5],也就是chunkHeaderOff = recordOff + 32 - 若是 linked chunk:
icon[5..12] = u64be(parity3_set(next_indexval))(big-endian)icon[13..] = payload_fragment
- 若是 tail chunk:
icon[5..] = payload_fragment
- 最后用
0x00填充到总长度 $L$
这样一来,MapDB 反序列化时会把 chunkHeaderOff 当作 record 数据源,读取 next_indexval 并拼接各段 fragment。
3. 可用容量与特殊冲突
如果某次上传的 icon 长度为 $L$:
- tail chunk 可用容量:$L-5$
- linked chunk 可用容量:$L-13$(额外消耗 8 字节保存 next 指针)
本题还有一个特殊点:recid=18 的 leaf record 固定在 0x100300,而我们用 offset-7 trick patch leaf 时会写入 "<start>" 覆盖 0x1002f9..0x1002ff,刚好落在 recordOff=0x100250 这段 icon 内容区的最后 7 字节。因此当 $L=149$ 对应 recordOff=0x100250 时,需要额外预留 7 字节不使用(等价于把该 chunk 可用容量再减 7)。
4. fresh 容器下的确定性 recordOff
在一个全新启动的容器里,按如下顺序反复上传同名文件 out.txt,并让 icon 长度依次取这些值,就能稳定获得一组 record 起始偏移(本地已验证):
L=5->recordOff=0x100470仅用于 allocator warmupL=21->0x100530L=37->0x100600L=53->0x1006e0L=69->0x1007d0L=85->0x1008d0L=101->0x1009e0L=117->0x100b00L=133->0x100c30L=149->0x100250注意与 leaf patch 的 7 字节冲突L=165->0x100e10L=181->0x100f70L=197->0x1010e0L=213->0x101260L=229->0x1013f0L=245->0x101590L=261->0x101740L=277->0x101900L=293->0x101ad0L=309->0x101cb0L=324->0x101ea0
最终我们把 payload(本地 3193 字节)拆成 20 段,按 324,309,...,21 的逆序组织成 linked record 链表,总容量约 3200 字节,能容纳该 payload。
为了直观看到每段能塞多少字节,这里给出按链表顺序(head -> tail)的容量列表(单位:字节):
L=324:311
L=309:296
L=293:280
L=277:264
L=261:248
L=245:232
L=229:216
L=213:200
L=197:184
L=181:168
L=165:152
L=149:129 (额外 -7,避开 leaf patch 污染)
L=133:120
L=117:104
L=101:88
L=85 :72
L=69 :56
L=53 :40
L=37 :24
L=21 :16 (tail chunk 用 L-5)
sum=3200
5. 最终 patch 与触发
最终只需要两次受限写:
- 找到 MapDB 临时文件 fd(通过
/proc/self/fd/<N>写探针偏移来判定 2MB 文件)。 - 选择一个未使用且
<128的new_recid(例如100):- 写入其 index slot(
slot = new_recid + 13,偏移0x8000 + slot*8)为 head chunk 的 indexVal(linked=1,offset=0x101ea0+32,size=8+head_len)。 - patch
recid=18的 leaf record(偏移0x100300)把valueRecid改为new_recid。
- 写入其 index slot(
- 访问
GET /files/icon触发反序列化执行命令:- payload 执行:
/bin/sh -c 'cat /flag-* > /app/uploads/out.txt' getIcon()会把返回值 cast 成byte[],所以可能出现 404,但命令已经执行。
- payload 执行:
GET /files/download下载out.txt取回 flag。
把这一步写得更“可复现/可审计”,需要补充 4 个细节:fd 定位、index slot 计算、写入的具体字节、以及为什么 new_recid<128。
5.1. MapDB fd 定位方法
我们只能写,不能读,因此 fd 定位采用“探测写入是否成功”的方式:
- 枚举
fd=0..max_fd,对每个 fd 走一次:PUT /files/modify,fileName=../../proc/self/fd/<fd>,offset=-1,data="A"- 如果返回
400 invalid offset,说明这个 fd 指向一个“存在且可打开”的文件(但不代表是 MapDB)。
- 对候选 fd 再做一次“文件长度探针”:
PUT /files/modify,offset=0x1f0000(接近 2MB 文件末尾,但不越界)- MapDB 临时文件长度稳定为
0x200000,因此这一步会返回200;而多数其他 fd 会失败。
注意:这一步会在 0x1f0000 附近写入 12..20 字节的 "<start>...<end>",但我们把探针位置放在“远离所有 chunk 与 index page”的区域,因此不会影响利用链。
5.2. index slot 计算与选择 new_recid
MapDB 的 index table 每个槽位 8 字节,从 0x8000 开始;对于本题 DB,recid 与 slot 的关系可视为:
slot = recid + 13slot_off = 0x8000 + slot * 8
我们需要 patch 一个“空槽位”,原因是 offset-7 trick 会污染相邻 7+5 字节。如果邻居 slot 已经被 MapDB 使用,就可能把 DB 打坏。因此:
new_recid选大一点(例如100),对应slot=113,偏移slot_off=0x8388- 本地 fresh 容器里这一区域是空的,污染不会影响正常读写
5.3. 写入的具体字节
1) 写 head indexVal 到新槽位
MapDB 的 indexVal(未加 parity1)可写为:
$$ v = (size \ll 48) + offset + linked \cdot 8 + unused \cdot 4 + archive \cdot 2 $$其中 offset 必须 16 对齐,且文件中是 big-endian 存储。
本地脚本里 head chunk 对应:
offset = 0x101ea0 + 0x20 = 0x101ec0- head 分片长度为
311字节,因此size = 8 + 311 = 319 linked=1,archive=1,unused=0
得到:
indexVal(raw) = 0x013f0000000101ecaindexVal(stored with parity1) = 0x013f0000000101ecb- 写入的 8 字节(big-endian):
01 3f 00 00 00 10 1e cb
然后用 offset-7 trick 把这 8 字节落到 slot_off:
- 调用
PUT /files/modify时使用offset = slot_off - 7 = 0x8381,data长度 8 data会写到[0x8388..0x838f],正好覆盖该 slot- 同时污染
[0x8381..0x8387]与[0x8390..0x8394],因此 slot 周围必须为空
2) patch recid18 leaf record
leaf record(recid=18)序列化后是一个 Object[]:
["icon", valueRecid, 0]
其编码方式是 MapDB 的 DataIO.packLong/packInt(7-bit big-endian,最后一字节带 0x80),并且本题 keySerializer(STRING) 会把每个 ASCII 字节 OR 上 0x80。
因此当 new_recid=100 时,leaf record 的 8 字节是:
83 84 e9 e3 ef ee e4 80
解释:
83:arrayLen=384 e9 e3 ef ee:字符串 “icon”e4:valueRecid=10080:expireRecid=0
同样用 offset-7 trick 写到 recid18_off=0x100300:
- 调用
PUT /files/modify使用offset = 0x100300 - 7 = 0x1002f9 data会写到[0x100300..0x100307]- 同时污染
[0x1002f9..0x1002ff]与[0x100308..0x10030c]
其中 [0x1002f9..0x1002ff] 正好落在 recordOff=0x100250 的 icon 内容区尾部 7 字节,因此我们在 $L=149$ 那块 chunk 里预留了 7 字节不使用,避免 payload 末尾被覆盖。
5.4. 为什么 new_recid 必须 <128
因为 leaf record 总长度必须为 8 字节(这样才能用一次 data<=8 写完),而 valueRecid 与 expireRecid 都用 pack7 varint 编码:
- 当
valueRecid<128,它只占 1 字节(例如100 -> e4) - 当
valueRecid>=128,编码会变成 2 字节甚至更多,leaf record 总长度超过 8,无法一次写完
这就是脚本里硬性要求 new_recid < 128 的原因。
6. 本地验证
脚本:solution/solution.py 已实现 full exploit,默认使用 analysis/tmp/payload_pq_catflag.bin。
常用参数:
--payload:切换 payload(例如测试版与最终版)--new-value-recid:选择用于挂链表头的 recid(必须<128,且建议选一个“空槽位”区域)--max-fd:扫描/proc/self/fd的上限(默认 128)--timeout:HTTP 超时--sleep:触发后等待秒数(给命令执行留时间)
本地复现:
- 启动容器:
docker run -d --name staircase_test -p 18081:8080 staircase_local - 运行利用:
python3 solution/solution.py --target 127.0.0.1:18081 --linked-exploit
一次成功运行的典型输出(节选):
[+] payload loaded: 3193 bytes <- analysis/tmp/payload_pq_catflag.bin
[+] planting icon records (this assumes a fresh container state)
- uploaded icon_len=5
...
- uploaded icon_len=324
[+] identified MapDB fd: 9
[+] wrote head index entry for recid=100 at slot113 off=0x8388
[+] patched recid18 leaf valueRecid -> 100
[+] trigger /files/icon status=404 body_len=0
[+] download len=15
flag{testflag}
其中 GET /files/icon 返回 404 的原因是:服务端会把 iconMap.get("icon") 的返回值强制 cast 成 byte[],而我们让它返回的是一个复杂对象(PriorityQueue gadget),所以 cast 抛异常,controller 捕获后返回 notFound。但反序列化发生在 cast 之前,因此命令仍会执行。
本地容器默认 flag 为:
flag{testflag}
脚本结构与关键函数对应
为了方便复现与二次调试,把 solution/solution.py 的关键函数与 WP 中的步骤对应起来:
-
构造 linked record 链
compute_linked_chunks(payload):用固定的icon_len -> recordOff映射构造 chunk 链表,并把 payload 按每段容量切片。- 容量计算要区分两类 chunk:
- linked chunk:前 8 字节存 “下一段 indexVal 指针”,因此可用容量为 $L-5-8$。
- tail chunk:没有 next 指针,可用容量为 $L-5$。
L=149有额外-7的特殊处理:因为我们用 offset-7 trick patchrecid18时会覆盖0x1002f9..0x1002ff这 7 字节,刚好落在recordOff=0x100250这条 icon 记录的内容区尾部,必须把 payload 在该段末尾空出 7 字节。
-
把 chunk 落盘到 MapDB(不通过受限写合成 bytes)
build_icon_bytes(icon_len, chunk_data, next_indexval):构造上传时的 icon 字节。- icon 的 byte[] 序列化流中,“数组内容”起点是
recordOff+27。脚本把 chunk 头对齐到recordOff+32,也就是 icon_bytes[5],这样每段的offset都天然 16 字节对齐(满足 MapDB 的indexValToOffset()掩码规则)。 Client.upload(...):对POST /files/upload发 multipart,同时传file与icon两个字段。这里的核心目标只是让 MapDB 自己把 icon 原样序列化落盘,不依赖/files/modify。
-
找到 MapDB 临时文件对应的
/proc/self/fd/Nscan_rw_fds(...):用PUT /files/modify+offset=-1触发服务端的 “invalid offset” 分支,筛出可写 fd 候选。find_mapdb_fd(...):对候选 fd 试探性写入offset=0x1f0000,MapDB 临时文件大小稳定约 2MB(0x200000),因此该 offset 合理落在文件范围内;返回 200 的 fd 视为 MapDB。- 注意:这个试探写会写入
"<start>" + data + "<end>",属于有副作用的探测,但通常落在未使用空洞区,对本题影响可忽略。
-
用两次受限写 patch 元数据
modify_upto8(...):封装PUT /files/modify,利用 offset-7 trick 把“真正的数据”对齐到目标地址(让<start>落到目标之前、<end>落到目标之后)。- 写 index entry(把
new_recid的 slot 指向我们构造的 linked record 头):- index page 起点
0x8000,每 slot 8 字节,slot 号为new_recid + 13。
- index page 起点
- patch
recid=18leaf(让"icon"的valueRecid指向new_recid):encode_leaf_value_external_icon(new_recid, 0)负责生成 8 字节 leaf:["icon", new_recid, 0]。- 这里要求
new_recid < 128,否则 pack7 varint 会变成 2 字节,leaf 总长度超过 8,无法一次写完。
-
触发与回显
Client.trigger_icon():访问GET /files/icon触发反序列化。Client.download():访问GET /files/download获取/app/uploads/out.txt。- 触发接口经常返回 404:因为服务端强制 cast
Object -> byte[],而 gadget 反序列化出来是复杂对象,会抛异常;但反序列化发生在 cast 之前,所以 RCE 成功与否应以/files/download为准。
补充:为什么不会被 MapDB cache 绕过?
iconMap.put("icon", <byte[]>)会把旧的"icon"value 放进 cache,但我们 patch 的是 leaf 中的valueRecid,使得之后iconMap.get("icon")会去加载一个全新的new_recid(cache 中没有),因此必然走到 store 读取并触发Serializer.JAVA。
常见失败与排错
- 现象:脚本输出
trigger /files/icon status=404,但download很短(例如只有几字节或十几字节),找不到 flag
- 原因:目标不是 fresh,导致 MapDB recordOff 分配顺序被打乱,脚本内置的
icon_len -> recordOff映射不再成立(chunk 头/数据段不在预期位置)。 - 处理:重开实例或重置容器,确保从全新状态跑一遍;远程端口变更时尤其要注意。
- 现象:脚本找不到 MapDB fd(没有候选或 probe 失败)
- 原因:
/proc/self/fd不可访问、path traversal 被修补、或目标协议/路径不是同一套服务。 - 处理:先确认
curl http://IP:PORT/与curl http://IP:PORT/files/info正常;再调整--max-fd或 probe offset(保持在 2MB 内)。
- 现象:脚本提示 “flag header not found”,但你怀疑已经跑通
- 原因:真实 flag 前缀可能不是
flag{(例如远程是alictf{...})。 - 处理:直接
curl -s http://IP:PORT/files/download | xxd看实际内容;脚本已在extract_flag中做了泛化匹配。
远程连通性与利用结果
远程端口在题面中多次变更(我曾遇到 tcp *.*.*.*:* 直接 curl 报 Empty reply from server,疑似非 HTTP 或当时环境未就绪)。本文最终成功复现的远程端口为:
tcp *.*.*.*:*
连通性确认(HTTP 200 返回前端页面):
curl -sv --max-time 6 http://*:*/ >/dev/null
关键注意点:
- 远程环境不出网不影响本利用链(命令执行只读本地
/flag-*并写回 uploads)。 - 本 exploit 强依赖 “fresh 容器” 的 MapDB recordOff 分配;同一实例上重复多次跑,可能会因为分配顺序变化导致失败。
远程利用实测(2026-02-01):
python3 solution/solution.py --target http://*:* --linked-exploit
第一次跑通后,直接 GET /files/download 可取回 flag 内容。注意:远程真实 flag 前缀未必是 flag{,我这次拿到的是 alictf{...},所以脚本后续已把 flag 提取逻辑做了泛化(solution/solution.py 的 extract_flag)。
远程实测过程记录(用于对照排错):
- 脚本第一次跑通时,核心输出类似:
[+] wrote head index entry for recid=100 at slot113 off=0x8388
[+] patched recid18 leaf valueRecid -> 100
[+] trigger /files/icon status=404 body_len=0
[+] download len=45
这里的 download len=45 已经强烈暗示“命令执行成功并写入了一个短文本”,只是在当时脚本还只匹配 flag{,导致它误报 “not found”。因此我直接用 curl 检查回显内容:
- 直接拉取回显文件(更可靠):
curl -s http://*:*/files/download
得到:
alictf{...}
- 在同一个实例上重复跑第二次,可能会失败(例如脚本输出
download len=12或返回 placeholder),原因就是前面强调的“非 fresh 导致 recordOff 分配顺序变化”。一旦出现这种情况,最省时间的处理方式是:重开实例/等待平台重置,再按 fresh 流程跑一遍。
为了避免“脚本输出 vs 实际内容”不一致,建议用最直接的方法确认回显:
curl -s http://*:*/files/download | xxd
远程 flag:
alictf{...}
当前状态
- 已完成:本地端到端利用拿到
flag{testflag},并把“受限写合成 payload”路线替换为 linked record 分块拼接方案 - 已完成:远程最新端口(
*.*.*.*:*)打通并拿到真实 flag(见上节)