大概半年前我接了个有点特别的活儿。朋友搬来一台旧服务器里面跑着一个快十年的小说站代码是杰奇1.7的解密版。他说得挺直白功能别大动能跑就行但得把安全方面的隐患清一遍。我一开始以为就是改改配置、升级下环境结果把源码铺开之后才发现这哪是修站这完全是给一位退休老同志做全面体检顺便复习了一遍老PHP CMS的完整演进史。这篇文章就当作我这轮拆解的记录。内容会围绕杰奇1.7的源码结构、请求调用链、数据库与缓存设计、典型安全漏洞和加固方案展开。如果你手头正好也有一套老CMS要维护或者你是想从老代码里学点架构思路的PHP开发者这篇文章应该能省你不少翻源码的时间。1. 解密版的来龙去脉这套源码为什么还在江湖上流传1.1 杰奇1.7是什么时代的产物先给不熟悉的朋友补个背景。杰奇CMS大概是2010年前后在国内小说站圈子里火起来的一套PHP内容管理系统1.7是它生命周期里最经典的版本之一。那时候个人站长做小说站是门显学一台虚拟主机、一套源码、一个域名再配上采集规则一个看起来有模有样的书站就能上线。那个年代的PHP CMS有个共同特征——它们不是为某一种业务设计的而是试图在一个统一框架里装下内容发布、用户管理、会员付费、搜索、采集等所有小说站可能用到的功能。杰奇1.7在这方面做得尤其极致。它的模块化程度在老系统里算高的文章、用户、支付、采集、广告、专题全部分开目录划分也相对清楚比很多同类系统要规整。但有个现实问题杰奇官方发布的1.7核心文件是用Zend Guard加密过的。加密的目的当然是防止商业源码被随意分发和二次修改但在当时的站长圈里这反而催生了另一种需求——有人想要一份能改的代码。解密版就是在这样的背景下出现的。1.2 解密版和官方原版的本质差异解密版和官方原版最核心的差别不在于功能而在于可修改性和风险边界两层。功能层面解密版就是把原来加密的PHP文件还原成明文代码。你能看到数据库操作、模板渲染、登录校验、采集逻辑的每一行真实写法想改模板结构、加自定义字段、修某个模块的Bug都不再受限于只能改模板和配置的官方设计边界。对很多老站长来说这是他们能继续维护这套系统的唯一方式。但风险也是双重的。第一层是加密壳被解开后原代码里一些隐藏逻辑会暴露出来有些逻辑写得并不好看甚至可以说相当粗暴第二层更关键——你拿到的这份解密版到底是不是纯粹解密的有没有被热心分享的人额外加过料这个后面第四章我会专门讲。我当时打开这套源码的第一感觉是杰奇1.7的解密版确实没有辜负它在圈子里的口碑——代码整体结构完整目录清晰虽然老但骨架是端正的。不过骨架端正不代表没有病真正的检查才刚开始。1.3 现在还在这套系统上折腾的人到底在折腾什么聊这个话题之前我想先澄清一个误区现在还接触杰奇1.7解密版的人大多不是想靠它做新站。新站用WordPress、用Halo、用苹果CMS甚至直接上SaaS建站哪个不比老古董省心还在折腾杰奇1.7的基本只有三类场景老站存量维护站还在跑数据量几十G用户习惯了这个前端不能轻易迁移。这类需求占比最高。数据迁移的跳板需要把老数据导到新系统但又没有现成迁移脚本只能先让老系统跑起来再写脚本慢慢导。学习早期PHP架构想了解2010年前后的PHP CMS是怎么做的路由怎么写、模板怎么渲染、数据库层怎么封装杰奇1.7是一份不错的活教材。所以下面这几章我既会从维护者的角度讲怎么让它继续安全跑也会从学习者的角度讲它的架构到底好在哪、坑在哪。不管你是哪种需求应该都能对号入座。2. 入口与路由从index.php到业务模块的调用链2.1 入口文件的前世今生老PHP系统几乎都有一个共同习惯入口文件承担了远超入口二字的职责。杰奇1.7的index.php一亮相就先把全局加载这件事做了个遍。我简化一下这套逻辑大概是这样的?php // index.php 入口结构示意非完整原文件 define(JIEQI_DIR, dirname(__FILE__)); define(JIEQI_VERSION, 1.7); // 1. 加载核心配置文件 require_once JIEQI_DIR . /configs/config.php; // 2. 注册自动加载 / 加载核心函数库 require_once JIEQI_DIR . /lib/jieqi.class.php; require_once JIEQI_DIR . /lib/database.php; // 3. 初始化数据库、缓存、session $db jieqiDatabase::getInstance(); $db-connect(DB_HOST, DB_USER, DB_PASS, DB_NAME); // 4. 解析请求参数确定模块和行为 $module isset($_REQUEST[module]) ? trim($_REQUEST[module]) : article; $action isset($_REQUEST[action]) ? trim($_REQUEST[action]) : list; // 5. 加载对应的模块文件 $module_file JIEQI_DIR . /modules/ . $module . / . $action . .php; if (file_exists($module_file)) { require_once $module_file; } else { die(Invalid module.); }现在的框架开发者看到这种写法可能会摇头——入口文件里连路由都一并做了耦合度拉满。但放在那个年代这恰恰是简单直观的代表一个请求进来加载什么文件、执行什么逻辑从入口文件一眼就能看出来出问题也好排查。这种写法的另一个现实意义是在虚拟主机时代绝大多数站长不具备配置伪静态和URL重写的能力所以杰奇1.7默认就支持?modulexxxactionyyy这种裸参数路由简单粗暴但也极其依赖对参数的过滤——这也是后文安全问题的伏笔之一。2.2 模块化路由的老三家写法杰奇1.7的模块划分沿用了早期CMS的老三家模式模块目录、模板目录、语言包目录三者一一对应。举个例子modules/ ├── article/ // 文章模块 │ ├── list.php // 列表页 │ ├── info.php // 详情页 │ ├── read.php // 阅读页 │ └── search.php // 搜索 ├── user/ // 用户模块 │ ├── login.php │ ├── register.php │ └── profile.php ├── pay/ // 支付模块 └── admin/ // 后台入口这种组织方式的优点在于功能边界清楚。你想改阅读页逻辑去modules/article/read.php就能找到你想调前端样式去templates/default/下找对应HTML模板想改后台admin独立一块不跟前台业务混在一起。从现代视角看这个结构虽然缺少统一路由配置、缺少中间件层但是按功能拆目录的思路并不过时。后来很多主流框架的Module化设计本质上也还是在做同样的事情——只不过包了一层更优雅的外壳。2.3 模板层是怎么和PHP逻辑拼在一起的杰奇1.7的模板用是Smarty但那个年代的用法比现在的模板引擎要原生得多。Smarty的assign往模板里丢数据模板里再通过{$变量}方式输出。可实际打开模板文件你会发现里面不止有模板语法还经常混着原生的PHP标签。比如这种写法在老系统里非常常见{if $is_login} a href/user/profile.php{$username}/a {else} a href/user/login.php登录/a {/if} !-- 偶尔还有这种 -- {php} echo 当前时间 . date(Y-m-d H:i:s); {/php}现在看这种做法确实不太优雅{php}标签更是被后来的Smarty版本直接移除但在当年模板能用就行是普遍心态。模板里直接写PHP页面上还能用{$var}输出变量对当时从纯HTML嵌套PHP转过来的站长来说已经算是一种进步了。我拆模板的时候发现一个值得注意的细节杰奇1.7的模板缓存是直接生成编译后的PHP文件存放在cache/templates目录下。这意味着模板更新后如果缓存没有自动清理前台展示的还是旧内容。这是老系统一个非常经典的灵异现象来源——改了半天模板刷新没变化最后发现是缓存目录权限问题编译文件没更新。3. 数据库与缓存老架构里最值得学习的两块3.1 数据库连接封装与SQL拼装方式杰奇1.7的数据库层封装在我的评价里是及格偏上。它没有用当时已经很成熟的PDO或者ADODB而是自己包了一层mysql_*函数。核心数据库类大概长这样class jieqiDatabase { public $conn; public $querynum 0; public function connect($host, $user, $pass, $name) { $this-conn mysql_connect($host, $user, $pass); if (!$this-conn) { die(数据库连接失败); } mysql_select_db($name, $this-conn); } public function query($sql) { $this-querynum; $result mysql_query($sql, $this-conn); return $result; } public function fetchArray($result) { return mysql_fetch_assoc($result); } }这层封装放在当时够用但有两个明显短板。第一是SQL语句几乎全在业务代码里手写拼接而且没做强制转义第二是没有统一的查询构造器导致同样一个获取多本书的逻辑在不同模块里可能写出多种风格完全不同的SQL。维护起来相当考验人的记忆力。关于SQL拼接的问题我先卖个关子第四章专门展开。3.2 那种一把梭的SQL写法有多可怕拆源码的过程中最让me揪心的就是书库查询这类核心逻辑。很多查询直接依赖外部参数却没有做类型校验。搜索功能更明显$keyword $_GET[keyword]; $sql SELECT * FROM jieqi_article WHERE title LIKE %$keyword% ORDER BY lastupdate DESC;典型的危险写法。$keyword没有转义、没有过滤、没有预处理直接进了SQL。如果在当年没有开启magic_quotes_gpc或者用了PHP 5.4以后的环境这个配置默认关闭这几乎就是裸奔的SQL注入点。这里我多说一句很多人以为SQL注入是老生常谈但真正要在老系统里排查一遍工作量远超想象。因为问题不只是某一个参数没过滤而是几乎所有外部参数都没统一过滤。你只能在入口层面做全局处理但全局处理又会碰到有些参数本来就要支持特殊字符的冲突。这种矛盾在老系统里被放得特别大。3.3 文件缓存和静态化设计其实很务实说到缓存杰奇1.7的做法反而让我有点意外——它并没有像很多老系统那样完全依赖MySQL硬扛而是实打实地做了文件缓存和静态化。小说站的流量模型比较特殊读多写少书籍详情和章节内容是热点但这些内容又不怎么变。所以杰奇默认就把书籍信息缓存到文件里甚至还支持把整本小说的阅读页生成静态HTML。我看了下它的缓存目录结构基本是按模块ID来组织文件的cache/ ├── article/ │ ├── 123.html // 文章详情静态页 │ ├── 124.html ├── list/ │ └── category_1.html └── templates/ // Smarty编译缓存这种设计有它的好处在虚拟主机那种连不上Redis、装不了Memcached的环境里文件缓存就是最高效的加速手段。直到今天你去一些还在运营的老牌小说站后台看它们的静态化逻辑里依然能看到杰奇这套思路的影子。不过问题也很现实缓存文件膨胀之后清理策略跟不上就会出现磁盘占用飙升、缓存命中率下降的问题。而且如果静态页生成逻辑和动态数据不同步比如最新章节没触发重新生成读者看到的就会是一本永远不更新的小说。这属于老系统里非常典型的慢性病。4. 安全之痛解密版源码里那些真实存在的坑4.1 后台认证与Session管理的薄弱点小说站后台是整站权限最高的地方但杰奇1.7的后台认证放到今天来看防护力度非常有限。后台登录的逻辑大概是校验用户名密码的MD5值写入Session。认证本身不复杂问题出在几个侧面第一后台地址是固定的默认就在/admin.php或者/modules/admin/下没有提供默认的路径修改机制——除非你自己改文件名。第二登录没有失败次数限制也不存在验证码至少1.7默认这样攻击者完全可以对着后台地址跑字典。第三Session的有效期管理很粗放服务端没有主动失效机制一旦SessionID泄露等于把后台钥匙交了出去。这里我不想把问题渲染得过于严重因为那个年代几乎所有的PHP CMS都是这个路数。但要论安全实践这第一条就必须改。4.2 SQL注入重灾区搜索、章节、参数SQL注入是我这次拆解的重点排查对象。我大概统计了一下最典型的注入点集中在三类参数上直接拼接进查询的数字型参数比如$_GET[id]、$_GET[cid]很多地方没有做intval()强制转换。搜索关键词没有转义就进入LIKE子句。排序字段和排序方式直接透传比如orderby参数拼进了ORDER BY这种情况即便你用预处理都拦不住因为表名、字段名没法参数化。举个例子阅读页的章节跳转$cid $_GET[cid]; $sql SELECT * FROM jieqi_chapter WHERE articleid $aid AND chapterid $cid;如果$cid没有强制转int那么$cid 1 UNION SELECT ...就能直接改写查询逻辑。更麻烦的是有些文件里的查询还涉及多表联合和子查询注入面比想象中大得多。我当时的处理原则是凡是进入SQL的数值参数一律intval()凡是字符串参数一律走转义函数凡是字段名、排序方式这类没法预处理的就用白名单映射。具体的修复方案第五章统一说。4.3 上传与XSS另一个容易被忽视的高危入口杰奇1.7的上传功能主要集中在用户头像、封面图片和附件管理这几个模块。那个年代的校验逻辑普遍是只判断扩展名、不校验文件内容。这意味着什么一个PHP文件只要把扩展名改成.jpg就能绕过扩展名校验。虽然上传后会重新命名但如果上传目录开启了对.php的执行权限并且Web服务器配置不当比如Nginx的cgi.fix_pathinfo没关那这个jpg里的PHP代码一样会被执行。这一整套组合拳打下来拿到webshell不算难事。XSS方面问题主要出在用户可控内容的输出上。用户名、评论内容、搜索关键词这些字段在渲染到页面前没有统一转义。尤其是在搜索结果页把关键词原样输出到HTML里经典的反射型XSS直接成立。对于小说站这种搜索功能高频使用的站点影响面不用我多说。4.4 解密版自带的后门风险这一段可能才是拆解密版源码时最需要警惕的部分。解密版不是官方直发的它经过了第三方的解密、打包、分发。这就引入了一个现代软件供应链里很核心的问题你拿到的二进制/源码和你以为的那份源码真的是同一个东西吗我当时对整套源码做了一遍地毯式扫描重点关注了几个特征eval()函数调用base64_decode()配合assert()或system()的组合编码混淆过的字符串隐藏在配置文件和模板缓存目录里的可疑PHP文件文件修改时间异常的可疑文件排查命令大概是这样的# 找出所有包含 eval 的文件 grep -rn eval( /path/to/jieqi --include*.php # 找出 base64_decode 和 assert 等危险函数的组合 grep -rn base64_decode /path/to/jieqi --include*.php # 找出最近7天内被修改过的PHP文件 find /path/to/jieqi -name *.php -mtime -7我确实在某个模板缓存目录里找到过一个看起来多出来的PHP文件文件名用了类似cache_1kp9x2.php这样的随机命名内容是加密混淆过的看不出业务含义。这种情况下最稳妥的做法不是尝试解密分析而是直接删除。因为你永远不知道它背后连的是什么也没必要去赌。这里我必须强调一句拿到任何来源不明的解密版源码第一件事永远不是上线部署而是先做安全审计。这不是对杰奇1.7的偏见而是对所有来路不明代码的一律原则。5. 老站安全加固不重写业务代码也能把洞堵上5.1 全局过滤与参数校验面对一整座年久失修的老代码最忌讳的想法是把所有文件打开逐个改。那样不仅工作量爆炸而且极易改出新的Bug。更现实的做法是在入口层做统一拦截同时针对高危模块的关键文件做点对点修复。我在入口文件顶部加了一个全局参数清洗的公共函数思路很简单function clean_input($data) { if (is_array($data)) { return array_map(clean_input, $data); } // 去除首尾空格和反斜杠HTML实体转义 $data trim($data); $data stripslashes($data); $data htmlspecialchars($data, ENT_QUOTES, UTF-8); return $data; } $_GET clean_input($_GET); $_POST clean_input($_POST); $_REQUEST array_merge($_GET, $_POST);这段代码解决的是显式XSS和部分注入的入口问题但要说明白htmlspecialchars处理的是输出场景对所有输入统一做HTML实体转义虽然粗暴却能挡住绝大多数反射型XSS。但全局清洗也有效率问题尤其对搜索功能——搜C的时候htmlspecialchars会把转成#43;导致搜索结果失真。所以我在搜索模块做了例外处理只对搜索关键词做一次trim和数据库层转义不做过度的HTML实体转换。像这种全局规则局部例外的模式在老系统加固里非常实用。它能在不大改业务逻辑的前提下把大部分浅层漏洞先堵上。5.2 用mysqli预处理替换mysql_*函数杰奇1.7的年代PHP还是5.2、5.3的天下mysql_*函数是标配。但在PHP 7时代这些函数早已被移除任何跑在新环境下的杰奇1.7都会直接报Call to undefined function mysql_connect()。所以这里有一个绕不开的硬性操作把数据库层从mysql_*迁移到mysqli。最基础的替换方案是这样的// 老代码 $result mysql_query($sql); // 新写法面向过程 $result mysqli_query($conn, $sql); // 更推荐的写法预处理 $stmt mysqli_prepare($conn, SELECT * FROM jieqi_article WHERE articleid ?); mysqli_stmt_bind_param($stmt, i, $articleid); mysqli_stmt_execute($stmt); $result mysqli_stmt_get_result($stmt);但杰奇1.7全站的查询调用量非常大不可能一个个去改。我的做法是在数据库类里做一层适配——新增一个query()方法的预处理版本不影响原调用方式只在底层确保SQL是经过预处理的。同时保留原有的query()方法但内部强制做一次SQL关键字过滤遇到拼装可疑的查询就立即终止执行并记录日志。注意这只是一个过渡方案。老系统只要还在跑数据库层的改造就必须一步步来不动业务代码的偷懒式修复只适合应急长期看还是要拆出来逐模块替换。5.3 上传目录与执行权限的运维级封锁代码层的修复做得再到位也抵不过Web服务器配置的松懈。上传目录那类问题最干净的解法是在Web服务器层直接切断PHP执行能力。Nginx的写法# 禁止 uploads 目录下所有 PHP 文件的执行 location ~ ^/uploads/.*\.(php|php5|phtml|pHp|Php)$ { deny all; }Apache的写法Directory /var/www/html/uploads php_flag engine off RemoveHandler .php .phtml .php5 /Directory配合这个配置我建议把所有上传文件统一改名为无扩展名或者随机扩展名存储前端输出时再通过读取真实MIME类型来告知浏览器文件类型。这样即使攻击者上传了一个内容为PHP的图片服务器也不会把它当PHP解释浏览器端也不会直接执行它。5.4 后门排查与文件完整性检查排查后门这件事我在第四章已经提了几句这里说几个更实操的经验。首先用命令扫描一遍危险函数是第一步# 找 eval、assert、call_user_func 等高风险函数 grep -rn -E (eval|assert|call_user_func|preg_replace.*\/e) /path/to/jieqi --include*.php # 找 base64_decode 与 execute 的组合 grep -rn base64_decode /path/to/jieqi --include*.php | grep -E (eval|assert|system|exec|shell_exec)其次重点关注那些看起来不该出现在这里的文件。模板缓存目录、配置目录、上传目录都是后门的高发区。文件名如果是随机字符串且不是正常模板命名规则就需要逐个打开看内容。第三招如果你有最初从官方渠道获取的原始加密版文件可以对比一下解压时间、文件列表和大小。解密版和原版在文件数量、大小上如果有异常差异多出来的文件基本就是后门候选。最后也是我最想强调的一点后门排查不是一次性的。上线之后建立一套文件完整性监控机制定时扫描核心目录的修改时间、文件哈希、可疑函数调用才是长期有效的做法。很多站长觉得查一次没发现问题就放心了但入侵者往往在你放心之后才进场。定期检查比什么都重要。我在处理这套杰奇1.7的时候顺手写了一个简单的文件哈希基线脚本每周自动跑一遍把当前目录下所有PHP文件的MD5和上次备份的基线做对比发现异常就发邮件提醒。这个方案成本极低但对老站来说安全感提升了不止一个量级。5.5 上线前的最后一步环境层面的底线设置代码修完、后门清完别急着上线。PHP运行环境的底线配置也得跟着补一遍。这套老代码跑在新版PHP上本身兼容性就有压力所以我在php.ini里做了一些针对性设置; 关闭错误显示避免路径和SQL语句泄露 display_errors Off log_errors On ; 关闭危险函数 disable_functions exec, system, shell_exec, passthru, popen, proc_open ; 限制上传大小和文件类型 upload_max_filesize 2M post_max_size 8M还有一个容易被忽略的细节老系统很多文件是直接通过include或require加载的如果allow_url_include开着远程文件包含漏洞就有了可乘之机。务必确保它是Offallow_url_include Off这些环境层面的配置配合代码层的参数清洗、数据库层的预处理替换、Web服务器层的目录权限封锁才能算是一套相对完整的老站加固方案。任何单一层面的修复都有被绕过可能。说实话拆完这一轮杰奇1.7之后我对老系统的心态反而平和了很多。它的代码确实老骨头缝里塞满了时代局限但那种一个文件干一件事、一个模块管一块业务的朴素结构放在今天看也依然有值得借鉴的地方。我甚至认为如果你能把老代码里的坑都摸清再看新框架的设计会多一种知其所以然的底气。至于安全问题我的态度更直接不要迷信任何来源不明的代码不要指望一套系统上线后就永远安全持续检查和即时响应才是维护老系统真正的长期主义。