KodExplorer漏洞打包

引入

逛github的时候偶然看到这个CMS,本来想测下有没有符号链接导致文件读取的洞,下下来看了下,找到几个水洞,没什么价值.

下午和学长聊起,才知道都被他交过了.🤢

SSRF

黑盒测的时候看到一个远程下载的功能点,感觉有洞.

找到代码,在app/controller/explorer.class.php中第1043行,

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
public function serverDownload() {
$uuid = 'download_'.$this->in['uuid'];
if ($this->in['type'] == 'percent') {//获取下载进度
if (isset($_SESSION[$uuid])){
$info = $_SESSION[$uuid];
$result = array(
'supportRange' => $info['supportRange'],
'uuid' => $this->in['uuid'],
'length' => (int)$info['length'],
'name' => $info['name'],
'size' => (int)@filesize(iconv_system($info['path'])),
'time' => mtime()
);
show_json($result);
}else{
show_json('uuid_not_set',false);
}
}else if($this->in['type'] == 'remove'){//取消下载;文件被删掉则自动停止
$theFile = str_replace('.downloading','',$_SESSION[$uuid]['path']);
del_file($theFile.'.downloading');
del_file($theFile.'.download.cfg');
unset($_SESSION[$uuid]);
show_json('remove_success',false);
}
//下载
$savePath = _DIR(rawurldecode($this->in['savePath']));
mk_dir($savePath);
if (!$savePath || !path_writeable($savePath)){
show_json(LNG('no_permission_write'),false);
}
$url = rawurldecode($this->in['url']);
if(isset($this->in['name'])){
$filename = rawurldecode($this->in['name']);
}else{
$header = url_header($url);
if (!$header){
show_json(LNG('download_error_exists'),false);
}
$filename = $header['name'];
}
$saveFile = $savePath.$filename;
if (!checkExt($saveFile)){//不允许的扩展名
$saveFile = $savePath.date('h:i:s').'.dat';
}
$saveFile = get_filename_auto(iconv_system($saveFile),'',$this->config['user']['fileRepeat']);
$saveFileTemp = $saveFile.'.downloading';
Hook::trigger("explorer.serverDownloadBefore",$saveFile);
session_start();
$_SESSION[$uuid] = array(
'supportRange' => $header['supportRange'],
'length'=> $header['length'],
'path' => $saveFileTemp,
'name' => get_path_this($saveFile)
);
session_write_close();
$result = Downloader::start($url,$saveFile);
session_start();unset($_SESSION[$uuid]);session_write_close();
if($result['code']){
$name = get_path_this(iconv_app($saveFile));
Hook::trigger("explorer.serverDownloadAfter",$saveFile);
show_json(LNG('download_success'),true,_DIR_OUT(iconv_app($saveFile)) );
}else{
show_json($result['data'],false);
}
}

这里有3个我们可控的东西,

  • 下载地址:url
  • 保存的文件夹:savePath
  • 保存的文件名:name

url完全可控,传入后进入Downloader::start($url,$saveFile);,跟进app/kod/Downloader.class.php第16行左右,

1
2
3
4
5
if(is_array($url)){
$fileHeader = $url;
}else{
$fileHeader = url_header($url);
}

如果url不是数组,进入url_header(),在app/function/web.function.php第479行左右,

1
2
3
4
function url_header($url){
$name = '';$length=0;
$header = get_headers_curl($url);//curl优先
......

进入get_headers_curl(),在第427行,

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
function get_headers_curl($url,$timeout=30,$depth=0,&$headers=array()){
if(!function_exists('curl_init')){
return false;
}
if ($depth >= 10) return false;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL,$url);
curl_setopt($ch, CURLOPT_HEADER,true);
curl_setopt($ch, CURLOPT_NOBODY,true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT,$timeout);
curl_setopt($ch, CURLOPT_REFERER,get_url_link($url));
curl_setopt($ch, CURLOPT_USERAGENT,'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.94 Safari/537.36');
$res = curl_exec($ch);
$res = explode("\r\n", $res);
$location = false;
foreach ($res as $line) {
list($key, $val) = explode(": ", $line, 2);
$the_key = trim($key);
if($the_key == 'Location' || $the_key == 'location'){
$the_key = 'Location';
$location = trim($val);
}
if( strlen($the_key) == 0 &&
strlen(trim($val)) == 0 ){
continue;
}
if( substr($the_key,0,4) == 'HTTP' &&
strlen(trim($val)) == 0 ){
$headers[] = $the_key;
continue;
}
if(!isset($headers[$the_key])){
$headers[$the_key] = trim($val);
}else{
if(is_string($headers[$the_key])){
$temp = $headers[$the_key];
$headers[$the_key] = array($temp);
}
$headers[$the_key][] = trim($val);
}
}
if($location !== false){
$depth++;
get_headers_curl($location,$timeout,$depth,$headers);
}
return count($headers)==0?false:$headers;
}

