基于JIT技术的动态协议识别和应用防护

🙈 By 赵延刚, 韩赓 2024-07-10

1. 背景需求

由于提供了动态TCP/UDP proxy和proxy_protocol2的设置能力,NJet在非HTTP的私有TCP协议方面得到了较多的应用。常用的场景是NJet作为标准的TCP4层proxy,把请求转发到后端的应用服务,类似如下架构: img

当然市场上也有n多的4层代理,社区选择NJet,除了通过PROXY_PROTOCOL2可以灵活的设置属性外,还因为:

  • NJet提供了灵活的主动健康检查,可以在后端应用服务故障时自动切换
  • NJet CoPilot自带HA能力,无需额外配置可以自动构建HA集群

在实际使用中,有客户报告其端口常常会受到扫描的攻击,比如该端口是一个提供dubbo应用的服务,但有太多的时间收到非正常dubbo client发过来的连接请求。因此客户报告常常是应用服务器收到太多的连接请求导致服务关闭。

在目前阶段,采用的一个临时性的方案是限流(NJet可以配置并发的连接数),限制最大连接数。但这种情况无法区分非法和合法的client,会造成误伤,影响到正常服务。

所以NJet开发了协议识别功能,该功能目的是在连接建立阶段,识别出非法的连接,并立即关闭,避免连接请求发送到后端,从而对后端服务进行了保护。

如同Nginx, NJet可以在client到NJet建立连接,并在预读一段数据、分析校验后,再同后端的服务器建立连接。因此可以通过分析发过来的数据是否符合特定的私有协议,决定是否关闭连接。

2. 实现设计

为了应对企业千差万别的私有协议,NJet必须能够对协议可配置,由现场运维人员编辑应用协议的分析报文。一种直观的思维就是利用脚本来编写协议解析,比如openresty提供的LUA,就提供了类似的能力。但出于性能考虑,NJet决定引入JIT技术,把脚本在NJet的启动阶段编译成标准的c函数,并动态加载进NJet中,从而即实现了灵活性,又保证了优越于LUA等脚本语言的高性能。

此外,为了保证垃圾连接对NJet所在机器系统资源的影响,NJet在管理连接时,引入了快速关闭,绕过了TCP连接关闭的4次挥手机制,直接释放了系统资源。

2.1 指令设计

NJet 为该功能增加了两个配置指令,sniffer控制是否进行协议分析,sniffer_filter_file指明使用的具体分析脚本。

Syntax: sniffer on/off; 开关ddos分析功能:on 打开;off 关闭
Default: off
Context: stream server
Syntax: sniffer_filter_file [ c 文件路径文件名]; 制定执行过滤文件的路径,以及文件名。

例如: sniffer_filter_file “conf/sniffer.c”;
Default:
Context: stream server

2.2 脚本格式

sniffer.c文件是遵循C99标准格式的c语言代码块,必须以return结束,可以返回3个值:

  • NJT_ERROR:协议解析失败,是不合法的数据,NJet会关闭client连接
  • NJT_OK: NJet会转发该连接到后端
  • NJT_AGAIN: 数据长度不够,NJet会等待更多的数据

其骨架代码如下:

//变量定义
//字节流比较,做协议分析
return  NJT_OK ; //或NJT_AGAIN,NJT_ERROR

2.2.1 脚本中可使用的预定义变量

以下两个变量定义了目前client发送到的初始报文数据

  • bytes_len: 当前收到的数据长度。
  • bytes 当前收到的数据buffer 指针

2.2.2 脚本中可使用的函数

