通達OA任意文件上傳/文件包含RCE漏洞分析

0x01 前提

關於這個漏洞的利用方式:

利用方式大致有兩種:

  1. 包含日誌文件。
  2. 繞過身份驗證文件上傳然後在文件包含。

下面主要分析第二種

0x01 漏洞介紹

通達OA系統代表了協同OA的先進理念,16年研發鑄就成熟OA產品,協同OA軟件行業唯一央企團隊研發,多次摘取國內OA軟件金獎,擁有2萬多家正式用戶,8萬多家免費版用戶,超過…

主要危害:

攻擊者可以在爲登陸或者說,無任何條件觸發漏洞,上傳圖片木馬文件,請求進行文件包含最終可達成遠程命令執行

影響版本:

  • V11版
  • 2017版
  • 2016版
  • 2015版
  • 2013版
  • 2013增強版

0x02 漏洞分析

我用的官網下載的V11.3

利用方式大致有兩種:

  1. 包含日誌文件。
  2. 繞過身份驗證文件上傳然後在文件包含。

下面我主要分析饒過權限上傳,然後文件包含的方式:

首先下載安裝

打開源碼一看,都加密了,使用zend進行了加密。

所以先要進行解密,百度即可。

在這裏插入圖片描述

繞過身份驗證文件上傳部分

存在漏洞的上傳功能文件爲 webroot\ispirit\im\upload.php

解密後的源碼

<?php

