安全矩阵

 找回密码
 立即注册
搜索
查看: 2677|回复: 0

浅谈Log4j2不借助dnslog的检测

[复制链接]

855

主题

862

帖子

2940

积分

金牌会员

Rank: 6Rank: 6

积分
2940
发表于 2021-12-29 17:57:26 | 显示全部楼层 |阅读模式
原文链接:浅谈Log4j2不借助dnslog的检测

0x00 介绍目前的Log4j2检测都需要借助dnslog平台,是否存在不借助dnslog的检测方式呢
也许在甲方内网自查等情景下有很好的效果
笔者实习期间参与过xray的一些开发,对其中的反连平台有一些了解。正好天下大木头师傅找到我,提出了它同样的思路,于是我们交流后编写了一款工具,目前功能简单,后续可能会加强
主要原理是参考LDAP和RMI协议文档,编写解析协议的代码,获取我们需要的数据,保存即可
所以本文主要就是分析该工具的介绍和编写思路,首先来看看效果
运行工具:./Log4j2Scan.exe -p 8000

由于我在本地测试,所以ip地址为127.0.0.1
使用RMI触发漏洞(RMI方式的Payload必须有Path否则不会发请求)
  1. public static void main(String[] args) {
  2.    logger.error("${jndi:rmi://127.0.0.1:8000/xxx}");
  3. }
复制代码


使用LDAP触发漏洞
  1. public static void main(String[] args) {
  2.    logger.error("${jndi:ldap://127.0.0.1:8000}");
  3. }
复制代码


可以看到命令行的输出

我另外做了一个动态更新的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协议的探测指纹信息,在 官方文档 中确认了为什么是这样的字符串
  1. 30 0c -- Begin the LDAPMessage sequence
  2.   02 01 01 -- The message ID (integer value 1)
  3.   60 07 -- Begin the bind request protocol op
  4.     02 01 03 -- The LDAP protocol version (integer value 3)
  5.     04 00 -- Empty bind DN (0-byte octet string)
  6.     80 00 -- Empty password (0-byte octet string with type context-specific
  7.           -- primitive zero)
复制代码

于是我们用Golang编写了类似的逻辑,构造了一个虚假的LDAP Server分析来自漏洞触发端的TCP连接监听Socket
  1. log.Info("start fake reverse server")
  2. listen, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", config.Port))
  3. ...
  4. for {
  5.    conn, err := listen.Accept()
  6.    ...
  7.    // 分析
  8.    go acceptProcess(&conn)
  9. }
复制代码

根据上述指纹进行分析
  1. func acceptProcess(conn *net.Conn) {
  2.   buf := make([]byte, 1024)
  3.   num, err := (*conn).Read(buf)
  4.   ...
  5.   hexStr := fmt.Sprintf("%x", buf[:num])
  6.   // LDAP 指纹
  7.   if "300c020101600702010304008000" == hexStr {
  8.      // 如果符合则记录该请求
  9.      res := &model.Result{
  10.         Host:   (*conn).RemoteAddr().String(),
  11.         Name:   "LDAP",
  12.         Finger: hexStr,
  13.     }
  14.   }
  15. }
复制代码

