1. 为什么“pikachu通关记”不是游戏攻略,而是安全工程师的入门体检报告
“pikachu通关记之sql注入”——看到这个标题,别急着点开找通关秘籍。它压根不是什么休闲向的游戏笔记,而是一份被无数安全初学者反复翻烂、贴在显示器边框上当座右铭的实战训练日志。pikachu 是一个开源的、专为安全教学设计的漏洞靶场平台,它的核心价值不在于“多难”,而在于“多真”:所有漏洞模块都基于真实Web应用中高频出现的缺陷逻辑构建,SQL注入模块更是把从最基础的字符型注入到盲注、报错注入、堆叠注入、宽字节绕过等全链路场景,拆解成可触摸、可调试、可验证的独立关卡。我带过的某高校安全实训班里,A同学第一次接触时以为只是改几行代码就能过,结果卡在第二关整整三天——不是因为不会写payload,而是根本没意识到自己提交的' or 1=1#为什么在页面上只显示“用户名不存在”,却在数据库日志里悄悄执行了SELECT * FROM users WHERE username='' OR 1=1#' AND password='xxx'。这恰恰暴露了新手最常踩的坑:把SQL注入当成“输入框里拼字符串”的技巧题,而忽略了它本质是一场前后端数据流与数据库执行上下文的对抗实验。关键词里虽未明写,但整套流程天然锚定在三个不可绕开的技术坐标上:输入点识别、语句结构还原、执行结果回显控制。它适合谁?不是想速成“黑客”的人,而是愿意花一整天盯着Burp Suite的Request/Response面板,对比id=1和id=1'之间响应头Content-Length差了7个字节、HTML body里多出一个MySQL错误提示的人。如果你的目标是能独立审计一个电商后台的用户登录接口,那这份“通关记”就是你必须亲手走一遍的、没有捷径的起跑线。
2. SQL注入模块的底层架构:靶场不是玩具,而是精密设计的故障注入器
pikachu的SQL注入模块绝非简单地在PHP代码里加个mysql_query("SELECT * FROM users WHERE id=".$_GET['id'])就完事。它的精妙之处在于,每个关卡都通过可控的代码分支+预设的数据库状态+隔离的执行环境,构建出不同难度等级的对抗沙盒。以最经典的“字符型注入”关卡为例,后端实际执行的代码逻辑是这样的:
// pikachu/vul/sql/sqli_str.php $id = $_GET['id']; // 关键:此处对输入做了单引号包裹,但未做任何过滤 $sql = "SELECT * FROM users WHERE name='".$id."'"; $result = mysql_query($sql);注意这个$sql变量的拼接方式——它把用户输入$id直接用单引号包住后塞进SQL语句。这意味着,当用户传入admin' --+时,最终执行的SQL变成:
SELECT * FROM users WHERE name='admin' --+'--是MySQL的行注释符,+在这里被URL解码为空格,于是--+等效于--,成功注释掉末尾多余的单引号,语句语法合法。但这里埋着第一个关键认知陷阱:很多初学者以为只要闭合单引号就能执行任意SQL,却忽略了数据库驱动层对语句类型的硬性约束。比如,上述语句是SELECT查询,你无法在其中直接执行INSERT或DROP。pikachu刻意将“堆叠注入”(Stacked Injection)单独设为一关,其后端代码是:
// pikachu/vul/sql/sqli_stacked.php $id = $_GET['id']; $sql = "SELECT * FROM users WHERE id=".$id; // 注意:这里没加引号! $result = mysql_query($sql); // 且该连接允许多语句执行(mysqli_multi_query)只有当$id=1; DROP TABLE users; --这种形式传入时,mysql_query()才会因配置允许而执行多条语句。这解释了为什么在第一关用分号会报错——不是payload写错了,而是底层函数根本不支持。再看“盲注”关卡,它的数据库查询逻辑是:
// pikachu/vul/sql/sqli_blind.php $id = $_GET['id']; $sql = "SELECT * FROM users WHERE id=".$id; $result = mysql_query($sql); if(mysql_num_rows($result) == 1){ echo "exists"; } else { echo "not exists"; }这里没有任何错误信息、没有数据回显,甚至连HTTP状态码都是200。你唯一能利用的,是exists和not exists这两个布尔型响应差异。这就逼你必须用AND (SELECT SUBSTRING(password,1,1) FROM users WHERE id=1)='a'这类条件判断来逐字猜解密码。pikachu的每个关卡,本质上都是在模拟真实业务中不同的防御策略失效点:有的是开发者忘了过滤,有的是用了不安全的拼接方式,有的是错误处理机制泄露了信息,有的是业务逻辑本身限制了反馈渠道。它不教你怎么黑进银行,但它强迫你理解:每一个看似微小的代码习惯,都在为攻击者铺就一条确定性的路径。
3. 从“能过”到“真懂”:四步穿透式通关法与每一步的致命细节
在pikachu SQL注入模块里,刷出“Congratulations”弹窗只是起点,真正的学习发生在弹窗消失后的复盘里。我总结出一套四步穿透法,每一步都对应一个必须亲手验证的认知断层:
3.1 第一步:定位输入点与确认注入点(不是所有GET参数都可注入)
很多人一上来就对URL里的id参数狂轰乱炸,却忽略了一个铁律:注入点必须是SQL语句中直接参与字符串拼接或数值计算的变量。以http://pikachu/vul/sql/sqli_str.php?id=admin为例,先手工测试:
id=admin'→ 页面报错:“You have an error in your SQL syntax...”id=admin' and '1'='1→ 页面正常显示数据id=admin' and '1'='2→ 页面空白或提示“no data”
这三步确认了id参数存在字符型注入。但注意,如果测试id=1'(数字型)却报错,而id=admin'不报错,说明后端代码对id做了类型转换(如(int)$_GET['id']),此时数字型参数已被过滤,而字符型参数因未加引号反而成了突破口。实操中我见过某学员在“搜索框”关卡死磕,反复提交' or 1=1#无果,最后发现搜索功能调用的是Elasticsearch API,根本没走MySQL——这就是没先确认技术栈的典型失误。pikachu的每个关卡URL路径都暗示了技术栈(sqli_str.php= 字符型,sqli_int.php= 数字型),这是你必须养成的第一个阅读习惯。
3.2 第二步:推导SQL语句结构(闭合符号、字段数、表名是解题钥匙)
确认注入点后,90%的人停在“能执行and 1=1”就以为通关了。但真正拉开差距的,是能否还原出原始SQL的完整骨架。以字符型注入为例,已知id=admin'报错,说明原始SQL类似WHERE name='xxx'。那么要执行联合查询(UNION SELECT),必须满足两个前提:列数一致、数据类型兼容。测试列数的方法是:
id=admin' order by 1#→ 正常id=admin' order by 2#→ 正常id=admin' order by 3#→ 报错
→ 确认原始查询有2列。接着用UNION SELECT 1,2#验证是否能回显,若页面显示“12”,说明第二列内容被渲染到HTML中。此时再用UNION SELECT 1,database()#获取当前库名,UNION SELECT 1,table_name FROM information_schema.tables WHERE table_schema=database() limit 0,1#爆表名。这里有个极易被忽略的细节:information_schema在MySQL 5.7+默认开启,但在某些精简版Docker镜像中可能被禁用,此时需改用UNION SELECT 1,group_concat(table_name) FROM mysql.innodb_table_stats等替代方案。pikachu的数据库是预置的,但这个推导过程,正是你在真实渗透中面对未知CMS时必须现场完成的逆向工程。
3.3 第三步:构造有效Payload并控制回显(别让数据“飞”在空中)
很多学员能爆出表名,却卡在“怎么把密码取出来”。问题出在回显控制上。例如,在UNION SELECT 1,password FROM users#中,如果password字段内容是$2y$10$...这样的哈希值,而页面只渲染了前50个字符,或者前端JS对特殊字符做了转义,你看到的可能是一串乱码。这时必须用MySQL内置函数处理:
UNION SELECT 1,hex(password) FROM users#→ 转为十六进制,避免特殊字符干扰UNION SELECT 1,concat('user:',username,'|pass:',password) FROM users#→ 拼接标识符,明确区分字段UNION SELECT 1,substr(password,1,32) FROM users#→ 分段提取,规避长度截断
更关键的是时间盲注的精度控制。在盲注关卡,if((select ascii(substr(password,1,1)) from users where id=1)=97,sleep(3),1)中,sleep(3)是为制造可感知的时间差,但实际网络延迟可能波动±200ms。我实测过,在局域网内sleep(1)足够稳定,而在公网靶场则需sleep(5)才能100%区分。这不是教科书参数,而是你必须用curl -w "@time.txt"反复测试得出的经验值。
3.4 第四步:绕过WAF与编码变形(靶场里的“假墙”教你识破真墙)
pikachu的“WAF绕过”关卡常被误解为“学怎么绕过云WAF”,其实它模拟的是最原始的正则过滤逻辑。例如,后端代码可能是:
$id = str_replace(['union','select','from'],'',$id); // 简单黑名单 $sql = "SELECT * FROM users WHERE id=".$id;此时id=1 union select 1,2会被过滤成1 1,2,语句失效。但id=1 uNIon selECT 1,2(大小写混淆)或id=1%09union%09select%091,2(URL编码Tab符)即可绕过。这里的关键认知是:所有基于字符串替换的WAF,都存在“匹配粒度”与“执行顺序”的博弈空间。真实企业级WAF(如ModSecurity)规则更复杂,但原理相通——它检测的是“特征模式”,而SQL解析器执行的是“语法树”。当你用id=1/**/union/**/select/**/1,2(注释符分割)或id=(select*from(select+1)a+join(select+2)b)(子查询嵌套)时,你是在用SQL语法的灵活性,对抗正则表达式的机械性。pikachu不提供WAF源码,但它逼你思考:如果union被拦截,能否用extractvalue()报错注入?如果sleep()被禁,能否用benchmark()?这些不是炫技,而是告诉你:攻击面永远比防御面更广,因为防御是静态规则,而攻击是动态创造。
4. 那些藏在通关截图背后的血泪教训:五个必须写进笔记的避坑清单
在带教过程中,我整理了一份“pikachu SQL注入通关血泪清单”,里面全是学员反复踩坑、最终靠抓包和查文档才解决的细节。这些内容不会出现在任何官方教程里,但它们决定了你是“刷完关卡”还是“掌握能力”。
提示:所有错误都源于对“数据流向”的误判,而非技术本身
4.1 编码问题:URL解码、HTML实体、MySQL客户端编码,三层解码链不能漏一层
最经典的案例:某学员在盲注关卡用id=1 and (select ascii(substr(password,1,1)) from users)=97始终返回“not exists”,但用98却返回“exists”。他以为逻辑反了,折腾半天。最后用Burp Suite抓包才发现,他提交的=被浏览器自动URL编码为%3D,而后端PHP的$_GET自动解码后,%3D变回=,但MySQL在解析时,如果客户端连接编码是latin1,而数据库是utf8mb4,=符号可能被错误解析。解决方案是强制指定连接编码:在payload开头加SET NAMES utf8mb4;,或直接用id=1 and (select ascii(substr(password,1,1)) from users) like 0x61(十六进制表示a)。记住:一次HTTP请求,数据要经历浏览器编码→Web服务器解码→PHP变量赋值→MySQL协议传输→数据库存储引擎解析,共5个环节,任一环节编码不一致,你的payload就变成乱码。
4.2 注释符失效:--后面必须跟空格,#在URL中需编码为%23
id=1' -- abc是合法的MySQL注释,但id=1' --abc(--后无空格)会被MySQL视为非法符号。pikachu的PHP环境默认使用mysql扩展(已废弃),其对注释符的解析比新版mysqli更严格。更隐蔽的坑是:在URL中直接写#,浏览器会将其视为锚点,#及之后的内容根本不会发送到服务器。正确写法是id=1' %23(%23是#的URL编码)。我曾见学员调试半小时,就因为URL里写了id=1' #,而Burp里看到的Request是id=1'——#被浏览器吃掉了。解决方案:永远用%23代替#,或用--(--加空格)更稳妥。
4.3 布尔盲注的“假阳性”:响应差异未必来自SQL,可能是前端JS或缓存
在布尔盲注关卡,id=1 and 1=1返回“exists”,id=1 and 1=2返回“not exists”,看起来完美。但某次测试中,id=1 and (select count(*) from users)=100返回“exists”,而实际用户数是99。排查发现,前端页面有一段JS逻辑:if(data.length > 95) { showResult('exists'); }——它根本没读数据库,而是根据返回的JSON数组长度做判断!这意味着,你的盲注条件必须作用于JS能感知的维度,比如id=1 and (select length(username) from users where id=1)>5,然后观察页面是否显示用户名字段。永远先确认“响应差异”的根源是数据库查询结果,而不是中间件、CDN缓存或前端逻辑。方法很简单:用Burp重复发送同一请求,看Response的Date头和X-Cache头是否变化。
4.4 时间盲注的“伪延迟”:sleep()不是唯一选择,benchmark()更可靠
sleep(5)在pikachu里很直观,但在真实环境中,sleep()可能被数据库权限限制(SUPER权限才允许)。此时benchmark(1000000,ENCODE('hello','world'))是更好的选择——它通过执行大量哈希运算消耗CPU时间,无需特殊权限。但benchmark()的耗时不稳定,受服务器负载影响大。我的经验是:先用sleep(1)测基线延迟,再用benchmark(500000,...)做对比,若后者响应时间比前者长2倍以上,即可作为盲注依据。另外,id=1 and if(1=1,sleep(1),0)中的if函数在MySQL 5.0.2以下不支持,pikachu用的是5.7,但你在线下复现时得先select version();确认。
4.5 UNION注入的“列数陷阱”:order by只能测上限,group by能精确定位
order by N是测列数的常用法,但当N=5报错、N=4正常时,你只能知道列数≤4。但真实列数可能是3,也可能是4。更精准的方法是用group by:
id=1 group by 1→ 正常(按第1列分组)id=1 group by 1,2→ 正常id=1 group by 1,2,3→ 报错
→ 确认列数为2。因为group by要求列出所有非聚合字段,报错即说明第3列不存在。这个技巧在真实渗透中极有用,尤其当目标站对order by做了WAF拦截时,group by往往能绕过。
5. 通关之后:如何把pikachu的“玩具漏洞”迁移到真实业务审计中
完成pikachu所有SQL注入关卡,不等于你能审计生产系统。真正的迁移,是把靶场里的“确定性路径”,转化为真实环境中的“概率性侦查”。我带过的某公司安全团队,在用pikachu培训后,对内部OA系统做了一次摸底审计,过程极具代表性:
5.1 从“固定参数”到“动态入口”:识别所有潜在注入点
pikachu的URL参数是明摆着的id、name,但真实系统有几十种入口:RESTful API的Path参数(/api/user/{id})、GraphQL的变量、AJAX请求的JSON Body、文件上传的filename字段、甚至HTTP Header里的X-Forwarded-For。他们第一步不是写payload,而是用Burp Suite的Target → Site map功能,爬取整个OA站,导出所有带参数的请求,用正则筛选出.*[?&](id|user|name|search|keyword)=.*的URL,再人工确认哪些参数会进入数据库查询。结果发现,一个被忽略的/report/export?format=pdf&date=2023-01-01接口,date参数直接拼进WHERE create_time >= '$date',成了高危点。
5.2 从“标准错误”到“自定义异常”:构建私有化错误指纹库
pikachu的报错信息是标准MySQL格式,但真实系统会屏蔽错误,或返回自定义错误页(如“系统繁忙,请稍后再试”)。团队的做法是:收集所有400/500错误响应,用Python脚本提取HTML title、body长度、关键词(如“exception”、“error”、“ORA-”、“PG::”),建立指纹库。当扫描到某个参数触发500错误且title含“Database Error”,就标记为高可疑。他们发现,某Java系统用Hibernate,错误页里藏着org.hibernate.exception.SQLGrammarException,这就是典型的JDBC驱动报错,比MySQL原生报错更隐蔽,但指向性更强。
5.3 从“手动验证”到“半自动探测”:用SQLMap定制化绕过
pikachu练手后,SQLMap是必经之路。但直接sqlmap -u "url" --dbs在真实环境大概率失败——WAF会拦截。他们的做法是:先用pikachu练熟--tamper脚本,如space2comment.py(空格转/**/)、randomcase.py(随机大小写),再针对目标WAF规则,写一个定制tamper:
# waf_bypass.py from lib.core.enums import PRIORITY __priority__ = PRIORITY.LOW def dependencies(): pass def tamper(payload, **kwargs): payload = payload.replace(' ', '/**/') payload = payload.replace('union', 'unIOn') payload = payload.replace('select', 'sEleCt') return payload然后sqlmap -u "url" --tamper=waf_bypass --batch。关键是--batch参数,它跳过交互式确认,用预设策略全自动跑,效率提升10倍。pikachu的价值,正在于让你在无风险环境下,把SQLMap的每个参数、每个tamper的生效逻辑,都亲手验证过一遍。
5.4 从“漏洞利用”到“风险闭环”:用POC验证业务影响而非仅技术存在
最后一步,也是最容易被忽视的:证明漏洞真的能造成业务损失。他们对那个date参数,没止步于爆出数据库名,而是构造了date=2023-01-01' AND (SELECT COUNT(*) FROM admin_users) > 0 --,验证是否能判断管理员表是否存在;再用date=2023-01-01' UNION SELECT username,password FROM admin_users --,确认是否能导出凭证。当看到响应里返回admin:$2y$10$...时,风险等级立刻从“中危”升为“严重”。这才是安全审计的终点——不是告诉开发“这里有SQL注入”,而是说“攻击者可以用这个参数,10分钟内获取全部管理员账号,建议立即下线修复”。
我在某次复盘会上问团队:“如果现在删掉pikachu,你们还能否独立完成这次审计?”所有人沉默了几秒,然后点头。那一刻我知道,通关记的终点,从来不是靶场里的“Congratulations”,而是你关掉浏览器,打开企业内网系统,心里清楚:那些曾经在pikachu里被你一行行敲出来的payload,此刻正安静地躺在真实代码的缝隙里,等待一个更清醒的你,亲手把它缝上。