|
原文链接:浅谈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-FP02B
- 0010 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操作
报文如下,开头的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);
复制代码
因此能否从后往前读,如果已读到的长度等于当前读到的字节代表的数字,那么认为已读到的字符串翻转后是路径参数
(这种手段也许会有误报,但由于字母的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
- // 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
- }
- }
复制代码
上面这个问题最后将结果放入了一个新的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()
- }
- }
- }
复制代码
上一个问题涉及到了互斥锁,正是为了解决这个问题
接收到请求会在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
- }
复制代码
|
|