扒一扒這個註解,我發現還有點意思。

why技術發表於2021-12-20

你好呀,我是歪歪。

不是 Log4j 爆出漏洞了嘛,然後前幾天有小夥伴來問我:我專案裡面用的是 Lombok 的 @Slf4j 這個會有影響嗎?

你說這事多巧,我也用的這個註解,所以我當時稍微的看了一下。

先說結論:有沒有影響還是取決於你專案中依賴的 log4j2 包,和 Lombok 沒有任何關係。

另外“求求你提問之前三思,不要浪費我們的時間,不要問那些你自己就能搞清楚的問題”這句話不是我說的,是 Lombok 的作者說的:

他為什麼會說出這樣的略帶一絲絲氣憤的話呢?

我帶你看看。

從issue 說起

你在 github 上找到 Lombok 專案,然後檢視它的 issue 會看到關於 log4j 的問題已經被置頂了:

所以關於這個問題,我就從這個 issue 說起吧,這裡面有 Lombok 維護者的權威回答。

https://github.com/projectlombok/lombok/issues/3063

這個 issue 的標題翻譯過來就是:

結論:關於 log4j 的 0day 問題,Lombok 本身不受影響。

注意啊,這個裡面有個非常耐人尋味的單詞:itself。

一般我們說 Lombok 不受影響就行了,為什麼還得加個 itself(本身)呢?

這裡面就是有故事的。

首先是 12 月 10 日下午 5 點 41 分,也就是漏洞被爆出的那個下午,有個哥們在 Lombok 的 issue 提出了這個問題:

他說:鐵子們,出事了,log4j 爆出驚天大漏洞,趕緊補吧。

然後有個叫做 Rawi01 的老鐵跳出來說:

老哥,你能解釋一下這個漏洞 對 Lombok 有什麼影響嗎?據我所知,log4j 只是在執行測試時不出現編譯錯誤時才需要。

這個老哥明顯是對 Lombok 比較瞭解的,他其實已經指出關鍵的地方了:Lombok 並沒有依賴 log4j。

但是緊接著有人指出:

Lombok 提供了一個註解,叫做 @log4j。這玩意使用了 Log4J 的庫吧,畢竟要用它記錄日誌。

確實有這個註解啊,給大家看一下:

我一般是用 @Slf4j 這個註解,但是它確實是還提供了其他的日誌註解,這個先按下不表,等下再說。

現在的主線任務是這個 issue,接著往下看。

接下來,來了一個控場的哥們,看一下他說了啥:

他先艾特了前面的說 @log4j 註解的這個哥們,給他解釋說:雖然 Lombok 會生成程式碼來建立一個 logger 的例項,但我們並沒有 ship, distribute or require 任何特定的版本,如果使用者要用這個註解,需要他們自己提供對應的依賴。

這裡我有三個詞我還沒翻譯:ship, distribute or require。

require 其實很簡單,就是要求。

意思是我們沒有要求使用者使用的時候必須給到指定版本的依賴。說白了就是,假設你要用 @Log4j,那就提供對應的依賴,至於這個依賴的版本是什麼 Lombok 並不關心。

剩下的 ship 直譯過來是“運送” 的意思,而 distribute 有“分發”的意思。

這個我用一張圖片來解釋吧:

可以看到,在專案中我引用了 Lombok,但是它並沒有傳遞依賴進來任何其他的包。不像是 spring-boot-starter 後面跟著一大坨東西。

我理解這就是他想要表達的:we do not ship, distribute or require any specific version.

然後接著說:在我們的程式碼庫中確實可以找到一個 log4j2 的版本,但那只是在測試程式碼裡面使用,以便能夠無錯誤地編譯生成的程式碼。

最後這個哥們總結到:放心,老鐵們,Lombok 仍然可以安全的使用。但我覺得雖然是測試程式碼中引用到了 Log4j ,也還是應該把依賴關係更新到安全的版本。

怎麼樣,是不是感覺這個老哥說話有一種不可置疑的自信感。

我讀完這個回答之後就是有這樣的感覺,隱隱覺得這就是個大佬。

於是我去扒了一下這個老哥的背景:

本來我是想從他的 github 主頁上尋找答案,居然沒有發現和 Lombok 相關的專案。

