Xiuno BBS 审计之问题:插件无隔离沙箱机制
贰先生 5小时前

此文章为XIUNOX版本重构审计时发现问题,XIUNOX版本已优化修复此问题。分享出来方便后续想基于xiuno bbs4.0.4版本制作维护版本或插件模板等需求的开发者和站长参考。

现象

Xiuno BBS 4.0.4 的插件机制采用"源码合并编译"方式:在 plugin_compile_srcfile() 中通过正则匹配 // hook xxx 标记,将插件 hook/*.php 文件内容直接拼接进宿主源文件,然后写入 tmp/ 目录作为编译产物 include 执行。整个过程无 try/catch 包裹、无错误隔离、无插件依赖检查、无运行时降级,任意一个插件的语法错误、致命错误或未捕获异常都会让整页(甚至整站,因为 model.min.php 也走同一编译流程)直接白屏。

源码证据

1. plugin_compile_srcfile:源码字符串拼接,无任何隔离

文件:xiunobbs_4.0.4/model/plugin.func.php 第 282-304 行

function plugin_compile_srcfile($srcfile) {
    global $conf;
    // 判断是否开启插件
    if(!empty($conf['disabled_plugin'])) {
        $s = file_get_contents($srcfile);
        return $s;
    }
    // 如果有 overwrite,则用 overwrite 替换掉
    $srcfile = plugin_find_overwrite($srcfile);
    $s = file_get_contents($srcfile);
    // 最多支持 10 层
    for($i = 0; $i < 10; $i++) {
        if(strpos($s, '<!--{hook') !== FALSE || strpos($s, '// hook') !== FALSE) {
            $s = preg_replace('#<!--{hook\s+(.*?)}-->#', '// hook \\1', $s);
            $s = preg_replace_callback('#//\s*hook\s+(\S+)#is', 'plugin_compile_srcfile_callback', $s);
        } else {
            break;
        }
    }
    return $s;
}

2. plugin_compile_srcfile_callback:直接 file_get_contents 拼接,无校验

文件:xiunobbs_4.0.4/model/plugin.func.php 第 342-389 行

function plugin_compile_srcfile_callback($m) {
    static $hooks;
    if(empty($hooks)) {
        $hooks = array();
        $plugin_paths = plugin_paths_enabled();
        foreach($plugin_paths as $path=>$pconf) {
            $dir = file_name($path);
            $hookpaths = glob(APP_PATH."plugin/$dir/hook/*.*");
            if(is_array($hookpaths)) {
                foreach($hookpaths as $hookpath) {
                    $hookname = file_name($hookpath);
                    $rank = isset($pconf['hooks_rank']["$hookname"]) ? $pconf['hooks_rank']["$hookname"] : 0;
                    $hooks[$hookname][] = array('hookpath'=>$hookpath, 'rank'=>$rank);
                }
            }
        }
        // ...
    }
    $s = '';
    $hookname = $m[1];
    if(!empty($hooks[$hookname])) {
        $fileext = file_ext($hookname);
        foreach($hooks[$hookname] as $path) {
            $t = file_get_contents($path);              // 直接读取插件源码
            if($fileext == 'php' && preg_match('#^\s*<\?php\s+exit;#is', $t)) {
                $t = preg_replace('#^\s*<\?php\s*exit;(.*?)(?:\?>)?\s*$#is', '\\1', $t);
            }
            $s .= $t;                                    // 字符串拼接进宿主文件
        }
    }
    return $s;
}

3. 编译产物直接 include,无 try/catch、无错误捕获

文件:xiunobbs_4.0.4/model/plugin.func.php 第 15-37 行

function _include($srcfile) {
    global $conf;
    // 合并插件,存入 tmp_path
    $len = strlen(APP_PATH);
    $tmpfile = $conf['tmp_path'].substr(str_replace('/', '_', $srcfile), $len);
    if(!is_file($tmpfile) || DEBUG > 1) {
        $s = plugin_compile_srcfile($srcfile);
        // 支持 <template> <slot>
        $g_include_slot_kv = array();
        for($i = 0; $i < 10; $i++) {
            $s = preg_replace_callback('#<template\sinclude="(.*?)">(.*?)</template>#is', '_include_callback_1', $s);
            if(strpos($s, '<template') === FALSE) break;
        }
        file_put_contents_try($tmpfile, $s);
        $s = plugin_compile_srcfile($tmpfile);
        file_put_contents_try($tmpfile, $s);
    }
    return $tmpfile;
}

调用方(如 index.inc.php 第 59 行):

case 'user': 	include _include(APP_PATH.'route/user.php'); 	break;

include 编译后的 tmp 文件,无任何错误隔离层。

4. model.inc.php 同样走编译流程,model.min.php 全局合并放大故障半径

文件:xiunobbs_4.0.4/model.inc.php 第 41-62 行

if(DEBUG) {
    foreach ($include_model_files as $model_files) {
        include _include($model_files);
    }
} else {
    $model_min_file = $conf['tmp_path'].'model.min.php';
    $isfile = is_file($model_min_file);
    if(!$isfile) {
        $s = '';
        foreach($include_model_files as $model_files) {
            $t = file_get_contents(_include($model_files));
            $t = trim($t);
            $t = ltrim($t, '<?php');
            $t = rtrim($t, '?>');
            $s .= "<?php\r\n".$t."\r\n?>";
        }
        $r = file_put_contents($model_min_file, $s);
        unset($s);
    }
    include $model_min_file;
}

任何 model 钩子中的插件代码语法错误都会让 model.min.php 编译失败,全站业务瘫痪。

风险等级与结论

架构缺陷(严重)

危害:

  • 单个插件的 PHP 语法错误(如少一个分号、未闭合括号)会让编译产物整体语法错误,整页白屏
  • 单个插件运行时致命错误(如调用未定义函数、内存溢出)无 try/catch 兜底,错误直接抛给用户
  • model.min.php 全局合并放大故障半径,一个 model 钩子插件出错全站瘫痪
  • 无插件依赖检查(仅 plugin_dependencies 函数声明但 plugin_install 未调用),缺依赖插件加载即报错
  • 无插件熔断/降级机制,问题插件持续影响主站,只能手工删除 tmp/ 目录或禁用插件
  • 编译产物缓存于 tmp/,DEBUG=0 时不会自动重建,调试困难

修复建议:

  • 改为运行时事件分发器模式(如 Hook::trigger('user_login_post_start')),插件以闭包/类注册回调,单插件异常被 try/catch 捕获并记录日志后继续
  • 引入插件健康度检测:插件加载失败超过阈值自动禁用并通知管理员
  • 编译期增加 php -l 语法检查,避免语法错误写入 tmp
  •  _include 增加 fallback:编译失败时回退到无插件源码并记录告警
相关帖子
最新回复 (0)
全部楼主
返回