简介ZZCMS 8.3 是一套广泛使用的内容管理系统其开源源码为代码审计与 PHP 架构学习提供了极佳的实战样本既适合网站开发者、安全测试人员也适合想深入理解 CMS 底层机制以及具备一定 PHP 基础、期望从实例中掌握安全最佳实践的中高级开发者。压缩包约 2.8MB包内文件总数与类型明细未提供下载解压后可直接浏览源码目录结构。当前已有 786 人浏览学习说明这一版本在实际教学与源码研究中有一定热度。资源重点围绕三大维度展开一是安全审计可结合源码分析 SQL 注入、XSS、CSRF 等漏洞成因学习输入验证、数据过滤与权限控制方法二是架构学习通过 MVC 模式、路由机制、模板引擎与插件钩子理解可扩展的 PHP 工程结构三是建站实践覆盖内容发布、分类管理、用户注册登录与响应式布局等真实需求帮助读者将代码审计能力与建站落地结合起来。研读源码不仅能提升编码规范与安全防护意识也能为独立开发 CMS 或二次开发积累可复用的设计与排错思路。1. 拿到这套老 PHP 建站系统先别急着删它把代码审计的经典案例全留下来了某做外包的开发者把一套跑了好几年的老站源码丢给我说要加新功能又担心安全问题。我解压之后第一反应是代码原始得不像话没有框架、没有 Composer、没有 autoloadindex.php 开头就是一连串 require接着就是拼 SQL。但顺着数据流走了一遍之后我反而觉得这套 zzcms 8.3 是练代码审计和二次开发的完整标本——前台展示、后台管理、会员系统、上传功能一应俱全而且漏洞不是藏起来的是直接摆在代码里等你去发现的。对刚入门代码审计的人来说它比新框架下的安全问题直观得多对要接手老网站开发的团队来说这种扁平结构也更容易做局部改造。2. 架构与目录结构三个目录锁定了审计重点2.1 目录结构按功能分目录的老式组织方式这类老式 PHP 建站系统解压后目录是扁平的根目录下直接按功能放文件夹没有现代框架的强制分层所以一眼能看完。我一般会先扫这几个目录目录典型职责审计时的关注点/admin后台管理功能页管理员权限校验是否完整/inc公共函数、配置、数据库连接全局过滤、敏感函数实现/template前台模板文件模板变量输出是否转义/upload用户上传文件保存位置是否允许执行脚本/install安装脚本初始化逻辑与默认账号/module 或 /api业务模块或接口参数校验完整度这种组织方式的优点是定位文件快出问题的页面在哪一层直接进对应目录搜。缺点是缺少统一的服务层同一个校验逻辑可能复制到了十几个页面里。审计时要格外注意同功能的多个副本有的页面做了权限判断有的页面漏掉了。我拿到代码会先用编辑器全目录搜索 mysql_query、$_GET、$_POST 这些关键词把入口点列出来再逐个追踪参数是否经过过滤。这个动作比先读架构文档有效得多因为老项目的文档大概率已经和代码脱节了。2.2 入口文件与全局请求链路入口文件决定了全站请求的走向。典型链路是index.php 引入配置、连接数据库、加载公共函数然后从 $_GET 或 $_POST 拿参数直接拼 SQL 查询最后用 include 加载模板输出。index.php 的简化版本?php // 第一步载入配置文件拿到数据库账号、路径常量等 require_once inc/config.php; // 第二步建立数据库连接 require_once inc/conn.php; // 第三步载入公共函数库比如过滤、分页、模板渲染 require_once inc/function.php; // 第四步根据 action 参数决定加载哪个业务模块 $action isset($_GET[action]) ? $_GET[action] : index; // 拼接路径后直接包含文件 include module/ . $action . .php; ?这段代码的关键点在于最后两行。$action 直接取自 $_GET没有做白名单限制include 的文件路径由用户输入拼接而成。如果传入的 action 是../../../../etc/passwd%00这种值配合 PHP 5 时代对空字节和路径穿越的宽容态度就存在本地文件包含风险。参数说明action字符串类型控制加载哪个模块文件影响面最大必须做白名单映射不能用用户输入直接拼路径。config.php存放数据库连接信息与站点绝对路径审计时要确认里面是否禁止了调试输出。function.php全站函数入口稍后的过滤函数就在这个文件里定义。这类入口文件是整个审计的第一站。我把请求链路摸清楚后会沿着最后一步的 include 继续追踪看看被加载的模块文件里还有没有二次包含、参数拼接。很多老系统的漏洞不在入口而在入口后面的业务文件里。2.3 数据库连接封装mysql_* 时代的遗留写法数据库连接代码集中在 inc/conn.php 里典型实现?php // 数据库连接配置 $dbhost 127.0.0.1; $dbuser root; $dbpass 123456; $dbname zzcms; // 建立连接注意这里是 mysql_connect不是 mysqli 或 PDO $conn mysql_connect($dbhost, $dbuser, $dbpass) or die(数据库连接失败); mysql_select_db($dbname, $conn); // 设置操作编码 mysql_query(SET NAMES utf8); ?这个代码里有三个值得注意的问题也是审计时会重点看的方向。第一连接账号直接写在 PHP 文件里如果源码泄露数据库就完全暴露了。生产环境应该把配置放到 Web 根目录以外或者至少做一层环境变量读取。第二mysql_* 函数族在 PHP 7.0 里已经被移除想在更高版本运行必须要做兼容层。第三全局 $conn 变量在业务代码里被直接使用如果某个页面忘记引入 conn.php 又使用了 $conn会报未定义变量这类问题在错误日志里会大量出现容易干扰审计判断。数据查询的写法就更直接了几乎全站都是字符串拼接?php // 文章列表查询 $catid $_GET[catid]; $sql SELECT id, title FROM article WHERE catid $catid ORDER BY addtime DESC; $result mysql_query($sql); while ($row mysql_fetch_array($result)) { echo $row[title]; } ?$catid 没做任何处理就进入 SQL 语句数字型参数在这种情况下不需要引号包裹注入难度很低。而且这条查询的结果直接循环输出到页面如果 title 字段包含自定义内容又叠加了存储型 XSS 的可能。一条小功能代码把注入和 XSS 两个问题全占了这在老建站系统里很常见。到这里架构层面的判断已经够了扁平目录、无框架、mysql_* 直连、SQL 全拼接。下一步进入具体模块看攻击是怎么一步步构成的。3. 核心模块拆解一条 POST 请求如何变成一次注入3.1 全局过滤函数的假防护inc/function.php 里定义了一个安全过滤函数大致的处理逻辑?php // 所谓的全局安全过滤函数 function safe_replace($string) { // 去掉百分号防止 LIKE 注入 $string str_replace(%, , $string); // 去掉 SQL 注释符 $string str_replace(--, , $string); // 去掉块注释 $string str_replace(/*, , $string); $string str_replace(*/, , $string); // 去掉连续空格 $string preg_replace(/\s/, , $string); return $string; } ?这个函数看着做了不少防护但只能算安慰性过滤。它拦截了注释符号和空格却完全没有处理引号转义、union 关键字、函数调用这些真正危险的部分。攻击者在数字型参数后直接拼union select不需要注释符空格被压缩成单空格也不影响 SQL 语法等于过滤了个寂寞。再看参数说明%在 LIKE 查询里是通配符过滤掉它的思路对但只处理了这一个场景。--和/* */SQL 注释去掉能阻止一部分老式注入但联合查询和报错注入完全不依赖注释。\s压缩空格目的是防关键字拆分但 MySQL 支持 tab、换行作为分隔符而且内联注释/*!50000union*/也能绕过空格过滤。这种黑名单思路的弊端就在这里永远列不完所有攻击形态。审计时看到这类函数不能默认全站安全了要确认哪些业务代码真正调用了它又有哪些入口绕过了它。我经常见到的情况是过滤函数写了但某几个页面为了省事直接用原始 $_GET导致全局防护在局部失效。所以审计不能只读公共文件得跟着参数走一遍业务文件。3.2 核心漏洞链路数字型参数注入分析在文章详情页这类模块中典型的注入点代码如下?php // article.php 文章详情 $id isset($_GET[id]) ? $_GET[id] : 0; // 直接把 id 拼进 SQL没有 intval 也没有引号包裹 $sql SELECT * FROM article WHERE id $id; $result mysql_query($sql); if (!$result) { die(查询失败: . mysql_error()); } $row mysql_fetch_array($result); // 输出标题 echo h($row[title]); ?这里有两个致命点第一个是 $id 原样进入 SQL数字型参数连引号都不用闭合直接空格接 union 就可以继续后面的查询。第二个是 mysql_query 执行出错后把 mysql_error() 输出了这让攻击者能直接在页面看到 SQL 语句相关错误信息等于把调试信息送给了对方。攻击者访问的 URL 大概长这样/article.php?id-1 union select 1,2,3,4,5,6,7,8执行后的 SQL 变成SELECT * FROM article WHERE id -1 union select 1,2,3,4,5,6,7,8前面的 id-1 查不到数据union 后面构造的结果集就自然显示在页面上了。接下来只需要逐步替换数字位置把数据库名、表名、管理员密码 hash 一层层查出来。整个过程就是标准的联合查询注入流程没有任何阻碍。那么修复或加固时该把注意力放在哪给一张参考表参数位置问题类型优先处理方式id、catid 等数字参数数字型注入用 intval 强制转换杜绝字符串拼接keyword 搜索参数字符型注入绑定参数配合 LIKE 转义通配符order、sort 排序参数字段名注入改为白名单映射不接受用户直接传字段名page、pagesize 分页参数边界问题统一 intval 后再参与计算这四种是建站系统里最常出现的参数类型审计时照着表逐个模块排查效率比漫无目的地看代码高得多。3.3 文件上传扩展名黑名单与存储路径的双重缺陷上传功能位于 /upload 相关模块处理逻辑如下?php // 上传处理 $file $_FILES[file]; $temp $file[tmp_name]; $origin_name $file[name]; // 取最后一次出现的点后面的字符串作为扩展名 $ext strtolower(substr(strrchr($origin_name, .), 1)); // 扩展名黑名单只允许图片 if (!in_array($ext, array(jpg, gif, png))) { exit(只允许上传图片文件); } // 保存路径使用原始文件名 $save_path upload/ . date(Ymd) . / . $origin_name; move_uploaded_file($temp, $save_path); // 输出访问路径 echo $save_path; ?这段代码的绕过思路非常直观主要漏洞集中在两处。第一处扩展名校验存在缺陷。把文件名改成shell.php.jpg时strrchr 取到的是最后一个点后面的 jpg黑名单通过文件成功保存。如果目标服务器老版本 Apache 启用了多后缀解析shell.php.jpg会被当作 PHP 脚本执行。即便服务器是 Nginx只要配置里有 cgi.fix_pathinfo 相关行为也可能解析到前面的 php 部分。换句话说黑名单校验根本没挡住真正该挡的 php 后缀。第二处存储路径直接拼接原始文件名。攻击者可以把文件名改成../../shell.php传入move_uploaded_file 按路径移动时就可能被写到 upload 目录之外的位置。如果 Web 目录下有可写权限的其他目录配合前面的扩展名绕过等于直接给了一个可执行脚本入口。参数说明tmp_namePHP 生成的在服务器端的临时文件move_uploaded_file 会先检查它是否通过 POST 上传这也是唯一正向的校验。name浏览器上报的原始文件名完全不可信必须用来做白名单和后缀重写不能参与路径拼接。date(Ymd)按日期分子目录思路正常但目录要设置成禁止执行 PHP或者干脆把上传目录移出 Web 根目录。3.4 后台登录与越权权限判断分散导致的控制缺失后台模块是审计的另一片重点区域。老系统的登录验证一般靠 session 里的一个标志位比如?php // admin/login_check.php session_start(); if ($_SESSION[admin] ! true) { header(Location: login.php); exit; } ?问题在于这个判断是分散到每个后台页面自己 include 的。有的页面写上了有的页面因为是从对外接口复制改造的漏掉了 login_check。访问后台某个独立功能页时如果它没有校验 session就可以直接操作数据。越权测试时我一般会开多个浏览器一个登录管理员一个不登录直接访问后台各个页面的 URL看哪些能返回数据。这种分散校验带来的越权漏洞在老建站系统里几乎一测一个准。合理的做法是把权限判断收敛到一个入口文件所有后台业务页面必须统一 include而不是让每个页面各写各的。4. 本地运行从 PHP 版本选择到后台登录的完整链路4.1 环境选型为什么建议 PHP 5.6开始运行之前要先明白这套代码的运行条件。mysql_* 函数在 PHP 7.0 已经被移除所以本地环境选 PHP 5.6 是最省事的路线不需要改代码。集成环境面板通常支持多版本 PHP 切换装一个 5.6 专门跑老项目其他新项目用高版本互不冲突。推荐组合组件版本建议理由PHP5.6.x支持 mysql_*代码零改动MySQL5.6 或 5.7兼容老建表语句和编码设定Apache2.4对 .htaccess 支持好老系统配置依赖它如果你非要让这套代码跑在 PHP 7 以上也不是不行但要先写一个 mysql_* 到 mysqli 的兼容函数层而且代码里大量直接使用 mysql_query 的地方需要逐个替换。我试过一次改到一半就发现有些业务逻辑里传入了连接参数兼容层越改越长最后还是切回 PHP 5.6 了。本地测试阶段没必要在环境兼容上花太多时间。4.2 安装步骤建库、改配置、进后台假设集成环境的站点根目录是 D:/www把压缩包解压到 D:/www/zzcms接下来按顺序操作。创建数据库mysql -uroot -p -e CREATE DATABASE zzcms DEFAULT CHARACTER SET utf8;在数据库管理工具里操作也可以重点是字符集选 utf8避免老系统数据导入后出现乱码。导入初始数据。通常安装目录下会有 install.sql 或 zzcms.sql 文件执行导入mysql -uroot -p zzcms D:/www/zzcms/install.sql修改数据库配置。编辑 inc/config.php把数据库名、账号、密码改成自己的。?php // 本地环境配置 $dbhost 127.0.0.1; $dbuser root; $dbpass root; $dbname zzcms; // 站点路径一般保持相对路径即可 $site_path ; ?访问前台与后台。前台入口是http://localhost/zzcms/后台入口一般是http://localhost/zzcms/admin/。老系统的默认管理员账号常见组合是 admin / admin888登录后第一件事就是修改默认密码。这个过程走到位了系统就能跑起来。4.3 高频报错排查运行过程中最容易遇到三类问题直接对应三个现象。第一整站白屏或 500。把 PHP 版本切成 7.x 后几乎必现原因就是 mysql_* 被移除页面在连接数据库那一步就 fatal error。解决切换回 PHP 5.6或者在入口处引入兼容层。第二页面能打开但全是乱码。一般是数据库字符集和页面声明不一致。检查 inc/config.php 里 SET NAMES 的语句再确认数据库建库时字符集是 utf8两边统一后再刷新。第三后台登录提示验证码或 session 异常。老系统经常把验证码写入 session但 php.ini 里的 session.save_path 不可写导致验证码永远对不上。解决方式是检查 session 保存目录的写权限或者在集成环境中把 session.save_path 改成可写路径。按这个顺序跑完系统基本就能正常前台上传、后台发布内容了。接下来真正费时间的是动手改代码的部分。5. 常见问题与排查代码审计时最容易误判的四类情况5.1 把 HTML 转义当成 SQL 注入防护现象在某个页面看到输出前调用了 htmlentities($content)就判断这个位置没有注入问题。原因htmlentities 处理的是输出阶段的转义目的是防 XSS跟 SQL 语句里参数的安全性完全是两回事。SQL 注入发生在数据进入数据库查询的那一刻而 HTML 转义发生在数据渲染到页面的那一刻时间节点不同威胁模型也不同。解决审计注入点时只看数据从请求到 SQL 语句的完整路径确认参数是否有 intval、addslashes、参数化绑定不要因为输出转义就放松对查询语句的检查。反之检查 XSS 时也不要因为查询用了参数化绑定就认为输出层安全。5.2 忽略全局变量注册带来的覆盖风险现象测试时发现 URL 里加一个?isadmin1某个页面里没有定义过的 isadmin 变量就拥有了值甚至能让普通访问拥有管理员视角。原因这个现象需要两个条件共同作用。一是 PHP 配置开启了 register_globals二是业务代码直接依赖未显式初始化的变量。早期的 PHP 版本默认会把 GET、POST、Cookie 里的键名注册成全局变量也就是说?isadmin1直接等价于在代码里写$isadmin 1。业务代码里判断if ($isadmin) { ... }时这个变量可能根本没被初始化。解决在 PHP 5.6 环境中确认 register_globals 已关闭同时把代码中直接使用的变量统一改成从 $_GET、$_POST、$_SESSION 显式取值。审计时同样要留意那些从不赋值但直接被读的变量它们往往是这类外部注入点的入口。5.3 拿新 PHP 的语法标准倒推老代码现象用 PHP 8 的思维看老代码觉得空值、动态属性、弱类型到处都是误判为系统质量极差甚至怀疑代码无法运行。原因代码写于 PHP 5 时代当时的弱类型、动态变量、没有类型声明都是正常写法。老代码要看的不是是否符合新规范而是在它目标运行的版本里能否按预期工作。解决搭建与源码同时代的环境再去评估。先确认目标版本再判断某个写法在那时的行为和风险。比如 PHP 5 下函数参数没有标类型并不意味着所有参数都是漏洞要看具体数据流向。这个偏差是新手审计老项目最容易翻车的地方。5.4 只盯注入和上传忽略了越权与逻辑漏洞现象审计报告里全是 SQL 注入和文件上传但系统真正被突破的路径其实是普通会员接口里的越权查询。原因注入和上传是最容易用工具扫描出来的类型而越权、验证码复用、状态篡改这类逻辑漏洞需要手工跟流程才能发现。建站系统的业务链路本来就不复杂会员中心、订单状态、信息修改这些功能都值得一条条走后端接口验证。解决除了参数层面的漏洞还要做业务流程的穿越测试。比如普通用户修改个人资料时POST 请求里如果把 userid 改成其他人的接口是否校验当前登录用户与目标用户一致。这类问题在老系统里比注入更常见也更容易被忽略。这三类误判有个共同特征都是把审计焦点错放到了代码表面而不是数据流上。审计的核心只有一条线输入从哪里来过滤在哪一步做输出到哪里去中间有没有断点。6. 二次开发与安全加固三种低成本方案直接落地6.1 统一参数获取入口老系统各页面过滤逻辑不一致最容易出现漏网之鱼。我会在 inc/function.php 里加一个统一获取参数的函数?php function get_param($key, $type string, $default ) { $value isset($_REQUEST[$key]) ? $_REQUEST[$key] : $default; if ($type int) { return intval($value); // 数字型参数直接强制转换 } return addslashes($value); // 字符型参数做转义 }参数说明第一个参数是键名第二个指定类型int 返回强制转换结果string 返回加反斜杠转义后的值。新页面统一用 get_param 取值至少保证新增代码的起点一致。6.2 上传改成白名单加随机重命名把黑名单校验改成白名单加随机文件名彻底去掉原始文件名参与路径的机会?php $ext_map array(jpg jpg, jpeg jpg, gif gif, png png); $ext strtolower(pathinfo($file[name], PATHINFO_EXTENSION)); if (!isset($ext_map[$ext])) { exit(仅允许常见图片格式); } $new_name date(Ymd) . / . md5(uniqid()) . . . $ext_map[$ext]; move_uploaded_file($temp, $upload_dir . $new_name); ?原始文件名不再参与路径拼接攻击者没法通过文件名穿越目录。upload 目录还要禁止执行 PHP一般靠 Nginx location 或 Apache 配置就能挡掉这一步和代码同样重要。注意只改上传代码不关执行权限等于只补了一半。6.3 权限判断收敛到统一入口把散落在后台页面里的登录校验判断抽成 auth.php后台页面统一 require删掉各自写的 session 判断。这样新增后台功能时不可能再漏校验老功能也不会因为某次复制粘贴丢了权限控制。我最早审计这种老站时自己写了脚本把几十个后台页面逐个检查权限判断漏了十几个文件才回头做统一治理。从那以后我每次接老项目都先把入口链路和权限收敛梳理完再谈新增功能。这套改动成本很低但能挡住最常见的那批攻击面希望帮到你。本文还有配套的精品资源点击获取