出于安全考虑,脚本中可以使用的函数是有限的,具体为

  • void sniffer_log(int level, const char *fmt, …) 写日志数据。 level 日志等级:
    • NJT_LOG_DEBUG debug 日志。
    • NJT_LOG_INFO info 日志。
    • NJT_LOG_ERR 错误日志。
  • int sniffer_get_hex_cmp(int pos, char* src,int len) 比对数据是否一致。
    • pos: 起始位置。
    • src : 对比的源数据buffer。
    • len :对比的数据长度 。
    • 返回值:
      • 比对失败:NJT_ERROR
      • 比对成功:NJT_OK
      • 数据长度不够:NJT_AGAIN 继续等待数据。
  • GET_BIT(x, bit) 获取某字节对应 bit位的值,该实现是一个宏定义,
    • x: 为char ,或int32, short, long, int64
    • bit: 可选值有x的类型决定
    • 返回值: 0 或 1
  • int sniffer_get_data(int pos,char* buffer,int buffer_len); 从预读的bytes中copy最大buffer_len的内容到buffer中
    • pos :从bytes[pos]起copy
    • buffer: 需预先分配内存
    • buffer_len: 接收的长度。
    • 返回值:实际copy的长度
  • int sniffer_get_hex_data(int pos,char* buffer,int buffer_len) 从预读的bytes中copy最大buffer_len的内容到buffer中,并把一个字节copy成两个字节,参数同sniffer_get_data

2.2.3样例

下列代码是一个完整的验证client发送的请求是MQTT连接报文的例子。

char buffer3[100] = {0x10,0x1f,0x00,0x04,0x4d,0x51,0x54,0x54,0x05,0x00,0x00,0x3c,0x03,0x21,0x00,0x14,0x00,0x0f,0x6e,0x6f,0x64,0x65,0x2d,0x75,0x30,0x31,0x5f,0x77,0x5f,0x38,0x34,0x31,0x35};
//参考https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901006,可以定义出
//需校验的协议头,buffer3
char name[128] = {0};
int ret = NJT_DECLINED;
char byte;
if (bytes_len < 5) {
        sniffer_log(NJT_LOG_DEBUG,"sniffer protocol agin=%d",bytes_len);
         return NJT_AGAIN;  //数据不够,等待数据。
}

sniffer_get_data(0,(char *)&byte,1);   

int Packet_type = GET_BIT(byte,4);     //取第1个字节从左数第5位

if (Packet_type != 1) {         //判断是否为1,不是1则返回error

    return NJT_ERROR;
}

char length_byte = 0;         

sniffer_get_data(1,(char *)&byte,1);

int Packet_bit = GET_BIT(byte,7);    //取第2个字节从左数第8位

if (Packet_bit == 0) {               //判断是否为0,为0根据mqtt协议,packet_length字段长度为1
          
  length_byte = 1;
  
}

int  len = byte;           //mqtt协议中,如果packet_length为1,则第二个字节为mqtt包长度

short  name_len = 0;

char name_len_buffer[100]  = {0};
sniffer_get_data(2,(char *)&name_len,2);    //取第3,4个字节
name_len = ntohs(name_len);                //获取协议名长度
sniffer_get_data(2+sizeof(name_len),(char *)&name,name_len);   //获取协议名
sniffer_get_data(2+sizeof(name_len)+name_len,(char *)&byte,1);   //获取版本号

int version=byte;
sniffer_log(NJT_LOG_DEBUG,"sniffer protocol name=%s,version=%d,len=%d,name_len=%d",name,version,len,name_len);

return NJT_OK;

3. 测试

下面是一个完整的验证client发送的请求是必须携带PROXY_PROTOCOL,否则被拒绝的例子。

3.1配置文件

如下例,njet作为4层proxy,监听2223,并转发到后端的2224应用服务,打开sniff开关及sniff文件

helper broker modules/njt_helper_broker_module.so conf/mqtt.conf; 
helper ctrl modules/njt_helper_ctrl_module.so conf/ctrl.conf;

user  root;
worker_processes 1;
#daemon off;
worker_rlimit_nofile 655350;

cluster_name helper;
node_name node-u01;

error_log  logs/error.log debug;
pid        logs/njet.pid;
events {
    worker_connections  1024;
}

