首页
社区
课程
招聘
[原创] CVE-2025-54068 实战:打穿 Livewire 站点并落地 WebShell
发表于: 5小时前 170

[原创] CVE-2025-54068 实战:打穿 Livewire 站点并落地 WebShell

5小时前
170

目标:936K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6@1j5i4u0Y4k6i4c8Q4x3X3g2W2P5r3q4E0M7r3I4W2i4K6u0W2j5$3!0E0(共享主机,PHP 8.3.36)
漏洞:livewire/livewire v3.4.11 — CVE-2025-54068,属性注入导致任意 PHP 函数调用
起点前置条件:无。不登录、不需要 APP_KEY、不需要任何凭证
结果:任意 PHP 函数调用 → .env 全量读取(2908 字节)→ APP_KEY 与 DB 口令到手 → 伪造会话进后台 → 上传点落地 WebShell → 完整命令执行

整条链是两段拼起来的:

注:APP_KEY 在这次链里没有用于伪造会话。实际进入后台靠的是日志泄露的重置令牌(见 10.1)。

时间线(第一阶段来自流量时间戳;第二阶段来自文件落盘时间):

42 KB 的页面,看起来只是个普通博客列表。但 Livewire 站点会把组件的完整状态烘焙进 HTML,这是整条链的起点。

id=44144c23 这串 hash 是 Livewire 的构建标识。它直接由官方 versions.json 反查版本:

版本 = v3.4.11。CVE-2025-54068 影响区间是 >= 3.0.0-beta.1, < 3.6.3,修复在 3.6.4,CVSS 4.0 = 9.2。v3.4.11 正落在区间内。

注意 v3.6.3 和 v3.6.4 的 hash 相同(df3a17f2),说明修复没有动构建产物,只是逻辑变了——所以不能靠升级后的 hash 判断。

Livewire 把组件状态塞在 wire:snapshot 属性里,HTML 实体转义后的 JSON:

解转义后是一个完整的组件快照:

关键点:data.paginators 的第一项是 {"page": 1} —— 它被标记成了 {"s": "arr"} 之外的复合结构。也就是说:paginators 是对象类型。这决定了攻击能否成立——只有对象类型的属性,Livewire 才会在 updates 阶段走 Synth 反序列化路径。

同时取 CSRF token 和 cookie:

这 40 位字符串就是后面 POST 要带的 _token。会话 cookie 在同一个响应里,用 requests.Session() 自动就能保住。

四行输出把四个前置条件全确认了:版本脆弱、无需 APP_KEY、有可挂载组件、paginators 是对象类型。

工具默认用 system('id') 试探,这一步会失败(后面会解释),所以不能只信工具的输出。

在注入之前,必须先证明「匿名请求能被当作合法组件更新处理」。这一步不做,后面拿到 500 就分不清是漏洞没打通还是 payload 写错了。

请求(482 字节):

响应(980 字节,gzip):

三个信号说明链路通了:

第 3 点最关键。服务端愿意给一个匿名请求重新签名,意味着它把这个请求当成了合法的组件更新,而不是可疑流量。

Livewire v3 的 wire:snapshot 里,data 和 memo 由 checksum 保护——改一个字节就验签失败。

但 POST /livewire/update 里的 updates 段不在签名范围内。

这是 CVE-2025-54068 的本体。服务端在 HandleComponents::hydrateProperties() 处理 updates.<property> 时,会检查目标属性的声明类型:

而 Synth 反序列化认的是攻击者传来的元组标记:

也就是说,攻击者可以指定要实例化哪个类、塞什么属性,从而在 hydrate 阶段拼出任意对象图。

所以整个攻击不需要破解 checksum——checksum 保护的东西我们不碰,只碰它没保护的那一段。

目标是让 PHP 最终执行 call_user_func($fn, $param)。选的链条是:

三个 Laravel 内置类各司其职:

两个长度前缀是变量,必须精确计算:

读 .env 时这两个值要重算:

长度算错一个字节,反序列化直接失败,表现出来就是 500 或者空响应。这是手工构造 payload 时最容易踩的坑。

序列化串本身只是一段字符串,需要被 Synth 元组链承载:

几个细节值得注意:

请求(1,510 字节):

注意 snapshot 里的 checksum 是原样保留服务端签发的值,一个字节没改。

响应(gzip 传输 37 字节,解码后 28 字节):

print_r('PWNEDPROBE_ABC') 的返回值被直接写进了响应体,出现两次是因为链子在 a/b 两处都挂了。

到这里,任意 PHP 函数调用 + 结果回显已经实锤。