而且只有 49 個 followers,看著也不符合大佬的資料呀:

於是我轉戰瀏覽器,搜尋了:Roel Spilker Lombok.

找到了這個:

才發現,好傢伙,他是 Lombok 的爸爸啊,難怪說話這麼硬氣。

還發現了一個花邊新聞:他們之前還把關於 Lombok 的想法貢獻給了 Oracle,但是被拒絕了。官方不支援,他們只有揭竿而起,自己搞了。

現在 Lombok 的市場佔有率還是很高的吧,算是把這事兒幹成功了。

另外,其實這裡就可以直接看到他的身份:

Collaborator,就是合作人。他的回答可以等同於官方回答了。

到這裡事情還算是發展的比較順利,官方親自下場來解答這個問題,且已經明說了:

算是可以完結撒花了。

但是,就怕出現但是啊,後面發生的事情我就覺得有點離譜了。

首先是 SunriseChair 老哥獻出了一殺:

他首先引用了作者的回覆,然後說,如果你在你的 maven 或者 gradle 中宣告 Lombok 這個依賴項,那麼 Log4j 的依賴項不也會被包括在內嗎?這可能是 classpath 上唯一的依賴,而 Lombok 生成的程式碼也會用到它?如果我說錯了,請糾正我......

他想要表達的是什麼意思呢。

首先我覺得是他讀完作者的回答後覺得 Lombok 是依賴了 Log4j 的,所以他的核心問題就是,如果我引用了 Lombok 那麼 Log4j 的依賴豈不是也會傳遞進來?

但是作者都說了:there is a version to be found in our code base, but that's 僅僅用於測試類中。

但凡你稍微瞭解一點開源專案的工作原理,也就知道測試相關的部分都不會提供出去,如果你要看測試類,你得把專案原始碼拉下來。

簡單來說就是如果你通過 maven 依賴了 Lombok,測試相關的東西你肯定是看不到的。

接著一位叫做 RuanNunes 的老哥也出來補槍了,獻上了二殺,他說他在整個專案裡面搜尋到了這個東西:

這個配置是存在漏洞的。

然後作者對這兩個問題進行了一一回復:

首先,說 Lombok 依賴了 log4j 的老哥聽著:Lombok 沒有對 log4j 或任何其他庫進行依賴。如果你在你的程式碼中使用 @Log4j 註解,但是又不直接或間接地依賴 log4j ,你的編譯就會產生一個錯誤資訊。

關於這一點,前面的依賴分析截圖也已經說明了,我就不再貼一次了。

然後說找到漏洞的哥們聽著:在你找到的這個漏洞的同一目錄下有一個文字檔案,但凡你去看一眼,你就知道我們不會將該檔案作為我們釋出的一部分。

所以我理解壓根不會發布出去,即使有漏洞,對於 Lombok 的使用者來說也沒有任何毛病吧?

我也去找了一下作者說的這個檔案,就是它:

https://github.com/projectlombok/lombok/blob/d3b763f9dab4a46e88ff10bc2132fb6f12fda639/src/support/info.txt

這個檔案裡面清清楚楚的說了:not part of lombok itself.

如果前面這兩個問題,已經讓作者有點點不爽的感覺的話,那麼接下來這個問題,可能就是引爆點,完成三殺,一波帶走:

這個哥們上來就說:老鐵,我問個問題哈。我使用的是 @slf4j 註解,這個漏洞和我有關係嗎?

不行了不行了,血壓上來了。

我覺得這個哥們提問之前完全沒有看前面的回覆,如果他在作者進行了兩次解釋之前提出這個問題,那麼我覺得完全可以理解。

但是在作者已經在這個 issue 裡面解釋了兩次“Lombok 不受影響”的前提下,他譁一下,上來就是一個暴擊:

老哥,我用的這個註解有問題嗎?

我寫到這裡都有點血壓上來了。

這特麼特別像是之前有一次在一個群裡和別人討論事務失效的場景,已經討論到尾聲了,突然跳出來一個哥們,甩出來一個程式碼片段,這個片段的寫法就是一個經典的 this 呼叫導致事務註解失效的場景,而這就是我們剛剛討論中的一部分。

知道我們在討論這個問題,哪怕你稍微往上翻一翻呢,瞭解一下上下文再提問不好嗎?

