February 3, 2026

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:
    1. 远程环境不出网
    2. MapDB 支持“链表式”记录(linked record)存储/读取,用于巨型记录

最终结论 本地已打通

本题最终落地利用链不再依赖“用受限写原语合成任意字节串”,而是:

  1. POST /files/upload 多次上传不同长度的 icon,让 MapDB 把这些 icon 字节以 Serializer.JAVA 正常序列化落盘。
  2. 利用 MapDB 的 linked record 机制,把若干个落盘的 icon 内容区拼成一个完整 record 字节流(几千字节也可以)。
  3. 通过 /files/modify 的 path traversal 写 /proc/self/fd/<N>,对 MapDB 临时文件做极少量元数据 patch:
    • 新建一个 new_recid 的 index entry 指向我们构造的 linked record 链表头
    • patch recid=18 leaf,把 "icon"valueRecid 指向 new_recid
  4. 访问 GET /files/icon 触发 MapDB Serializer.JAVA 反序列化,执行命令把 /flag-* 写入 /app/uploads/out.txt,再 GET /files/download 取回 flag。

对应脚本:solution/solution.py


附件与本地复现

附件位于 task/

  • task/chal.jar:Spring Boot fat jar
  • task/Dockerfileopenjdk:17.0.2-jdk-slim
  • task/run.sh:启动时把 $FLAG 写入 /flag-<random>.txt,然后 java -jar chal.jar

启动逻辑(关键信息):

  1. 容器启动时会写入 flag 文件:
    • 有环境变量 $FLAG:写入真实 flag
    • 否则写入 flag{testflag}
  2. 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 <= offsetoffset + writeLen <= file.length()
    • 不能扩展文件,只能在现有长度内覆盖写

这意味着我们拥有一个“非常受限的随机写”,且写入内容会被固定前后缀污染。

为了后续描述“受限写如何 patch MapDB 元数据”,把一次写展开成更具体的“内存布局视角”:

  • 调用 PUT /files/modify,传入 offset = Odata = 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=18 leaf record(与某段 icon 内容区末尾 7 字节存在冲突,需要预留)

写原语的组合限制与可达性观察

这一节是最近的关键进展:用 Z3 把写原语当作“字符串重写系统”去做可达性分析,发现它并不是一个“任意字节可写”的原语,而是存在非常强的结构性限制。这也解释了为什么朴素地“把 Java 序列化 payload 写进空洞区”会频繁卡死。

0. 新的离线建模工具

为了避免“按字节模拟 K 次写操作”导致的 Z3 爆炸,我新增了一个更稳的建模方式:对每个位置只关心最后一次触碰它的写操作(last-writer model)。

对应脚本:

  • analysis/staircase_lastwriter.py:给定 init windowtarget 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 的起始位置如果位于“我们自己挑选的空洞区窗口起点”,很可能天然不可达。更现实的策略可能是:

  1. 利用 MapDB 文件中已经存在的 Java 序列化流(本题只有一处:0x100250 的 icon byte[] 记录),把它当成“引导/种子”
  2. 或者允许在 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_BLOCKDATA0x77)块中,以便把它们当作“普通数据”吞掉。

但在 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)。