光有回显还不够——万一只是某种字符串回显呢?用 sleep 做时间验证:

同期基线请求耗时 0.70s。sleep(2) 让请求多了约 4.2 秒,远超网络抖动(同批次其他请求都在 0.65–1.01s)。

sleep 的返回值是 0 或空,不会回显,只有真的在 PHP 层执行了才能观察到这个延迟。

把函数换成 readfile,参数换成 .env 的绝对路径:

拿到的内容(已脱敏):

三个字段直接决定后续危害等级:

APP_KEY 泄露意味着可以离线伪造任意已签名的会话 cookie,绕过全部认证中间件直接进管理后台——这比 RCE 本身更难防,因为服务器日志里看不到异常。

APP_ENV=local + LOG_LEVEL=debug 是另外一个放大器:所有失败的函数调用都会带着完整路径写进 laravel.log。

这一步不依赖客户端回显,属于独立证据。

通过应用上另一处 public/storage/..%2f..%2f 路径穿越取到 laravel.log:

这些是 PHP 函数签名级错误:

时间戳与请求一一对应。框架确实把攻击者可控的字符串送进了 PHP 引擎——这是服务端自己的记录,不依赖任何客户端回显。

工具默认想打 system('id'),失败。于是把可疑函数全试一遍:

完整结果:

规律很明显:

第一阶段的教训:仅靠行为观测(500 / 空体 / 耗时)推断服务端配置,会得出错误结论。本文最初把 /etc/hostname 的失败归因为 open_basedir,把命令执行的失败归因为笼统的“禁用”,都是阶段性误判——必须等到能读取 php.ini 实际内容才能定论。

直接尝试 system() 写 shell:

进一步逐个测试写文件族(都是在第一阶段“单参函数直调”原语下):

重要区分:这里失败的原因是**“只能传一个参数”,而不是这些函数被封禁。file_put_contents、unlink、scandir 等在前述 php.ini 里都不在 disable_functions 名单中**。

换句话说:

这个区别是整条链的关键转折——第一阶段拿不到 shell,不是因为服务器不给写,而是因为原语太窄(只能给一个参数);一旦通过别的路径拿到能带完整参数的代码执行点,写文件立刻可用。

但同一原语还能调 unserialize()(单参),等于额外拿到「任意对象反序列化 → 析构副作用」的能力,这是后续探索的基础。

拿到 WebShell 之后(第 10 节),我们直接从服务器读出了真相。

读到 /opt/alt/php83/etc/php.ini(124,250 字节):

两个关键更正:

这直接推翻了第 9.2 节最初的归因,也是本文保留“推断 → 更正”过程的原因:仅凭 500/空体/耗时做行为观测,会得出错误结论。

同时注意:第一阶段测得 proc_open 返回 500,与后期“proc_open 可用”看似矛盾。合理解释是:第一阶段调用只传了 1 个参数(proc_open('id')),签名不匹配必然 500;而 disable_functions 名单里没有它。这也是“单参原语”的固有限制——它只能调用恰好接受一个参数的函数。

第一阶段的结论是「命令执行族被禁、内容写入被堵」,但第 7 步已经拿到了 APP_KEY。

实际取得后台会话的路径(如实记录):

也就是说:APP_KEY 本身在本条链里没有直接用于伪造会话。用 APP_KEY 离线构造 super_admin 会话是一条理论上可行但本次未验证的路径(需要正确复现 Laravel 的 cookie 加密+签名格式),本文不把它写成既成事实。

真正起作用的是“日志泄露重置令牌 → 重设管理员密码”这条更朴素的链条。

纵向排查发现后台存在一个语言管理功能:

这个上传点只看文件内容和客户端 Content-Type,不校验扩展名。

构造一个以 GIF89a 开头的 PHP 文件,伪装成图片:

上传:

响应 200,spatie/laravel-medialibrary 把它落到 public 磁盘:

mime_type 记录为 image/gif —— 服务端信了 Content-Type 头。

文件确实落盘了。 这一步是成功的。

拦截发生在 GET 阶段,且是按 URL 里的文件名关键字匹配的。shell 这个词在黑名单里。

注意:上传始终是成功的,被拦的只有访问。文件静静躺在磁盘上。

RASP 只做字符串匹配,那改个名字就行。重新上传,同样的内容,只换文件名:

命中规则很明确:shell / webshell / c99 之类的关键字被匹配。

用 ws01.php 上传后:

完整命令执行,PHP 8.3.36,用户 <SITE_USER>。

POST 通道同样可用:

RASP 的规则基于 .php 扩展名。既然上传目录可写 .htaccess,就能改变解析规则。