set_time_limit(0);
$P = $_POST['P'];
if (isset($P) || $P != '') {
    ob_start();
    include_once 'inc/session.php';
    session_id($P);
    session_start();
    session_write_close();
} else {
    include_once './auth.php';
}
include_once 'inc/utility_file.php';
include_once 'inc/utility_msg.php';
include_once 'mobile/inc/funcs.php';
ob_end_clean();
$TYPE = $_POST['TYPE'];
$DEST_UID = $_POST['DEST_UID'];
$dataBack = array();
if ($DEST_UID != '' && !td_verify_ids($ids)) {
    $dataBack = array('status' => 0, 'content' => '-ERR ' . _('½ÓÊÕ·½IDÎÞЧ'));
    echo json_encode(data2utf8($dataBack));
    exit;
}
if (strpos($DEST_UID, ',') !== false) {
} else {
    $DEST_UID = intval($DEST_UID);
}
if ($DEST_UID == 0) {
    if ($UPLOAD_MODE != 2) {
        $dataBack = array('status' => 0, 'content' => '-ERR ' . _('½ÓÊÕ·½IDÎÞЧ'));
        echo json_encode(data2utf8($dataBack));
        exit;
    }
}
$MODULE = 'im';
if (1 <= count($_FILES)) {
    if ($UPLOAD_MODE == '1') {
        if (strlen(urldecode($_FILES['ATTACHMENT']['name'])) != strlen($_FILES['ATTACHMENT']['name'])) {
            $_FILES['ATTACHMENT']['name'] = urldecode($_FILES['ATTACHMENT']['name']);
        }
    }
    $ATTACHMENTS = upload('ATTACHMENT', $MODULE, false);
    if (!is_array($ATTACHMENTS)) {
        $dataBack = array('status' => 0, 'content' => '-ERR ' . $ATTACHMENTS);
        echo json_encode(data2utf8($dataBack));
        exit;
    }
    ob_end_clean();
    $ATTACHMENT_ID = substr($ATTACHMENTS['ID'], 0, -1);
    $ATTACHMENT_NAME = substr($ATTACHMENTS['NAME'], 0, -1);
    if ($TYPE == 'mobile') {
        $ATTACHMENT_NAME = td_iconv(urldecode($ATTACHMENT_NAME), 'utf-8', MYOA_CHARSET);
    }
} else {
    $dataBack = array('status' => 0, 'content' => '-ERR ' . _('ÎÞÎļþÉÏ´«'));
    echo json_encode(data2utf8($dataBack));
    exit;
}
$FILE_SIZE = attach_size($ATTACHMENT_ID, $ATTACHMENT_NAME, $MODULE);
if (!$FILE_SIZE) {
    $dataBack = array('status' => 0, 'content' => '-ERR ' . _('ÎļþÉÏ´«Ê§°Ü'));
    echo json_encode(data2utf8($dataBack));
    exit;
}
if ($UPLOAD_MODE == '1') {
    if (is_thumbable($ATTACHMENT_NAME)) {
        $FILE_PATH = attach_real_path($ATTACHMENT_ID, $ATTACHMENT_NAME, $MODULE);
        $THUMB_FILE_PATH = substr($FILE_PATH, 0, strlen($FILE_PATH) - strlen($ATTACHMENT_NAME)) . 'thumb_' . $ATTACHMENT_NAME;
        CreateThumb($FILE_PATH, 320, 240, $THUMB_FILE_PATH);
    }
    $P_VER = is_numeric($P_VER) ? intval($P_VER) : 0;
    $MSG_CATE = $_POST['MSG_CATE'];
    if ($MSG_CATE == 'file') {
        $CONTENT = '[fm]' . $ATTACHMENT_ID . '|' . $ATTACHMENT_NAME . '|' . $FILE_SIZE . '[/fm]';
    } else {
        if ($MSG_CATE == 'image') {
            $CONTENT = '[im]' . $ATTACHMENT_ID . '|' . $ATTACHMENT_NAME . '|' . $FILE_SIZE . '[/im]';
        } else {
            $DURATION = intval($DURATION);
            $CONTENT = '[vm]' . $ATTACHMENT_ID . '|' . $ATTACHMENT_NAME . '|' . $DURATION . '[/vm]';
        }
    }
    $AID = 0;
    $POS = strpos($ATTACHMENT_ID, '@');
    if ($POS !== false) {
        $AID = intval(substr($ATTACHMENT_ID, 0, $POS));
    }
    $query = 'INSERT INTO im_offline_file (TIME,SRC_UID,DEST_UID,FILE_NAME,FILE_SIZE,FLAG,AID) values (\'' . date('Y-m-d H:i:s') . '\',\'' . $_SESSION['LOGIN_UID'] . '\',\'' . $DEST_UID . '\',\'*' . $ATTACHMENT_ID . '.' . $ATTACHMENT_NAME . '\',\'' . $FILE_SIZE . '\',\'0\',\'' . $AID . '\')';
    $cursor = exequery(TD::conn(), $query);
    $FILE_ID = mysql_insert_id();
    if ($cursor === false) {
        $dataBack = array('status' => 0, 'content' => '-ERR ' . _('Êý¾Ý¿â²Ù×÷ʧ°Ü'));
        echo json_encode(data2utf8($dataBack));
        exit;
    }
    $dataBack = array('status' => 1, 'content' => $CONTENT, 'file_id' => $FILE_ID);
    echo json_encode(data2utf8($dataBack));
    exit;
} else {
    if ($UPLOAD_MODE == '2') {
        $DURATION = intval($_POST['DURATION']);
        $CONTENT = '[vm]' . $ATTACHMENT_ID . '|' . $ATTACHMENT_NAME . '|' . $DURATION . '[/vm]';
        $query = 'INSERT INTO WEIXUN_SHARE (UID, CONTENT, ADDTIME) VALUES (\'' . $_SESSION['LOGIN_UID'] . '\', \'' . $CONTENT . '\', \'' . time() . '\')';
        $cursor = exequery(TD::conn(), $query);
        echo '+OK ' . $CONTENT;
    } else {
        if ($UPLOAD_MODE == '3') {
            if (is_thumbable($ATTACHMENT_NAME)) {
                $FILE_PATH = attach_real_path($ATTACHMENT_ID, $ATTACHMENT_NAME, $MODULE);
                $THUMB_FILE_PATH = substr($FILE_PATH, 0, strlen($FILE_PATH) - strlen($ATTACHMENT_NAME)) . 'thumb_' . $ATTACHMENT_NAME;
                CreateThumb($FILE_PATH, 320, 240, $THUMB_FILE_PATH);
            }
            echo '+OK ' . $ATTACHMENT_ID;
        } else {
            $CONTENT = '[fm]' . $ATTACHMENT_ID . '|' . $ATTACHMENT_NAME . '|' . $FILE_SIZE . '[/fm]';
            $msg_id = send_msg($_SESSION['LOGIN_UID'], $DEST_UID, 1, $CONTENT, '', 2);
            $query = 'insert into IM_OFFLINE_FILE (TIME,SRC_UID,DEST_UID,FILE_NAME,FILE_SIZE,FLAG) values (\'' . date('Y-m-d H:i:s') . '\',\'' . $_SESSION['LOGIN_UID'] . '\',\'' . $DEST_UID . '\',\'*' . $ATTACHMENT_ID . '.' . $ATTACHMENT_NAME . '\',\'' . $FILE_SIZE . '\',\'0\')';
            $cursor = exequery(TD::conn(), $query);
            $FILE_ID = mysql_insert_id();
            if ($cursor === false) {
                echo '-ERR ' . _('Êý¾Ý¿â²Ù×÷ʧ°Ü');
                exit;
            }
            if ($FILE_ID == 0) {
                echo '-ERR ' . _('Êý¾Ý¿â²Ù×÷ʧ°Ü2');
                exit;
            }
            echo '+OK ,' . $FILE_ID . ',' . $msg_id;
            exit;
        }
    }
}

