简介:ZZCMS 8.3 是一套广泛使用的内容管理系统,其开源源码为代码审计与 PHP 架构学习提供了极佳的实战样本,既适合网站开发者、安全测试人员,也适合想深入理解 CMS 底层机制以及具备一定 PHP 基础、期望从实例中掌握安全最佳实践的中高级开发者。压缩包约 2.8MB,包内文件总数与类型明细未提供,下载解压后可直接浏览源码目录结构。当前已有 786 人浏览学习,说明这一版本在实际教学与源码研究中有一定热度。资源重点围绕三大维度展开:一是安全审计,可结合源码分析 SQL 注入、XSS、CSRF 等漏洞成因,学习输入验证、数据过滤与权限控制方法;二是架构学习,通过 MVC 模式、路由机制、模板引擎与插件钩子理解可扩展的 PHP 工程结构;三是建站实践,覆盖内容发布、分类管理、用户注册登录与响应式布局等真实需求,帮助读者将代码审计能力与建站落地结合起来。研读源码不仅能提升编码规范与安全防护意识,也能为独立开发 CMS 或二次开发积累可复用的设计与排错思路。
1. 拿到这套老 PHP 建站系统,先别急着删:它把代码审计的经典案例全留下来了
某做外包的开发者把一套跑了好几年的老站源码丢给我,说要加新功能,又担心安全问题。我解压之后第一反应是代码原始得不像话:没有框架、没有 Composer、没有 autoload,index.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_name:PHP 生成的在服务器端的临时文件,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 专门跑老项目,其他新项目用高版本,互不冲突。
推荐组合:
| 组件 | 版本建议 | 理由 |
|---|---|---|
| PHP | 5.6.x | 支持 mysql_*,代码零改动 |
| MySQL | 5.6 或 5.7 | 兼容老建表语句和编码设定 |
| Apache | 2.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 里加一个?isadmin=1,某个页面里没有定义过的 isadmin 变量就拥有了值,甚至能让普通访问拥有管理员视角。
原因:这个现象需要两个条件共同作用。一是 PHP 配置开启了 register_globals,二是业务代码直接依赖未显式初始化的变量。早期的 PHP 版本默认会把 GET、POST、Cookie 里的键名注册成全局变量,也就是说?isadmin=1直接等价于在代码里写$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 判断。这样新增后台功能时不可能再漏校验,老功能也不会因为某次复制粘贴丢了权限控制。
我最早审计这种老站时,自己写了脚本把几十个后台页面逐个检查权限判断,漏了十几个文件才回头做统一治理。从那以后,我每次接老项目都先把入口链路和权限收敛梳理完,再谈新增功能。这套改动成本很低,但能挡住最常见的那批攻击面,希望帮到你。
本文还有配套的精品资源,点击获取