url直接进入了curl,所以SSRF产生了,验证一下,

任意文件读取

这里并不是一个bind ssrf,是能把请求的内容写入到文件的,所以可以用file协议来读取任意文件.

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
static function fileDownloadFopen($url, $fileName,$headerSize=0){
@ini_set('user_agent','Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.94 Safari/537.36');
$fileTemp = $fileName.'.downloading';
@set_time_limit(0);
@unlink($fileTemp);
if ($fp = @fopen ($url, "rb")){
if(!$downloadFp = @fopen($fileTemp, "wb")){
return array('code'=>false,'data'=>'open_downloading_error');
}
while(!feof($fp)){
if(!file_exists($fileTemp)){//删除目标文件;则终止下载
fclose($downloadFp);
return array('code'=>false,'data'=>'stoped');
}
//对于部分fp不结束的通过文件大小判断
clearstatcache();
if( $headerSize>0 &&
$headerSize==get_filesize(iconv_system($fileTemp))
){
break;
}
fwrite($downloadFp, fread($fp, 1024 * 8 ), 1024 * 8);
}
//下载完成,重命名临时文件到目标文件
fclose($downloadFp);
fclose($fp);
$filesize = get_filesize(iconv_system($fileTemp));
if($headerSize != 0 && $filesize != $headerSize){
return array('code'=>false,'data'=>'file size error');
}
if(!@rename($fileTemp,$fileName)){
usleep(round(rand(0,1000)*50));//0.01~10ms
@unlink($fileName);
$res = @rename($fileTemp,$fileName);
if(!$res){
return array('code'=>false,'data'=>'rename error![open]');
}
}
return array('code'=>true,'data'=>'success');
}else{
return array('code'=>false,'data'=>'url_open_error');
}
}

读到admin用户的密码,

getshell

app/controller/explorer.class.php第1085行左右,

1
2
3
if (!checkExt($saveFile)){//不允许的扩展名
$saveFile = $savePath.date('h:i:s').'.dat';
}

对保存的文件名做了后缀检查,

跟入app/function/helper.function.php,第4行左右,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function checkExt($file){
// $ext = get_path_ext($file);
// if($ext == 'php' || $ext == 'txt') return 0;
// return 1;
if (strstr($file,'<') || strstr($file,'>') || $file=='') {
return 0;
}
$notAllow = $GLOBALS['auth']['extNotAllow'];
$extArr = explode('|',$notAllow);
foreach ($extArr as $current) {
if ($current !== '' && stristr($file,'.'.$current)){//含有扩展名
return 0;
}
}
return 1;
}

找到$GLOBALS['auth']['extNotAllow']的定义,在app/controller/share.class.php中第384行,

1
$GLOBALS['auth']['extNotAllow'] = "php|asp|jsp|html|htm";

也就是说,文件后缀不能包含php,php345也不能用了,但是还有phtml没被过滤.

程序的其他新建文件的地方,也都是用的这个函数来check,所以我直接新建了一个phtml文件.

可能不是每个服务器的配置都会解析phtml,所以更通用的方法是创建一个.htaccess文件,

1
AddType application/x-httpd-php .dog

windows下任意文件删除