stream {
        server {
                listen 2223;
            
                sniffer  on;
                sniffer_filter_file  "conf/sniffer.c";
                proxy_pass 127.0.0.1:2224;
        }
}

具体 sniffer.c 文件:

char v_2[100] = {0x0D,0x0A,0x0D,0x0A,0x00,0x0D,0x0A,0x51,0x55,0x49,0x54,0x0A};
char v_1[100] = {0x50,0x52,0x4f,0x58,0x59};


int ret = NJT_DECLINED;

if (bytes_len < 5) {
        sniffer_log(NJT_LOG_DEBUG,"sniffer protocol agin=%d",bytes_len);
         return NJT_AGAIN;  //数据不够,等待数据。
}
ret = sniffer_get_hex_cmp(0,v_1,5);
if (ret == NJT_OK) {
    
    sniffer_log(NJT_LOG_DEBUG,"sniffer protocol v1");
    return ret;  // 识别成功 protocol v1
} 

ret = sniffer_get_hex_cmp(0,v_2,12);
if (ret == NJT_OK) {
    sniffer_log(NJT_LOG_DEBUG,"sniffer protocol v2");
    return ret;  // 识别成功 protocol v2
    
} else if (ret == NJT_AGAIN) {
   sniffer_log(NJT_LOG_DEBUG,"sniffer protocol agin=%d",bytes_len);
   return  NJT_AGAIN;  //数据不够,等待数据。
} 


return NJT_ERROR;  //默认拦截。

3.1.1 用例一:protocol v1 版本

请求2221 端口的 protocol v1 版本。  
[root@k8s-139 proxy2]# curl  127.0.0.1:2221/test

成功输出:

[root@k8s-139 proxy2]# curl  127.0.0.1:2221/test
src:127.0.0.1:56254,dst:127.0.0.1:2221,tlv01:,tlv02:

日志输出:

2024/05/09 18:40:33 [debug] 32699#0: sniffer protocol v1

3.1.2 用例二:protocol v2 版本

请求2222 端口的 protocol v2 版本。  
[root@k8s-139 proxy2]# curl  127.0.0.1:2222/test

成功输出:

[root@k8s-139 tcc-0.9.26]# curl  127.0.0.1:2222/test
src:127.0.0.1:39046,dst:127.0.0.1:2222,tlv01:,tlv02:

日志输出:

2024/05/09 18:42:02 [debug] 32699#0: sniffer protocol v2

3.1.3 用例三:测试快速关闭

需要在另外一台机器上,telnet该端口,模拟非法访问

telnet 192.168.40.139  2223 端口。 

[root@CDN156 ~]# telnet  192.168.40.139  2223
Trying 192.168.40.139...
Connected to 192.168.40.139.
Escape character is '^]'.

[root@CDN156 ~]# netstat -an |grep 2223
tcp        0      0 192.168.40.156:58192    192.168.40.139:2223     ESTABLISHED

当在telnet中输入随机字符时,会被NJet拦截 日志输出:

2024/05/09 18:58:39 [debug] 4688#0: sniffer protocol fail=54

查看网络连接:没有ESTABLISHED状态。表明快速关闭,成功。

[root@k8s-139 tcc-0.9.26]# netstat -an |grep 2223
tcp        0      0 0.0.0.0:2223            0.0.0.0:*               LISTEN  

4. 其它说明

  • 只支持tcp的协议。
  • 快速端口关闭及资源释放请参考 https://oroboro.com/dealing-with-network-port-abuse-in-sockets-in-c/
  • 感谢TCC提供的JIT实现及C语言脚本支持
  • Just In Time(JIT):是一种把脚本由解释执行预先编译成机器码,从而加速执行的技术。NJet使用的是Pre-JIT,即把所有的脚本代码都预先编译成类似so,从而被业务当作普通的库函数执行
  • NJ动态协议识别需要NJet3.0及以上