26长城杯半决赛IntraBadge复现

admin 2026-05-08 05:10:32 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 该文档复现了长城杯半决赛IntraBadge题目的攻击过程,通过代码审计发现系统存在安全漏洞:可利用avatar/refresh路由的fetchresource函数读取Redis中的flag值,并通过修改自定义模板调用avatarrawtext或avatarraw_b64函数在preview路由回显flag。攻击步骤包括设置用户cookie、上传Redis协议URL、刷新头像解析flag、修改模板实现数据回显。 综合评分: 85 文章分类: CTF,WEB安全,代码审计,漏洞分析,红队


cover_image

26长城杯半决赛IntraBadge复现

原创

浪漫土狗 浪漫土狗

正在思考ing

2026年5月7日 15:29 江苏

在小说阅读器读本章

去阅读

参考链接

https://mei-debug.github.io/2026/03/24/2026CISCN-CCB%E5%8D%8A%E5%86%B3%E8%B5%9B/

攻击思路

 通过上传头像url读取存在redis中的flag->利用/avatar/refresh路由解析flag值->写入自定义模板利用可以读取信息的函数->利用沙箱二次渲染执行自定义模板回显flag->访问preview路由读取flag

代码审计

题目描述:某企业开发了一套用于内部员工展示的“徽章卡片系统”,员工可自定义展示模板并填写头像 URL,由系统自动抓取并缓存头像资源后进行页面渲染。在开发阶段,为了便于调试与快速上线,部分模板渲染与资源抓取功能未进行严格安全限制。

题目并没有想象中那么难,关键还是要看懂代码逻辑,把每个路由的作用审清楚。

@app.get("/prefs")
def prefs():
    u = safe_key(request.args.get("u", "guest"))
    resp = make_response(redirect(url_for("dashboard")))
    resp.set_cookie("user", u, max_age=86400, path="/")
    return resp

该路由的主要作用是设置用户cookie,然后跳转到dashboard路

@app.get("/dashboard")
def dashboard():
    user = _get_user()#从用户cookie中获取用户名
    tpl = _get_tpl(user)#通过用户名在redis中获取模板
    avatar_url = _get_avatar_url(user)#在redis中获取头像url
    avatar_data, avatar_ctype, avatar_updated = _get_avatar_blob(user)#获取头像二进制数据及原信息(二进制数据、MIME 类型、更新时间字符串)

    avatar_ok = avatar_ctype.startswith("image/") and len(avatar_data) > 0

    return render_template(
        "dashboard.html",
        user=user,
        tpl=tpl,
        avatar_url=avatar_url,
        avatar_ok=avatar_ok,
        avatar_ctype=avatar_ctype or"-",
        avatar_size=len(avatar_data),
        avatar_updated=avatar_updated or"-",
    )#对dashboard.html进行渲染

下面是获取自定义模板的代码,我们可以点击主页上的Edit Template按钮跳转至template路由修改自定义的模板

def _get_tpl(user):
    k = f"tpl:{user}"
    if not rdb.exists(k):
        rdb.set(
            k,
            """
<div class="badge">
&nbsp; <div class="badge-left">
&nbsp; &nbsp; <div class="avatar">
&nbsp; &nbsp; &nbsp; {% if avatar_ok %}
&nbsp; &nbsp; &nbsp; &nbsp; <img src="/avatar/file" alt="avatar"/>
&nbsp; &nbsp; &nbsp; {% else %}
&nbsp; &nbsp; &nbsp; &nbsp; <div class="avatar-ph">No Avatar</div>
&nbsp; &nbsp; &nbsp; {% endif %}
&nbsp; &nbsp; </div>
&nbsp; </div>
&nbsp; <div class="badge-right">
&nbsp; &nbsp; <div class="title">{{ name }}</div>
&nbsp; &nbsp; <div class="sub">IntraBadge · Internal</div>
&nbsp; &nbsp; <div class="meta">Last refresh: {{ avatar_updated or "never" }}</div>
&nbsp; </div>
</div>
""",
&nbsp; &nbsp; &nbsp; &nbsp; )
&nbsp; &nbsp;&nbsp;return&nbsp;(rdb.get(k)&nbsp;or&nbsp;b"").decode("utf-8",&nbsp;"ignore")

审计dashboard路由,可以看到,虽然tpl是我们可以自定义进行操作,但是因为这里没有二次渲染,也不好进行ssti,接着往下审计