不扯遠了,也許是我過度解讀把,反正我覺得提問的藝術大家都得好好學一下。

回到我們的主線劇情,看一下作者是怎麼回答這個問題的:

他說:老鐵,這個問題我幫不了你啊。我對於這個漏洞一無所知,我甚至不明白為什麼你會認為它也會影響 @Slf4j。

我可以告訴你的是,Lombok 沒有使用、傳遞、要求對這些庫的依賴。

我們的工作原理是生成了你"看不見的原始碼"。

如果你有任何理由懷疑類似的問題也可能發生在他們的產品中,請聯絡 Slf4j 的維護者。

不知道是不是我個人的感覺,我覺得作者回復這個問題的時候已經有一點怒氣了:這都是些什麼問題啊?前我已經回覆了兩次不受影響啊?怎麼感覺我的專案使用者怎麼都不瞭解 Lombok 的基本原理呢?

在作者回復完這個冒失的提問者之後,這個提問者還是很禮貌的回覆了一下:

謝了老鐵,我檢查了,我使用的是 SpringBoot 預設的 logback。

作者也把這個問題給置頂了,且修改了這個 issue 的標題。

所以,經過前面的一番解讀,現在你再看這個標題:

為什麼作者要強調“itself”,因為 Lombok 確實是提供了日誌的功能,但是至於引用什麼包,哪個版本的包,和 Lombok 都沒有任何關係。 Lombok 本身(itself)是安全的。

最最後,作者給了 log4j 漏洞對於 Lombok 的 Latest assesment(最新評估),算是總結性發言:

給大家翻譯一下關鍵的東西。

這個漏洞只存在於 2.16.0 版本以下的 Log4j code 包中,而不存在於任何其他日誌框架中。

Lombok 沒有傳遞依賴任何 Log4j 包,也沒有宣告對任何東西的依賴。

如果你使用任何 Lombok 的註解,比如 @Log4j,Lombok 將生成使用這些庫的程式碼,但是你的專案裡面必須要包含對這些庫的依賴,否則 Lombok 生成的程式碼將無法編譯。

同樣地,你要負責在你的執行時中擁有這些包,否則類的初始化可能會失敗。

在 Lombok 測試程式碼中,我們曾經有一個包含這個漏洞的版本,但是由於測試不處理任何使用者輸入(測試是硬編碼的),而且生成的程式碼甚至沒有被執行,執行測試並沒有導致執行測試的機器上出現 RCE(遠端程式碼/命令執行漏洞)。

所以,老鐵們,Lombok 本身不需要做任何改變,也不對你的專案負任何安全責任,畢竟包不是我們引進來的。

如果你不同意目前的評估,請在這個問題上新增評論。

但是,請確保你已經閱讀了其他評論,並確保你理解了這個問題。

最後這兩句話,單獨拎出來,我可太喜歡這兩句話了:

求求你提問之前三思,不要浪費我們的時間,不要問那些你自己就能搞清楚的問題。

如果你認為我們遺漏了什麼,或者有新的資訊,請大聲的說出來。

然後,你注意這裡作者用的小標題是:The balancing act.

翻譯過來是“平衡的行為”,啥玩意?

NO,NO,NO:

一個小俚語送給大家,不必客氣。

補充說明

前面把主線劇情過完了,現在我來幾個補充說明吧。

先說前面按下不表的關於日誌的註解。

其實 Lombok 裡面關於日誌的註解還真是挺多的,可以直接看官方的文件:

https://projectlombok.org/features/log

這麼多註解,一個個的講也沒啥意思,我這裡就挑 @Slf4j 、@Log4j2 這兩個演示一下吧。

首先,我們可以搞個純淨的 SpringBoot 專案,只包含這兩個依賴:

這個時候如果我什麼都不動,只是稍微改一下啟動類:

然後為了排除干擾項我把日誌列印的級別調整到 Error:

logging.level.root=error

同時關閉 banner 輸出:

spring.main.banner-mode=off

banner 就是這個玩意:

這個時候啟動專案,日誌輸出是這樣的:

可以看到我們這個時候使用的日誌是 logback,原因我在之前的文章裡面也講過了,因為 Springboot 預設使用的日誌實現是 logback。

