靶场一共十二个学员轮着用我自己前后过完整个XSS模块大概花了一个下午。这个“XSS之href和js”的关卡属于典型的上下文注入题在真实业务里也是最容易被忽略的一种。说明本文全流程均在本机虚拟机靶场环境Kali pikachu中操作目的是学习漏洞原理和对应的防御思路请勿在未授权的目标上测试。1. 环境准备Kali上部署pikachu靶场1.1 为什么选pikachu而不是DVWA很多人一提到XSS靶场第一反应是DVWA。DVWA确实经典但它的XSS模块只有反射型、存储型、DOM型三个维度过滤绕过也只有medium/high两档强度对“输出点在不同上下文里如何构造payload”这个核心能力的训练是不够细的。pikachu这个靶场是某安全团队开源的PHP项目中文界面关卡设计更贴近国内Web应用的开发习惯。它的XSS模块拆得特别细反射型GET、反射型POST、存储型、DOM型、盲打、过滤绕过、href输出、js输出一共八个场景。其中“href输出”和“js输出”这两个关卡几乎就是真实渗透测试里前端注入点的复刻——一个是URL参数拼进a标签的href属性一个是用户输入直接落到script代码块里。所以我的建议很直接新手先用DVWA建立XSS的整体概念然后立刻转到pikachu练“上下文感知”。这两个靶场配合着用XSS这块的基本功会扎实很多。1.2 Kali上部署pikachu的完整步骤pikachu依赖PHP环境和一个MySQL数据库。Kali默认带了Apache和MySQL但PHP不一定装全这里把步骤从头捋一遍。# 1. 安装PHP及相关扩展 sudo apt update sudo apt install php php-mysql php-gd php-xml php-mbstring -y # 2. 启动Apache和MySQL sudo systemctl start apache2 sudo systemctl start mysql # 3. 下载pikachu源码到Web目录 cd /var/www/html sudo git clone https://github.com/pikachu靶场/pikachu.git # 4. 初始化数据库 sudo mysql -uroot -p # 进入MySQL后执行 # create database pikachu; # exit; # 5. 修改配置文件 sudo vim /var/www/html/pikachu/inc/config.inc.php # 把数据库账号密码改成你自己的root密码改完配置后浏览器访问http://127.0.0.1/pikachu页面会提示先初始化点击初始化按钮会自动建表。这一步完成后进入XSS模块找到“XSS之href输出”和“XSS之js输出”两个入口就可以开始实战了。注意Kali的Apache默认站点根目录是/var/www/html如果你用的是Nginx或者宝塔面板路径要对应调整。另外数据库账号不建议用root生产环境里这是大忌本地靶场倒是无所谓怎么方便怎么来。2. XSS触发链路与两种输出场景的原理2.1 一条XSS的完整触发链路要理解这个关卡先得把XSS的触发链路在脑子里刻清楚。一次XSS的完整流转是用户输入 → 后端接收 → 后端拼接 → 浏览器解析 → 恶意代码执行。这里面的关键不在输入而在“拼接”之后的浏览器解析上下文。同样是一段用户输入放在HTML标签的文本节点里、放在标签属性里、放在script代码块里、放在CSS的url()里payload的写法完全不同。这就是为什么XSS被称为“上下文注入漏洞”——你注入的不是普通数据而是破坏了原有上下文结构的代码片段。拿这个href关卡来举例后端代码大概是这样的$html a href{$_GET[url]}链接/a; echo $html;开发者的本意是让用户提交一个URL生成一个可点击的链接。他只预期了https://开头的输入但代码里没有任何协议白名单校验。这时候如果我提交的不是https://而是javascript:alert(document.cookie)浏览器在解析a标签的href属性时会识别到javascript:这个伪协议直接执行后面的JavaScript代码。这就是“预期外上下文”带来的危害。2.2 href输出和js输出的核心差异这两个关卡放在一起练本质上是对比两种完全不同的浏览器解析行为href输出注入点落在HTML标签的属性值里。攻击者的任务是改变属性值的语义让属性从“一个URL”变成“一段可执行的JS代码”利用的是javascript:伪协议。js输出注入点落在script代码块的字符串变量里。攻击者的任务是提前闭合字符串的引号让自己输入的内容逃逸出字符串边界变成真正的JS语句。打个比方href注入像是往路牌上贴纸条改的是指示方向js注入像是直接撬开箱子的锁改的是箱子里的内容。前者骗的是浏览器对协议的理解后者骗的是JS引擎对字符串边界的理解。明白这个区别后面构造payload就顺了。3. XSS之href输出从探测到弹窗3.1 先观察再动手进入关卡后页面是一个表单提示输入一个URL进行测试。我第一件事不是急着输入payload而是先输入一个正常值观察回显结构。输入https://www.baidu.com提交后页面多了一行链接点击链接会跳转到百度。右键查看源代码能看到这样的输出a hrefhttps://www.baidu.com你的输入:/a这一眼就确认了输入值被原样拼到了a标签的href属性里而且用的是单引号包裹。这意味着我完全不需要处理引号闭合的问题——只需要改变href属性值的协议头。3.2 构造payloadjavascript伪协议在表单里输入这段javascript:alert(document.cookie)提交后页面出现一个链接点击链接浏览器直接弹出了当前页面的Cookie。这背后的原理很简单HTML规范里a标签的href属性支持javascript:这个伪协议浏览器解析到它时会创建一个JavaScript URL并执行其后的代码。注意这里不需要script标签不需要处理引号甚至不需要闭合任何东西——因为javascript:协议本身就是可执行上下文。这个案例在真实世界里非常典型。很多开发者以为在a标签的href里放URL是安全的觉得“用户能输入啥最多是个链接地址”。但恰恰是这种思维让形形色色的javascript伪协议注入钻了空子。3.3 过滤存在时的绕过思路有些业务后端会做基础的过滤比如检测javascript:关键字。这类防护很容易绕过我实测过几种有效的方式大小写混合JaVaScRiPt:alert(1)部分过滤函数只匹配小写。HTML实体编码#106;avascript:alert(1)#106;是j的十进制实体如果输出点位于HTML属性中且浏览器在解析属性值前先解码实体就能绕过关键字检查。Tab和换行混淆java\tscript:alert(1)有些正则没考虑协议头中间插入空白字符的情况。但要注意绕过手段能不能生效完全取决于后端过滤函数怎么写的。靶场这个关卡本身没有过滤直接输入javascript协议就能触发我们练习的目的是先把这条链路跑通再去想各种奇葩绕过。3.4 扩展玩法data协议与编码传输除了javascript协议还可以测试data协议data:text/html,scriptalert(1)/script这个payload在本关卡同样能弹窗。原理是data协议允许把一段HTML内容直接作为链接的目标资源浏览器解析时会按text/html渲染并执行其中的脚本。还有一种思路是在提交前对payload做URL编码。比如javascript:alert(1)编码后变成javascript%3Aalert%281%29如果后端做了urldecode再输出那最终浏览器解析的还是可执行的协议头。这种情况在处理PHP的$_GET参数时尤其常见因为PHP本身会自动解码一次URL编码。实操这一关的时候可以在Burp Suite的Repeater里改包把请求参数发给服务器后观察响应中的拼接位置这样能更精准地判断过滤是否存在。4. XSS之js输出逃逸字符串边界4.1 从源码理解js上下文切到“XSS之js输出”关卡页面依然是个输入框。我输入一个普通字符串test提交后查看前端源码看到关键的回显位置script var $a test; /script这个输出点不在HTML标签里而是在一对script标签包裹的JS代码中变量$a被赋值为我输入的内容。这里用的是双引号括起来的字符串。此时就算我输入scriptalert(1)/script也没用因为尖括号在script标签内部不会被解析为HTML标签浏览器会把整个script块内容当作JS代码执行。所以触发XSS的思路完全变了要让我的输入提前终止这个字符串把剩余部分变成可执行的JS语句。4.2 三种payload构造方式第一种最直接的闭合引号。输入;alert(document.cookie);//拼接后服务端输出script var $a ;alert(document.cookie);//; /script我的输入先用第一个闭合了变量赋值开头的双引号然后用分号结束整个赋值语句接着执行alert最后用//把原代码末尾的;注释掉。这样alert就作为独立语句被JS引擎执行了。第二种闭合整个script标签。输入/scriptscriptalert(document.cookie)/script拼接后输出script var $a /scriptscriptalert(document.cookie)/script; /script浏览器先解析到第一个/script结束了当前script块然后script开启一个新的script块里面的alert被执行。这种手法在处理多处拼接时特别有效因为等于完全接管了页面的一部分。第三种如果开发者在外部引号上做了转义防护可以考虑JS的注释闭合思路。输入;alert(1)//这种观察服务端是否把转义成了\。如果转义了payload就不成立这时可以尝试换行符、模板字符串等更多变形这部分我会在下一节展开。4.3 同一关卡中的编码绕过js输出的过滤要比href那边常见得多因为很多开发者知道JS代码块里不能直接拼用户输入会习惯性地做引号转义或剥离尖括号。我这里实测了一套绕过思路。当后端把转义成\后直接闭合是行不通的但可以转向JS的编码机制十六进制转义\x22在JS字符串里会被解析成字符。Unicode转义\u0022同样是双引号的Unicode表示。八进制转义\074这种写法在一些旧版本的解析器中有效。如果输入\x22;alert(1);//后端转义后变成\\x22;alert(1);//这里\\在JS字符串中会被解析成单个反斜杠最终变成\;alert(1);//这个\在字符串外会被当成源码的一部分alert反而能执行。这种编码链绕起来比较复杂新手容易被绕晕但值得反复练习——因为真实环境的XSS从来不会只用一种编码。还有一个容易踩的坑浏览器对JS代码中的实体编码是敏感的。在script标签内部HTML实体比如quot;不会被解码成引号所以quot;;alert(1)在script里是不会生效的。很多新手在HTML环境下用实体编码成功了切到js环境就忘了这个差异。这就是为什么要反复强调“看上下文”。4.4 从input到执行完整链路复盘把js输出这一关的完整链路再拉一遍用户在输入框提交;alert(document.cookie);//后端接收参数拼接到scriptvar $a 输入;/script浏览器解析到script标签启动JS引擎JS引擎逐字符解析先遇到闭合了字符串起始边界接着;结束赋值语句alert(document.cookie)成为独立的函数调用语句执行弹窗最后的//把原始代码的尾部变成注释避免语法错误每一步都不能少。尤其是最后那个注释符如果忘了加多出来的;会影响整句的语法结构弹窗往往因为后续报错被吞掉这是新手最常犯的错误。5. 常见问题与排查技巧实录5.1 弹窗不弹先从这四个方向查我自己练这个关卡时踩过几次坑总结下来弹窗失效无非四类原因输出点判断错误。你以为输入进了a标签的href实际进的是text节点。这种情况payload写法完全不同先右键查看源代码确认拼接的上下文。引号闭合方向搞反了。源码里用的是单引号你payload却用双引号去闭合肯定不生效。先看源码再决定用哪种引号。过滤把尖括号或引号转义了。target到script内部尖括号被转义成lt;这类实体最常见的解法是改用伪协议或编码绕过的payload而不是纠结于构造完整的script。浏览器或插件拦截了alert。Chrome对跨域iframe里的alert有拦截很多浏览器扩展也会拦截alert函数。最稳的办法是用console.log先验证执行点是否生效或者用document.title1这类副作用更小的语句。排查的时候建议用Burp Suite的Repeater直接发请求替换参数后看响应里payload被渲染成了什么样。这一步能直观看到转义和过滤的痕迹比自己盲猜快得多。5.2 靶场练习之外的修复建议练完靶场总得知道真实系统里怎么补漏洞。这里给三条在代码层面最直接有效的修复思路第一输出编码。在href输出时用htmlspecialchars($url, ENT_QUOTES, UTF-8)在JS输出时用json_encode()对参数做JSON编码所有单双引号、尖括号、斜杠都会被转成安全形式。第二协议白名单。用户提交URL时只允许http://和https://开头其余一律拦截。这个校验在后端做不要只在前端做前端校验只是用户体验后端校验才是安全边界。第三CSP策略。在响应头里加Content-Security-Policy: script-src self即使XSS payload被注入了浏览器也会因为CSP禁止执行内联脚本而拦截。CSP是最后一道防线但确实是性价比最高的兜底方案。5.3 一些建议练这个关卡练到某个阶段你会开始习惯性地在拿到一个输入点后先问自己三个问题输入拼到哪个位置了那个位置的解析规则是什么有哪些字符能改变当前上下文的边界当你能在几秒钟内回答出这三个问题XSS基本就入门了。pikachu这个靶场好就好在把每一个上下文场景都拆出来了。你不需要通过大量真实漏洞去积累经验光是把八个XSS关卡轮流吃透就能比很多只会往输入框里扔script的测试人员强不少。下次我在带人做代码审计的时候一定会先让他们过一遍href和js这两个关卡——这两个位置的漏洞频率在真实业务里远高于其他位置。