@app.post("/avatar")
def&nbsp;avatar_set():
&nbsp; &nbsp; user = _get_user()
&nbsp; &nbsp; url = (request.form.get("avatar_url",&nbsp;"")&nbsp;or&nbsp;"").strip()
&nbsp; &nbsp; rdb.set(f"avatar_url:{user}", url[:2000])#这里仅仅是保存头像url
&nbsp; &nbsp;&nbsp;return&nbsp;redirect(url_for("dashboard"))

设置头像 URL,然后存储在redis数据库中,接着再跳转到dashboard路由

@app.post("/avatar/refresh")
def&nbsp;avatar_refresh():
&nbsp; &nbsp; user = _get_user()
&nbsp; &nbsp; url = _get_avatar_url(user)
&nbsp; &nbsp;&nbsp;ifnot&nbsp;url:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;redirect(url_for("dashboard"))

&nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; data, ctype, meta = fetch_resource(url)
&nbsp; &nbsp;&nbsp;except&nbsp;Exception:
&nbsp; &nbsp; &nbsp; &nbsp; rdb.set(f"avatarbin:{user}",&nbsp;b"")
&nbsp; &nbsp; &nbsp; &nbsp; rdb.set(f"avatarctype:{user}",&nbsp;"application/octet-stream")
&nbsp; &nbsp; &nbsp; &nbsp; rdb.set(f"avatarupd:{user}",&nbsp;"fetch failed")
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;redirect(url_for("dashboard"))

&nbsp; &nbsp; rdb.set(f"avatarbin:{user}", data[:MAX_AVATAR])
&nbsp; &nbsp; rdb.set(f"avatarctype:{user}", ctype[:120])
&nbsp; &nbsp; rdb.set(f"avatarupd:{user}",&nbsp;"just now")
&nbsp; &nbsp; rdb.set(f"avatarmeta:{user}", str(meta)[:500])
&nbsp; &nbsp;&nbsp;return&nbsp;redirect(url_for("dashboard"))

可以看到这段代码是在解析头像url,并且会调用fetch_resource函数,函数逻辑如下。分析可知,该函数主要作用是获取头像url的信息,支持http协议和redis请求

def&nbsp;fetch_resource(url: str, timeout: float =&nbsp;2.0):
&nbsp; &nbsp; u = urlparse((url&nbsp;or"").strip())
&nbsp; &nbsp; scheme = (u.scheme&nbsp;or"").lower()

&nbsp; &nbsp;&nbsp;if&nbsp;scheme&nbsp;in&nbsp;("http",&nbsp;"https"):
&nbsp; &nbsp; &nbsp; &nbsp; resp = requests.get(url, timeout=timeout, allow_redirects=True)
&nbsp; &nbsp; &nbsp; &nbsp; ctype = resp.headers.get("Content-Type",&nbsp;"application/octet-stream")
&nbsp; &nbsp; &nbsp; &nbsp; data = (resp.content&nbsp;orb"")[:200000]
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;data, ctype, {"scheme": scheme,&nbsp;"status": resp.status_code}

&nbsp; &nbsp;&nbsp;if&nbsp;scheme ==&nbsp;"redis":
&nbsp; &nbsp; &nbsp; &nbsp; host = u.hostname&nbsp;or"127.0.0.1"
&nbsp; &nbsp; &nbsp; &nbsp; port = u.port&nbsp;or6379
&nbsp; &nbsp; &nbsp; &nbsp; path = (u.path&nbsp;or"/").lstrip("/")
&nbsp; &nbsp; &nbsp; &nbsp; parts = path.split("/",&nbsp;1)
&nbsp; &nbsp; &nbsp; &nbsp; db = int(parts[0])&nbsp;if&nbsp;parts&nbsp;and&nbsp;parts[0].isdigit()&nbsp;else0
&nbsp; &nbsp; &nbsp; &nbsp; key = parts[1]&nbsp;if&nbsp;len(parts) >&nbsp;1else""

&nbsp; &nbsp; &nbsp; &nbsp; r = redis.Redis(host=host, port=port, db=db, socket_timeout=1)
&nbsp; &nbsp; &nbsp; &nbsp; val = r.get(key)&nbsp;orb""
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;val[:200000],&nbsp;"application/octet-stream", {"scheme":&nbsp;"redis",&nbsp;"db": db,&nbsp;"key": key}

&nbsp; &nbsp;&nbsp;raise&nbsp;ValueError("unsupported scheme")