這一點,從專案依賴上也可以看出來:

另外,你注意一下,我特意把 import 部分也截出來了,除了 @Slf4j 註解外,這裡並沒有引入任何日誌相關的註解。

然後,我再關注一下這個時候編譯出來的 class 檔案:

自動引入了 slf4j 相關的包,然後生成了這行程式碼:

private static final Logger log = LoggerFactory.getLogger(LogdemoApplication.class);

這個時候不知道你有沒有想到編譯時註解相關的東西,但是不慌,這裡還是先按下不表。

來,我問你:為什麼它能引入 slf4j 相關的包?

因為我依賴了呀:

好,如果這個時候我把 logback 的核心依賴給拿掉,會出現什麼事情,你覺得會不會編譯不過呢?

不會編譯不過,因為 Slf4j 包還在,它只是一個日誌門面。

但是執行的時候會丟擲異常,因為找不到日誌相關的具體實現類:

然後,如果我想用 log4j2 日誌實現怎麼辦呢?

之前的文章中也寫過:

把 Springboot 預設的依賴拿掉,然後引入 Log4j2 的包。

這個時候專案依賴圖是這樣的,可以看到沒有 logback-core 了,只有 log4j-core:

再次執行專案,日誌實現就變成了 Log4j:

你發現了嗎,我除了動了一下 pom 依賴外,其他的程式碼一行都沒有動,日誌框架就從 logback 變化為了 log4j。

而且 class 檔案沒有任何變化,所以我也就不去截圖了。

這就是 Slf4j 的功勞,這就是“門面”的含義,這就是為什麼都建議大家在專案中使用 Slf4j,而不是具體的諸如 logback、log4j 這樣具體的日誌實現。

接下來說一下 @Log4j2 這個註解。

我們還是把依賴恢復到最開始純淨的狀態,也就是這樣:

然後我們把註解修改為 @Log4j2,但是我們專案中這個時候並沒有引入 Log4j-core 包,那麼你覺得會有問題嗎?

不會有問題的,我們可以看一下。

先看一下輸出:

此時的日誌實現類是 SLF4JLogger。

這玩意哪裡來的?

看一下 class 檔案:

這個兩個類是來自於 log4j-api 包裡面,同時由於 log4j-to-slf4j 包的存在,所以最後的實現類橋接到了 SLF4JLogger 中去:

如果我把 log4j-api 包移除掉,你說會不會編譯不過呢?

肯定編譯不過的,因為包都不存在了,搞不出來 class 檔案:

如果我不想用 SLF4JLogger 這個類呢,我就想用真正的 log4j。

簡單,把 log4j 的依賴搞進來:

好,我前面說了這麼多的廢話,不厭其煩的給你排除、引入日誌相關的包,給你看輸出啥的,而且整個過程中並不涉及到 Lombok 包的變化,都是為了再次印證這兩句話:

如果你使用任何 Lombok 的註解,比如 @Log4j,Lombok 將生成使用這些庫的程式碼,但是你的專案裡面必須要包含對這些庫的依賴,否則 Lombok 生成的程式碼將無法編譯。

比如我前面把 log4j-api 包移除掉了,是不是編譯就沒有過?

同樣地,你要負責在你的執行時中擁有這些包,否則類的初始化可能會失敗。

比如我前面把 logback-core 的包移除了,編譯的時候沒有問題,但是服務執行的時候,是不是丟擲找不到類的異常?

是不是再次證明:

聊聊原理

前面我提到了一句“編譯時註解”,不知道大家對於這個玩意了不瞭解。

不瞭解其實也很正常,因為我們寫業務程式碼的時候很少自定義編譯時註解,頂天了搞個執行時註解就差不多了。

Lombok 的核心工作原理就是編譯時註解。

其實我瞭解的也不算深入,只是大概知道它的工作原理是什麼樣的,對於原始碼沒有深入研究。

但是我可以給你分享一下兩個需要注意的地方和可以去哪裡瞭解這個玩意。

首先第一個需要注意的地方是這裡:

log 相關注解的原始碼位於這個部分,可以看到很奇怪啊,這些檔案是以 SCL.lombok 結尾的,這是什麼玩意?

