前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >浅谈Log4j2不借助dnslog的检测

浅谈Log4j2不借助dnslog的检测

作者头像
亿人安全
发布2022-06-30 15:51:55
6260
发布2022-06-30 15:51:55
举报
文章被收录于专栏:红蓝对抗

0x00 介绍

目前的Log4j2检测都需要借助dnslog平台,是否存在不借助dnslog的检测方式呢

也许在甲方内网自查等情景下有很好的效果

笔者实习期间参与过xray的一些开发,对其中的反连平台有一些了解。正好天下大木头师傅找到我,提出了它同样的思路,于是我们交流后编写了一款工具,目前功能简单,后续可能会加强

主要原理是参考LDAPRMI协议文档,编写解析协议的代码,获取我们需要的数据,保存即可

所以本文主要就是分析该工具的介绍和编写思路,首先来看看效果

运行工具:./Log4j2Scan.exe -p 8000

由于我在本地测试,所以ip地址为127.0.0.1

使用RMI触发漏洞(RMI方式的Payload必须有Path否则不会发请求)

代码语言:javascript
复制
public static void main(String[] args) {
    logger.error("${jndi:rmi://127.0.0.1:8000/xxx}");
}

使用LDAP触发漏洞

代码语言:javascript
复制
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协议的指纹,正常情况下客户端都会向服务端首先发送这样一个字符串,为了进一步确认,我尝试到googlegithub进行搜索

在 Github类似代码 中发现该字符串被很多脚本作为LDAP协议的探测指纹信息,在 官方文档 中确认了为什么是这样的字符串

代码语言:javascript
复制
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

代码语言:javascript
复制
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)
}

根据上述指纹进行分析

代码语言:javascript
复制
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

于是继续查看官方文档,构造标准的返回包

代码语言:javascript
复制
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位是对应的路径参数

按照这个规则编写,即可取到其中的参数

代码语言:javascript
复制
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

后面两个字节分别表示VersionProtocol信息,按照RMI协议的规定,这里的Version应该是0x00 0x01,实际抓包看到的是0x00 0x02,或许是文档较老的原因?

末尾的0x4b表示这是StreamProtocol协议方式,没有什么问题

代码语言:javascript
复制
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服务端返回数据给漏洞触发端(客户端)

原始报文为

代码语言:javascript
复制
0000   4e 00 0f 44 45 53 4b 54 4f 50 2d 46 50 30 32 42   N..DESKTOP-FP02B
0010   4b 48 00 00 f8 8e                                 KH....

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

代码语言:javascript
复制
In:
    ProtocolAck Returns

ProtocolAck:
    0x4e

简单分析了下这里0x00 0x0f表示长度15,后15位DESKTOP-FP02BKH是服务端的主机名

最后的0xf8 0xfeRMI客户端的端口:63630

Wireshark中可以看到解析结果和分析一致

(3)Client -> Server

接下来客户端会向服务端发送如下的数据,报文如下

代码语言:javascript
复制
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操作

代码语言:javascript
复制
Call:
    0x50 CallData

报文如下,开头的aced0005是经典序列化数据头,结尾的jlmz6v是我们需要的路径参数

代码语言:javascript
复制
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..jlmz
0030   36 76                                             6v

现在问题来了,这是什么类的序列化数据

想办法对这个数据进行反序列化,发现报错

代码语言:javascript
复制
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

代码语言:javascript
复制
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();
// 打印:jlmz6v
System.out.println(obj);

发现字符串数据位于末尾,且之前有一个表示长度的字节,如这里06 6a 6c 6d 7a06表示jlmz6v长度为6

因此能否从后往前读,如果已读到的长度等于当前读到的字节代表的数字,那么认为已读到的字符串翻转后是路径参数

(这种手段也许会有误报,但由于字母的ASCII码数值很大,所以大概率不会出问题)

(6)实现

首先根据第一步判断是否为RMI协议

代码语言:javascript
复制
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
}

进一步获取路径参数比较麻烦

代码语言:javascript
复制
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用于传输(给出默认长度就不阻塞了)

代码语言:javascript
复制
ResultChan = make(chan *model.Result, 100)

收到LDAPRMI请求后将数据输入channel

代码语言:javascript
复制
// LDAP
if "300c020101600702010304008000" == hexStr {
   // 记录数据
   res := &model.Result{
      Host:   (*conn).RemoteAddr().String(),
      Name:   "LDAP",
      Finger: hexStr,
   }
   // 数据输入channel
   ResultChan <- res
}

这时候其他的goroutine就可以取到channel中的结果

代码语言:javascript
复制
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

代码语言:javascript
复制
RenderChan <- res

在开启web服务的时候,建一个goroutine用于接收这个数据

代码语言:javascript
复制
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页面实时显示

上一个问题涉及到了互斥锁,正是为了解决这个问题

接收到请求会在HandlerServeHTTP中处理,上文中维护的全局列表在实时地添加最新扫描结果,如果这里直接取全局列表会出现并发问题,所以选用了互斥锁(也有其他的解决方案这种最简单)

代码语言:javascript
复制
type resultHandler struct {
}

func (handler *resultHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
    // 申请锁
    lock.Lock()
    // 根据当前list中的结果返回
    _, _ = w.Write(RenderHtml(resultList))
    lock.Unlock()
}

如何让前端实时刷新:首先想到的是Ajax定时请求插入新的数据,实现起来麻烦

于是想到暴力办法,定时刷新页面

代码语言:javascript
复制
<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注入漏洞组件的扫描

代码语言:javascript
复制
{
"@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
}                                  
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-12-29,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 亿人安全 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 0x01 LDAP
  • 0x02 RMI
    • (1)Client -> Server
      • (2)Server -> Client
        • (3)Client -> Server
          • (4)Server -> Client
            • (5)Client -> Server
              • (6)实现
              • 0x03 其他
              • 0x04 总结
              相关产品与服务
              文件存储
              文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档