看下開頭這一塊,就是繞過的核心部分

set_time_limit(0);
$P = $_POST['P'];
if (isset($P) || $P != '') {
    ob_start();
    include_once 'inc/session.php';
    session_id($P);
    session_start();
    session_write_close();
} else {
    include_once './auth.php';
}
  • 這裏獲取了一個P,如果P存在或者不爲空,就要包含上面的auth.php,看名字就知道是一個主要實現身份認證功能,所以通過這裏的參數"P"繞過登錄認證,就可以去下面的上傳了
  • 在往後就是兩個IF條件句,只要進去了都要exit退出,所以要繞過才能進入上傳的邏輯裏面
$DEST_UID = $_POST['DEST_UID'];
還好這個參數可控,要求不能爲 0 也不能爲空就可以了

在這裏插入圖片描述

  • 進入循環後使用PHP的 $_FILES 函數來獲取我們上傳的文件信息
$_FILES['ATTACHMENT']['name']
  • 第一個下標必須是我們的input name值,因此我們的POST包的Content-Disposition: form-data; name=“ATTACHMENT”; filename="xxx.php.png"中的name必須是’ATTACHMENT’。
  • 也就是有文件上傳就會調用upload函數
  • 後續對獲取的文件名處理了一下,對獲取的文件名行一次url解碼,對比文件名長度是否有變化,如果有變化,則將url解碼後的文件名作爲最後的文件名
  • 在45行有upload函數,要跟進看一下幹了什麼,inc/utility_file.php的1321行
 $ATTACHMENTS = upload('ATTACHMENT', $MODULE, false);

函數具體代碼如下:

