严重 漏洞预警

只写不读:OpenCATS 导入模块的 RCE 与任意文件读取

OpenCATS 导入模块保留客户端文件名致认证后 RCE(CVE-2026-54454)

OpenCATS 是一套用 PHP 写的开源招聘管理系统(ATS),Import 模块负责把 CSV/TSV 批量导入候选人、职位、公司和联系人。CVE-2026-54454(GHSA-v9gx-5qhj-jf7c)出在这个模块里:一个有 Import 权限的账号,可以让服务器把上传的文件以自己指定的名字存进 Web 目录,然后请求它。CVSS 3.1 = 9.1,CWE-434,影响 0.9.7.4 及更早版本,0.10.0 修复。

公告的描述是准确的:"Most uploaded files were assigned a random temporary filename, but one code path preserved the original uploaded filename."但它把两件本该分开讲的事压在了一句里——为什么恰好是这一条路径保留了客户端的名字,以及这个名字在流程里还会被用第二次。

本文补三个公告没展开的点:

  • 那条else分支服务的入口在路由表里根本不存在。importUploadResume()把dataType钉成Resume再调onImport(),但handleRequest()的 13 个case里没有一个叫这个名字。这条链在两处断了:渲染ImportResumesBulk.tpl的showMassImport虽然有case,全仓却没有一个界面链接指向它(批量导入的入口都指向另一个动作massImport);模板里的表单action又至今仍指向那个不存在的路由。可它携带的信任决定不需要走界面——dataType从不与"可选集合"比对,伪造一个字段就能到达。
  • 同一个类里还有第二个 sink。第二步/第三步回传的fileName被直接拼进临时目录路径,中间没有任何校验——这条是任意文件读取,公告只在 "Details" 里用"可能留下文件"带过。
  • 作者当年写了一条注释和一份清单。注释说这份清单是为了"让用户不能注入别的文件 ID 去读不该读的文件",但全仓检索validImportFileIDs,只有一处赋值、零处读取。防护被写成了注释加一次append。

取证基于 GitHub 上0.9.7.4与v0.10.0两个标签的源码逐行比对、修复提交7f15174的元数据、以及 GHSA/NVD 记录;本文未起完整实例实跑,所有行号都标了版本,可按标注复核。

一、一次导入请求要经过哪几个岔口

modules/import/ImportUI.php的handleRequest()有 13 个case,经由路由能走进onImport()的只有两个:importUploadFile(上传 CSV,服务端把dataType强制改成Text File)与import/default这一支(带postback的 POST)。另外两个常被当成同类的动作其实都不进onImport():importSelectType主要用来渲染上传页(传typeOfImport=resume时转调massImport()),而massImport是一整套独立的四步简历导入向导——step 1 扫描上传目录(:1519),step 4 由getMassImportCandidates()直接生成候选人并入库(:1601),全程不碰onImport()。

// modules/import/ImportUI.php:117-127(v0.9.7.4)
            case 'import':
            default:
                if ($this->isPostBack())
                {
                    $this->onImport();
                }
                else
                {
                    $this->import();
                }
                break;

isPostBack()的判据是 POST 里有没有postback这个键(lib/UserInterface.php:209-215),而导入首页的表单恰好带了一个value="postback"的隐藏字段(Import.tpl:91)。所以凡是提交表单的请求都进onImport()。

onImport()是整条链路的唯一入口,它把自己分成两半:一半处理"刚上传的文件",一半处理"用户已经选好列、现在要落库"。分水岭是一个字段名:

