背景
本来不打算再研究这方面了,上一篇文章基于godlp做了redis的解析发表之后有小伙伴私信说想看下mongodb的,本来这也是当初的的目标因为时间耽误了没完成,在这方面性格也有点强迫症,没做完心里总觉得还是有啥事一直吊着,所以就还是趁周末把这件事给补一下。至此已经完成的数据库有关系型数据库(mysql,postgrepsql,maraidb),非关系型数据库(redis,mongodb)常用的开源数据库基本就这些了吧,至于hive,hbase,elasticsearch等作为数据仓库使用的意义不大,sqlserver,oracle等非开源数据库的报文内容解析出来了又不方便公布,其他的数据库使用都不是很广泛了,至此这部分关于数据库报文解析的课题分享到这里基本就大结局了,这一片文章将最后讲解关于mongodb的返回报文格式并且对他的内容进行解析处理。
个人理念
只发表网上找不到且感觉有意义的内容,对于网上已经有很多版本的基础内容没有再写一遍的必要。
原理
做一个端口代理拦截服务端返回给客户端的数据包,解析出相应的数据内容并且做脱敏处理。
技术核心
1.做关于tcp端口的端口双向代理,拦截到所有数据并且转发(相对来说比较简单)。
2.对拦截到的报文进行解析,提取出他的返回的数据(技术核心)。
3.对数据进行识别脱敏处理(这里java语言写的写死算法,go语言写的直接调用godlp的内置识别规则和脱敏算法)。
4.对脱敏后的数据封装成正确的报文格式返回给客户端解析(技术核心)。
5.对于返回的大量数据可能会存在分包转发的情况,需要正确识别并处理(这里目前只做了数据合包处理后当成一个数据包解析的,牺牲了性能来简化代码,后续再考虑优化)。
说明
这只是一个验证猜想的实验产品,重心都放在解析流量上了,本人又是go语言小白现学现卖的,所以代码就是写流水先实现功能便于排查问题的,优化空间还很大后续再处理。
实现
返回报文格式
/**
* Not a real BSON type. Used to signal the end of a document.
*/
END_OF_DOCUMENT(0x00),
// no values of this type exist it marks the end of a document
/**
* A BSON double.
*/
DOUBLE(0x01),
/**
* A BSON string.
*/
STRING(0x02),
/**
* A BSON document.
*/
DOCUMENT(0x03),
/**
* A BSON array.
*/
ARRAY(0x04),
/**
* BSON binary data.
*/
BINARY(0x05),
/**
* A BSON undefined value.
*/
UNDEFINED(0x06),
/**
* A BSON ObjectId.
*/
OBJECT_ID(0x07),
/**
* A BSON bool.
*/
BOOLEAN(0x08),
/**
* A BSON DateTime.
*/
DATE_TIME(0x09),
/**
* A BSON null value.
*/
NULL(0x0a),
/**
* A BSON regular expression.
*/
REGULAR_EXPRESSION(0x0b),
/**
* A BSON regular expression.
*/
DB_POINTER(0x0c),
/**
* BSON JavaScript code.
*/
JAVASCRIPT(0x0d),
/**
* A BSON symbol.
*/
SYMBOL(0x0e),
/**
* BSON JavaScript code with a scope (a set of variables with values).
*/
JAVASCRIPT_WITH_SCOPE(0x0f),
/**
* A BSON 32-bit integer.
*/
INT32(0x10),
/**
* A BSON timestamp.
*/
TIMESTAMP(0x11),
/**
* A BSON 64-bit integer.
*/
INT64(0x12),
/**
* A BSON Decimal128.
*
* @since 3.4
*/
DECIMAL128(0x13),
/**
* A BSON MinKey value.
*/
MIN_KEY(0xff),
/**
* A BSON MaxKey value.
*/
MAX_KEY(0x7f);
mongodb的报文格式如上图所示,一般来说前21位都不用太关注,后面的报文格式是一层一层的,有种类似于洋葱的感觉,每一层的单元以4个字节存入这个单元的数据包长度,读取结束后以0结尾,单元可能是对象,也可能是数组,当读取内容的时候再以第一个字符位后面对象的数据类型,比如2表示string,4表示array,11表示时间戳,具体见代码,然后对象里面包对象的形式来组装成数据的,具体怎么来的表示几句话能说清楚的,建议多看看源码就明白了。
基于go的实现
代码:
package main
import (
"bytes"
"fmt"
"net"
"sync"
dlp "github.com/bytedance/godlp"
)
type ConnInfo struct {
//数据库类型:预留字段
dbType string
//会话的pid:预留字段
sessionId string
//连接的type
conType string
//发送的连接
sendConn net.Conn
//接收数据的连接
receConn net.Conn
//当前信息状态
status int
//当前数据是否接收完整,默认是false
//接收的buffer
rebuffer bytes.Buffer
//发送的buffer,预留属性,后续可能会用到
sebuffer bytes.Buffer
}
var (
wg sync.WaitGroup
)
func main() {
start()
}
/**
*这里只写了点对点代理,完全可以多加循环做成用不同端口代理多个服务器
*/
func start() {
wg.Add(1)
//与客户端的连接,其实是客户端需要连接的服务端
cliAdd, _ := net.ResolveTCPAddr("tcp4", "172.x.x.x:7003")
cliListen, _ := net.ListenTCP("tcp4", cliAdd)
fmt.Println("代理工具启动监听端口:", cliAdd)
//无限循环,接收到来自于客户端的连接的时候创建一个函数去处理,暂时用chan通道去处理
chConn := make(chan ConnInfo)
go func() {
fmt.Println("准备开始监听通道")
dealChannel(chConn)
}()
for {
conn, _ := cliListen.Accept()
fmt.Println("接收到新请求")
//这里接收到一个客户端请求则直接创建一个与服务端相连接的请求
serAdd, _ := net.ResolveTCPAddr("tcp4", "172.x.x.x:27017")
serConn, _ := net.DialTCP("tcp4", nil, serAdd)
//封装服务端的连接和客户端的连接对象建立关系
serConnInfo := new(ConnInfo)
serConnInfo.conType = "mogcli"
serConnInfo.receConn = conn
serConnInfo.status = 0
serConnInfo.sendConn = serConn
cliConnInfo := new(ConnInfo)
cliConnInfo.conType = "mogser"
cliConnInfo.receConn = serConn
cliConnInfo.sendConn = conn
cliConnInfo.status = 0
chConn <- *serConnInfo
chConn <- *cliConnInfo
}
}
/**
处理channel通道的数据
*/
func dealChannel(chConn chan ConnInfo) {
//这里无限循环一直从通道中去获取连接信息,目前这种处理方式可能并发性能差点,待研究深入一点再考虑更好的方案
for {
conInfo := <-chConn
//当从通道读取到连接信息的时候创建一个协程去读取数据,这样会一直创建协程,因为go语言的协程本来久很轻量级,不确定这么处理是否有问题,待后续深入了解了再改进
go func() {
dealConnInfo(conInfo, chConn)
}()
}
}
//对接收到的信息再进行分析处理
func dealConnInfo(conInfo ConnInfo, chConn chan ConnInfo) {
//获取到通道的信息
datas := make([]byte, 1024)
dlen, err := conInfo.receConn.Read(datas)
if err != nil {
//如果出错大概率是因为某个通道被断开了,会话断开直接连接,通道中丢掉这个连接信息
fmt.Print("出现错误")
conInfo.receConn.Close()
conInfo.sendConn.Close()
} else if conInfo.conType == "mogser" {
//1.判断当前的数据包是否完整,如果数据包接收不完整就存入通道的连接对象中去,等数据接收完毕再进行解析处理后统一转发给客户端。
//这里这种方式存在风险,当返回的数据量足够多的话这样一直把所有数据存入到buffer中去肯定会出现问题,本来处理大量的内容就比较慢了,还要把这些内容存储在缓存里肯定是对机器资源耗费的很多的,
//这里需要自己分包去解析处理的,这里为了方便就暂时先这么玩了,也留一点思考的空间给大家自己扩展
//说明当前数据包中没有数据,需要等待
if conInfo.rebuffer.Len() == 0 {
//获取整体数据包的长度
allPkLen := getBytesLen(datas, 0)
if allPkLen == int64(dlen) {
result := dealDatas(datas[:dlen])
conInfo.sendConn.Write(result)
conInfo.rebuffer.Reset()
} else {
conInfo.rebuffer.Write(datas[:dlen])
}
} else {
buffer := conInfo.rebuffer
buffer.Write(datas[:dlen])
reDatas := buffer.Bytes()
allPkLen := getBytesLen(reDatas, 0)
if allPkLen == int64(buffer.Len()) {
result := dealDatas(reDatas)
conInfo.sendConn.Write(result)
conInfo.rebuffer.Reset()
} else {
conInfo.rebuffer.Write(datas[:dlen])
}
}
chConn <- conInfo
} else {
//这里直接取了所有数据,如果返回的数据量大可能会分包,暂时不处理后续再想办法。
conInfo.sendConn.Write(datas[:dlen])
//读取结束再把信息加入通道,等待下次循环再次读取
chConn <- conInfo
}
}
/**
*对读取到的整体数据包进行解析处理,初学阶段就写流水帐了方便理解了,后续熟悉了再考虑用其他工具来封装简化处理。
*/
func dealDatas(datas []byte) (result []byte) {
var position int64 = 26
strTag, position := readBytesStr(datas, position)
//说明当前收到的报文是数据接收报文
if strTag == "cursor" {
//读取数据包长度
allPkLen := getBytesLen(datas, 0)
//如果当前数据包长度等于整体数据包长度说明数据包接收完毕,执行正确的流程
if allPkLen == int64(len(datas)) {
//这里直接从49位开始读取,别问为啥,多读一下源码就知道了
//先读取数据包长度
dataPkLen := getBytesLen(datas, 49)
//说明这里面有数据,需要进行脱敏处理
position = 53
endposition := position + dataPkLen
if dataPkLen > 4 {
for true {
b := datas[position]
position++
if b == 0 || (int64(position) >= endposition) {
break
}
position += 23
for true {
tag := datas[position]
position++
if tag == 0 {
break
} else if tag == 2 {
_, dlen := readBytesStr(datas, position)
position = dlen
//读取这个value的数据包长度
vlen := getBytesLen(datas, position)
position += 4
//读取value的值
dealValueBytesStr(datas, position, vlen)
position += vlen
} else {
// fmt.Println("读取到其他类型数据了")
}
}
}
}
}
}
return datas
}
/**
*根据起始位获取数据包的长度
*/
func getBytesLen(datas []byte, position int64) (len int64) {
b0 := int64(datas[position])
b1 := int64(datas[position+1]) << 8
b2 := int64(datas[position+2]) << 16
b3 := int64(datas[position+3]) << 24
return b3 + b2 + b1 + b0
}
/**
*处理数据把value读取处理后脱敏然后再把读取后的数据写入字byte种
*/
func dealValueBytesStr(datas []byte, position int64, vlen int64) {
value := string(datas[position : position+vlen-1])
nValue := maskValue(value)
nByte := []byte(nValue)
for i := int64(0); i < vlen-1; i++ {
if i < int64(len(nByte)) {
datas[position+i] = nByte[i]
} else {
datas[position+i] = 32
}
}
}
//脱敏传入的字符串并且返回脱敏后的结果,这里用godlp实现,所有的识别及脱敏算法全都用godlp的开源内容,当然也可以自己写或者扩展
func maskValue(inStr string) (res string) {
caller := "replace.your.caller"
if eng, err := dlp.NewEngine(caller); err == nil {
eng.ApplyConfigDefault()
if outStr, _, err := eng.Deidentify(inStr); err == nil {
//fmt.Println(inStr, "--------->", outStr)
return outStr
}
eng.Close()
} else {
fmt.Println("[dlp] NewEngine error: ", err.Error())
}
return inStr
}
/**
*从当前位置读取数据一直读取到下一位为0的时候结束
*/
func readBytesStr(datas []byte, position int64) (str string, dlen int64) {
buffer := new(bytes.Buffer)
for true {
b := datas[position]
position++
if b == 0 {
break
}
buffer.WriteByte(b)
}
str = buffer.String()
return str, position
}
运行效果:
说明:
主体基于golang的端口代理实现,数据识别算法完全依赖于godlp,核心在于上面的报文解析,注意别整大数据量,超出了一定数据量会报错,建议用小数据量测试,大数据量需要自己去兼容改写。
基于netty的实现
代码:
/**
* @description:
* @author: yx
* @date: 2022/3/26 9:17
* <p>
* <p>
* SocketOutputStream.socketWrite的数据是客户端发送给服务器的
* 初步估计只取前26位的内容才有需要区分
* 后面开始的就都是其他内容了
* 也就是说如果是相同的语句执行多次只有前八位不一样,大概猜测前四位是整个数据包的长度,第5-8位是会话次数的系统偏移量,不用理会
* <p>
* 前四位的变化是整体数据包长度,5-12位都是数据偏移量的长度,13-20位目前看到的都是一样的,但是理论上说有存在数据包格式的内容,暂时不处理。
* 目前需要研究的是数据包的第22-25位是做什么的,也是随着数据包长度变化的,这个要搞清楚,从这里看起,读取了4位:BsonDocumentCodec.decode
*
* 第22-25位其实是后面的字节长度,目前推测是前面的总数据包长度-21就等于后面的数据包长度.
* 去掉前16位后关注这部分内容是变化的内容
* postion 5-8
* postion 17-20
* postion 33-36
* postion 37-43
* 经测试少量数据没问题,大量当数据量达到一定限度后会出问题,暂时不考虑那么细了
*/
@Slf4j
public class MongoDbParser extends DefaultParser {
//处理当返回数据太大分包的情况
Map<String, ByteBuf> bufferMap = new HashMap();
public void dealChannel(ChannelHandlerContext ctx, ProxyConfig config, Channel channel, Object msg) {
Channel ctxChannel = ctx.channel();
InetSocketAddress inetSocketAddress = (InetSocketAddress) ctxChannel.remoteAddress();
String hostString = inetSocketAddress.getHostString();
int port = inetSocketAddress.getPort();
ByteBuf readBuffer = (ByteBuf) msg;
if (Objects.equals(config.getRemoteAddr(), hostString) && Objects.equals(port, config.getRemotePort())) {
//第一步先获取会话的id,如果当前会话的pid没有被结束则直接把所有的数据写入到缓冲区buffer里面
String localPid = channel.localAddress().toString();
if (bufferMap.containsKey(localPid)) {
ByteBuf byteBuf = bufferMap.get(localPid);
//如果写入完全了则直接进行sql解析
byteBuf.writeBytes(readBuffer);
if (byteBuf.writerIndex() == byteBuf.capacity()) {
dealBuffer(ctx, config, channel, byteBuf);
}
} else {
//读取到的数据包总长度
int buffLen = readBuffer.readableBytes();
int packLen = readPackLen(readBuffer);
readBuffer.readerIndex(0);
if (packLen == buffLen) {
//如果数据包长度相等说明数据包接收完全了,可以直接处理接下来的数据了
dealBuffer(ctx, config, channel, readBuffer);
} else if (packLen > buffLen) {
//说明数据包接收不完全,需要新建一个buffer存储当前的内容到map,这个少量数据没问题,
//当数据量达到一定大小有可能会有问题,研究原理就暂时不考虑这么细了,这部分留给大家自己扩展
ByteBuf tmpBuffer = Unpooled.buffer(packLen);
tmpBuffer.writeBytes(readBuffer);
bufferMap.put(localPid, tmpBuffer);
}
}
} else {
readBuffer.retain();
channel.writeAndFlush(readBuffer);
}
}
/**
* 读取数据一直到下一个字节位0
*
* @param readBuffer
* @return
*/
public String readStrToNull(ByteBuf readBuffer) {
//直接创建一个buffer
ByteBuf tmpBuffer = Unpooled.buffer();
byte tmpByte;
while ((tmpByte = readBuffer.readByte()) != 0) {
tmpBuffer.writeByte(tmpByte);
}
return tmpBuffer.toString(Charset.defaultCharset());
}
/**
* 处理数据,代码可能还不稳定,先写简单易懂的流水账等熟悉了再考虑封装优化
*
* @param ctx
* @param config
* @param channel
* @param readBuffer
*/
private void dealBuffer(ChannelHandlerContext ctx, ProxyConfig config, Channel channel, ByteBuf readBuffer) {
//1.跳过前25位直接从第26位读取;
readBuffer.readBytes(25);
//先读取一位判断他的类型
byte b = readBuffer.readByte();
//从这里读数据一直读取到结尾数位0然后返回字符串内容
String result = readStrToNull(readBuffer);
String localPid = channel.localAddress().toString();
bufferMap.remove(localPid);
if (Objects.equals(result, "cursor")) {
int readableBytes = readBuffer.readableBytes();
byte[] contentbytes = new byte[readableBytes];
readBuffer.getBytes(0, contentbytes);
//说明是查询的对象,需要对数据进行脱敏处理
//这里直接从49位开始读取,别问为啥,多读一下源码就知道了
readBuffer.readerIndex(49);
//从这里读取的数据就是数组中的数据包长度
int lisPackSize = readPackLen(readBuffer);
//如果数组大于5说明这里面有数据,需要读取他的内容,否则不做处理直接返回数据
if (lisPackSize > 5) {
while (true) {
if (readBuffer.readByte() == 0) {
//如果为0表示这个对象结束了
break;
}
//去掉数据包的前23位
readBuffer.readBytes(23);
//这里预览看是否会报错再做处理
while (true) {
byte tag = readBuffer.readByte();
if (tag == 0 || readBuffer.readerIndex() >= lisPackSize) {
//如果为0表示这个对象结束了
break;
}
//2表示字符串,这里暂时只处理字符串类型的,其他的再做考虑
if (tag == 2) {
//从这里读取数据一直读取到为0的位置就是key
String key = readStrToNull(readBuffer);
//读取下一个数据包长度为
dealValue(readBuffer);
} else {
}
}
}
}
readBuffer.readerIndex(0);
channel.writeAndFlush(readBuffer);
} else {
//其他数据,直接返回即可
readBuffer.readerIndex(0);
channel.writeAndFlush(readBuffer);
}
}
/**
* 读取这个key的value并且进行处理替换成新的字符串写进去
* @param readBuffer
*/
void dealValue(ByteBuf readBuffer) {
int valueSize = readPackLen(readBuffer);
int index = readBuffer.readerIndex();
ByteBuf byteBuf = readBuffer.readBytes(valueSize);
int readableBytes = byteBuf.readableBytes() - 1;
byte[] datas = new byte[readableBytes];
byteBuf.getBytes(0, datas);
String value = new String(datas);
String maskValue = replaceStr(value);
byte[] bytes = maskValue.getBytes();
for (int i = 0; i < readableBytes; i++) {
if (i < bytes.length) {
datas[i] = bytes[i];
} else {
datas[i] = 32;
}
}
int writerIndex = readBuffer.writerIndex();
int readerIndex = readBuffer.readerIndex();
//最终要把这个key写进去
readBuffer.readerIndex(index);
readBuffer.writerIndex(index);
readBuffer.writeBytes(datas);
//再把写入索引还原
readBuffer.writerIndex(writerIndex);
readBuffer.readerIndex(readerIndex);
}
/**
* 对读取到的字符串进行遮罩处理
* @param content
* @return
*/
String replaceStr(String content) {
int start = (int) (content.length() * 0.3);
int end = (int) (content.length() * 0.7);
//不确定需要脱敏的长度是多少,先这样处理后续再优化
List<String> replaceContent = new ArrayList<>();
for (int a = start; a < end; a++) {
replaceContent.add("*");
}
String join = StringUtils.join(replaceContent, "");
return content.substring(0, start) + join + content.substring(end);
}
/**
* 根据数组获取数据包的长度
* @param byteBuf
* @return
*/
int readPackLen(ByteBuf byteBuf) {
byte b1 = byteBuf.readByte();
byte b2 = byteBuf.readByte();
byte b3 = byteBuf.readByte();
byte b4 = byteBuf.readByte();
return makeInt(b4, b3, b2, b1);
}
/**
* 根据4位字节获取数据包的长度
* @param b3
* @param b2
* @param b1
* @param b0
* @return
*/
int makeInt(byte b3, byte b2, byte b1, byte b0) {
return (((b3) << 24) |
((b2 & 0xff) << 16) |
((b1 & 0xff) << 8) |
((b0 & 0xff)));
}
}
运行效果:
说明:
主体基于netty的端口代理实现,脱敏算法是不管任何内容直接写死脱敏30%-70%,如果需要可以自行改写扩展,核心在于上面的报文解析:注意别整大数据量,超出了一定数据量会报错,建议用小数据量测试,大数据量需要自己去兼容改写。
温馨提示
这玩意虽然用起来方便,但是用不用还是要斟酌一哈,写的目的本来就只是为了学习,还预留了一点发挥空间的,需要自己去处理大量数,因为这个是基于返回内容识别,所有的内容都需要交给dlp去通过他们的算法识别并脱敏,虽然安全(都交给dlp了)这个时间是无法预估的,返回结果刚好又是一个很大数据量的东西所以这么处理还是有问题的,这种对缓存的代理肯定还是不希望太慢了。理想状态最好还是根据key进行识别,只有敏感的key才对内容进行处理能节省很多时间(安全和方便本来就是互相冲突的,想要马儿跑就要给马儿吃草),本人学习项目,有兴趣的小伙伴欢迎交流。
github地址
大结局!