1. 内容整体设计与思路拆解1.1 为什么在这个工具遍地走的时代,还要死磕手工注入做Web安全测试的人应该都有体会,现在市面上自动化扫描工具一大堆,挂上代理点一下按钮,漏洞报告就出来了。但真到实战或者做代码审计复盘的时候,工具给出的结果往往只能告诉你“这里可能有问题”,至于问题是怎么产生的、能造成多大影响、后面还能怎么利用,全靠自己手动验证。我接触到的大多数入门安全爱好者都有个误区,觉得手工注入是“老古董”,是教科书里才有的东西。但恰恰相反,手工注入是整个SQL注入知识体系的地基。你只有亲手在URL后面加一个单引号,看着页面报错,再一步步通过order by、union select把数据掏出来,才能真正理解注入发生的原因、闭合方式对payload的影响、以及过滤逻辑为什么可以被绕过。这些经验是任何扫描器都给不了你的。这篇文章专门讲PHP+MySQL环境下的手工注入,是因为这套组合在现实世界里仍然占据相当大的比例。很多老业务系统、CMS二次开发项目、以及教学用的靶场环境,基本都是这个技术栈。搞懂这一套,你再去碰其它数据库、其它语言的注入,思路是完全通用的,区别只在于语法细节和系统表结构。1.2 这篇博文能帮你解决什么问题如果你是刚接触渗透测试不久,跟着网上各种“一键工具”教程试了一圈,发现离开工具就不知道怎么下手,那这篇文章就是给你写的。我会带着你从头到尾走一遍手工注入的完整流程:怎么判断一个参数是不是存在注入、怎么确认注入类型、怎么通过报错信息和联合查询把数据库里的数据一层层扒出来。如果你是做开发的,这篇文章同样有参考价值。很多开发者写SQL的时候,只知道用拼接字符串有风险,但不知道风险具体是怎么被利用的。跟着这篇文章走一遍攻击者的思路,你以后写代码的时候自然就会多一根弦,知道哪些写法等于把数据库裸奔在大马路上。基于常见实践,我会把整个测试过程拆成几个明确的阶段,每个阶段都配合实际现象和操作讲解。这样你以后遇到类似问题,能对照着快速定位自己卡在哪一步。2. 核心细节解析与实操要点2.1 判断注入点:闭合方式的识别是第一步开始手工注入之前,你得先找到入口。最常见的注入点在URL参数里,比如http://example.com/news.php?id=1,这个id参数就是我们要测试的对象。需要说明的是,生活中的站点千奇百怪,参数名可能是page、type、cat,或者藏在POST请求的body里,但测试思路是一样的。第一步永远是试探。在参数后面加一个单引号,看页面返回什么。这个动作的原理是:如果Web应用直接把参数拼进SQL语句,那么单引号会破坏原有的SQL语法结构,导致数据库在执行时产生语法错误。如果Web应用使用了参数化查询或者做了严格过滤,单引号会被转义或者直接当成普通字符串处理,页面表现和原来没什么区别。具体操作是访问http://example.com/news.php?id=1',然后观察页面。可能会出现几种情况:报SQL语法错误、页面白屏、返回500、或者弹出一个非常详细的数据库错误信息。不管出现哪种,都说明这个参数后端的SQL语句可能存在问题,值得深入测试。这里有个小技巧我习惯先做:在参数后面分别加1 and 1=1和1 and 1=2,观察页面返回是否一致。如果前者正常、后者不正常,说明这个位置很可能存在数字型注入。这种测试方式比直接加单引号要温和一些,有些开发者做了简单的单引号过滤,但没防住布尔逻辑注入,这时候就能试出来。2.2 MySQL下两种主流注入类型怎么区分在PHP+MySQL环境下,你大概率会遇到两种注入类型:数字型和字符型。数字型注入的SQL语句一般是SELECT * FROM news WHERE id = $id这样的写法。这种写法里$id外面没有引号包裹,你输入的内容会被数据库当作数字来处理。测试方法很简单,加and 1=1和and 1=2看返回差异。字符型注入的SQL语句一般是SELECT * FROM news WHERE title = '$title'。这时候参数被单引号包裹,你插入的内容会被当作字符串。测试时要先输入一个单引号,把前面的引号闭合掉,再用注释符把后面的引号处理掉,这样才能破坏原有SQL结构。经常要用到的闭合方式有两类:一类是单引号加注释符,也就是'--,注意--后面还有一个空格,这个空格在MySQL里是必须的;另一类是单引号加#注释符,也就是'#,这个在MySQL里更常用一些,因为不需要额外加空格。为什么要强调区分这两种类型?因为后面构造统一查询语句的时候,字符型注入比数字型多了一步“先闭合再注释”的操作,如果一开始判断错了类型,后面怎么构造payload都不对,页面要么不回显数据,要么一直报语法错误,非常折腾人。2.3 判断字段数量的标准方法:order by排序试探确认了注入类型之后,接下来要做的是判断这条SQL查询到底返回了几个字段。这一步是union注入的基础,因为使用联合查询时,前后两个查询的字段数量必须一致,否则MySQL直接报错,页面什么都不回显。这里我用order by来做。原理是:ORDER BY 1表示按第一列排序,ORDER BY 8表示按第八列排序。如果实际查询结果只有5列,那ORDER BY 8就会报错,提示“Unknown column '8' in 'order clause'”之类的内容。通过不断调整数字,二分法试探,就能确定字段总数。基于我的习惯,我会从order by 1开始,然后按2、4、8、16这样的倍数递增,直到报错为止。比如我测试order by 8正常,order by 16报错,那就说明字段数量在9到15之间,再折半测试,通常三次请求就能确定准确数字。这种做法比从1数到10省时间,尤其面对上百个字段的复杂查询时优势明显。确定字段数量之后,我还会顺手记录一下报错信息的样子。因为不同的MySQL版本、不同的错误显示方式,会影响后面用报错注入还是联合注入的选择。2.4 寻找回显位置:union select联合查询的核心逻辑字段数量确定后,就该确认这条SQL到哪个位置可以看到数据了。因为union select null,null,null...会把联合查询的结果拼在后面,但页面上显示的是第一个查询的结果集。只有当第一个查询的结果为空时,联合查询的内容才会顶替上来显示。这里有个非常经典的技巧,就是把前面条件改成一个不可能成立的值。比如原来的查询是id=1,我改成id=-1,或者id=999999,这样第一个查询查不到任何记录,联合查询的记录就成了唯一的结果集,页面上就会显示我们构造的数据。判断回显位置的方法是在联合查询的每个字段位置放一个不同的标记数字。比如字段数量是3,那就构造union select 1,2,3。如果页面显示出了2,说明第二列有回显;显示出3,说明第三列有回显。用不同的数字,是为了区分到底哪一个字段位置是可控输出的。基于实际经验,大多数情况下回显位置只有一两处,有的页面结构复杂时,甚至需要试几次才能找到。这个阶段比较枯燥,但非常重要,因为后面查表名、查字段名、查数据,全靠这几个位置输出。3. 实操过程与核心环节实现3.1 一次完整的联合查询注入流程演示为了让整个流程清楚明了,我以一个模拟的新闻发布系统为例,这套系统的后端SQL写法比较典型,符合常见实践中的经典漏洞场景。假设目标URL是http://target.local/news.php?id=1。第一步,加上单引号测试,页面返回了MySQL语法错误,说明存在字符型注入的可能。再用id=1' and '1'='1和id=1' and '1'='2做对比,前面正常、后面无回显,确认是字符型注入。第二步,确定字段数。构造id=1' order by 3 --,页面正常;构造id=1' order by 4 --,页面报错“Unknown column”,说明字段数是3。第三步,找回显位。构造id=-1' union select 1,2,3 --,页面内容区域显示了数字2和3,说明第二个和第三个字段位置可以用。第四步,查当前数据库名和版本信息。MySQL里有几个内置函数很好用:database()返回当前库名,version()返回数据库版本,user()返回当前连接用户。构造id=-1' union select 1,database(),version() --,页面显示库名为news_db,版本为5.7.34。第五步,查表名。这里要用到MySQL的系统信息库information_schema。它存储了整个数据库实例的元数据,其中tables表保存了所有库的表名和归属关系。常用查询是SELECT table_name FROM information_schema.tables WHERE table_schema=database(),这条语句能从当前数据库里取到所有表名。构造id=-1' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database() --,页面显示admin,news,users。其中group_concat函数的作用是把多行查询结果拼成一列,这样限定了每条记录只能输出N个字符时也能拿到完整结果。第六步,有了admin表之后,查它的字段名。字段信息存在information_schema.columns表里,主要关注table_name、column_name、data_type三个字段。构造id=-1' union select 1,group_concat(column_name),3 from information_schema.columns where table_schema=database() and table_name='admin' --,页面显示id,username,password,email。第七步,直接捞数据。构造id=-1' union select 1,group_concat(username,0x3a,password),3 from admin --。这里用0x3a作为分隔符,把用户名和密码拼在一起。由于这一系统未做密码加盐处理,直接从页面就能看到管理员账号和密码的明文或弱哈希值。走完这七步,一个完整的联合查询注入流程就结束了。整个过程只需要浏览器和一点耐心,不需要任何自动化工具。这也说明了为什么手工注入是必修课——你真正理解了每个步骤的原理,才能灵活应对各种变形场景。3.2 报错注入的触发思路:为什么不用回显也能拿到数据有些场景下,页面不会直接把查询结果展示出来,这时候联合查询注入往往失效。但页面只要会把SQL报错信息显示出来,就有机会用报错注入。报错注入的核心思路是:想办法让MySQL在执行SQL时产生一个带有关键信息的报错。MySQL 5.x版本里常用的是extractvalue和updatexml这两个函数,它们都是XML处理函数,在参数格式不正确时会报错,并且把错误的参数内容原样输出到错误信息里。常见的构造方式如id=1' and extractvalue(1,concat(0x7e,(select database()),0x7e)) --。这里的concat函数把波浪号0x7e、查询结果、波浪号拼成一个字符串,extractvalue在处理这个非法参数时,会把字符串内容带进错误信息里,页面就会显示出数据库名。需要注意一个细节:extractvalue报错的内容有长度限制,通常不超过32个字符,查询结果太长的话,需要配合substr函数分段截取。3.3 布尔盲注与时间盲注:当页面什么都不显示时的备选方案如果页面不管怎么测试都只显示“查询无结果”或“加载失败”,既没有报错,也没有回显,那就是盲注场景了。盲注的核心是利用页面响应差异推断信息,虽然一次只能推断一两个字符,但胜在可靠。布尔盲注利用的是页面在条件为真和假时的不同表现。比如构造id=1' and ascii(substr(database(),1,1))>100 --,如果页面正常,说明数据库名的第一个字符的ASCII码大于100。配合二分法,用不了几个请求就能确定一个字符。时间盲注利用的是sleep函数产生的延迟。构造id=1' and if(ascii(substr(database(),1,1))>100,sleep(2),0) --,如果响应时间明显变长,说明条件成立。时间盲注在页面完全无回显差异时很有用,但速度慢,而且如果目标数据库连接池设置了超时阈值,容易误判。3.4 绕过后端过滤的常见思路现实环境里很多系统并不会裸奔,开发者多少会做些过滤,最常见的是过滤单引号和过滤关键字。手工测试时必须知道怎么绕过,否则连闭合都做不到。如果单引号被过滤,可以尝试十六进制编码。比如把admin转换成0x61646d696e,MySQL会把它当字符串处理。常见实践是把字符串用hex()函数转换后作为条件值传入SQL,从而避开引号。但需要注意的是,这种方法对依赖引号闭合的语法点位不适用,只适合对参数值做编码绕过。如果select、union等关键字被过滤,可以尝试大小写混写,比如SeLeCt、UnIoN;或者用注释符插进关键字中间,比如sel/**/ect。这些手法在目标过滤逻辑为简单字符串匹配时有效,但如果使用了黑名单正则,就需要更复杂的变形了。上面这些内容基于常见实践和公开知识整理,目的是帮助安全测试人员在授权环境中理解漏洞原理。不论什么方法,测试前务必确认目标系统已取得合法授权,切勿用于未经许可的系统。4. 常见问题与排查技巧实录4.1 为什么order by判断出的字段数和union select对不上我在教别人的时候,最常遇到的一个问题是:明明order by 5是正常的,order by 6才报错,说明有5个字段,但构造union select 1,2,3,4,5时,页面却提示“The used SELECT statements have a different number of columns”。这个报错说明前面还有一句查询语句的字段数和你构造的不一致。常见原因是目标SQL本身是一个多表关联查询,后面拼接了别的子查询,导致整体字段数并不是你order by能得到的那一层。这事常有,比如主查询返回5列,但后面跟了个union查询返回4列。处理办法是把目标条件改回正常存在的记录,然后一步步调整自己构造的字段数,从1开始逐次增加,直到不报错为止。这种手动对齐字段数的过程虽然费时间,但能帮你搞清楚整个查询的结构。4.2 页面回显了数字,但联合查询的内容不出现字符型注入场景下,id=-1' union select 1,2,3 --页面只显示原始的1,2,3,却没有我们想要的数据库内容。很多人卡在这里。最可能的原因是闭合没闭合完整。你可能用了--注释符,但后面的空格被过滤了,或者数据库把--后面的内容当作注释处理时,紧跟的换行符或结束符位置不对。换用#注释符试试,或者把注释符后面的空格换成URL编码%20。另一个可能是目标SQL外层还套了一层查询。比如SELECT * FROM (SELECT * FROM news WHERE id='$id') temp这种写法,你注入的内容落在外层还是内层,完全影响union是否生效。这时需要通过构造子查询嵌套来突破,比如id=-1' union select 1,2,3 from (select 1 union select 2)x --。4.3 为什么extractvalue报错注入只显示一半数据前面提过extractvalue报错信息最长32个字符。如果查询结果包含多个字段拼在一起,或者表名太长,页面只会显示最后22到27个字符,前面被截断的部分拿不到。解决方案是分段截取。利用substr(查询语句,1,25)先取前25个字符,再substr(查询语句,26,25)取下25个字符,依次推进。每条报错只拿一小截,凑几次也能拼出完整数据。这种做法虽然慢,但很稳定。4.4 盲注时响应时间不稳定怎么判断时间盲注最怕网络环境波动大,熬夜测试时碰到响应忽快忽慢,容易把网络延迟误判成sleep生效。我通常先用一个sleep(5)的payload测一次,看响应是不是"肉眼可见"地等了5秒以上,再测一个sleep(0)的payload做对照组。两次结果对比清晰,才能判定时间盲注可用。如果对照组也经常超过3秒,这个目标就不适合时间盲注,老老实实回去用布尔盲注。4.5 加固建议:修复SQL注入的几条常见实践前面讲了一整篇攻击思路,如果不谈怎么修复,那就只做了一半的功。作为安全测试人员,你有责任在报告里提供修复建议。第一条是用参数化查询,替代字符串拼接。PHP里最常用的是PDO预处理语句,写prepare加bindParam,根本不给用户输入进SQL结构的机会。这也是目前最治本的方案。第二条是输入校验。对所有参数做严格的白名单校验,比如id必须是纯数字,不是就直接拒绝请求。这一招简单有效,能挡掉大多数脚本小子的攻击。第三条是数据库权限控制。应用账号不应该用root或管理员账号连接数据库,最好只授予SELECT、INSERT、UPDATE、DELETE等最小权限,这样就算注入成功,拿到的信息也有限。第四条是关闭错误回显。只要页面不在生产环境显示SQL错误,报错注入就无处下手。开发环境错误日志另说,生产环境一律关掉详细报错。5. 实战经验总结与个人体会把整套手工注入流程走完,你会发现真正难的并不是那几句payload,而是判断“当前这个场景到底适合哪种注入方式”。我见过不少人背了一堆payload,结果一换环境就不会用了,原因就是没理解每个步骤背后的逻辑。这篇文章里讲的每一步,从单引号试探、order by定字段,到union select找位、查库表字段,再到报错注入和盲注兜底,都是这个逻辑链条上的一环。我个人在多次授权测试中的体会有两点。第一点是手工注入虽然效率不如工具,但它带来的信息更可靠,你能精准地知道漏洞点在哪一行代码、影响哪一张表、波及哪些数据,这些信息写进报告才真正有分量。第二点是手工注入的经验能直接迁移到代码审计上,你看一遍别人写的SQL语句,基本能预判哪里可能出问题,这比事后跑扫描器效率高得多。最后分享一个小技巧:测试时养成随手记录的习惯很有用。每打一个payload,就把URL、参数、回显内容、响应时间存下来,分析时能省很多回头找记录的力气。遇到复杂的注入场景,这些记录就是你判断形势最重要的依据。还有,千万别在未授权的系统上做这些测试,这是底线。在自己搭的靶场里把流程跑通,比去公网上冒险要值得多。
1. 内容整体设计与思路拆解1.1 为什么在这个工具遍地走的时代,还要死磕手工注入做Web安全测试的人应该都有体会,现在市面上自动化扫描工具一大堆,挂上代理点一下按钮,漏洞报告就出来了。但真到实战或者做代码审计复盘的时候,工具给出的结果往往只能告诉你“这里可能有问题”,至于问题是怎么产生的、能造成多大影响、后面还能怎么利用,全靠自己手动验证。我接触到的大多数入门安全爱好者都有个误区,觉得手工注入是“老古董”,是教科书里才有的东西。但恰恰相反,手工注入是整个SQL注入知识体系的地基。你只有亲手在URL后面加一个单引号,看着页面报错,再一步步通过order by、union select把数据掏出来,才能真正理解注入发生的原因、闭合方式对payload的影响、以及过滤逻辑为什么可以被绕过。这些经验是任何扫描器都给不了你的。这篇文章专门讲PHP+MySQL环境下的手工注入,是因为这套组合在现实世界里仍然占据相当大的比例。很多老业务系统、CMS二次开发项目、以及教学用的靶场环境,基本都是这个技术栈。搞懂这一套,你再去碰其它数据库、其它语言的注入,思路是完全通用的,区别只在于语法细节和系统表结构。1.2 这篇博文能帮你解决什么问题如果你是刚接触渗透测试不久,跟着网上各种“一键工具”教程试了一圈,发现离开工具就不知道怎么下手,那这篇文章就是给你写的。我会带着你从头到尾走一遍手工注入的完整流程:怎么判断一个参数是不是存在注入、怎么确认注入类型、怎么通过报错信息和联合查询把数据库里的数据一层层扒出来。如果你是做开发的,这篇文章同样有参考价值。很多开发者写SQL的时候,只知道用拼接字符串有风险,但不知道风险具体是怎么被利用的。跟着这篇文章走一遍攻击者的思路,你以后写代码的时候自然就会多一根弦,知道哪些写法等于把数据库裸奔在大马路上。基于常见实践,我会把整个测试过程拆成几个明确的阶段,每个阶段都配合实际现象和操作讲解。这样你以后遇到类似问题,能对照着快速定位自己卡在哪一步。2. 核心细节解析与实操要点2.1 判断注入点:闭合方式的识别是第一步开始手工注入之前,你得先找到入口。最常见的注入点在URL参数里,比如http://example.com/news.php?id=1,这个id参数就是我们要测试的对象。需要说明的是,生活中的站点千奇百怪,参数名可能是page、type、cat,或者藏在POST请求的body里,但测试思路是一样的。第一步永远是试探。在参数后面加一个单引号,看页面返回什么。这个动作的原理是:如果Web应用直接把参数拼进SQL语句,那么单引号会破坏原有的SQL语法结构,导致数据库在执行时产生语法错误。如果Web应用使用了参数化查询或者做了严格过滤,单引号会被转义或者直接当成普通字符串处理,页面表现和原来没什么区别。具体操作是访问http://example.com/news.php?id=1',然后观察页面。可能会出现几种情况:报SQL语法错误、页面白屏、返回500、或者弹出一个非常详细的数据库错误信息。不管出现哪种,都说明这个参数后端的SQL语句可能存在问题,值得深入测试。这里有个小技巧我习惯先做:在参数后面分别加1 and 1=1和1 and 1=2,观察页面返回是否一致。如果前者正常、后者不正常,说明这个位置很可能存在数字型注入。这种测试方式比直接加单引号要温和一些,有些开发者做了简单的单引号过滤,但没防住布尔逻辑注入,这时候就能试出来。2.2 MySQL下两种主流注入类型怎么区分在PHP+MySQL环境下,你大概率会遇到两种注入类型:数字型和字符型。数字型注入的SQL语句一般是SELECT * FROM news WHERE id = $id这样的写法。这种写法里$id外面没有引号包裹,你输入的内容会被数据库当作数字来处理。测试方法很简单,加and 1=1和and 1=2看返回差异。字符型注入的SQL语句一般是SELECT * FROM news WHERE title = '$title'。这时候参数被单引号包裹,你插入的内容会被当作字符串。测试时要先输入一个单引号,把前面的引号闭合掉,再用注释符把后面的引号处理掉,这样才能破坏原有SQL结构。经常要用到的闭合方式有两类:一类是单引号加注释符,也就是'--,注意--后面还有一个空格,这个空格在MySQL里是必须的;另一类是单引号加#注释符,也就是'#,这个在MySQL里更常用一些,因为不需要额外加空格。为什么要强调区分这两种类型?因为后面构造统一查询语句的时候,字符型注入比数字型多了一步“先闭合再注释”的操作,如果一开始判断错了类型,后面怎么构造payload都不对,页面要么不回显数据,要么一直报语法错误,非常折腾人。2.3 判断字段数量的标准方法:order by排序试探确认了注入类型之后,接下来要做的是判断这条SQL查询到底返回了几个字段。这一步是union注入的基础,因为使用联合查询时,前后两个查询的字段数量必须一致,否则MySQL直接报错,页面什么都不回显。这里我用order by来做。原理是:ORDER BY 1表示按第一列排序,ORDER BY 8表示按第八列排序。如果实际查询结果只有5列,那ORDER BY 8就会报错,提示“Unknown column '8' in 'order clause'”之类的内容。通过不断调整数字,二分法试探,就能确定字段总数。基于我的习惯,我会从order by 1开始,然后按2、4、8、16这样的倍数递增,直到报错为止。比如我测试order by 8正常,order by 16报错,那就说明字段数量在9到15之间,再折半测试,通常三次请求就能确定准确数字。这种做法比从1数到10省时间,尤其面对上百个字段的复杂查询时优势明显。确定字段数量之后,我还会顺手记录一下报错信息的样子。因为不同的MySQL版本、不同的错误显示方式,会影响后面用报错注入还是联合注入的选择。2.4 寻找回显位置:union select联合查询的核心逻辑字段数量确定后,就该确认这条SQL到哪个位置可以看到数据了。因为union select null,null,null...会把联合查询的结果拼在后面,但页面上显示的是第一个查询的结果集。只有当第一个查询的结果为空时,联合查询的内容才会顶替上来显示。这里有个非常经典的技巧,就是把前面条件改成一个不可能成立的值。比如原来的查询是id=1,我改成id=-1,或者id=999999,这样第一个查询查不到任何记录,联合查询的记录就成了唯一的结果集,页面上就会显示我们构造的数据。判断回显位置的方法是在联合查询的每个字段位置放一个不同的标记数字。比如字段数量是3,那就构造union select 1,2,3。如果页面显示出了2,说明第二列有回显;显示出3,说明第三列有回显。用不同的数字,是为了区分到底哪一个字段位置是可控输出的。基于实际经验,大多数情况下回显位置只有一两处,有的页面结构复杂时,甚至需要试几次才能找到。这个阶段比较枯燥,但非常重要,因为后面查表名、查字段名、查数据,全靠这几个位置输出。3. 实操过程与核心环节实现3.1 一次完整的联合查询注入流程演示为了让整个流程清楚明了,我以一个模拟的新闻发布系统为例,这套系统的后端SQL写法比较典型,符合常见实践中的经典漏洞场景。假设目标URL是http://target.local/news.php?id=1。第一步,加上单引号测试,页面返回了MySQL语法错误,说明存在字符型注入的可能。再用id=1' and '1'='1和id=1' and '1'='2做对比,前面正常、后面无回显,确认是字符型注入。第二步,确定字段数。构造id=1' order by 3 --,页面正常;构造id=1' order by 4 --,页面报错“Unknown column”,说明字段数是3。第三步,找回显位。构造id=-1' union select 1,2,3 --,页面内容区域显示了数字2和3,说明第二个和第三个字段位置可以用。第四步,查当前数据库名和版本信息。MySQL里有几个内置函数很好用:database()返回当前库名,version()返回数据库版本,user()返回当前连接用户。构造id=-1' union select 1,database(),version() --,页面显示库名为news_db,版本为5.7.34。第五步,查表名。这里要用到MySQL的系统信息库information_schema。它存储了整个数据库实例的元数据,其中tables表保存了所有库的表名和归属关系。常用查询是SELECT table_name FROM information_schema.tables WHERE table_schema=database(),这条语句能从当前数据库里取到所有表名。构造id=-1' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database() --,页面显示admin,news,users。其中group_concat函数的作用是把多行查询结果拼成一列,这样限定了每条记录只能输出N个字符时也能拿到完整结果。第六步,有了admin表之后,查它的字段名。字段信息存在information_schema.columns表里,主要关注table_name、column_name、data_type三个字段。构造id=-1' union select 1,group_concat(column_name),3 from information_schema.columns where table_schema=database() and table_name='admin' --,页面显示id,username,password,email。第七步,直接捞数据。构造id=-1' union select 1,group_concat(username,0x3a,password),3 from admin --。这里用0x3a作为分隔符,把用户名和密码拼在一起。由于这一系统未做密码加盐处理,直接从页面就能看到管理员账号和密码的明文或弱哈希值。走完这七步,一个完整的联合查询注入流程就结束了。整个过程只需要浏览器和一点耐心,不需要任何自动化工具。这也说明了为什么手工注入是必修课——你真正理解了每个步骤的原理,才能灵活应对各种变形场景。3.2 报错注入的触发思路:为什么不用回显也能拿到数据有些场景下,页面不会直接把查询结果展示出来,这时候联合查询注入往往失效。但页面只要会把SQL报错信息显示出来,就有机会用报错注入。报错注入的核心思路是:想办法让MySQL在执行SQL时产生一个带有关键信息的报错。MySQL 5.x版本里常用的是extractvalue和updatexml这两个函数,它们都是XML处理函数,在参数格式不正确时会报错,并且把错误的参数内容原样输出到错误信息里。常见的构造方式如id=1' and extractvalue(1,concat(0x7e,(select database()),0x7e)) --。这里的concat函数把波浪号0x7e、查询结果、波浪号拼成一个字符串,extractvalue在处理这个非法参数时,会把字符串内容带进错误信息里,页面就会显示出数据库名。需要注意一个细节:extractvalue报错的内容有长度限制,通常不超过32个字符,查询结果太长的话,需要配合substr函数分段截取。3.3 布尔盲注与时间盲注:当页面什么都不显示时的备选方案如果页面不管怎么测试都只显示“查询无结果”或“加载失败”,既没有报错,也没有回显,那就是盲注场景了。盲注的核心是利用页面响应差异推断信息,虽然一次只能推断一两个字符,但胜在可靠。布尔盲注利用的是页面在条件为真和假时的不同表现。比如构造id=1' and ascii(substr(database(),1,1))>100 --,如果页面正常,说明数据库名的第一个字符的ASCII码大于100。配合二分法,用不了几个请求就能确定一个字符。时间盲注利用的是sleep函数产生的延迟。构造id=1' and if(ascii(substr(database(),1,1))>100,sleep(2),0) --,如果响应时间明显变长,说明条件成立。时间盲注在页面完全无回显差异时很有用,但速度慢,而且如果目标数据库连接池设置了超时阈值,容易误判。3.4 绕过后端过滤的常见思路现实环境里很多系统并不会裸奔,开发者多少会做些过滤,最常见的是过滤单引号和过滤关键字。手工测试时必须知道怎么绕过,否则连闭合都做不到。如果单引号被过滤,可以尝试十六进制编码。比如把admin转换成0x61646d696e,MySQL会把它当字符串处理。常见实践是把字符串用hex()函数转换后作为条件值传入SQL,从而避开引号。但需要注意的是,这种方法对依赖引号闭合的语法点位不适用,只适合对参数值做编码绕过。如果select、union等关键字被过滤,可以尝试大小写混写,比如SeLeCt、UnIoN;或者用注释符插进关键字中间,比如sel/**/ect。这些手法在目标过滤逻辑为简单字符串匹配时有效,但如果使用了黑名单正则,就需要更复杂的变形了。上面这些内容基于常见实践和公开知识整理,目的是帮助安全测试人员在授权环境中理解漏洞原理。不论什么方法,测试前务必确认目标系统已取得合法授权,切勿用于未经许可的系统。4. 常见问题与排查技巧实录4.1 为什么order by判断出的字段数和union select对不上我在教别人的时候,最常遇到的一个问题是:明明order by 5是正常的,order by 6才报错,说明有5个字段,但构造union select 1,2,3,4,5时,页面却提示“The used SELECT statements have a different number of columns”。这个报错说明前面还有一句查询语句的字段数和你构造的不一致。常见原因是目标SQL本身是一个多表关联查询,后面拼接了别的子查询,导致整体字段数并不是你order by能得到的那一层。这事常有,比如主查询返回5列,但后面跟了个union查询返回4列。处理办法是把目标条件改回正常存在的记录,然后一步步调整自己构造的字段数,从1开始逐次增加,直到不报错为止。这种手动对齐字段数的过程虽然费时间,但能帮你搞清楚整个查询的结构。4.2 页面回显了数字,但联合查询的内容不出现字符型注入场景下,id=-1' union select 1,2,3 --页面只显示原始的1,2,3,却没有我们想要的数据库内容。很多人卡在这里。最可能的原因是闭合没闭合完整。你可能用了--注释符,但后面的空格被过滤了,或者数据库把--后面的内容当作注释处理时,紧跟的换行符或结束符位置不对。换用#注释符试试,或者把注释符后面的空格换成URL编码%20。另一个可能是目标SQL外层还套了一层查询。比如SELECT * FROM (SELECT * FROM news WHERE id='$id') temp这种写法,你注入的内容落在外层还是内层,完全影响union是否生效。这时需要通过构造子查询嵌套来突破,比如id=-1' union select 1,2,3 from (select 1 union select 2)x --。4.3 为什么extractvalue报错注入只显示一半数据前面提过extractvalue报错信息最长32个字符。如果查询结果包含多个字段拼在一起,或者表名太长,页面只会显示最后22到27个字符,前面被截断的部分拿不到。解决方案是分段截取。利用substr(查询语句,1,25)先取前25个字符,再substr(查询语句,26,25)取下25个字符,依次推进。每条报错只拿一小截,凑几次也能拼出完整数据。这种做法虽然慢,但很稳定。4.4 盲注时响应时间不稳定怎么判断时间盲注最怕网络环境波动大,熬夜测试时碰到响应忽快忽慢,容易把网络延迟误判成sleep生效。我通常先用一个sleep(5)的payload测一次,看响应是不是"肉眼可见"地等了5秒以上,再测一个sleep(0)的payload做对照组。两次结果对比清晰,才能判定时间盲注可用。如果对照组也经常超过3秒,这个目标就不适合时间盲注,老老实实回去用布尔盲注。4.5 加固建议:修复SQL注入的几条常见实践前面讲了一整篇攻击思路,如果不谈怎么修复,那就只做了一半的功。作为安全测试人员,你有责任在报告里提供修复建议。第一条是用参数化查询,替代字符串拼接。PHP里最常用的是PDO预处理语句,写prepare加bindParam,根本不给用户输入进SQL结构的机会。这也是目前最治本的方案。第二条是输入校验。对所有参数做严格的白名单校验,比如id必须是纯数字,不是就直接拒绝请求。这一招简单有效,能挡掉大多数脚本小子的攻击。第三条是数据库权限控制。应用账号不应该用root或管理员账号连接数据库,最好只授予SELECT、INSERT、UPDATE、DELETE等最小权限,这样就算注入成功,拿到的信息也有限。第四条是关闭错误回显。只要页面不在生产环境显示SQL错误,报错注入就无处下手。开发环境错误日志另说,生产环境一律关掉详细报错。5. 实战经验总结与个人体会把整套手工注入流程走完,你会发现真正难的并不是那几句payload,而是判断“当前这个场景到底适合哪种注入方式”。我见过不少人背了一堆payload,结果一换环境就不会用了,原因就是没理解每个步骤背后的逻辑。这篇文章里讲的每一步,从单引号试探、order by定字段,到union select找位、查库表字段,再到报错注入和盲注兜底,都是这个逻辑链条上的一环。我个人在多次授权测试中的体会有两点。第一点是手工注入虽然效率不如工具,但它带来的信息更可靠,你能精准地知道漏洞点在哪一行代码、影响哪一张表、波及哪些数据,这些信息写进报告才真正有分量。第二点是手工注入的经验能直接迁移到代码审计上,你看一遍别人写的SQL语句,基本能预判哪里可能出问题,这比事后跑扫描器效率高得多。最后分享一个小技巧:测试时养成随手记录的习惯很有用。每打一个payload,就把URL、参数、回显内容、响应时间存下来,分析时能省很多回头找记录的力气。遇到复杂的注入场景,这些记录就是你判断形势最重要的依据。还有,千万别在未授权的系统上做这些测试,这是底线。在自己搭的靶场里把流程跑通,比去公网上冒险要值得多。