function upload($PREFIX = 'ATTACHMENT', $MODULE = '', $OUTPUT = true)
{
    if (strstr($MODULE, '/') || strstr($MODULE, '\\')) {
        if (!$OUTPUT) {
            return _('參數含有非法字符。');
        }
        Message(_('錯誤'), _('參數含有非法字符。'));
        exit;
    }
    $ATTACHMENTS = array('ID' => '', 'NAME' => '');
    reset($_FILES);
    foreach ($_FILES as $KEY => $ATTACHMENT) {
        if ($ATTACHMENT['error'] == 4 || $KEY != $PREFIX && substr($KEY, 0, strlen($PREFIX) + 1) != $PREFIX . '_') {
            continue;
        }
        $data_charset = isset($_GET['data_charset']) ? $_GET['data_charset'] : (isset($_POST['data_charset']) ? $_POST['data_charset'] : '');
        $ATTACH_NAME = $data_charset != '' ? td_iconv($ATTACHMENT['name'], $data_charset, MYOA_CHARSET) : $ATTACHMENT['name'];
        $ATTACH_SIZE = $ATTACHMENT['size'];
        $ATTACH_ERROR = $ATTACHMENT['error'];
        $ATTACH_FILE = $ATTACHMENT['tmp_name'];
        $ERROR_DESC = '';
        if ($ATTACH_ERROR == UPLOAD_ERR_OK) {
            if (!is_uploadable($ATTACH_NAME)) {
                $ERROR_DESC = sprintf(_('禁止上傳後綴名爲[%s]的文件'), substr($ATTACH_NAME, strrpos($ATTACH_NAME, '.') + 1));
            }
            $encode = mb_detect_encoding($ATTACH_NAME, array('ASCII', 'UTF-8', 'GB2312', 'GBK', 'BIG5'));
            if ($encode != 'UTF-8') {
                $ATTACH_NAME_UTF8 = mb_convert_encoding($ATTACH_NAME, 'utf-8', MYOA_CHARSET);
            } else {
                $ATTACH_NAME_UTF8 = $ATTACH_NAME;
            }
            if (preg_match('/[\\\':<>?]|\\/|\\\\|"|\\|/u', $ATTACH_NAME_UTF8)) {
                $ERROR_DESC = sprintf(_('文件名[%s]包含[/\\\'":*?<>|]等非法字符'), $ATTACH_NAME);
            }
            if ($ATTACH_SIZE == 0) {
                $ERROR_DESC = sprintf(_('文件[%s]大小爲0字節'), $ATTACH_NAME);
            }
            if ($ERROR_DESC == '') {
                $ATTACH_NAME = str_replace('\'', '', $ATTACH_NAME);
                $ATTACH_ID = add_attach($ATTACH_FILE, $ATTACH_NAME, $MODULE);
                if ($ATTACH_ID === false) {
                    $ERROR_DESC = sprintf(_('文件[%s]上傳失敗'), $ATTACH_NAME);
                } else {
                    $ATTACHMENTS['ID'] .= $ATTACH_ID . ',';
                    $ATTACHMENTS['NAME'] .= $ATTACH_NAME . '*';
                }
            }
            @unlink($ATTACH_FILE);
        } else {
            if ($ATTACH_ERROR == UPLOAD_ERR_INI_SIZE) {
                $ERROR_DESC = sprintf(_('文件[%s]的大小超過了系統限制(%s)'), $ATTACH_NAME, ini_get('upload_max_filesize'));
            } else {
                if ($ATTACH_ERROR == UPLOAD_ERR_FORM_SIZE) {
                    $ERROR_DESC = sprintf(_('文件[%s]的大小超過了表單限制'), $ATTACH_NAME);
                } else {
                    if ($ATTACH_ERROR == UPLOAD_ERR_PARTIAL) {
                        $ERROR_DESC = sprintf(_('文件[%s]上傳不完整'), $ATTACH_NAME);
                    } else {
                        if ($ATTACH_ERROR == UPLOAD_ERR_NO_TMP_DIR) {
                            $ERROR_DESC = sprintf(_('文件[%s]上傳失敗:找不到臨時文件夾'), $ATTACH_NAME);
                        } else {
                            if ($ATTACH_ERROR == UPLOAD_ERR_CANT_WRITE) {
                                $ERROR_DESC = sprintf(_('文件[%s]寫入失敗'), $ATTACH_NAME);
                            } else {
                                $ERROR_DESC = sprintf(_('未知錯誤[代碼:%s]'), $ATTACH_ERROR);
                            }
                        }
                    }
                }
            }
        }
        if ($ERROR_DESC != '') {
            if (!$OUTPUT) {
                delete_attach($ATTACHMENTS['ID'], $ATTACHMENTS['NAME'], $MODULE);
                return $ERROR_DESC;
            } else {
                Message(_('錯誤'), $ERROR_DESC);
            }
        }
    }
    return $ATTACHMENTS;
}
  • 看下is_uploadable()函數對文件名進行檢查,跟進到該函數,同樣位於inc/utility_file.php

在這裏插入圖片描述

  • 這個仔細看一下,代碼意思是查找 “.” 在文件名中最後一次出現的位置然後
strtolower(substr($FILE_NAME, $POS + 1, 3)) == 'php'
  • 這是 substr( 文件名,最後一次點的位置+1,3個位置)
  • 從存在 ”.“ 開始匹配3位,判斷後綴是否爲php,,如果爲php則返回false,否則將"."之前的作爲EXT_NAME。