这一步分两步完成:

第一步:把 .htaccess 当成普通文件直接上传(文件名以点开头,media id 60/61,落盘成功;直接 GET 它会被 LiteSpeed 403,但文件已在磁盘上)。

第二步:用已经落地的 ws01.php(能执行 proc_open,也就具备完整文件写入能力)往 uploads/language_flag/62/ 写入 AddType 规则和一个 .txt 测试文件:

访问验证:

然后访问 .txt:

.txt 被当成 PHP 解析了。 基于 .php 扩展名的 RASP 规则被彻底绕过,且完全不触发 URL 文件名黑名单。

补充一条实测:e01.PHP(大写扩展名)也被正常解析——PHP 对扩展名大小写不敏感,而 RASP 黑名单按小写匹配,这构成了第三种绕过。

查看根 .htaccess 后发现路由规则:

所以写进 public_html 根的文件,只能通过 /public/storage/..%2f..%2f<name> 两级穿越访问。

用已拿到的 webshell 把内容写到 document root:

然后直接访问:

document root 级别的代码执行成立,服务器权限目标达成。

读回的 .env(1454 字节)与第 7 步 readfile 拿到的是同一份内容——两条独立路径互为印证。

上面这一节记录的是首次 GET 成功时的形态(明文 webshell,靠 URL 参数传命令)。实际运营中这不够隐蔽——命令会出现在访问日志与 WAF 记录里。

后续演进为:

换加密马的原因是规避 GET 阶段的命令关键字过滤:实测带 cat /etc/passwd 的 GET 请求会被 RASP 403,改用 POST 才能通过。加密马把整个会话打包成加密 POST body,从根上绕开了这层。

这也说明:RASP 的关键字黑名单是一个持续对抗的面,不是一次绕过就结束。

判读标准:

关键点:

任何一段被单独修掉,另一段依然能造成实质危害。这是纵深防御失效的典型形态。

每一层防护都被单独绕过,说明这些措施之间没有形成互补。特别值得注意的是最后一条:一个看似无关的信息泄露(log-viewer),直接把前面所有“拿不到后台”的假设全部推翻。

APP_KEY 泄露的严重性通常被低估。它不像 RCE 那样有明确的命令回显,但:

一个完整的实战记录,写清楚“哪里走不通”比写清楚“哪里走通了”更有价值。下面这些边界,都是这次真正撞上的。

这套单参原语能做的,比它看起来的样子要少。

system、exec、shell_exec、passthru、popen 全被 disable_functions 挡住,返回 500。proc_open 虽然不在禁用名单里,但它至少要三个参数,而原语只能传一个,结果同样是 500——这是签名不匹配,不是禁用,两者在日志里长得一模一样,很容易误判。

写文件这边更彻底。file_put_contents、fopen、fwrite、mkdir、copy、rename、symlink、chmod、unlink、tempnam 一个都过不去,只有 touch 能建出空文件。也就是说,第一段拿不到 shell,不是构造错了 payload,而是原语本身太窄——它只能调用恰好接受一个参数的函数。

这一点当时没看透。最初把 proc_open 的 500 也归进“禁用”,直到后来读到 php.ini 才纠正过来。

第一阶段所有结论都建立在 HTTP 状态码、响应体长度和耗时上。这套方法能证伪,却很难证实。

最典型的是 /etc/hostname:readfile 对它返回 500 加 No such file or directory,当时判断是 open_basedir 限制。后来从 webshell 读到 php.ini,发现 open_basedir 是空的,而同一把 readfile 能读 /etc/passwd(1948 字节)、/etc/hosts(3628 字节)、/proc/self/*、php.ini 全量(124,250 字节)。

/etc/hostname 读不到,只是这台主机的 CageFS 视图里没有这个文件。

同样的错还发生在 disable_functions 上——只能说“大概是禁用”,具体名单要等能读 php.ini 才知道。

教训:500 只能说明“这次调用失败了”,说明不了“这个能力不存在”。在没有服务端配置的情况下,任何归因都应当标注为推断。

APP_KEY 泄露的危害毋庸置疑——它可以用来伪造任意已签名的会话 cookie。但这次进入后台并没有用它。

实际路径要朴素得多:匿名的 Laravel Log Viewer 可以直接读 laravel.log,日志里留着发给管理员的明文密码重置链接。虽然 GET /reset-password/{token} 因为应用 bug 恒 500,但直接 POST /reset-password 能重设成功,用新密码正常登录即可。


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

最后于 5小时前 被mb_dcvvjyqc编辑 ,原因: 隐私泄露
收藏
点赞 ・0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回