這是 lombok 的小心思,其實這些都是 class 檔案,但是為了避免汙染使用者專案,它做了特殊處理。

所以你開啟這類檔案的時候選擇以 class 檔案的形式開啟就行了,就可以看到裡面的具體內容。

比如你可以看看這個檔案:

lombok.core.handlers.LoggingFramework

你會發現你們就像是列舉似的,寫了很多日誌的實現:

這個裡面把每個註解需要生成的 log 都硬編碼好了。正是因為這樣,Lombok 才知道你用什麼日誌註解,應該給你生成什麼樣的 log。

比如 log4j 是這樣的:

private static final org.apache.logging.log4j.Logger log = org.apache.logging.log4j.LogManager.getLogger(TargetType.class);

這也同時可以和我們前面的 class 檔案對應起來:

而 SLF4J 是這樣的:

private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TargetType.class);

第二個需要注意的地方是找到入口:

這些 class 檔案載入的入口在於這個地方,是基於 Java 的 SPI 機制:

AnnotationProcessorHider 這個類裡面有兩行靜態內部類,我們看其中一個, AnnotationProcessor ,它是繼承自 AbstractProcessor 抽象類:

javax.annotation.processing.AbstractProcessor

這個抽象類,就是入口中的入口,核心中的核心。

在這個入口裡面,初始化了一個類載入器,叫做 ShadowClassLoader:

它乾的事兒就是載入那些被標記為 SCL.lombok 的 class 檔案。

然後我是怎麼知道 Lombok 是基於編譯時註解的呢?

其實這玩意在我看過的兩本書裡面都有寫,有點模糊的印象,寫文章的時候我又翻出來讀了一遍。

首先是《深入理解 Java 虛擬機器(第三版)》的第四部分程式編譯與程式碼優化的第 10 章:前端編譯與優化一節。

裡面專門有一小節,說插入式註解的:

Lombok 的主要工作地盤,就在 javac 編譯的過程中。

在書中的 361 頁,提到了編譯過程的幾個階段。

從Java程式碼的總體結構來看,編譯過程大致可以分為一個準備過程和三個處理過程:

  • 1.準備過程:初始化插入式註解處理器。
  • 2.解析與填充符號表過程,包括:
    • 詞法、語法分析。將原始碼的字元流轉變為標記集合,構造出抽象語法樹。
    • 填充符號表。產生符號地址和符號資訊。
  • 3.插入式註解處理器的註解處理過程:插入式註解處理器的執行階段,本章的實戰部分會設計一個插入式註解處理器來影響Javac的編譯行為。
  • 4.分析與位元組碼生成過程,包括:
    • 標註檢查。對語法的靜態資訊進行檢查。
    • 資料流及控制流分析。對程式動態執行過程進行檢查。
    • 解語法糖。將簡化程式碼編寫的語法糖還原為原有的形式。(java中的語法糖包括泛型、變長引數、自動裝拆箱、遍歷迴圈foreach等,JVM執行時並不支援這些語法,所以在編譯階段需要還原。)
    • 位元組碼生成。將前面各個步驟所生成的資訊轉換成位元組碼。

如果說 javac 編譯的過程就是 Lombok 的工作地盤,那麼其中的“插入式註解處理器的註解處理過程”就是它的工位了。

書中也提到了 Lombok 的工作原理:

第二本書是《深入理解 JVM 位元組碼》,在它的第 8 章,也詳細的描述了外掛化註解的處理原理,其中也提到了 Lombok:

最後畫了一個示意圖,是這樣的:

如果你看懂了書中的前面的十幾頁的描述,那麼看這個圖就會比較清晰了。

總之,Lombok 的核心原理就是在編譯期對於 class 檔案的魔改,幫你生成了很多程式碼。

這也是作者提到的:

invisible source code,看不見的原始碼。

這裡的看不見,指的是 java 檔案中的看不見,在 class 檔案中它還是無處遁形。

如果你有興趣深入瞭解它的原理的話,可以去看看我前面提到的這兩本書,裡面都有手把手的實踐開發。

我就不寫了,一個原因是因為確實門檻較高,寫出來生澀難懂。另外一個原因那不是因為我懶嘛。

本文已經收錄至個人部落格,歡迎大家來玩:

https://www.whywhy.vip/

相關文章