在這裏插入圖片描述

  • 這麼判斷 .php肯定是不行了,只能是 shell.php. 或者 shell.php.png
  • 那麼只能是配合文件包含漏洞了

變量傳遞問題

  • 由於在upload.php中UPLOAD_MODE值的是一個重要的流程走向的判斷

  • 但是並沒有發現是從哪來的,所以一直很疑惑,

  • 但根據payload中POST的UPLOAD_MODE值可以被正常帶入且影響文件上傳走向

  • 預測 UPLOAD_MODE值的方法存在於被包含的文件中,

  • 但是UPLOAD_MODE這個參數名僅存在於upload.php中

  • 開始追溯,發現下面的路徑

  • 具體調用爲upload.php -> session.php -> coon.php -> td_config.php -> common.inc.php

關鍵部分

if (0 < count($_POST)) {
    $arr_html_fields = array();
    foreach ($_POST as $s_key => $s_value) {
        if (substr($s_key, 0, 7) == '_SERVER') {
            continue;
        }
        if (substr($s_key, 0, 15) != 'TD_HTML_EDITOR_') {
            if (!is_array($s_value)) {
                $_POST[$s_key] = addslashes(strip_tags($s_value));
            }
            ${$s_key} = $_POST[$s_key];
        } else {
            if ($s_key == 'TD_HTML_EDITOR_FORM_HTML_DATA' || $s_key == 'TD_HTML_EDITOR_PRCS_IN' || $s_key == 'TD_HTML_EDITOR_PRCS_OUT' || $s_key == 'TD_HTML_EDITOR_QTPL_PRCS_SET' || isset($_POST['ACTION_TYPE']) && ($_POST['ACTION_TYPE'] == 'approve_center' || $_POST['ACTION_TYPE'] == 'workflow' || $_POST['ACTION_TYPE'] == 'sms' || $_POST['ACTION_TYPE'] == 'wiki') && ($s_key == 'CONTENT' || $s_key == 'TD_HTML_EDITOR_CONTENT' || $s_key == 'TD_HTML_EDITOR_TPT_CONTENT')) {
                unset($_POST[$s_key]);
                $s_key = $s_key == 'CONTENT' ? $s_key : substr($s_key, 15);
                ${$s_key} = addslashes($s_value);
                $arr_html_fields[$s_key] = ${$s_key};
            } else {
                $encoding = mb_detect_encoding($s_value, 'GBK,UTF-8');
                unset($_POST[$s_key]);
                $s_key = substr($s_key, 15);
                ${$s_key} = addslashes(rich_text_clean($s_value, $encoding));
                $arr_html_fields[$s_key] = ${$s_key};
            }
        }
    }
    reset($_POST);
    $_POST = array_merge($_POST, $arr_html_fields);
}
  • 首先一開始對POST,_POST長度進行了判斷,這裏_POST實際是一個數組,接着使用foreach函數對數組進行遍歷,
  • 在這裏$_POST數組中key爲"UPLOAD_MODE",value爲"2",那麼根據配會到
