Suricata源码阅读笔记:Flow Engine

简介

Suricata中用于管理和维护流的模块称为Flow Engine,主要由两部分实现,第一部分的入口点是FlowHandlePacket函数,用于为新数据包进行流查找/分配,另一部分是FlowManagerThread线程,用于对超时的流进行删除。

初始化

初始化在FlowInitConfig中完成,与之相关的配置结构体为FlowConfig,其字段含义如下:

字段 含义
hash_rand 用于随机化hash table的分布,后面会介绍
hash_size 流表所使用的hash table的大小(桶数量),默认为65536
memcap flow engine所允许使用的最大内存数量,默认为32MB
prealloc 初始时预分配的流的数量,默认为10000
emergency_recovery 退出紧急状态前需要删除的流的百分比,默认为30

初始化的流程如下:

  1. 使用FlowQueueInit初始化一个用于存放空闲流(预分配的/回收后的)的flow_spare_q。这里专门使用了一个FlowQueue结构体来实现队列,内部是一个标准的链式队列(首指针、尾指针、元素个数),与Ring Buffer那种基于数组实现的循环队列相比,更适合存储容量未知的数据。
  2. 从配置中读取emergency-recovery、memcap、hash_size、prealloc等值,填充到flow_config的相应字段。
  3. 建立流表:按照hash_size为流表flow_hash分配内存,初始化各个bucket的锁,并将这个hash表大小记录到当前flow engine占用的内存flow_memuse中。
  4. 预分配流:调用FlowAlloc新建prealloc个Flow,然后使用FlowEnqueue将其放入flow_spare_q中。
  5. 调用FlowInitFlowProto,初始化各种协议相关的timeout和清理/状态函数。

流查找/分配

下面将按照FlowHandlePacket的流程,分析flow engine对于新送入的解码后的数据包是如何处理的。

对于一个Packet,首先在流表中查找其所属的流是否已经存在,若存在,则直接返回该流的引用即可,否则就需要分配一个新流。

该过程的实现由FlowGetFlowFromHash完成,函数会返回一个Flow指针,表示找到的或新分配的流。具体流程为:

  1. 调用FlowGetKey计算该包的hash key。对于IPv4包,使用FlowHashKey4结构体作为hash输入:
    typedef struct FlowHashKey4_ {
    union {
    struct {
    uint32_t src, dst;
    uint16_t sp, dp;
    uint16_t proto; /**< u16 so proto and recur add up to u32 */
    uint16_t recur; /**< u16 so proto and recur add up to u32 */
    uint16_t vlan_id[2];
    };
    const uint32_t u32[5];
    };
    } FlowHashKey4;

    其中,recur设置为Packet的recursion_level,防止隧道包与普通包hash到一块;vlan_id设置为Packet的vlan_id,这样就能将不同的VLAN的流进行隔离;sp和dp,在TCP/UDP情况下就设置为端口,其余包就设置为两个特殊值(0xfeed、0xbeef)。特别地,ICMP unreachable包的src, dst, sp, dp, proto都设置成了其附带的数据包头中的相应信息(Why?)。

    对于IPv6包,使用FlowHashKey6结构体作为hash输入,结构与FlowHashKey4类似,只是src和dst长度为16字节。

    实际计算hash的函数为hashword,该函数以word(4字节)为单位对输入数据进行hash。值得注意的是,该函数带了个参数initval,hash时会把这个值也考虑进去。FlowGetKey中对应这个参数传递的值为flow_config.hash_rand,这个值是在FlowInitConfig中随机计算出来的。这样一来,即使对于相同的数据流量,Suricata每次运行时所生成的流表结构都会不一样,这种随机行为能否防止针对Suricata本身的攻击(例如找到了hash算法的漏洞,构造特定的数据包试图让Suricata的流表退化成链表)。

  2. 以获取到的key为索引,在流表flow_hash中获取到一个FlowBucket的指针,该结构定义为:
    /* flow hash bucket -- the hash is basically an array of these buckets.
    * Each bucket contains a flow or list of flows. All these flows have
    * the same hashkey (the hash is a chained hash). When doing modifications
    * to the list, the entire bucket is locked. */
    typedef struct FlowBucket_ {
    Flow *head;
    Flow *tail;
    #ifdef FBLOCK_MUTEX
    SCMutex m;
    #elif defined FBLOCK_SPIN
    SCSpinlock s;
    #else
    #error Enable FBLOCK_SPIN or FBLOCK_MUTEX
    #endif
    } __attribute__((aligned(CLS))) FlowBucket;

  3. 使用FBLOCK_LOCK对该bucket上锁。实际使用的锁可能为spin lock或mutext,取决于FBLOCK_SPIN是否定义。
  4. 若head为NULL,说明这个bucket中还没有流,因此首先调用FlowGetNew新分配一个流,然后把它与bucket互相链接起来。接着,FlowInit函数使用Packet的信息初始化这个新流,然后FBLOCK_UNLOCK解锁并返回这个流。由于FlowGetNew中会调用FLOWLOCK_WRLOCK对flow进行上锁(因为后面需要使用),因此这里就不需要再锁了。
  5. 不为NULL,则bucket中有流,将尝试从其中的Flow链表中查找该packet所属的Flow。值得注意的是,这里做了个基于时间局部性(与缓存原理类似)的优化:将最近访问的流放到链表的头部,这样对于活跃的流的查找代价就会大大减少。在实施这种优化的情况下,处理流程为:
    • 使用FlowCompare比较head flow与packet,若相匹配,则说明已经找到了,且这个流已经在头部不需要再调整,则先锁上该flow再解锁bucket,然后返回。
    • 若不匹配,则继续遍历flow链表,如果找到了,则先把这个flow移到链表头部,再与上面一样lock & return。
    • 若未找到,则与4类似,获取一个新流并初始化,然后挂到链表尾部再返回。注意,这里并没有移到头部,因为新流不代表就是活跃流。

