实战代码审计:从一个逻辑缺陷到前台无授权 RCE 的奇妙之旅 声明本文记录的是一次授权/合规的源码审计过程。为保护厂商安全本文已对目标 CMS 名称、敏感目录及变量名进行打码或化名处理。本文仅供安全技术交流与学习请勿用于非法用途。在最近的一次日常代码审计中我看上了一款小众但功能完备的 PHP CMS 系统。本来只是抱着随便看看的心态没想到顺藤摸瓜竟然在这个系统的安装模块里发现了一处由于多重逻辑缺陷叠加最终导致前台无授权 RCE远程命令执行的高危漏洞。整个挖掘和利用的过程非常有趣尤其是最后通过代码执行顺序的“盲点”实现一击必杀的思路我觉得非常值得记录下来和大家分享。0x01 形同虚设的安装锁对于开源 CMS安装目录install/历来是兵家必争之地。通常系统在安装完成后会生成一个install.lock文件。如果再次访问安装页面程序检测到该文件存在就会终止运行。在这款 CMS 中开发者确实做了防护。如果我尝试访问前端安装引导页index.php系统会老老实实地提示“系统已安装请勿重复操作”。但是安全审计第一法则永远不要相信前端的拦截。我直接翻开了处理后端安装请求的核心文件installdb.php。令人窒息的操作出现了// installdb.php 源码片段经过抽象化处理$check $_POST[check];if ($check conn) {// 测试数据库连接} elseif ($check install_data) {// 写入数据库配置并导入数据$dbhost $_POST[dbhost];// ... 获取各种 POST 参数$pdo connDb($dbhost, ...); // 连接数据库// 【致命缺陷 1】从头到尾没有检查 install.lock// ... 写入配置文件代码 ...}在处理最核心的数据库连接和配置写入的 API 接口中开发者竟然完全忘记了加入物理锁文件install.lock的校验这意味着即使受害者的网站已经平稳运行了半年只要我直接向这个后端接口发送 POST 请求我就能强行调用它的安装逻辑。0x02 危险的配置写入既然可以越权调用安装接口那我能做什么呢继续往下看当$check install_data时程序会接收用户通过 POST 传来的数据库账号密码并将它们写入到本地的配置文件dbconfig.php中。我们来看它是怎么写的// install.common.php 中的写文件逻辑function set_php_arr($phpPath, $filename, $saveData) {// 【致命缺陷 2】无任何转义直接纯字符串拼接$str ?php\r\nreturn [\r\n;foreach ($saveData as $key $val) {$str . \t$key $val,\r\n;}$str . ];;file_put_contents($phpPath . $filename, $str);}看到这段代码审计人的 DNA 动了。这是经典的单引号闭合导致代码注入的场景。 开发者试图将数组写成 PHP 代码但只是简单地把变量用单引号包裹了起来既没有使用安全的var_export()也没有用addslashes()对传入的参数进行转义。只要我们在传入的数据库密码$dbpwd中自己闭合单引号就能将恶意的 PHP 代码注进去0x03 构造Payload思路有了准备构造请求。我需要自己在公网搭建一个恶意的 MySQL 数据库假设 IP 为attacker-ip并创建一个包含恶意代码作为密码的用户。我设计的密码Payload是safe_password, pwned_by_audit true, 这样当它被写入配置数组时就会变成password safe_password, pwned_by_audit true, ,完美的 PHP 语法没有任何报错。由于此文件属于全局配置文件只要网站被访问这串木马就会被执行。但在实操发包时遇到了两个小阻碍也是非常有意思的对抗点密码正则校验源码中对password参数做了极强的正则限制必须包含大小写、数字和特殊字符。应对策略很简单我的 Payload 是放在dbpwd数据库密码里的而表单密码password随便填个Admin123!绕过即可。HTTP 换行截断由于 Payload 比较复杂如果直接复制很容易带入不可见的换行符\n导致$_POST解析失败。解决办法是对 Payload 进行严格的 URL 编码并确保整个 HTTP body 都在单行内传输。0x04 被颠倒的代码执行顺序准备工作就绪。按照正常的安装流程我应该先发送一个建表请求install_struct在我的恶意数据库里建好表然后再发写入数据请求install_data。但是在我仔细阅读install_data的代码时发现了一个极具戏剧性的逻辑漏洞——代码执行顺序的错位。try {$pdo-query(USE $dbname);// 1. 先把传进来的恶意参数写死到本地 dbconfig.php$saveData[password] $dbpwd;set_php_arr($phpPath, $filename, $saveData);// 2. 然后再去读取 SQL 文件准备向数据库里插数据$sql_data file_get_contents(...);$pdo-exec($sql_data);// ...} catch (PDOException $e) {exit(false . $e-getMessage());}你看懂了吗 程序是先强行修改本地的 PHP 配置文件然后再去操作数据库插数据的这意味着什么这意味着作为攻击者我根本不需要提前在恶意数据库里建什么表结构我只要建一个空库确保USE $dbname这一句不报错就行了。 当我把恶意请求直接打过去时程序连上我的空库。程序把我的木马写进了受害者服务器的磁盘里木马已生效。程序尝试往空库里插数据发现找不到表抛出SQLSTATE[42S02]: Base table or view not found异常并崩溃。虽然后端报错崩溃了并返回了false但此时文件已经被篡改了。报错之日即是 RCE 成功之时。这种跳过前置步骤、利用执行顺序缺陷“一击必杀”的快感可以说是这次审计中最让我兴奋的一刻。0x05 总结与反思回顾这条完整的攻击链后端接口未鉴权 - 字符串拼接引发注入 - 代码执行顺序倒置导致直接 GetShell。这给了我们安全开发人员三个血淋淋的教训权限校验必须在底层 API 中做兜底。前端的路由跳转和重定向永远防不住直接的接口请求。写配置文件永远不要用字符串硬拼接。如果要保存 PHP 数组请使用var_export($data, true)如果可以优先使用 JSON json_encode/json_decode来保存系统配置彻底杜绝 PHP 代码注入的可能。敏感的“不可逆操作”如覆写核心文件必须放在事务逻辑的最后。应先验证环境如表结构是否完整全部验证通过后再执行磁盘写入。每一次源码审计都像是和开发者的跨时空对话。寻找逻辑的缝隙还原攻击的链路正是安全研究最纯粹的魅力所在。