1. 动手前先分清:Pikachu靶场里这10种注入到底怎么分类
1.1 为什么新手首选Pikachu
如果你是第一次接触SQL注入,千万不要去公网上随便找个站点练手。我见过太多新人直接对着线上业务乱打,结果要么被防护组件拦下来,要么把别人测试数据洗了一遍,最后被请去喝茶。正规的做法,是在本地搭一套开源的漏洞靶场,比如Pikachu。它把OWASP Top 10里最经典的漏洞类型都收纳成了独立“练习关卡”,尤其是SQL注入这一块,从数字型、字符型、搜索型到宽字节,几乎覆盖了入门阶段能遇到的绝大多数形态。
这套靶场的优势在于“开箱即用”。它不要求你有多强的运维能力,只需要把PHP环境和MySQL准备好,解压后改一下数据库连接配置就能跑起来。靶场内部自带初始化数据,你不需要自己造表造数据,所有注入点都放在明显的位置,甚至还在页面里提示了注入类型。对于新手来说,这相当于拿到了一张标注好的地图,你只要顺着路线把每个坑都踩一遍,就能建立对注入漏洞的立体认知。
还有一个关键点,Pikachu的注入点覆盖了GET、POST、HTTP头、数据库操作语句等多个位置,不只是页面URL里那一种简单形态。很多人学SQL注入只会在URL后拼接参数,遇到POST表单注入、请求头注入就完全懵了,而Pikachu正好把这些场景全部补齐。练完这一轮,你再去看真实环境里的注入点,起码知道“该往哪里看”。
1.2 分类维度:参数位置、回显方式、闭合形式
很多新手面对10种注入会觉得乱,其实它们可以按三个维度来拆分。第一个维度是“参数提交位置”,对应的是GET型、POST型和HTTP头型。GET型参数直接拼在URL上,适合用浏览器或者Burp改包来测试;POST型参数在请求体里,你在地址栏里改是没用的,必须在抓包工具里改;HTTP头型则藏在User-Agent、Referer这些字段里,属于非显眼位置,容易被忽略。
第二个维度是“数据库返回结果是否可见”,这决定了你用“联合查询”还是“盲注”。如果页面会直接把查询结果显示出来,或者把SQL报错打印出来,那就属于“有回显”,可以快速拿数据;如果页面只有“正常/异常”或者“时间快/慢”两种反馈,那就只能走盲注路线。
第三个维度是“SQL语句的拼接方式”。数字型直接把参数当数值用,字符型外面套了单引号,搜索型可能还拼了百分号,宽字节则是因为编码转换导致引号被绕过。你得先搞清楚程序是用什么方式把参数塞进SQL语句里的,才能构造出闭合语句的payload。Pikachu的每个注入点都在页面里标注了类型,目的就是让你把“类型特征”和“payload写法”一一对应起来。等练完这10个点,你再遇到一个未知的注入点,就会主动去想:参数在哪里提交?回显能不能看到?语句是拿它当数字还是当字符串?
2. 从数字型打到字符型:两种最典型的注入链路
2.1 数字型注入:联合查询三步走
Pikachu的数字型注入在“SQL注入”模块下,点进去是一个球员信息查询的列表,URL里的参数是id,比如pikachu/vul/sqli/sqli_id.php?id=1。这种参数直接被拼进SQL语句时,程序大概是这样写的:
select id, name, email from member where id = $id注意这里没有引号,参数是纯数字,所以先拿它试“真和假”最合适。把id=1改成id=1 and 1=1,页面正常返回;改成id=1 and 1=2,页面就查不到数据了。这两个结果一对比,基本可以确定这个点存在数字型注入,而且SQL语句里没有对它做任何过滤。
确定存在注入之后,接下来就是判断字段数。用order by:
id=1 order by 1 -- 正常 id=1 order by 2 -- 正常 id=1 order by 3 -- 正常 id=1 order by 4 -- 报错当我把order by从1一路往上试,发现到4时报错,说明这条查询结果只有3列。这一步的意义在于为联合查询腾位置。联合查询要求前后两个select返回的列数一致,否则数据库会直接报错,之前的工作就白做了。
字段数确定后,把查询结果“挤空”,然后联合注入:
id=1 and 1=2 union select 1,2,3页面会显示2和3,说明第二列和第三列的内容被回显到了页面上。接下来把数字位替换成敏感信息即可:
id=1 and 1=2 union select 1,database(),version()得到当前数据库名和版本。我一般还会顺手测一下user(),确认连接数据库的账号权限。到这里,一次标准的数字型注入就算复现完了。整个过程中最重要的思路是:先用真假对比确认注入存在,再用order by确认列数,最后用union获取数据。这“三步走”在后续字符型、搜索型甚至其他数据库里都通用。
2.2 字符型注入:闭合引号与注释符
字符型注入和数字型的最大区别,就是参数外面包了一层引号。Pikachu的“字符型注入(get)”模块,页面点“查询”之后URL里会出现name参数,后台拼接方式大概是:
select id, name, email from member where name = '$name'此时直接传1 and 1=1是无效的,因为$name被单引号包住,你输入的内容被当成字符串的一部分。要让后面的SQL代码生效,必须先把这个引号“闭合”掉,再用注释符把多余的单引号干掉。最常用的payload是:
name=admin' and 1=1 -- '最后的--是MySQL注释符,它会注释掉原本闭合字符串的那个单引号。注意我写的是“双减号加空格”,在MySQL里也可以写成#。如果你想用联合查询,可以这样:
name=admin' and 1=2 union select 1,database(),version() -- '这里有个新手容易踩的坑:在某些环境下,--后面必须带一个空格,否则注释不生效。Pikachu是MySQL环境,--和#都能用,但你去其他靶场或者真实环境,最好先试#再试--。字符型注入复现的意义在于让你养成一个习惯:不要一上来就写union,先想清楚“我要闭合什么、注释掉什么”。
2.3 搜索型与XX型:容易被新手忽略的两个变种
Pikachu里还有两个特殊变种,一个是搜索型注入,一个是XX型注入(这里的“XX”指的是闭合方式不是单纯的引号)。搜索型注入通常出现在站内搜索功能里,后台拼接语句类似:
select id, name, email from member where name like '%$name%'因为搜索本身就是模糊匹配,程序会把你的关键字放进一对百分号中间。测试的时候,输入a正常,输入a%正常,输入a'报错,输入a' and 1=1 --能正常返回数据,那就可以确认存在注入。payload写法其实是字符型的升级版,只不过你需要考虑百分号的存在:
name=a' and 1=2 union select 1,database(),version() -- '这里最开始用的a会顶掉左边的百分号,最后的--会注释掉右边的%',于是SQL就变成了完整的注入语句。
XX型注入则是闭合方式更像“代码逻辑”,后台拼接可能是:
select id, name, email from member where id = ('$id')参数既被单引号包住,外层又套了括号。测试时直接传1 and 1=1没用,传1'会报错,传1') and 1=1 --才能绕过。这种类型是专门为了告诉你:闭合方式不能靠猜,要观察页面的报错信息,或者尝试多种括号组合。很多人在真实环境里一上来就固定用',遇到括号型的就直接卡死。Pikachu把这几个变种放在一起,就是为了打破你“只会一种payload”的惯性。
3. 增删改场景与堆叠注入:别只盯着select
3.1 Insert/Update/Delete注入的利用思路
很多人练SQL注入只盯着查询语句,觉得后面带数据的select才是重点,但Pikachu里的“insert/update/delete注入”模块会直接把你拉到另一个场景:写入、更新、删除功能的参数同样能“带病运行”。这个模块的页面长得很像后台管理界面,可以新增用户、修改用户信息、删除记录。比如新增用户时,后台可能是这样拼接的:
insert into member(id, username, password) values ('$id', '$username', '$password')此时你在用户名输入框里填入的值,会直接进入insert语句的字符串位置。要注入,必须先闭合前面的单引号,然后想办法处理后面括号里的值。一个常见的思路是“用子查询代替当前值”:
' or extractvalue(1, concat(0x7e, (select database()))) -- '这样会把数据库名拼到报错信息里返回在页面上。这是因为在insert语句的values区域里,你闭合引号后,插入了一个子查询表达式。对update和delete也是同理,它们同样会把参数拼进SQL语句,区别只在于你利用时需要考虑原语句的语法结构。比如update场景中你可以在set username=...的赋值位置注入,delete场景则可以在where id=...的位置注入。
这个模块真正要训练的是“SQL语句结构感”。select注入时你只要关注闭合和注释,但insert/update/delete注入时,你还得保证你插入的payload在语法上能让整条语句保持成立,不能破坏后面的括号和逗号。新手容易犯的错是:在insert里用了剩一个单引号的payload,结果整条语句语法错误,页面报错一堆,却不知道哪一步错了。建议复现时把后台SQL语句先写在本地文本里,自己拼一遍,再拿到靶场里去验证。
3.2 堆叠注入:一个分号的价值
堆叠注入是Pikachu里一个单独模块,它利用的是“一个请求可以执行多条SQL语句”的特性。之前的注入都是在原语句上做修改,而堆叠注入是直接结束当前语句,再执行一条全新的语句。比如页面传入id参数,后台查询是:
select id, name, email from member where id = $id如果你输入:
1; delete from member where id = 2整个语句会变成:
select id, name, email from member where id = 1; delete from member where id = 2MySQL会依次执行两条语句,结果是数据被删除。堆叠注入之所以单独拿出来讲,是因为它和联合查询有本质区别:联合查询只能往后追加select类型的数据查询,而堆叠注入可以执行insert、update、delete,甚至调用存储过程。当然,在真实环境里,有很多数据库驱动和中间件会禁止多条语句同时执行,比如一些ORM框架默认就只允许单条SQL,所以堆叠注入的可利用条件比普通注入苛刻得多。
Pikachu里这个模块建议你重点观察“能否回显”的区别。当你执行1; insert into member(username, password) values('aaa','bbb')时,页面上不一定能看到插入结果,但你再打开查询模块,就会发现多了一条记录。这就是典型的“不回显但是生效”。我个人的习惯是,每执行一条堆叠payload,都去对应的数据展示页面确认一下数据是否真的变了,而不是只看当前页面的反应。这个习惯在真实渗透测试里非常重要,因为很多注入点都是盲的,你需要通过二次确认来验证命令是否执行成功。
4. 盲注三兄弟:报错、布尔、时间
4.1 报错注入:让数据库把错误说出来
报错注入在Pikachu里是独立的一个模块,它解决了一个很尴尬的问题:有些页面把SQL语句的错误信息直接打印出来,但查询结果不会回显到页面。这时候union没得用了,你得想办法让数据库“报错”,并且把你要的数据带进错误信息里。MySQL里最常见的报错函数是updatexml()和extractvalue(),它们本来是用来处理XML文档的,当传入的XPath表达式格式不对时,MySQL会把表达式内容原样返回到错误信息里。Pikachu的报错注入点大概是:
select id, name, email from member where id = $id我们可以用:
id=1 and updatexml(1, concat(0x7e, (select database())), 1)这里concat(0x7e, (select database()))构造出一个以~开头的字符串,updatexml()第二个参数要求是合法的XPath路径,这个字符串不合法,于是数据库把它当作错误信息的一部分打印出来。页面会显示类似XPATH syntax error: '~pikachu',数据库名就泄露了。
报错注入看起来比盲注舒服,但要注意长度限制,extractvalue()和updatexml()的错误信息最多只显示32个字符。如果你想查的数据很长,比如字段值是一长串,就得配合substr()分批拿。Pikachu里的报错注入模块并不是让你只跑一次就完事,而是建议你把目标数据拆解成多段,逐段拼接。我个人会用二分法来计算字符位置,比如:
id=1 and updatexml(1, concat(0x7e, (select substr(database(),1,1))), 1)把database()换成库名,跑一次拿到第一个字符,再改位置参数拿下一个字符。虽然麻烦,但这一步能帮你彻底理解“报错信息是可控输出点”这件事。
4.2 布尔盲注:逐字符猜解的体力活
布尔盲注的页面特征是你只能看到“正常”和“异常”两种结果。Pikachu的布尔盲注模块,查询时如果条件成立,页面返回“查询成功”之类的字样;不成立,则页面回显“查询不到数据”。后台SQL大概是:
select id, name, email from member where id = $id但这个查询结果不会直接展示表数据,你只能靠页面的“真/假”信号来判断。所以盲注的核心思路就是:把SQL语句改造成一个“条件判断”,然后根据页面反馈去猜数据。
比如判断当前数据库名的第一个字符是不是A,可以这样构造:
id=1 and substr(database(),1,1)='a'如果页面正常返回,说明数据库名的首字符是a。把a换成b、c一直到全部字符猜完,你就拼出了整个库名。这个过程用手工做会非常痛苦,一个库名、表名、字段名猜下来,请求次数几百次起步。所以我强烈建议你第一次复现时,写一个简单的Python脚本来做这件事。核心逻辑其实很简单:
import requests import string chars = string.ascii_letters + string.digits + '@_.' result = '' for i in range(1, 20): for c in chars: url = f"http://127.0.0.1/pikachu/vul/sqli/sqli_blind_b.php?id=1 and substr(database(),{i},1)='{c}'" r = requests.get(url) if "正常标记" in r.text: result += c break if len(result) < i: break print(result)这个脚本里“正常标记”要根据靶场实际页面的特征字符串来替换。我自己在跑的时候发现,直接判断整条SQL执行后页面内容的差异其实很容易写错,最好是先手动请求两次,一次真条件一次假条件,然后用diff的方式找出区分点,再把这个区分点写在脚本里。布尔盲注虽然体力活,但它训练的是你对条件表达式的控制能力。
4.3 时间盲注:没有回显就用延迟说话
时间盲注比布尔盲注更极端,页面不管你条件真假,反馈都一样,没有任何差异。Pikachu的时间盲注模块就是如此,你输入1和输入1 and 1=1页面长得一样,输入1 and 1=2也一样。此时唯一能利用的通道就是“时间”。
MySQL里触发延迟的常用方法是sleep(),配合if()条件函数:
id=1 and if(substr(database(),1,1)='a', sleep(3), 0)如果当前库名的首字符是a,MySQL会睡3秒再返回,页面响应时间明显变长;如果不是,则瞬间返回。通过多次测试延迟出现与否,就能逐字符猜出数据。需要注意,时间盲注受网络环境影响很大,如果靶场放在远程服务器上,延迟波动可能掩盖掉注入产生的延迟。我的建议是基准测试时多测几次正常请求的响应时间,确定一个阈值,比如正常响应是200毫秒,那么sleep(3)至少会有3秒以上的响应,比较明显。
Pikachu的时间盲注复现时,我喜欢用如果条件包一层sleep(),因为这样能保证响应时间只和当前猜测的字符相关。还有一个小技巧,判断完一个字符后,下一轮脚本要重新测量基准延迟,避免把网络抖动引起的长响应误判成真条件。实际写脚本时,可以设置超时时间为5秒,凡是超过2秒的请求都记作“真”,低于1秒的记作“假”,两次判定之间加一个短暂的退避间隔,防止把靶场数据库的连接池打满。时间盲注是三种盲注里最慢、最容易被发现的一种,但正因为如此,它往往是真实场景里最后一个选择,学会它是为了让你“多一张底牌”。
5. 宽字节注入:编码层的魔法攻防
5.1 宽字节注入的原理与触发条件
宽字节注入是中文网站时代非常经典的一种SQL注入。很多程序为了防注入,会把用户输入里的单引号、双引号等特殊字符在前面加反斜杠转义,比如输入'会变成\',这样在SQL语句里它就不再是字符串结束符,而是一个普通字符。这种防护看起来没什么问题,但一旦数据库连接使用了GBK这类多字节字符集,就会产生编码层面的漏洞。
攻击者传入一个%df%27,程序看到%27是单引号,于是给它加上反斜杠,变成%df%5c%27。问题在于,MySQL在解析GBK编码时,会把%df%5c当成一个完整的汉字“運”,后面的%27就变成了独立的单引号。本来用来转义单引号的反斜杠,反而被“吃掉”成了汉字的一部分。这就是宽字节注入的基本原理。Pikachu里的宽字节注入模块,后台拼接大概是:
select id, name, email from member where name = '$name'同时代码里用了addslashes()或者类似函数来转义输入。直接输入1'会被变成1\',没有作用。但输入1%df'时,经过转义和GBK解码后,%df和加上的反斜杠%5c合并成一个字符,单引号成功逃逸。payload可以写成:
name=1%df' and 1=1 -- '页面返回正常,说明注入成立了。再换成:
name=1%df' and 1=2 union select 1,database(),version() -- '就能把数据带出来。这里要注意,%df在不同的字符集下并不总是有效,比如在UTF-8环境下这种绕过就很难成功,所以宽字节注入的前提是目标站点使用了GBK/GB2312这类多字节字符集。Pikachu靶场默认已经配置好这种环境,你不需要自己去改PHP编码设置,直接打开模块就能复现。
5.2 实操复现与修复建议
在Pikachu里复现宽字节注入有个坑很容易踩:你要是直接在浏览器的URL地址栏里输中文编码后的payload,浏览器可能会自动再做一次编码,导致%df被解析成别的字符。我建议你使用Burp Suite的Repeater模块来发送请求,这样能保证payload里的百分号编码不被二次篡改。具体操作是先打开宽字节注入模块,在查询框里输入一个正常关键词,然后拦截请求,把参数值替换成1%df' and 1=2 union select 1,database(),version() --,再放行。如果页面出现了数据库名,说明注入成功。
修复宽字节注入的办法,最彻底的不是继续加过滤,而是从字符集统一上解决。把PHP和MySQL的连接字符集全部改成UTF-8,同时在程序入口处设置SET NAMES utf8mb4,让输入输出和数据库存储全部走同一种编码。另一种方式是使用参数化查询,SQL语句模板和参数分离,数据库驱动会自动处理特殊字符的转义,从根上杜绝这类拼接问题。Pikachu作为攻击靶场不会修,但我在实际项目里做过类似的代码审计,见过很多团队以为“只要addslashes就能防注入”,结果就是被这类编码绕过打得措手不及。复现宽字节注入的深层价值,是让你明白:安全防护不能靠“这里挡一下、那里转义一下”的补丁思路,而要从数据流的源头统一约束。
6. 复现过程中的常见问题与避坑清单
6.1 高频问题速查表
我在带新人复现Pikachu SQL注入时,遇到最多的几个问题,整理成了一张速查表。
| 现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
输入1 and 1=1页面无变化 | 不是数字型注入,参数被当成字符串了 | 改用字符型payload,先测引号闭合 |
order by一直不出错 | 查询列数很多,或者参数被过滤 | 从1开始每次加1,不要跳着试,上限设到20左右 |
| 联合查询页面只显示数字不显示表头 | 列数判断错误 | 返回union select 1,2,3时,看页面出现哪几个数字,以页面回显的位置为准 |
字符型payload用了--不生效 | 注释符后面没加空格,或数据库不是MySQL | 换成#试试,或者用--+在URL里代替空格 |
| 报错注入不报错 | 函数名写错,或数据库版本不支持 | 先试extractvalue(),再试updatexml() |
| 布尔盲注脚本判断不准 | 页面特征字符串选错 | 手动请求两个真假条件,对比HTML差异找出唯一区分点 |
宽字节注入输入%df'无效果 | 浏览器做了二次编码,或数据库不是GBK | 用Burp的Repeater发送原始payload,检查目标连接字符集 |
这些问题是每一个刚开始打Pikachu的人几乎都会碰到的。尤其是--注释符的问题,在URL里因为空格会被解析成%20或者被截断,我建议直接改用#,它在MySQL里同样能注释掉尾部内容,而且不需要空格,能省掉很多麻烦。
6.2 个人实操心得
打完这一整套靶场,我自己最深的体会是:SQL注入复现的核心不是背payload,而是建立“判断链”。看到参数第一反应不是抄payload,而是先判断类型、闭合、回显位置,再决定用哪种方案。Pikachu把这10种注入按由浅入深的顺序排好,本质上就是让你把这个判断链走十遍,直到形成肌肉记忆。
还有一个特别想提醒的细节:每次提交payload之前,先在本地把最终拼接出来的SQL语句写一遍。比如字符型是where name = '$name',你把$name替换成你的payload,看看会不会多出一个引号或者少一个注释符。很多新人报错后一脸懵,就是因为他们根本不知道后台SQL长什么样。Pikachu的源码是开放的,遇到猜不透的闭合方式,直接去看后台代码,这比盲试一百遍都有效。
最后,不要忽略“手工会话”的基本功。虽然现在有各种自动化工具,但工具报错时你很难定位问题。手工把这个靶场全部打一遍之后,你再去用工具,会有一种“原来如此”的感觉。真实环境里的注入点不可能像靶场这样标注得清清楚楚,而你练过这10种之后,至少能在混乱的请求包里快速定位到参数位置,判断出该走哪条路。这比记住一堆payload有用得多。