到这一步只能确定是LDAP协议还拿不到传过来的参数(ldap://127.0.0.1:1389/4ra1n中的4ra1n)于是继续查看官方文档,构造标准的返回包
  1. 30 0c -- Begin the LDAPMessage sequence
  2.   02 01 01 -- The message ID (integer value 1)
  3.   61 07 -- Begin the bind response protocol op
  4.     0a 01 00 -- success result code (enumerated value 0)
  5.     04 00 -- No matched DN (0-byte octet string)
  6.     04 00 -- No diagnostic message (0-byte octet string)
复制代码

按照标准返回之后,会再次从客户端得到输入不过这个包并不能匹配到LDAP官方文档中任意一种协议(也许是我没找到)
通过大量请求做diff后发现这里新输入的规律
  •         输入前7位是固定的
  •         输入的第8位代表路径的长度n(例如4ra1n长度为05)
  •         从第9位到第9+n位是对应的路径参数

按照这个规则编写,即可取到其中的参数
  1. if "300c020101600702010304008000" == hexStr {
  2.    data := []byte{
  3.        0x30, 0x0c, 0x02, 0x01, 0x01, 0x61, 0x07,
  4.        0x0a, 0x01, 0x00, 0x04, 0x00, 0x04, 0x00,
  5.   }
  6.    _, _ = (*conn).Write(data)
  7.    _, _ = (*conn).Read(buf)
  8.    length := buf[8]
  9.    pathBytes := bytes.Buffer{}
  10.    for i := 1; i <= int(length); i++ {
  11.        temp := []byte{buf[8+i]}
  12.        pathBytes.Write(temp)
  13.   }
  14.    // 得到path
  15.    path := pathBytes.String()
  16.    ...
  17.    _ = (*conn).Close()
  18.    return
  19. }
复制代码

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协议方式,没有什么问题
  1. Header:
  2.   0x4a 0x52 0x4d 0x49 Version Protocol

  3. Version:
  4.   0x00 0x01

  5. Protocol:
  6.   StreamProtocol
  7.   SingleOpProtocol
  8.   MultiplexProtocol

  9. StreamProtocol:
  10.   0x4b

  11. SingleOpProtocol:
  12.   0x4c

  13. MultiplexProtocol:
  14.   0x4d
复制代码


其实仔细看Wireshark的解析,和我做的分析一致

如果只为了确认RMI协议,那么到这里就可以了
但我们的目的是获取路径参数,在RMI协议中这一步尤其复杂
(2)Server -> Client接下来应该是RMI服务端返回数据给漏洞触发端(客户端)
原始报文为
  1. 0000   4e 00 0f 44 45 53 4b 54 4f 50 2d 46 50 30 32 42   N..DESKTOP-FP02B
  2. 0010   4b 48 00 00 f8 8e                                 KH....
复制代码


根据官方文档不难看出0x4e表示ProtocolAck且后续内容应该是具体返回的值
  1. In:
  2.   ProtocolAck Returns

  3. ProtocolAck:
  4.   0x4e
复制代码


简单分析了下这里0x00 0x0f表示长度15,后15位DESKTOP-FP02BKH是服务端的主机名
最后的0xf8 0xfe是RMI客户端的端口:63630
在Wireshark中可以看到解析结果和分析一致

(3)Client -> Server接下来客户端会向服务端发送如下的数据,报文如下
  1. 0000   00 0b 31 39 32 2e 31 36 38 2e 31 2e 34 00 00 00   ..192.168.1.4...
  2. 0010   00   
复制代码


                                           .其中0b表示一个内网地址长度,正好是192.168.1.4,其余部分用00填充
于是想到这里的地址是否可以伪造
(4)Server -> Client接下来服务端需要向客户端传一个空(至关重要)
(5)Client -> Server下一步是客户端继续向服务端发送,报文以0x50开头,表示call操作
  1. Call:
  2.   0x50 CallData
复制代码


报文如下,开头的aced0005是经典序列化数据头,结尾的jlmz6v是我们需要的路径参数
  1. 0000   50 ac ed 00 05 77 22 00 00 00 00 00 00 00 00 00   P....w".........
  2. 0010   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
  3. 0020   02 44 15 4d c9 d4 e6 3b df 74 00 06 6a 6c 6d 7a   .D.M...;.t..jlmz
  4. 0030   36 76                                             6v
复制代码

现在问题来了,这是什么类的序列化数据想办法对这个数据进行反序列化,发现报错
  1. byte[] data = new byte[]{
  2.   (byte)0xac, (byte)0xed, (byte)0x00, (byte)0x05, (byte)0x77, (byte)0x22,
  3.   (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00,
  4.   (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00,
  5.   (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00,
  6.   (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00,
  7.   (byte)0x00, (byte)0x02, (byte)0x44, (byte)0x15, (byte)0x4d, (byte)0xc9,
  8.   (byte)0xd4, (byte)0xe6, (byte)0x3b, (byte)0xdf, (byte)0x74, (byte)0x00,
  9.   (byte)0x06, (byte)0x6a, (byte)0x6c, (byte)0x6d, (byte)0x7a, (byte)0x36, (byte)0x76
  10. };
  11. ByteArrayInputStream is = new ByteArrayInputStream(data);
  12. ObjectInputStream ois = new ObjectInputStream(is);
  13. Object obj = ois.readObject();
  14. ois.close();
  15. System.out.println(obj);
复制代码

在尝试研究后,发现这个序列化数据类似String
  1. byte[] data = new byte[]{
  2.   (byte)0xac, (byte)0xed, (byte)0x00, (byte)0x05, (byte)0x74, (byte)0x00,
  3.   (byte)0x06, (byte)0x6a, (byte)0x6c, (byte)0x6d, (byte)0x7a, (byte)0x36, (byte)0x76
  4. };
  5. ByteArrayInputStream is = new ByteArrayInputStream(data);
  6. ObjectInputStream ois = new ObjectInputStream(is);
  7. Object obj = ois.readObject();
  8. ois.close();
  9. // 打印:jlmz6v
  10. System.out.println(obj);
复制代码


因此能否从后往前读,如果已读到的长度等于当前读到的字节代表的数字,那么认为已读到的字符串翻转后是路径参数
(这种手段也许会有误报,但由于字母的ASCII码数值很大,所以大概率不会出问题)
(6)实现首先根据第一步判断是否为RMI协议
  1. func checkRMI(data []byte) bool {
  2. if data[0] == 0x4a &&
  3. data[1] == 0x52 &&
  4. data[2] == 0x4d &&
  5. data[3] == 0x49 {
  6. if data[4] != 0x00 {
  7. return false
  8. }
  9.        // 0x01是官方规定的 0x02是实际抓包的结果
  10.        // 所以可以认为0x01和0x02都为RMI协议
  11. if data[5] != 0x01 && data[5] != 0x02 {
  12. return false
  13. }
  14. if data[6] != 0x4b &&
  15. data[6] != 0x4c &&
  16. data[6] != 0x4d {
  17. return false
  18. }
  19. lastData := data[7:]
  20. for _, v := range lastData {
  21. if v != 0x00 {
  22. return false
  23. }
  24. }
  25. return true
  26. }
  27. return false
  28. }
复制代码

进一步获取路径参数比较麻烦
  1. if checkRMI(buf) {
  2.    // 需要发的数据(这里模拟了127.0.0.1)
  3.    // 实际上这个数据可以随意模拟
  4.    // 只要保证4e00开头
  5.    data := []byte{
  6.        0x4e, 0x00, 0x09, 0x31, 0x32,
  7.        0x37, 0x2e, 0x30, 0x2e, 0x30,
  8.        0x2e, 0x31, 0x00, 0x00, 0xc4, 0x12,
  9.   }
  10.    _, _ = (*conn).Write(data)
  11.    // 这里读到的数据没有用处
  12.    _, _ = (*conn).Read(buf)
  13.    // 需要发一次空数据然后接收call信息
  14.    _, _ = (*conn).Write([]byte{})
  15.    _, _ = (*conn).Read(buf)
  16.    var dataList []byte
  17.    flag := false
  18.    // 从后往前读因为空都是00
  19.    for i := len(buf) - 1; i >= 0; i-- {
  20.        // 这里要用一个flag来区分
  21.        // 因为正常数据中也会含有00
  22.        if buf[i] != 0x00 || flag {
  23.            flag = true
  24.            dataList = append(dataList, buf[i])
  25.       }
  26.   }
  27.    // 拿到翻转路径索引
  28.    // 原理在上文已写:
  29.    // 已读到的长度等于当前读到的字节代表的数字
  30.    // 那么认为已读到的字符串翻转后是路径参数
  31.    var j int
  32.    for i := 0; i < len(dataList); i++ {
  33.        if int(dataList[i]) == i {
  34.            j = i
  35.       }
  36.   }
  37.    // 拿到翻转路径参数
  38.    temp := dataList[0:j]
  39.    pathBytes := &bytes.Buffer{}
  40.    // 翻转后拿到真正的路径参数
  41.    for i := len(temp) - 1; i >= 0; i-- {
  42.        pathBytes.Write([]byte{dataList[i]})
  43.   }
  44.    ...
  45.    _ = (*conn).Close()
  46.    return
  47. }
复制代码

0x03 其他最后分享一些简单的安全开发技术,对于想自己写安全工具师傅可能会有帮助
监听Socket收到的结果如何传递记录
构造一个非阻塞channel用于传输(给出默认长度就不阻塞了)

ResultChan = make(chan *model.Result, 100)
收到LDAP或RMI请求后将数据输入channel
  1. // LDAP
  2. if "300c020101600702010304008000" == hexStr {
  3.   // 记录数据
  4.   res := &model.Result{
  5.      Host:   (*conn).RemoteAddr().String(),
  6.      Name:   "LDAP",
  7.      Finger: hexStr,
  8.   }
  9.   // 数据输入channel
  10.   ResultChan <- res
  11. }
复制代码

这时候其他的goroutine就可以取到channel中的结果
  1. for {
  2.    select {
  3.        // 从channel中取到结果
  4.        case res := <-ResultChan:
  5.        // 输出结果
  6.        info := fmt.Sprintf("%s->%s", res.Name, res.Host)
  7.        log.Info("log4j2 detected")
  8.        log.Info(info)
  9.        // 第二个问题
  10.        RenderChan <- res
  11.   }
  12. }
复制代码


上面这个问题最后将结果放入了一个新的channel

RenderChan <- res
在开启web服务的时候,建一个goroutine用于接收这个数据
  1. var (
  2.    // 新channel的指针
  3. resultList []*model.Result
  4.    // 为什么要上锁参考下一个问题
  5. lock       sync.Mutex
  6. )

  7. func StartHttpServer(renderChan *chan *model.Result) {
  8. log.Info("start result http server")
  9.    // 开启web服务
  10. mux := http.NewServeMux()
  11. mux.Handle(config.DefaultHttpPath, &resultHandler{})
  12. server := &http.Server{
  13. Addr:         fmt.Sprintf(":%d", config.HttpPort),
  14. WriteTimeout: config.DefaultHttpTimeout,
  15. Handler:      mux,
  16. }
  17.    // 负责接收实时数据
  18. go listenData(renderChan)
  19. _ = server.ListenAndServe()
  20. }

  21. func listenData(renderChan *chan *model.Result) {
  22. for {
  23. select {
  24. case res := <-*renderChan:
  25.            // 申请锁
  26.            // 为什么要上锁参考下一个问题
  27. lock.Lock()
  28.            // 将结果加入到list中
  29. resultList = append(resultList, res)
  30. lock.Unlock()
  31. }
  32. }
  33. }
复制代码


上一个问题涉及到了互斥锁,正是为了解决这个问题
接收到请求会在Handler的ServeHTTP中处理,上文中维护的全局列表在实时地添加最新扫描结果,如果这里直接取全局列表会出现并发问题,所以选用了互斥锁(也有其他的解决方案这种最简单)
  1. type resultHandler struct {
  2. }

  3. func (handler *resultHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
  4.    // 申请锁
  5. lock.Lock()
  6.    // 根据当前list中的结果返回
  7. _, _ = w.Write(RenderHtml(resultList))
  8. lock.Unlock()
  9. }
复制代码

如何让前端实时刷新:首先想到的是Ajax定时请求插入新的数据,实现起来麻烦于是想到暴力办法,定时刷新页面
  1. <script>
  2.    function fresh()
  3. {
  4.        window.location.reload();
  5.   }
  6.    setTimeout('fresh()',3000);
  7. </script>
复制代码


0x04 总结项目地址:https://github.com/EmYiQing/JNDIScan
由于一些原因,木头师傅要求我在项目中删除了他的ID,但木头师傅在该项目中的贡献不可否认。由于同样的原因,我不得不删除其中的动态web页面,转为生成本地的html文件。做安全真难,写个工具都不能安稳
最后我将项目名称从Log4j2Scan改为JNDIScan并加入了一些小功能
  •         自动获取内网和外网的IP,方便用户直接使用
  •         添加路径外带参数的功能,方面批量扫描(使用UUID等方式来确认漏洞)


最后,该项目不仅可用于Log4j2的扫描,也可用于Fastjson等可能存在JDNI注入漏洞组件的扫描
  1. {
  2. "@type": "com.sun.rowset.JdbcRowSetImpl",
  3. "dataSourceName": "rmi://your-ip:port/xxx",
  4. "autoCommit": true
  5. }

  6. {
  7. "@type": "com.sun.rowset.JdbcRowSetImpl",
  8. "dataSourceName": "ldap://your-ip:port/params",
  9. "autoCommit": true

  10. }
复制代码




回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|安全矩阵

GMT+8, 2025-4-23 11:42 , Processed in 0.017448 second(s), 18 queries .

Powered by Discuz! X4.0

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表