// modules/import/ImportUI.php:409-443(v0.9.7.4)
    private function onImport()
    {
        if ($this->getUserAccessLevel('import.import') < ACCESS_LEVEL_EDIT)
        {
            CommonErrors::fatal(COMMONERROR_PERMISSION, $this, 'Invalid user level for action.');
        }

        if( ini_get('safe_mode') )
        {
            //don't do anything in safe mode
        }
        else
        {
            /* limit the execution time of import to 500 secs. */
            set_time_limit(500);
        }

        $this->setImportTypes();

        $dataType   = $this->getTrimmedInput('dataType', $_POST);
        $importInto = $this->getTrimmedInput('importInto', $_POST);

        if (empty($dataType))
        {
            $this->_template->assign('errorMessage', 'No data type was specified.');
            $this->importSelectType();
            return;
        }

        if (empty($importInto) && $dataType != 'Resume')
        {
            $this->_template->assign('errorMessage', 'No destination was specified.');
            $this->importSelectType();
            return;
        }

权限门槛是import.import≥ACCESS_LEVEL_EDIT,也就是constants.php:77那个值200的"编辑级"——不需要管理员,被授予导入权限的普通招聘顾问就够。紧接着的$dataType != 'Resume'是一个提前埋下的记号:Resume(简历)被豁免了"必须指定导入目标"的检查——importInto为空时,只有它跳过那条报错。而这个值在第二、三节还会再出现两次:一次决定文件名的来源(:538),一次是那段没有路由的代码亲手写下的(:400)。

再往下,isset($_POST['fileName'])决定走哪一半:

// modules/import/ImportUI.php:446-469(v0.9.7.4)
        if (isset($_POST['fileName']))
        {
            if ($_SESSION['CATS']->isDemo())
            {
                CommonErrors::fatal(COMMONERROR_PERMISSION, $this, 'Demo user can not import data.');
            }

            if (!eval(Hooks::get('IMPORT_ON_IMPORT_1'))) return;

            switch($dataType)
            {
                case 'Text File':
                    $this->onImportFieldsDelimited();
                    return;

                default:
                    $this->_template->assign(
                        'errorMessage',
                        'No 2nd parser has been included for the specified data type.'
                    );
                    $this->import();
                    return;
            }
        }

fileName本来应该由服务端在第一步生成、随渲染出的页面一起回传;但代码只是"看到有这个字段"就往下走,fileName的值在这之前没有经过任何检验。这是第四个岔口,也是第四节的主角。

image.png

图 1:两条能进onImport()的入口、一条只渲染上传页、一条没有路由却还挂着表单;onImport()之后分出的两条 sink 是 ① 写侧(名与扩展名都来自客户端)与 ② 读侧(路径由客户端拼)。红框是本次修复涉及的两条分支,灰框是不进onImport()的。

二、写侧:一个else把客户端的名字当成了自己的名字

进到"处理上传"那一半,第一件事是从$_FILES里取元数据:

// modules/import/ImportUI.php:484-490(v0.9.7.4)

        /* Get file metadata. */
        $originalFilename = $_FILES['file']['name'];
        $tempFilename     = $_FILES['file']['tmp_name'];
        $contentType      = $_FILES['file']['type'];
        $fileSize         = $_FILES['file']['size'];
        $fileUploadError  = $_FILES['file']['error'];

$_FILES['file']['name']完全来自 multipart 请求头里的filename="...",是客户端可以任意书写的一段字符串。它在内存里被保存为$originalFilename,直到下面这十几行:

// modules/import/ImportUI.php:534-567(v0.9.7.4)
        /* Make a blind attempt to recover from invalid permissions. */
        @chmod(CATS_TEMP_DIR, 0777);

        /* Make a random file name for the file. */
        if ($dataType != 'Resume')
        {
            $randomFile = FileUtility::makeRandomFilename($tempFilename) . '.tmp';
        }
        else
        {
            $randomFile = $originalFilename;
        }

        /* Build new path information for the file. */
        $newFileFullPath  = CATS_TEMP_DIR . '/' . $randomFile;

        if (!@copy($tempFilename, $newFileFullPath))
        {
            $errorMessage = sprintf(
                'Cannot copy temporary file from %s to %s.',
                $tempFilename,
                $newFileFullPath
            );
            $this->_template->assign('errorMessage', $errorMessage);
            $this->importSelectType();
            return;
        }

        /* Try to remove the temp file; if it fails it doesn't matter. */
        @unlink($tempFilename);

        /* Store the file ID as a valid file ID (so users can't inject other file ids to read
           files they shouldn't be reading. */
        $_SESSION['CATS']->validImportFileIDs[] = $randomFile;

注释写着"给文件起个随机名",但真正的决策是一个二分支。非Resume走随机名,Resume分支的赋值右边是$originalFilename——客户端的字符串原样变成了服务器上的文件名。

随机名长什么样也能算出来:

// lib/FileUtility.php:259-262(v0.9.7.4)
    public static function makeRandomFilename($padding = '')
    {
        return md5($padding . time() . mt_rand()) . mt_rand(0, 9);
    }

md5()32 位十六进制拼一位十进制数字,33 个字符,再追加.tmp。所以正常路径下的落盘名是d41d8cd98f00b204e9800998ecf8427e3.tmp这种形态:名字由服务端决定,扩展名也由服务端决定。Resume分支把这两件事一起交了出去——名字来自客户端,扩展名自然也来自客户端。

落盘位置是CATS_TEMP_DIR,config.php:87定义为./temp,即应用根目录下的temp/,也就是 Web 根目录里的一个可访问目录。

为什么恰好是这个目录

仓库里对"用户上传的东西落在哪里"是有安全设计的。0.9.7.4 里有两份.htaccess专门干这件事,attachments/.htaccess与upload/.htaccess——后者的后 8 行与前者的 8 行逐字相同,只是在开头多了IndexIgnore *和一行注释:

# attachments/.htaccess:1-8(v0.9.7.4)
AddHandler cgi-script .php .php2 .php3 .php4 .php5 .php6 .php7 .php8 .php9 .pl .py .js .jsp .asp .htm .html .$

Options -ExecCGI -Indexes

#grant access only if files with specific extensions are uploaded
  <FilesMatch "(?i)\.(bmp|csv|doc|docx|heic|html|jpeg|jpg|msg|odg|odt|pages|pdf|png|ppt|pptx|rtf|tiff|wpd|wps|xls|xlsx|xps)$">
    Require all granted
  </FilesMatch>

头两行是本意:把脚本后缀注册成 CGI 处理器,紧接着的Options -ExecCGI又把 CGI 执行能力关掉,合起来等于禁止这些目录执行脚本。下面那段<FilesMatch>想表达"只放行图片和文档类扩展名",但它里面只有Require all granted、没有Require all denied兜底——Apache 2.4 下未匹配的文件不受这一段约束、照默认放行,所以它并不构成白名单,真正起作用的只有AddHandler加-ExecCGI那两行。而temp/目录里只有一个 0 字节的empty占位文件,没有任何.htaccess;根目录的.htaccess只有IndexIgnore *和Options -Indexes,管的是目录列表,不管脚本执行。

于是默认部署(Web 根指向应用根、使用 mod_php 或 php-fpm 的默认 handler)下,temp/是一个会执行 PHP 的目录。三份上传目录里,唯一没有加固的那份,恰好是唯一一个文件名由客户端决定的那份。

image.png

图 2:同一个if的两个出口,以及三份上传目录在“

# 漏洞分析

已在FreeBuf发表 0 篇文章

本文为 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)

被以下专辑收录,发现更多精彩内容

+ 收入我的专辑

+ 加入我的收藏

相关推荐

  • 0 文章数
  • 0 关注者

文章目录

一、一次导入请求要经过哪几个岔口

二、写侧:一个else把客户端的名字当成了自己的名字

  • 为什么恰好是这个目录
  • 文件为什么还留得下来

三、这条else分支为什么还活着

四、读侧:fileName从来没被问过是不是我们刚存的那份

  • 中间那句注释

五、补丁:把"名字"的所有权收回服务端

  • 判据
  • 两处调用
  • 写侧的三处改动
  • 补丁没有做什么

六、影响面与自查

七、防御建议

八、小结


原文链接: 只写不读:OpenCATS 导入模块的 RCE 与任意文件读取