-
-
[原创] CVE-2025-54068 实战:打穿 Livewire 站点并落地 WebShell
-
发表于: 4小时前 147
-
目标:349K9s2c8@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 能重设成功,用新密码正常登录即可。
[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)