if (substr($s_key, 0, 15) != 'TD_HTML_EDITOR_') {
            if (!is_array($s_value)) {
                $_POST[$s_key] = addslashes(strip_tags($s_value));
            }
            
            //直接來這
            ${$s_key} = $_POST[$s_key];
  • 最終數組鍵名UPLOAD_MODE成了了變量名,而他的對應鍵值成爲了變量值

  • 所以 upload.php 未直接接收UPLOAD_MODE值,而我們仍可以傳遞到這裏

  • upload函數的中 調用 add_attach函數,設置$ATTACHMENTS[‘ID’]

  • 再往後 繼續跟進函數add_attach,函數同樣位於inc/utility_file.php文件下
  • 找到了保存路徑的方式
function add_attach($SOURCE_FILE, $ATTACH_NAME, $MODULE, $YM, $ATTACH_SIGN, $ATTACH_ID)
{
    $ATTACH_PARA_ARRAY = TD::get_cache("SYS_ATTACH_PARA");
    $ATTACH_POS_ACTIVE = $ATTACH_PARA_ARRAY["SYS_ATTACH_POS_ACTIVE"];
    $ATTACH_PATH_ACTIVE = $ATTACH_PARA_ARRAY["SYS_ATTACH_PATH_ACTIVE"];

    if (!file_exists($SOURCE_FILE)) {
        return false;
    }

    if ($MODULE == "") {
        $MODULE = attach_sub_dir();
    }

    if ($YM == "") {
        $YM = date("ym");
    }

    $PATH = $ATTACH_PATH_ACTIVE . $MODULE;
    if (!file_exists($PATH) || !is_dir($PATH)) {
        @mkdir($PATH, 448);
    }

    $PATH = $PATH . "/" . $YM;
    if (!file_exists($PATH) || !is_dir($PATH)) {
        @mkdir($PATH, 448);
    }

    $ATTACH_NAME = (is_default_charset($ATTACH_NAME) ? $ATTACH_NAME : iconv("utf-8", MYOA_CHARSET, $ATTACH_NAME));
    $EXT_NAME = substr($ATTACH_NAME, strrpos($ATTACH_NAME, "."));
    $ATTACH_NAME = str_replace($EXT_NAME, strtolower($EXT_NAME), $ATTACH_NAME);
    $ATTACH_FILE = (MYOA_ATTACH_NAME_FORMAT ? md5($ATTACH_NAME) . ".td" : $ATTACH_NAME);
    $ATTACH_ID = mt_rand();
    $FILENAME = $PATH . "/" . $ATTACH_ID . "." . $ATTACH_FILE;

    if (file_exists($FILENAME)) {
        $ATTACH_ID = mt_rand();
        $FILENAME = $PATH . "/" . $ATTACH_ID . "." . $ATTACH_FILE;
    }

    $AID = mysql_insert_id();
    $ATTACH_ID_NEW = $AID . "@" . $YM . "_" . $ATTACH_ID;
    return $ATTACH_ID_NEW;
}
  • 可以看到返回值ATTACHIDNEWATTACH_ID_NEW有三部分組成AID,YMYM,ATTACH_ID

  • 其實UPLOAD_MODE值隨便爲1,2,3中的任意一個數字,都可以返回文件名字和部分路徑,不看也行

文件包含部分

  • 這個比較簡單
  • 文件包含功能的文件位於webroot\ispirit\interface\gateway.php
  • 具體代碼如下:
<?php
//decode by http://dezend.qiling.org  QQ 2859470

ob_start();
include_once 'inc/session.php';
include_once 'inc/conn.php';
include_once 'inc/utility_org.php';
if ($P != '') {
    if (preg_match('/[^a-z0-9;]+/i', $P)) {
        echo _('非法參數');
        exit;
    }
    session_id($P);
    session_start();
    session_write_close();
    if ($_SESSION['LOGIN_USER_ID'] == '' || $_SESSION['LOGIN_UID'] == '') {
        echo _('RELOGIN');
        exit;
    }
}
if ($json) {
    $json = stripcslashes($json);
    $json = (array) json_decode($json);
    foreach ($json as $key => $val) {
        if ($key == 'data') {
            $val = (array) $val;
            foreach ($val as $keys => $value) {
                ${$keys} = $value;
            }
        }
        if ($key == 'url') {
            $url = $val;
        }
    }
    if ($url != '') {
        if (substr($url, 0, 1) == '/') {
            $url = substr($url, 1);
        }
        if (strpos($url, 'general/') !== false || strpos($url, 'ispirit/') !== false || strpos($url, 'module/') !== false) {
            include_once $url;
        }
    }
    exit;
}
  • 這裏的參數也是,POST直接傳入就可以了,分析在上面也有主要是有這兩個個就可以

  • include_once 'inc/session.php';
    include_once 'inc/conn.php';
    
  • 邏輯較爲簡單,

  • 如果這裏不傳遞參數P爲空,就以繞過前面一系列的檢測直

  • 隨後從json中獲取url參數的值

  • 只有 general/、ispirit/、module/ 在url內,在直接包含 $url,

  • 文件包含結束

構造一個就好了

/ispirit/interface/gateway.php?json={"url":"/general/../../attach/im/2003/1153189608.jpg"}

0x03 修復方案

  • 更新官方發佈的補丁 http://www.tongda2000.com/news/673.php
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章