一、问题展开目录
Joe 再续前缘主题需要解决以下问题以实现纯本地运行:
授权验证弹窗:主题内置域名授权验证,未授权时 PHP 直接 echo 提示文字
数据库安装报错:主题重复激活时
ALTER TABLE ... ADD报Duplicate column name致命错误API 路由 404:主题
/joe/api/*接口全部返回Path not found错误点赞 / 收藏 / 关注按钮返回 HTML:AJAX 请求 API 但收到整页 HTML 而非 JSON
二、主题加密架构分析展开目录
2.1 识别加密文件展开目录
首先查看主题文件结构,发现 public/ 目录下三个文件体积异常大且内容为乱码:
ls -la public/ # common.php 59 KB # function.php 401 KB # api.php 341 KB
用 hexdump -C 查看文件头,发现都以正常的 <?php 开头,随后跟随大量 goto 标签和变量赋值,这是典型的 PHP goto-based obfuscator 特征。核心数据存储在长字符串变量中,运行时通过 VM 解释器执行。
2.2 确定 XOR 密钥展开目录
通过对每个文件的数据区域进行频率分析,逐字节 XOR 尝试不同密钥(0x00-0xFF),检查解码结果是否产生可读文本:
// 频率分析脚本
$file = file_get_contents('public/function.php');
for ($key = 0; $key < 256; $key++) {
$readable = 0;
for ($i = 5000; $i < 6000; $i++) {
$c = ord($file[$i]) ^ $key;
if ($c >= 32 && $c <= 126) $readable++;
}
if ($readable > 800) echo "key=0x" . dechex($key) . " readable=$readable\n";
}运行后输出:
2.3 XOR 解码方法
确定密钥后,使用以下方法解码指定偏移区间的字节:
$file = file_get_contents('public/function.php');
$key = 242; // 0xF2
for ($i = $start; $i < $end; $i++) {
$c = ord($file[$i]) ^ $key;
if ($c >= 32 && $c <= 126) echo chr($c);
else echo '.'; // 不可打印字符用 . 替代
}解码不会产生连续可读的 PHP 源码,而是产生夹杂 VM 操作码的片段。需要结合上下文辨认:
字符串常量:连续 ASCII 字符,如
hash_hmac,sha256,auth.ini等函数名 / 变量名:出现在 VM 调用指令附近
结构边界:通过搜索
function、return、if等关键字定位函数起止
2.4 加密体系概览展开目录
三个加密文件均采用 XOR 字节码 + goto 控制流 + 栈式虚拟机 架构:
加载链路:
functions.php → require public/common.php common.php → require public/function.php (所有 joe_xxx 函数在此定义) common.php → require public/api.php (JoeApi 类在此定义)
三、问题一:授权验证弹窗展开目录
3.1 症状展开目录
前台和后台页面均直接输出以下文字(非 JS 弹框,是 PHP echo):
欢迎您使用 Joe 再续前缘,请 赞助 后使用,联系方式:2136118039@qq.com
3.2 逆向分析过程展开目录
步骤 1:搜索错误文本来源展开目录
在主题所有文件中 grep 搜索 "赞助"、"auth.yihang" 等关键字 → 仅在文件头注释中找到,非功能代码。确认消息来自加密 VM 运行时生成。
步骤 2:在加密文件中定位 HTML 片段展开目录
对 function.php 的数据区进行滑动窗口 XOR 解码(key=0xF2),搜索 auth.yihang 子串:
$file = file_get_contents('public/function.php');
$key = 242;
$decoded = '';
for ($i = 0; $i < strlen($file); $i++) {
$decoded .= chr(ord($file[$i]) ^ $key);
}
$pos = strpos($decoded, 'auth.yihang');
echo "Found at offset: $pos\n"; // 输出: Found at offset: 60680...auth.yihang.info...赞助...2136118039@qq.com...
确认这就是弹窗消息的字符串数据。
步骤 3:追踪调用链展开目录
从偏移 60680 向前搜索,在偏移 60172 找到包含该字符串引用的函数边界。解码此区域识别出 joe_check_auth() 函数签名。
继续使用 grep -c "joe_check_auth" 对解码后的全文进行统计 → 发现 80+ 处调用,几乎每个主题函数入口都调用。直接注释不可行。
步骤 4:逆向核心验证函数展开目录
从 joe_check_auth() 追踪到 joe_is_auth()。在偏移 66458 处解码出完整的验证逻辑:
解码得到的关键字符串片段(偏移 66458-67500): ... HTTP_HOST ... auth.ini ... file_get_contents ... ... theme:joeAuthCode ... options ... ... hash_hmac ... sha256 ... base64_encode ... ... -BD2V6PfbmHnqjajvbb4awxjEJABup7Qn ... ... Y-m ... hash_equals ...
从这些片段重建出 joe_is_auth() 伪代码:
joe_is_auth($retry=true):
1. 若 HTTP_HOST 为空 → 返回 false
2. 读取 auth_code:
a. 先读 JOE_ROOT/public/auth.ini
b. 若空,查数据库 options 表 name='theme:joeAuthCode'
c. 若仍空,请求远程服务器获取
3. 计算本地哈希:
key = base64_encode(date('Y-m') + '-BD2V6PfbmHnqjajvbb4awxjEJABup7Qn')
hash = hash_hmac('sha256', domain, key)
4. hash_equals(hash, auth_code) → 通过/失败
5. 失败且 retry=true → 删本地记录,递归调用(retry=false)关键发现:HMAC 密钥是硬编码字符串 -BD2V6PfbmHnqjajvbb4awxjEJABup7Qn,且按月轮换(date('Y-m')),可在本地计算。
步骤 5:验证算法正确性展开目录
编写测试脚本生成 token 并比对:
$domain = 'localhost';
$key = base64_encode(date('Y-m') . '-BD2V6PfbmHnqjajvbb4awxjEJABup7Qn');
$hash = hash_hmac('sha256', $domain, $key);
echo $hash;
// 输出: c7a3e5f... (64位十六进制 HMAC-SHA256)将此值写入 public/auth.ini 后,主题不再输出授权提示。验证通过。
步骤 6:识别其他授权相关函数展开目录
在 joe_is_auth 前后区域继续解码:
joe_is_sdfkdifhb() 在 module/footer.php 中被调用,控制底部推广横幅显示。
3.3 修复方案展开目录
策略:在 common.php 加载前写入合法的 auth.ini,让 VM 内部验证直接通过。
修改 1:functions.php — 授权 Token 自动生成展开目录
在 require_once JOE_ROOT . 'public/common.php' 之前插入:
(function() {
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$domain = $host;
if (extension_loaded('intl') && function_exists('idn_to_ascii')) {
$ascii = idn_to_ascii($host);
if ($ascii !== false) $domain = $ascii;
}
$key = base64_encode(date('Y-m') . '-BD2V6PfbmHnqjajvbb4awxjEJABup7Qn');
$hash = hash_hmac('sha256', $domain, $key);
$authFile = JOE_ROOT . 'public/auth.ini';
if (!file_exists($authFile) || trim(file_get_contents($authFile)) !== $hash) {
file_put_contents($authFile, $hash);
}
})();原理:joe_is_auth() 优先读取 public/auth.ini。我们抢在 VM 加载之前写入正确哈希值,验证自然通过。Token 按月自动更新。
修改 2:functions.php — OB 缓冲安全网展开目录
用 ob_start / ob_get_clean 包裹 require,过滤初始化阶段残留的授权错误输出:
ob_start();
require_once JOE_ROOT . 'public/common.php';
$_joe_ob = ob_get_clean();
if (!empty($_joe_ob)) {
echo preg_replace('/[^<]*?Joe[^<]*?auth\.yihang\.info[^<]*?2136118039@qq\.com[^>]*/su', '', $_joe_ob);
}
unset($_joe_ob);修改 3:functions.php 中 joe_markdown_hide()展开目录
// joe_check_auth(); ← 注释掉此行
此处是明文 PHP 中唯一直接调用 joe_check_auth() 的位置。
修改 4:module/footer.php(第 73 行)展开目录
// 原始:if (!joe_is_sdfkdifhb()) echo '<span ...>本站同款主题模板</span>'; // 改为: if (false) echo '<span ...>本站同款主题模板</span>';
修改 5:assets/typecho/config/js/joe.config.js(约 186 行)展开目录
// 注释掉后台 theme-error API 轮询:
// {
// $.getJSON(`${Joe.BASE_API}theme-error`, (data) => {
// if (!data.message) return;
// layer.alert(data.message);
// });
// }四、问题二:数据库重复列错误展开目录
4.1 症状展开目录
SQLSTATE[42S21]: Column already exists: 1060 Duplicate column name 'views' Typecho\Db\Adapter\SQLException in Pdo.php:111
4.2 逆向分析过程展开目录
步骤 1:从堆栈追踪定位展开目录
堆栈显示错误发生在 function.php 内部 VM 执行的 Db::query() 调用中。
步骤 2:解码安装函数展开目录
对 function.php 偏移 152428-160509 区域 XOR 解码,识别出两个函数:
joe_install_sql()(偏移 152428)解码片段:
... theme:JoeInstall ... joe_install_sql ... joe_datebase_version_sql ... ... Db::query ... try ... catch ...
重建逻辑:读取 module/database/install/{adapter}.sql,替换表前缀后按 ; 分割,逐条返回。
joe_install()(偏移 160509)解码片段:
... theme:JoeInstall ... joe_install_sql ... joe_datebase_version_sql ... ... Db::query ... try ... catch ...
重建逻辑:获取安装 SQL,逐条执行。VM 内的 try-catch 存在缺陷,无法正确捕获 PDO 的 SQLException。
步骤 3:查看 SQL 文件展开目录
检查 module/database/install/mysql.sql,发现 4 条 ALTER TABLE ... ADD 无任何列存在性检查:
ALTER TABLE `prefix_contents` ADD `views` INT NOT NULL DEFAULT 0; ALTER TABLE `prefix_contents` ADD `agree` INT NOT NULL DEFAULT 0; ALTER TABLE `prefix_comments` ADD `agree` INT NOT NULL DEFAULT 0; ALTER TABLE `prefix_metas` ADD `image` VARCHAR(255) NULL DEFAULT NULL AFTER `description`;
MySQL 不支持 ADD COLUMN IF NOT EXISTS(仅 MariaDB 支持),且 VM 的 try-catch 无法捕获此异常。
4.3 修复方案展开目录
策略:从 SQL 文件中移除 ALTER TABLE,改用 PHP 原生 try-catch。
修改 1:module/database/install/mysql.sql展开目录
删除上述 4 行 ALTER TABLE,仅保留 CREATE TABLE IF NOT EXISTS 和 INSERT。
修改 2:functions.php — 安全列创建展开目录
在 require common.php 之后添加:
(function () {
try {
$db = \Typecho\Db::get();
$prefix = $db->getPrefix();
$adapterName = get_class($db->getAdapter());
if (stripos($adapterName, 'Mysql') === false && stripos($adapterName, 'pdo') === false) return;
$alterStatements = [
"ALTER TABLE `{$prefix}contents` ADD `views` INT NOT NULL DEFAULT 0",
"ALTER TABLE `{$prefix}contents` ADD `agree` INT NOT NULL DEFAULT 0",
"ALTER TABLE `{$prefix}comments` ADD `agree` INT NOT NULL DEFAULT 0",
"ALTER TABLE `{$prefix}metas` ADD `image` VARCHAR(255) NULL DEFAULT NULL AFTER `description`",
];
foreach ($alterStatements as $sql) {
try { $db->query($sql); }
catch (\Throwable $e) { /* 列已存在则忽略 */ }
}
} catch (\Throwable $e) { /* 数据库不可用则跳过 */ }
})();原理:PHP 原生 try-catch (\Throwable) 可靠捕获 PDO 的 SQLException,列已存在时静默忽略。
五、问题三:/joe/api/* 路由 404 错误展开目录
5.1 症状展开目录
访问主题 API 接口(如 /joe/api/motto)返回:
Path '/joe/api/motto' not found Typecho\Router\Exception in Router.php:103
前端所有 AJAX 功能(搜索框、点赞、评论相关弹窗等)全部失效。
5.2 分析过程展开目录
步骤 1:理解 Typecho 路由机制展开目录
Typecho 采用集中式路由表,存储在数据库 options 表 routingTable 字段中(序列化 PHP 数组)。请求流程:
index.php → Widget\Init::alloc() → 加载路由表 → Router::dispatch() → 遍历路由表逐条正则匹配 → Widget\Archive->execute() → 加载 functions.php → themeInit() → Widget\Archive->render() → 渲染模板
关键发现:functions.php 只在路由匹配成功后的 execute() 阶段加载。而 themeInit()(在加密 function.php 中定义)会加载 public/route.php,其中处理 API 请求。
这形成了 鸡与蛋 问题:route.php 中的 API 逻辑只有在路由已匹配时才执行,但 Typecho 默认路由表中没有任何路由能匹配 /joe/api/*。
步骤 2:查看默认路由表展开目录
通过 Typecho 安装文件 install.php 中的 install_get_default_routers() 函数,找到默认路由表(共 23 条路由)。关键路由如下:
结论:无任何默认路由能匹配 /joe/api/motto 这种路径格式。page 路由需要 .html 后缀;feedback 路由虽然正则能匹配但指向评论处理 Widget,会抛 404。
步骤 3:解码 route.php 中的路由注册展开目录
查看 public/route.php(未加密的明文文件),发现它在加载时会注册 user、create、goto、sitemap 等路由:
但这些路由是在 themeInit 内注册的,对 首次请求 有效(写入数据库后后续请求即可匹配)。而 /joe/api/* 路径没有被注册为 Typecho 路由。
route.php 使用 不同机制 处理 API:
但这段代码只有路由匹配后 themeInit() 执行时才能运行。路由不匹配 → 404 → themeInit 永远不会运行。
步骤 4:解码 joe_api_url () 函数展开目录
通过 XOR 解码 function.php 偏移 57138 区域,还原了 URL 生成函数:
重建逻辑:
关键发现:joe_api_url() 使用 \Typecho\Common::url() 做纯字符串拼接(rtrim($prefix,'/') . '/' . ltrim($path,'/')),完全不使用 Router::url() 反解析。这意味着注册路由不会影响出站 URL 生成。
5.3 修复方案(共 4 次迭代)展开目录
迭代 1:基础路由注册展开目录
修改:在 functions.php 中(require common.php 之后)添加自定义路由注册:
问题:部署后 Router::url('joe_api') 在参数缺失时将 URL 中的 [action] 占位符输出为字面 {action} → 前端出现 /joe/api/{action} 请求 → 死循环 404。
根因分析:Typecho 的 Router::url() 方法中:
当未传递 action 值时,生成 /joe/api/{action} 字面量。
迭代 2:使用 alphaslash 通配展开目录
修改:改用 [action:alphaslash:0] 类型,使参数为可选:
Router/Parser.php 将 [x:alphaslash:0] 转换为正则 ([_0-9a-zA-Z-/]*)(量词 * 匹配零次或多次)。
通过 Parser 测试验证:
新问题:友链申请页面发送 POST 到 /joe/api/,body 含 action=friend-apply。但路由参数也叫 action。Typecho 将路由捕获的参数注入 $archive->request,覆盖了 POST body 中的同名字段。
route.php 读取 $archive->request->action 时拿到的是路由参数(空字符串),而不是 POST 的 friend-apply → 返回 "未调用接口"。
迭代 3:重命名路由参数避免冲突展开目录
修改:将路由参数从 action 重命名为 _joe_path:
_joe_path 以下划线开头,不会与任何 POST 字段冲突。
通过测试验证 POST action=friend-apply 不再被路由参数覆盖。
新问题:部署后访问 API 仍报 404:
花括号 { } 不在 alphaslash 字符类 [_0-9a-zA-Z-/] 中,导致 Router::url() 生成的回退 URL /joe/api/{_joe_path} 无法匹配自身路由的正则。
同时,Widget\Archive::checkPermalink() 在 render() 阶段调用 Router::url('joe_api') 生成 "标准永久链接",得到 /joe/api/{_joe_path},与实际请求 URL 不匹配 → 301 重定向到 /joe/api/{_joe_path} → 该 URL 不匹配路由 → 404。
迭代 4:string:0 类型 + checkPermalink 禁用 + 路径解析加固展开目录
三重修复:
修改 1 — functions.php:路由参数类型改为 string:0
Parser 测试结果:
同时添加自动检测和替换旧版路由的逻辑:
修改 2 — public/route.php:禁用 checkPermalink
在 API 处理块开头添加:
checkPermalink() 在 render() 阶段运行(晚于 route.php 所在的 execute() 阶段),所以此设置已生效。彻底阻止 Router::url('joe_api') 生成 {_joe_path} 导致的 301 重定向。
修改 3 — public/route.php:路径解析加固
原始路径解析使用 explode('/', $path_info)[3],当路径含双斜杠(如 /joe/api//action)时,索引 3 为空字符串。改用前缀剥离 + ltrim:
测试结果:
修改 4 — public/route.php:exit 兜底防止落穿
原始代码中,JoeApi::$method() 返回 null/void/false 时,所有 if 条件均不满足,执行直接落穿回 execute(),继续查询文章并渲染首页 HTML。
关键发现:加密 VM 中的 API 方法分两种输出模式:
返回值模式:方法返回 array/string/true,由 route.php 的 if 分支调用
throwJson/throwContent(含exit)直接输出模式:方法内部直接
echo json_encode($result)+return(不调用throwJson,不执行exit)
使用直接输出模式的方法(如 action)返回 null/void,不能用 throwJson 错误覆盖其已输出的 JSON。正确做法是直接 exit:
六、问题四:点赞 / 收藏 / 关注返回 HTML 而非 JSON 展开目录
6.1 症状展开目录
点击文章页面的收藏 / 点赞按钮,发送 POST 到 https://site.com/joe/api/action,但响应是整页 HTML(首页文章列表),而非 JSON。
6.2 分析过程展开目录
步骤 1:追踪前端 JS 调用链展开目录
assets/js/main.js 第 2853-2882 行,点赞 / 收藏 / 关注按钮通过 action_ajax 函数发送 AJAX:
_win.ajax_url 在 module/js.php 第 39 行定义:
步骤 2:追踪 URL 生成展开目录
joe_api_url() 无参数时返回 /joe/api/(已含尾部 /)。然后 js.php 又追加了 / → ajax_url = /joe/api//(双斜杠)。
main.js 拼接:_win.ajax_url + 'action' = /joe/api//action。
步骤 3:双斜杠的影响展开目录
当路径为 /joe/api//action 时:
原始 route.php 的路径提取:empty($path_info_explode[3]) → true → 退回读取 $archive->request->action。
但 POST 数据只包含 {type, key, pid},没有 action 字段。因此 $route 为空,进入 else 分支调用 throwJson。
然而,即使服务端 Nginx 将 // 归一化为 /(使 pathInfo 变为 /joe/api/action),也会产生问题:$route = 'action',JoeApi::action() 方法被调用。该方法属于「直接输出模式」—— 内部通过 POST 的 type/key 字段分发子操作(如 collection、like 等),然后 echo json_encode($result) + return(返回 null/void,不调用 throwJson,也不执行 exit)。这导致 route.php 中所有 if 条件均不满足 → 执行落穿 → Widget\Archive::execute() 继续查询文章 → render() 渲染首页模板 → 前端收到 HTML 而非 JSON。
6.3 修复方案展开目录
修改 1:module/js.php — 规范化 ajax_url 尾部斜杠展开目录
joe_api_url() 的返回值在不同环境下不一致:本地可能返回 /joe/api/(含尾部 /),服务器上可能返回 /joe/api(无尾部 /)。使用 rtrim + 追加 / 统一规范化为 /joe/api/,确保拼接后为 /joe/api/action(正确),而不会出现 /joe/api//action(双斜杠)或 /joe/apimotto(缺斜杠)。
修改 2、3、4 展开目录
路径解析加固 + exit 兜底防落穿 + checkPermalink 禁用(见上文「问题三 迭代 4」,同一批修改)。
6.4 迭代 5:收藏按钮返回错误 JSON 展开目录
现象展开目录
修复双斜杠和路径提取后,点击「收藏」按钮,前端收到的 JSON 变为:
说明 JoeApi::action() 确实被调用了,但它返回了 null/void,触发了我们之前添加的 throwJson 错误兜底。
根因分析展开目录
通过对加密 VM 中 JoeApi::action() 的逆向分析,发现该方法使用的是「直接输出模式」:
而我们的 throwJson 错误兜底在 action() 返回后被触发,其内部调用 respond() → 新的 header('Content-Type: application/json') + echo + exit。由于 PHP 允许多次 echo,最终输出变成了:
前端 jQuery 解析 JSON 时取到的是后者(或解析失败),导致收藏功能无法正常工作。
修复展开目录
将 route.php 中的 throwJson 错误兜底改为简单的 exit;:
这样对两种 API 输出模式都安全:
返回值模式:在上方 if 分支中已调用
throwJson/throwContent(含exit),不会到达此处直接输出模式:方法已 echo 了 JSON,
exit终止脚本,保留已输出的内容
6.5 迭代 6:/joe/apimotto 路径拼接缺斜杠展开目录
现象展开目录
部署到服务器后报错:
motto(一言接口)等路由名直接拼在 /joe/api 后面,缺少中间的 /。
根因分析展开目录
迭代 4 中将 ajax_url 从 joe_api_url() . '/' 改为 joe_api_url(),假设该函数已返回含尾部 / 的 URL。
但 joe_api_url() 的返回值取决于 Common::url() 的拼接结果,在不同环境下表现不一致:
本地环境:返回
/joe/api/(含尾部/)生产服务器:返回
/joe/api(无尾部/)
main.js 中的拼接:_win.ajax_url + 'motto' = /joe/api + motto = /joe/apimotto。
修复展开目录
用 rtrim 规范化,确保无论 joe_api_url() 返回什么,最终都有且仅有一个尾部 /:
七、完整修改文件清单展开目录
functions.php 修改区域一览展开目录
八、技术备忘展开目录
Typecho Router 参数类型对照表展开目录
Router::url () 参数缺失行为展开目录
当调用 Router::url('routeName') 未提供参数值时,对每个路由参数生成 {参数名} 字面量:
这是一个设计特性,用于分页等场景的模板 URL。但对 API 路由会导致 checkPermalink 301 重定向到含花括号的 URL。解决方案:设置 $archive->parameter->checkPermalink = false。
Widget\Archive 执行流程展开目录
关键偏移量(function.php, XOR key 0xF2)展开目录
授权服务器展开目录
地址:
http://auth.yihang.info/server/typecho-joe/验证接口路径:
auth/domain/{domain}/version/{version}请求头:
Yihang-Typecho-Joe: trueHMAC 密钥:
-BD2V6PfbmHnqjajvbb4awxjEJABup7Qn(硬编码)Token 算法:
hash_hmac('sha256', $domain, base64_encode(date('Y-m') . $key))刷新周期:每月(
date('Y-m')变化时)
转载:https://blog.lhl.one/artical/1153.html

留言评论
暂无留言