app/controller/explorer.class.php第600行左右,

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
public function pathDelete(){
$list = json_decode($this->in['dataArr'],true);
$userRecycle = iconv_system(USER_RECYCLE);
if (!is_dir($userRecycle)){
mk_dir($userRecycle);
}
$removeToRecycle = $this->config['user']['recycleOpen'];
if(!path_writeable($userRecycle) ||
isset($this->in['shiftDelete'])
){//回收站不可写则直接删除;传入直接删除参数
$removeToRecycle = '0';
}
$success=0;$error=0;
foreach ($list as $val) {
if(!$val['path'] || $val['path'] == '/'){
$error++;
continue;
}
$pathThis = _DIR($val['path']);
//不是自己目录的分享列表,不支持删除
if( $GLOBALS['kodPathType'] == KOD_USER_SHARE &&
$GLOBALS['kodPathId'] != $_SESSION['kodUser']['userID'] &&
substr_count(trim($val['path'],'/'),'/') <= 1){ //分享根项目
show_json(LNG('no_permission_write'),false);
}
if(!path_writeable($pathThis)){
$error++;
continue;
}
// 群组文件删除,移动到个人回收站。
// $GLOBALS['kodPathType'] == KOD_GROUP_SHARE ||
// $GLOBALS['kodPathType'] == KOD_GROUP_PATH ||
if( $removeToRecycle !="1" ||
$GLOBALS['kodPathType'] == KOD_USER_RECYCLE ){//回收站删除 or 共享删除等直接删除
Hook::trigger("explorer.pathRemoveBefore",$pathThis);
if ($val['type'] == 'folder') {
if(del_dir($pathThis)) $success ++;
else $error++;
}else{
if(del_file($pathThis)) $success++;
else $error++;
}
Hook::trigger("explorer.pathRemoveAfter",$pathThis);
}else{
$filename = $userRecycle.get_path_this($pathThis);
$filename = get_filename_auto($filename,date('_H-i-s'),'folder_rename');//已存在则追加时间
if (move_path($pathThis,$filename,'',$this->config['user']['fileRepeat'])) {
$success++;
}else{
$error++;
}
}
}
$state = $error==0?true:false;
$info = $success.' success,'.$error.' error';
if ($error==0) {
$info = LNG('remove_success');
}
show_json($info,$state);
}

主要是这句,

1
$pathThis = _DIR($val['path']);

path参数传入后经过了_DIR函数处理,但是这个函数所在的源码是加密的,看不到源码.黑盒测了一下,在windows下可以删除任意文件,而在Linux下,传入的多个../会过滤成一个.

在windows下可以删除install.lock来重置admin的密码,

前台任意文件读取

前面几个都需要登陆,在app/controller/user.class.php第29行定义了两个不需要验证权限的控制器,

1
$this->notCheckST = array('share','debug');

app/controller/share.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
class share extends Controller{
private $sql;
private $shareInfo;
private $sharePath;
private $path;
function __construct(){
parent::__construct();
$this->tpl = TEMPLATE.'share/';
$auth = systemRole::getInfo(1);//经过role检测
$arrNotCheck = array('commonJs');
if(substr($this->in['fileUrl'],0,4) == 'http'){
$arrNotCheck[] = 'fileGet';
}
if (!in_array(ACT,$arrNotCheck)){
$this->initShare();
$this->checkShare();
$this->assign('canDownload',$this->shareInfo['notDownload']=='1'?0:1);
}
//需要检查下载权限的Action
$arrCheckDownload = array('fileDownload','zipDownload');//'fileProxy','fileGet'
if (in_array(ACT,$arrCheckDownload)){
if ($this->shareInfo['notDownload']=='1') {
show_json(LNG('share_not_download_tips'),false);
}
}
}

构造函数传入了一个fileUrl参数,如果是http开头的,就不对fileGet做权限验证,找到fileGet方法,在492行,

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
public function fileGet(){
if(isset($this->in['fileUrl'])){
$displayName = $this->in['name'];
$filepath = $this->in['fileUrl'];
}else{
$displayName = _DIR_CLEAR(rawurldecode($this->in['filename']));
$filepath= $this->sharePath.iconv_system($displayName);
if (!file_exists($filepath)){
show_json(LNG('not_exists'),false);
}
if (!path_readable($filepath)){
show_json(LNG('no_permission_read'),false);
}
if (filesize($filepath) >= 1024*1024*20){
show_json(LNG('edit_too_big'),false);
}
}
$fileContents=file_get_contents($filepath);//文件内容
$charset=get_charset($fileContents);
if ($charset!='' &&
$charset!='utf-8' &&
function_exists("mb_convert_encoding")
){
$fileContents=@mb_convert_encoding($fileContents,'utf-8',$charset);
}
$data = array(
'ext' => get_path_ext($displayName),
'name' => iconv_app(get_path_this($displayName)),
'filename' => $displayName,
'charset' => $charset,
'base64' => false,
'content' => $fileContents
);
if(!json_encode(array("data"=>$fileContents))){
$data['content'] = base64_encode($fileContents);
$data['base64'] = true;
}
show_json($data);
}

读取urlFile的内容,并以json形式输出到content字段.

由于前四个字符必须是http,所以需要用../来跳出去,并且http不是一个存在的目录,所以只能在windows下成功(这里多谢学长指出😉)