流更新

在获取到包所属的流后,接下来将根据这个包对流进行更新。

  1. 使用FlowReference将p->flow指向刚获取流,该函数内部会使用FlowIncrUsecnt增加该流的使用计数。注意,该机制的目的与通常的引用计数不同,不是为了在没有引用时回收资源,而是为了避免出现误删除等问题。
  2. 更新流的lastts_sec,即最近一次更新的时间。超时的判断就是基于这个时间的。
  3. 调用FlowGetPacketDirection获取数据包的方向。注意,这里的方向是相对于流来说的,而流的初始方向由该流的第一个包决定,若当前包与流的第一个包的方向一致(源端口相同或源地址相同),则方向为TOSERVER(可以认为是正向),否则为TOCLIENT(反向)。
  4. 若数据包方向为TOSERVER,则添加流状态标志FLOW_TO_DST_SEEN,并且为数据包的flowflags添加FLOW_PKT_TOSERVER;否则给流添加FLOW_TO_SRC_SEEN,给包添加FLOW_PKT_TOSERVER。
  5. 若流的FLOW_TO_DST_SEEN和FLOW_TO_SRC_SEEN都设置了,说明流已经建立,给包添加FLOW_PKT_ESTABLISHED标志。后面检测时会用到这个标志。
  6. 若流的FLOW_NOPACKET_INSPECTION(绕过包检测)或FLOW_NOPAYLOAD_INSPECTION(绕过负载检测)设置了,则给包也添加相应标志。
  7. 对流的更新完毕,解锁流(FLOWLOCK_UNLOCK)。
  8. 设置包的PKT_HAS_FLOW标志,然后返回。

流超时

流超时的实现由FlowManagerThread实现,该线程执行流程如下(通用的线程相关的处理未做记录):

  1. 调用FlowForceReassemblySetup为强制重组功能进行设置。
  2. 进入主循环。
  3. 首先检查flow_flags是否有FLOW_EMERGENCY标志,若有则打开emerg开关,表明进入紧急状态(内存吃紧)。
  4. 调用FlowUpdateSpareFlows,保证当前空闲流的数量正好等于prealloc,少的就alloc,多的就free。这样做应该是希望在性能和内存占用之间维持平衡。
  5. 流超时:调用FlowTimeoutHash进行实际的流超时处理。
  6. 其他超时:DefragTimeoutHash、HostTimeoutHash,与flow engine无关,估计是为了方便就放这里一起做了。
  7. 紧急情况处理:若emerg开关打开了,则进入紧急状态,将流超时的唤醒时间设置为1ms。若当前已经删除了emergency_recovery比率的流,就退出紧急模式并恢复正常唤醒时间(1s)。
  8. 接下来,就进入cond_timewait状态,如此循环下去。
  9. 若收到KILL信号而退出循环,进而结束进程。

如上所述,实际的超时处理由FlowTimeoutHash实现,流程为:

  1. 循环遍历整个hash表,获取FlowBucket。
  2. 若使用trylock尝试锁上FlowBucket失败,说明有其他线程正在该bucket上进行操作,则不去管这个bucket了,继续循环。原因?可能是因为超时的目的毕竟只是回收资源,并不是强制的,因此直接跳过去而不是等待解锁,更节省CPU时间。
  3. 若这个bucket上没流,则解锁,然后继续循环。
  4. 调用FlowManagerHashRowTimeout对这个bucket上的flow链表(Row)进行超时。
  5. 扫描完整个hash表后,就可以退出了,返回值为删除的超时流数目。

FlowManagerHashRowTimeout的流程如下:

  1. 从后往前(Why?)对flow链表进行遍历。
  2. 若使用trywrlock对flow上读写锁失败,则跳过。
  3. 调用FlowGetFlowState,获取流的状态(NEW、ESTABLISHED、CLOSED)。
  4. 调用FlowManagerFlowTimeout,判断该流失否应该超时,若不应该则解锁并跳过。首先需要用FlowGetFlowTimeout根据协议、状态和是否紧急来获取超时时间,然后看当前时间距离流的上一次更新时间lastts_sec是否已经大于这个超时时间。
  5. 调用FlowManagerFlowTimedOut,看是否可以把丢弃这个流。若有use_cnt则不能丢弃,若正处在重组过程中也不能丢弃,并且会调FlowForceReassemblyForFlowV2去强制重组(只有重组完成后数据才会交给应用层分析),这样下次再唤醒后就能删除了。
  6. 若可以丢弃,则从双向链表中删除这个流节点,然后调用FlowClearMemory清理该流所占内存,然后解锁并调用FlowMoveToSpare把它放到空闲流队列中去。

你可能感兴趣的:(Suricata)