当我们使用redis://127.0.0.1:6379/0/flag时,被解析的时候就会通过127.0.0.1:6379连接redis的0号数据库然后读取flag的键值,这里选0号数据库是因为代码里面有。

def&nbsp;get_redis():
&nbsp; &nbsp;&nbsp;return&nbsp;redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0, socket_timeout=1)

综上,当我们访问/avatar/refresh路由时,就会解析我们指定的头像的url,如果flag存放在redis数据库中,就可以利用这个路由解析到flag的键值。但是现在有一个问题,就是如何才能回显出flag?我们继续往下审计

@app.get("/preview")
def&nbsp;preview():
&nbsp; &nbsp; user = _get_user()
&nbsp; &nbsp; tpl = _get_tpl(user)
&nbsp; &nbsp; avatar_url = _get_avatar_url(user)
&nbsp; &nbsp; avatar_data, avatar_ctype, avatar_updated = _get_avatar_blob(user)
&nbsp; &nbsp; avatar_ok = (avatar_ctype&nbsp;or"").startswith("image/")&nbsp;and&nbsp;len(avatar_data) >&nbsp;0

&nbsp; &nbsp;&nbsp;def&nbsp;avatar_raw_text():
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;(avatar_data[:2000]).decode("utf-8",&nbsp;"ignore")#尝试将头像二进制数据以&nbsp;UTF-8 文本形式返回
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;except&nbsp;Exception:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return""

&nbsp; &nbsp;&nbsp;def&nbsp;avatar_raw_b64():
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;base64.b64encode(avatar_data[:5000]).decode("ascii",&nbsp;"ignore")
#将头像二进制数据以&nbsp;Base64 编码返回
&nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; rendered = render_user_template(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tpl,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; name=user,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; avatar_url=avatar_url,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; avatar_ok=avatar_ok,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; avatar_ctype=avatar_ctype&nbsp;or"",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; avatar_updated=avatar_updated&nbsp;or"",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; avatar_size=len(avatar_data),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; avatar_raw_text=avatar_raw_text,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; avatar_raw_b64=avatar_raw_b64,
&nbsp; &nbsp; &nbsp; &nbsp; )
&nbsp; &nbsp;&nbsp;except&nbsp;TemplateError&nbsp;as&nbsp;e:
&nbsp; &nbsp; &nbsp; &nbsp; rendered = (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;f"<div class='alert alert-danger'>Template error:&nbsp;{type(e).__name__}</div>"
&nbsp; &nbsp; &nbsp; &nbsp; )

&nbsp; &nbsp;&nbsp;return&nbsp;render_template(
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"preview.html",
&nbsp; &nbsp; &nbsp; &nbsp; user=user,
&nbsp; &nbsp; &nbsp; &nbsp; rendered=rendered,
&nbsp; &nbsp; &nbsp; &nbsp; tpl=tpl,
&nbsp; &nbsp; &nbsp; &nbsp; avatar_url=avatar_url,
&nbsp; &nbsp; &nbsp; &nbsp; avatar_ctype=avatar_ctype&nbsp;or"-",
&nbsp; &nbsp; &nbsp; &nbsp; avatar_size=len(avatar_data),
&nbsp; &nbsp; &nbsp; &nbsp; avatar_updated=avatar_updated&nbsp;or"-",
&nbsp; &nbsp; )

preview路由中的第一次渲染是通过render_user_template函数实现的,跟进查看会发现调用了沙箱,并且将tpl参数值,也就是将我们可以控制的值作为能够安全执行的模板。审计preview路由相关的代码看到两个可以获取到图片内容的函数,avatar_raw_text和avatar_raw_b64,可以任选其一完成回显flag的目标

_user_tpl_env = SandboxedEnvironment(
&nbsp; &nbsp; autoescape=True,
)

def&nbsp;render_user_template(tpl: str, **context)&nbsp;-> str:
&nbsp; &nbsp; template = _user_tpl_env.from_string(tpl&nbsp;or&nbsp;"")
&nbsp; &nbsp;&nbsp;return&nbsp;template.render(**context)

过程复现

我这里是先通过prefs路由设置了一个用户的cookie,传参u=qwe

然后在上传头像url的位置传入redis请求读取flag,先后点击图中标注的按钮,解析flag的值

接着再修改自定义模板,执行源码中的缺陷函数,让我们在访问preview路由时能回显flag值


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:正在思考ing 浪漫土狗 浪漫土狗《26长城杯半决赛IntraBadge复现》

评论:0   参与:  0