这在利用上可能有两层影响:

  1. 数据结构读取路径会优先命中内存 cache(例如 cacheRecords / cacheIndexVals),不一定每次都读磁盘文件。
  2. 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 = 0x100250
    • flagsarchive 位(+2
  • 紧邻槽位 0x80f8(槽位序号 31)为一个 8 字节小记录:
    • size = 8
    • offset = 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 >> 48
  • off = 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,offset 16 字节对齐(低 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 开始
  • 读取时会循环:读本段数据 -> 从 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.Pdie.verwandlung.F
  • 产物:
    • 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;此处保留用来说明当时的思考路径与不可行点。

完整利用链(计划):

  1. POST /files/upload 上传占位文件 out.txt,后续用于 /files/download 拉回结果
  2. 利用 /files/modify 的 path traversal 写 /proc/self/fd/<N> 来定位 MapDB 临时文件 fd
  3. 不再尝试直接 patch slot30/slot31(这是最容易把 DB 打炸的地方),改为利用 HTreeMap 的 leaf 记录把 "icon"valueRecid 改指向一个“新建的 recid”(例如 50
  4. 在 index table 的空槽位(远离 slot30/slot31)写入 recid=50 的 indexVal,让 MapDB 读到我们放在空洞区的 payload bytes
  5. 访问 GET /files/icon 触发反序列化执行命令:
    • sh -c 'cat /flag-* > /app/uploads/out.txt'
  6. 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$1javap 以及对本地 DB 的反射解码,确认:

  • leafValueExternalSerializer 记录的内容是一个 Object[],以三元组存储:
    • [key, valueRecid, expireRecid](本题未启用 expire,第三个恒为 0)
  • 本题初始状态下,recid=18 解码结果为:
["icon", 17, 0]

这意味着:

  1. recid=17 才是真正的 value record(里面是 Java 序列化的 byte[] icon)
  2. 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 ..

对应的 indexVal:

  • linked chunk:size = 8 + len(fragment)offset = chunkHeaderOff,flag linked=1
  • tail chunk:size = len(fragment)offset = chunkHeaderOff,flag linked=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 warmup
  • L=21 -> 0x100530
  • L=37 -> 0x100600
  • L=53 -> 0x1006e0
  • L=69 -> 0x1007d0
  • L=85 -> 0x1008d0
  • L=101 -> 0x1009e0
  • L=117 -> 0x100b00
  • L=133 -> 0x100c30
  • L=149 -> 0x100250 注意与 leaf patch 的 7 字节冲突
  • L=165 -> 0x100e10
  • L=181 -> 0x100f70
  • L=197 -> 0x1010e0
  • L=213 -> 0x101260
  • L=229 -> 0x1013f0
  • L=245 -> 0x101590
  • L=261 -> 0x101740
  • L=277 -> 0x101900
  • L=293 -> 0x101ad0
  • L=309 -> 0x101cb0
  • L=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 与触发

最终只需要两次受限写:

  1. 找到 MapDB 临时文件 fd(通过 /proc/self/fd/<N> 写探针偏移来判定 2MB 文件)。
  2. 选择一个未使用且 <128new_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
  3. 访问 GET /files/icon 触发反序列化执行命令:
    • payload 执行:/bin/sh -c 'cat /flag-* > /app/uploads/out.txt'
    • getIcon() 会把返回值 cast 成 byte[],所以可能出现 404,但命令已经执行。
  4. GET /files/download 下载 out.txt 取回 flag。

把这一步写得更“可复现/可审计”,需要补充 4 个细节:fd 定位、index slot 计算、写入的具体字节、以及为什么 new_recid<128

5.1. MapDB fd 定位方法

我们只能写,不能读,因此 fd 定位采用“探测写入是否成功”的方式:

  1. 枚举 fd=0..max_fd,对每个 fd 走一次:
    • PUT /files/modifyfileName=../../proc/self/fd/<fd>offset=-1data="A"
    • 如果返回 400 invalid offset,说明这个 fd 指向一个“存在且可打开”的文件(但不代表是 MapDB)。
  2. 对候选 fd 再做一次“文件长度探针”:
    • PUT /files/modifyoffset=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 + 13
  • slot_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=1archive=1unused=0

得到:

  • indexVal(raw) = 0x013f0000000101eca
  • indexVal(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 = 0x8381data 长度 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=3
  • 84 e9 e3 ef ee:字符串 “icon”
  • e4:valueRecid=100
  • 80: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 写完),而 valueRecidexpireRecid 都用 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 中的步骤对应起来:

  1. 构造 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 patch recid18 时会覆盖 0x1002f9..0x1002ff 这 7 字节,刚好落在 recordOff=0x100250 这条 icon 记录的内容区尾部,必须把 payload 在该段末尾空出 7 字节。
  2. 把 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,同时传 fileicon 两个字段。这里的核心目标只是让 MapDB 自己把 icon 原样序列化落盘,不依赖 /files/modify
  3. 找到 MapDB 临时文件对应的 /proc/self/fd/N

    • scan_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>",属于有副作用的探测,但通常落在未使用空洞区,对本题影响可忽略。
  4. 用两次受限写 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
    • patch recid=18 leaf(让 "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,无法一次写完。
  5. 触发与回显

    • 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

常见失败与排错

  1. 现象:脚本输出 trigger /files/icon status=404,但 download 很短(例如只有几字节或十几字节),找不到 flag
  • 原因:目标不是 fresh,导致 MapDB recordOff 分配顺序被打乱,脚本内置的 icon_len -> recordOff 映射不再成立(chunk 头/数据段不在预期位置)。
  • 处理:重开实例或重置容器,确保从全新状态跑一遍;远程端口变更时尤其要注意。
  1. 现象:脚本找不到 MapDB fd(没有候选或 probe 失败)
  • 原因:/proc/self/fd 不可访问、path traversal 被修补、或目标协议/路径不是同一套服务。
  • 处理:先确认 curl http://IP:PORT/curl http://IP:PORT/files/info 正常;再调整 --max-fd 或 probe offset(保持在 2MB 内)。
  1. 现象:脚本提示 “flag header not found”,但你怀疑已经跑通
  • 原因:真实 flag 前缀可能不是 flag{(例如远程是 alictf{...})。
  • 处理:直接 curl -s http://IP:PORT/files/download | xxd 看实际内容;脚本已在 extract_flag 中做了泛化匹配。

远程连通性与利用结果

远程端口在题面中多次变更(我曾遇到 tcp *.*.*.*:* 直接 curlEmpty 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.pyextract_flag)。

远程实测过程记录(用于对照排错):

  1. 脚本第一次跑通时,核心输出类似:
[+] 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 检查回显内容:

  1. 直接拉取回显文件(更可靠):
curl -s http://*:*/files/download

得到:

alictf{...}
  1. 在同一个实例上重复跑第二次,可能会失败(例如脚本输出 download len=12 或返回 placeholder),原因就是前面强调的“非 fresh 导致 recordOff 分配顺序变化”。一旦出现这种情况,最省时间的处理方式是:重开实例/等待平台重置,再按 fresh 流程跑一遍。

为了避免“脚本输出 vs 实际内容”不一致,建议用最直接的方法确认回显:

curl -s http://*:*/files/download | xxd

远程 flag:

alictf{...}

当前状态

  • 已完成:本地端到端利用拿到 flag{testflag},并把“受限写合成 payload”路线替换为 linked record 分块拼接方案
  • 已完成:远程最新端口(*.*.*.*:*)打通并拿到真实 flag(见上节)