← Back to all posts
Web Vulnerability Reproduction and Analysis12 min read

Tongda OA Arbitrary File Upload/File Inclusion RCE Vulnerability Analysis

Analysis of an unauthenticated remote code execution vulnerability in Tongda OA that can be exploited through log file inclusion or by bypassing authentication to upload a file and then include it.

#Code Audit
Read in Chinese→

Tongda OA Arbitrary File Upload/File Inclusion RCE Vulnerability Analysis

0x01 Prerequisites

Regarding methods for exploiting this vulnerability:

​

There are roughly two exploitation methods:

  1. Include a log file.
  2. Bypass authentication to upload a file, then include it.

The second method is analyzed below.

0x01 Vulnerability Introduction

The Tongda OA system represents advanced concepts in collaborative OA. Sixteen years of R&D have resulted in a mature OA product. It is developed by the only centrally administered state-owned enterprise team in the collaborative OA software industry, has won multiple domestic OA software gold awards, and has more than 20,000 licensed users and over 80,000 free-edition users, exceeding…

Primary impact:

An attacker can trigger the vulnerability without logging in—or, in other words, without any prerequisites—upload an image webshell file, and then request file inclusion to ultimately achieve remote command execution.

Affected versions:

  • V11
  • 2017
  • 2016
  • 2015
  • 2013
  • 2013 Enhanced Edition

0x02 Vulnerability Analysis

I used V11.3 downloaded from the official website.

There are roughly two exploitation methods:

  1. Include a log file.
  2. Bypass authentication to upload a file, then include it.

Below, I mainly analyze the method of bypassing authorization to upload a file and then including it:

Download and Install

Upon opening the source code, I found that it was all encrypted using Zend.

Therefore, it first needs to be decrypted; search Baidu for instructions.

在这里插入图片描述

Authentication Bypass and File Upload

The vulnerable upload functionality is located in webroot\ispirit\im\upload.php.

The decrypted source code:```php

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; } } } ``` Look at this section at the beginning; it is the core of the bypass: ``` 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'; } ``` - A parameter named `P` is obtained here. If `P` exists or is not empty, the `auth.php` file above must be included. Its name makes it clear that it primarily implements authentication. Therefore, by using the `P` parameter here to bypass login authentication, execution can proceed to the upload functionality below. - This is followed by two `if` statements. Entering either one causes execution to terminate via `exit`, so they must be bypassed to reach the upload logic. ``` $DEST_UID = $_POST['DEST_UID']; 还好这个参数可控,要求不能为 0 也不能为空就可以了 ```![在这里插入图片描述](/images/20200403094623450.png) - After entering the loop, PHP's `\$\_FILES` variable is used to obtain information about the uploaded file.``` $_FILES['ATTACHMENT']['name'] ``` - The first index must be the value of our input's `name` attribute. Therefore, in `Content-Disposition: form-data; name=“ATTACHMENT”; filename="xxx.php.png"` within our POST request, `name` must be `'ATTACHMENT'`. - In other words, the `upload` function is called whenever a file is uploaded. - The obtained filename is then processed. It is URL-decoded once, and the filename lengths before and after decoding are compared. If the length changes, the URL-decoded filename is used as the final filename. - The `upload` function is called on line 45. Follow it to see what it does; it is located on line 1321 of `inc/utility\_file.php`. ``` $ATTACHMENTS = upload('ATTACHMENT', $MODULE, false); ``` The function's specific code is as follows: ```php 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; } ```- Look at how the `is\_uploadable\(\)` function checks the filename. Follow it to the function, which is also located in `inc/utility\_file.php`. ![在这里插入图片描述](/images/20200330095139194.png) - Examine this carefully. The code locates the final occurrence of `"."` in the filename and then:``` strtolower(substr($FILE_NAME, $POS + 1, 3)) == 'php' ```- This is `substr\(filename, position of the final dot + 1, 3 characters\)`. - Starting after the `"."`, it matches three characters and determines whether the extension is `php`. If it is `php`, the function returns `false`; otherwise, the portion before `"."` is used as `EXT\_NAME`. ![在这里插入图片描述](/images/20200330095216717.png) - With this check, `.php` definitely will not work. It can only be something such as `shell.php.` or `shell.php.png`. - Therefore, this must be combined with a file inclusion vulnerability. ## Variable Passing Issue - In `upload.php`, the value of `UPLOAD\_MODE` is important for determining the direction of execution. - However, I could not find where it came from, which was puzzling. - Nevertheless, the `UPLOAD\_MODE` value supplied via POST in the payload can be passed in normally and affect the file upload flow. - I predicted that the method for setting the `UPLOAD\_MODE` value existed in an included file. - However, the parameter name `UPLOAD\_MODE` exists only in `upload.php`. - Tracing back through the code revealed the following path: - The specific call chain is `upload.php \-> session.php \-> coon.php \-> td\_config.php \-> common.inc.php`. The key section:``` 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); } ``` - First, the length of `POST` is checked. Here, `\_POST` is actually an array, which is then traversed using `foreach`. - In the `\$\_POST` array, the key is `"UPLOAD\_MODE"` and the value is `"2"`, so it is matched as follows: ``` 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]; ``` - Ultimately, the array key `UPLOAD\_MODE` becomes the variable name, while its corresponding value becomes the variable's value. - Therefore, although `upload.php` does not directly receive the `UPLOAD\_MODE` value, we can still pass it here. - The `upload` function calls the `add\_attach` function and sets `\$ATTACHMENTS\[‘ID’\]`. ![](/images/20200330095243732.png) - Continue following the `add\_attach` function, which is also located in `inc/utility\_file.php`. - This reveals how the storage path is generated. ``` 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; } ``` - As shown, the return value `ATTACH\_ID\_NEW` consists of three parts: `AID`, `YM`, and `ATTACH\_ID`. - In fact, `UPLOAD\_MODE` can be set to any of the numbers `1`, `2`, or `3`; each returns the filename and part of the path, so there is no need to examine it further. ## File Inclusion - This part is relatively simple. - The file implementing the file inclusion functionality is located at `webroot\ispirit\interface\gateway.php`. - The specific code is as follows: ``` $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; } ``` - These parameters can also be passed directly via POST. As analyzed above, the following two are the main ones required: - ``` include_once 'inc/session.php'; include_once 'inc/conn.php'; ``` - The logic is relatively simple. - If the parameter `P` is not supplied and remains empty, the preceding series of checks can be bypassed directly. - The value of the `url` parameter is then obtained from the JSON. - As long as `general/`, `ispirit/`, or `module/` is present in the URL, `\$url` is included directly. - This completes the file inclusion. Simply construct one as follows: ``` /ispirit/interface/gateway.php?json={"url":"/general/../../attach/im/2003/1153189608.jpg"} ``` 0x03 Remediation - Install the patch officially released by the vendor: http://www.tongda2000.com/news/673.php