致远oa 任意文件写入漏洞分析

前言

之前写的文章了,一开始发到了其他地方,想了想还是copy一份到博客里增加点数量。

​ 前段时间致远oa爆出了任意文件写入漏洞, 当时广为流传的poc中数据包一些参数值被编码, 最初由于不知道加密方式编写poc不太方便,在拿到了致远oa的源码后对该漏洞进行了分析并编写poc。

漏洞分析

​ 漏洞本身是一个很简单的漏洞,但是因为加密的原因稍微使利用麻烦了一点。

​ 漏洞出现在 \WEB-INF\lib\seeyon-apps-common.jar!\com\seeyon\ctp\common\office\HtmlOfficeServlet.class,该类为一个Servlet。

1
2
3
4
5
6
7
8
9
10
11
<servlet-mapping>
<servlet-name>htmlofficeservlet</servlet-name>
<url-pattern>/htmlofficeservlet</url-pattern>
</servlet-mapping>

<servlet>
<servlet-name>htmlofficeservlet</servlet-name>
<servlet-class>
com.seeyon.ctp.common.office.HtmlOfficeServlet
</servlet-class>
</servlet>

​ 在web.xml中, 将/htmlofficeservlet映射到了该Servlet。

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
public class HtmlOfficeServlet extends HttpServlet {
..............................
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
AppContext.initSystemEnvironmentContext(request, response);
HandWriteManager handWriteManager = (HandWriteManager)AppContext.getBean("handWriteManager");
HtmlHandWriteManager htmlHandWriteManager = (HtmlHandWriteManager)AppContext.getBeanWithoutCache("htmlHandWriteManager");
iMsgServer2000 msgObj = new iMsgServer2000();

try {
handWriteManager.readVariant(request, msgObj);
if (AppContext.currentUserId() == -1L) {
User user = handWriteManager.getCurrentUser(msgObj);
AppContext.putThreadContext("SESSION_CONTEXT_USERINFO_KEY", user);
}

msgObj.SetMsgByName("CLIENTIP", Strings.getRemoteAddr(request));
String option = msgObj.GetMsgByName("OPTION");
if ("LOADFILE".equalsIgnoreCase(option)) {
handWriteManager.LoadFile(msgObj);
.................................................
else if ("SAVEASIMG".equalsIgnoreCase(option)) {
String fileName = msgObj.GetMsgByName("FILENAME");
String tempFolder = (new File((new File("")).getAbsolutePath())).getParentFile().getParentFile().getPath();
String tempPath = tempFolder + "/base/upload/taohongTemp";
File folder = new File(tempPath);
if (!folder.exists()) {
folder.mkdir();
}

msgObj.MsgFileSave(tempPath + "/" + fileName);
}

​ 在该Servlet中, 存在一个SAVEASIMG操作, 从msgObj中获取到FILENAME后与tempPath拼接成最终保存路径,传递给MsgFileSave方法。

1
2
3
4
5
6
7
8
9
10
11
12
public boolean MsgFileSave(String var1) {
try {
FileOutputStream var2 = new FileOutputStream(var1);
var2.write(this.FMsgFile);
var2.close();
return true;
} catch (Exception var3) {
this.FError = this.FError + var3.toString();
System.out.println(var3.toString());
return false;
}
}

​ 在MsgFileSave方法当中, 直接使用拼接的路径与this.FMsgFile实现了文件保存。所以只要能够控制option、fileName和this.FMsgFile即可实现任意文件保存。这三个变量均来自iMsgServer2000实例对象, 在DBStep.jar包中能够找到iMsgServer2000类的代码。iMsgServer2000实例对象由handWriteManager.readVariant方法处理用户发送的request生成。

1
2
3
4
5
6
7
8
9
10
11
12
public void readVariant(HttpServletRequest request, iMsgServer2000 msgObj) {
msgObj.ReadPackage(request);
this.fileId = Long.valueOf(msgObj.GetMsgByName("RECORDID"));
this.createDate = Datetimes.parseDatetime(msgObj.GetMsgByName("CREATEDATE"));
String _originalFileId = msgObj.GetMsgByName("originalFileId");
this.needClone = _originalFileId != null && !"".equals(_originalFileId.trim());
this.needReadFile = Boolean.parseBoolean(msgObj.GetMsgByName("needReadFile"));
if (this.needClone) {
String _originalCreateDate = msgObj.GetMsgByName("originalCreateDate");
this.originalFileId = Long.valueOf(_originalFileId);
this.originalCreateDate = Datetimes.parseDatetime(_originalCreateDate);
}

​ 接着调用iMsgServer2000#ReadPackage生成msgObj对象, 在生成msgObj对象后从该对象获取参数值, 可以看出msgObj中必须含有RECORDID、CREATEDATE参数并且需要符合它的数据类型, 在获取到参数值后有进行类型转换等操作, 如果获取不到参数值或获取到的参数值与相应的数据类型不匹配, 那么在进行类型转换时会出现异常进而退出流程。这两个参数值和最终的漏洞利用并无关系, 只是为了避免异常而必须设置, 在该方法中可以看到还获取了一些其他的参数值, 不过其他的就算不设置也不会发生异常。

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
public byte[] ReadPackage(HttpServletRequest var1) {
int var2 = 0;
boolean var3 = false;
boolean var4 = false;
this.Charset = var1.getCharacterEncoding();
if (this.Charset == null) {
this.Charset = var1.getHeader("charset");
}

if (this.Charset == null) {
this.Charset = "GB2312";
}

try {
int var8 = var1.getContentLength();

int var7;
for(this.FStream = new byte[var8]; var2 < var8; var2 += var7) {
var1.getInputStream();
var7 = var1.getInputStream().read(this.FStream, var2, var8 - var2);
}

if (this.FError == "") {
this.StreamToMsg();
}

​ 该方法中, 首先获取request中的Content-Length, 然后从request body中获取对应Content-Length字节数的内容保存到this.FStream中, 接着继续调用当前类中的StreamToMsg方法。

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
private boolean StreamToMsg() {
byte var2 = 64;
boolean var3 = false;
boolean var4 = false;
boolean var5 = false;
boolean var6 = false;
String var7 = "";
String var8 = "";
this.FMd5Error = false;

try {
byte var14 = 0;
String var1 = new String(this.FStream, var14, var2);
this.FVersion = var1.substring(0, 15); // version
int var11 = Integer.parseInt(var1.substring(16, 31).trim()); // 355
int var12 = Integer.parseInt(var1.substring(32, 47).trim()); // 0
int var13 = Integer.parseInt(var1.substring(48, 63).trim()); // 666
this.FFileSize = var13; // var13 fileSize
int var15 = var14 + var2; // 0 + 64 = 64
if (var11 > 0) {
this.FMsgText = new String(this.FStream, var15, var11);
}

var15 += var11;
if (var12 > 0) {
this.FError = new String(this.FStream, var15, var12);
}

var15 += var12;
this.FMsgFile = new byte[var13];
if (var13 > 0) {
for(int var9 = 0; var9 < var13; ++var9) {
this.FMsgFile[var9] = this.FStream[var9 + var15];
}

var15 += var13;
if (this.FStream.length >= var15 + 32) {
var7 = new String(this.FStream, var15, 32);
var8 = this.MD5Stream(this.FMsgFile);
if (var7.compareToIgnoreCase(var8) != 0) {
this.SetMsgByName("DBSTEP", "ERROR");
this.FMd5Error = true;
} else {
this.FMd5Error = false;
}
}
}

​ 在该方法中获取流的前64字节保存到var1中, 前64字节类似报文头,前16字节为版本信息,16-31字节为FMsgText的大小(option、filename变量都是从FMsgText中获取),32到47字节为错误信息的大小 直接定义为0, 48到63字节为FMsgFile的大小。如果流中48到64字节转整后的内容(即var13变量)大于0, 那么会从 var15字节后开始读取内容保存到FMsgFile成员属性中, 从上面代码中可以看出var15 = var14 + var2 + var11 + var12 即跳过报文头和FMsgText,然后获取var13(即48到63字节内容转整)个字节保存到FMsgFile属性当中。

​ 在获取到FMsgFile后, 后面有类似校验签名的一段代码, 校验的是FMsgFile内容的md5值是否与FMsgFile后32字节内容相等, 流传的poc中在最后也带了一段md5,不过可以看出如果整个流的长度如果不大于var15 + 32 (即FMsgFile后的内容不超过32字节)就不会进入该逻辑。不过就算进入该逻辑且签名错了也没有影响, 在其他操作的地方并没有管这签名的正确性。

​ option、fileName变量都是通过调用iMsgServer2000类的GetMsgByName方法获取参数值,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public String GetMsgByName(String var1) {
boolean var2 = false;
boolean var3 = false;
String var4 = "";
String var6 = var1.trim().concat("=");
int var7 = this.FMsgText.indexOf(var6);
if (var7 != -1) {
int var8 = this.FMsgText.indexOf("\r\n", var7 + 1);
var7 += var6.length();
if (var8 != -1) {
String var5 = this.FMsgText.substring(var7, var8);
var4 = this.DecodeBase64(var5);
return var4;
} else {
return var4;
}
} else {
return var4;
}
}

​ GetMsgByName方法当中, 从FMsgText属性值中获取参数值, 获取到参数值为”=”与”\r\n”之间的内容, 获取到参数值后会调用当前对象的DecodeBase64方法进行处理。

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
public String DecodeBase64(String var1) {
ByteArrayOutputStream var2 = new ByteArrayOutputStream();
String var3 = "";
byte[] var8 = new byte[4];

try {
int var5 = 0;
byte[] var7 = var1.getBytes();

while(var5 < var7.length) {
for(int var4 = 0; var4 <= 3; ++var4) {
if (var5 >= var7.length) {
var8[var4] = 64;
} else {
int var6 = this.TableBase64.indexOf(var7[var5]);
if (var6 < 0) {
var6 = 65;
}

var8[var4] = (byte)var6;
}

++var5;
}

var2.write((byte)(((var8[0] & 63) << 2) + ((var8[1] & 48) >> 4)));
if (var8[2] != 64) {
var2.write((byte)(((var8[1] & 15) << 4) + ((var8[2] & 60) >> 2)));
if (var8[3] != 64) {
var2.write((byte)(((var8[2] & 3) << 6) + (var8[3] & 63)));
}
}
}
} catch (StringIndexOutOfBoundsException var11) {
this.FError = this.FError + var11.toString();
System.out.println(var11.toString());
}

try {
var3 = var2.toString(this.Charset);
} catch (UnsupportedEncodingException var10) {
System.out.println(var10.toString());
}

return var3;
}

​ DecodeBase64方法是原始base64decode方法的变异, 从EncodeBase64方法当中可以看出其实就是映射了一个转换表, 对应的转换表如下

1
2
private String TableBase64 = "gx74KW1roM9qwzPFVOBLSlYaeyncdNbI=JfUCQRHtj2+Z05vshXi3GAEuT/m8Dpk6";
private String TableBase60 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

​ 写个脚本实现该转换表即可加解密。

1
2
3
4
5
6
7
8
9
10
11
12
13
import string
import base64

STANDARD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
CUSTOM_ALPHABET = 'gx74KW1roM9qwzPFVOBLSlYaeyncdNbI=JfUCQRHtj2+Z05vshXi3GAEuT/m8Dpk6'

def encode(input):
ENCODE_TRANS = string.maketrans(STANDARD_ALPHABET, CUSTOM_ALPHABET)
return base64.b64encode(input).translate(ENCODE_TRANS)

def decode(input):
DECODE_TRANS = string.maketrans(CUSTOM_ALPHABET, STANDARD_ALPHABET)
return base64.b64decode(input.translate(DECODE_TRANS))

​ Python3.4 已经没有 string.maketrans() 方法, 所以3.4及后续版本需要稍微修改下代码。

1
2
3
def encode(input):
ENCODE_TRANS = input.maketrans(STANDARD_ALPHABET, CUSTOM_ALPHABET)
return str(base64.b64encode(input.encode("utf-8"))).translate(ENCODE_TRANS)

​ 首先需要使option的参数值为SAVEASIMG才能进入保存文件逻辑, 加密后为S3WYOSWLBSGr。保存路径是由tempPath和fileName组成。

1
2
3
4
String fileName = msgObj.GetMsgByName("FILENAME");
String tempFolder = (new File((new File("")).getAbsolutePath())).getParentFile().getParentFile().getPath();
String tempPath = tempFolder + "/base/upload/taohongTemp";
File folder = new File(tempPath);

​ tempFolder中进行了两次目录穿越, 应该是为了防止用户把文件写入到web目录当中, 不过因为fileName完全未过滤可以按照默认配置补全路径最终实现保存文件到web目录当中。所以这里首先需要目录穿越出/base/upload/taohongTemp三层目录, 然后按照默认配置补全路径, 使fileName为..\..\..\ApacheJetspeed\webapps\seeyon\abcd.txt, 编码得到qfTdqfTdqfTdVaxJeAJQBRl3dExQyYOdNAlfeaxsdGhiyYlTcATdeYMUy7T3brV6, 在算字节长度时, 需要算上\r\n。

pic1

pic2