tcp协议上的应用层协议检测时,需要做数据重组,这里简单介绍reassembly逻辑。

tcp处理的相关配置初始化位于main -> PostConfLoadedSetup -> PreRunInit -> StreamTcpInitConfig。

tcp reassembly的主体逻辑在FlowWorker -> StreamTcp中。

tcp状态机

tcp协议处理上细节很多,比如异常情况处理、midstream、async_oneside、4whs四次握手等,这里只关注普通tcp连接三次握手连接创建及数据收发。

async_oneside异步单边的含义目前理解为单向数据包捕获,即只有一个方向的数据包流经此IDS。
midstream理解为在一个tcp连接的中间建立IDS内部会话,因为没能从SYN包开始捕获到会话的全部数据包。
4whs四次握手顺序为,client发出SYN包、server发出SYN包、client发出SYNACK包、server发出ACK包。这里个人理解不很充分。

tcp数据包处理的有效逻辑位于FlowWorker -> StreamTcp -> StreamTcpPacket。可以看到这里出现了TcpSession结构体,指针所在位置为flow成员protoctx。这个结构体本身成员不多,也比较好理解,主要作用为指明这个tcp会话的当前状态。TcpSession内有client和server两个成员类型为TcpStream,这个结构体保存了一个方向数据流的全部信息,包括tcp头中体现的stream的状态和数据的缓存。TcpSteam中数据缓存保存在StreamingBuffer结构体中,接收到的数据segment的序号和长度保存在TcpSegment结构体中。这里介绍三次握手及后续数据包的基础处理逻辑,后面介绍相关结构体成员具体意义。

SYN数据包

SYN数据包是tcp连接中的首个数据包,由client发送给server,flow结构刚刚分配,session还没有创建,这时会进入StreamTcpPacketStateNone函数。这里我们不关注midstream和async_onside,只关注正常的三次握手,也就是这时进入只有SYN标记的分支。

  • 这里首先调用StreamTcpNewSession获得一个新的session,内部成员只进行了初始化,很简单。
  • 由于一个SYN包已经发送出来,session状态设置为TCP_SYN_SENT。
  • 更新session中client和server成员中tcp头相关的值,比如isn、next_seq、base_seq、last_ts、last_pkt_ts、flags、window、wscale等,这部分是tcp协议头相关,不做更多关注。

SYN/ACK数据包

SYN/ACK数据包是tcp连接中的第二个数据包,由server发送给client。这时session状态是TCP_SYN_SENT,因此调用栈为FlowWorker -> StreamTcp -> StreamTcpPacket -> StreamTcpPacketStateSynSent -> StreamTcp3whsSynAckUpdate。

  • session状态更新为TCP_SYN_RECV。
  • 更新session中client和server成员中tcp头相关的值。

ACK数据包

从第三个数据包开始,均为ACK数据包(这里先不考虑RST和FIN的情况),区别在于session状态的不同。第三个数据包收到时session状态为TCP_SYN_RECV,因此调用栈为FlowWorker -> StreamTcp -> StreamTcpPacket -> StreamTcpPacketStateSynRecv。

  • 更新session和其成员client与server的tcp协议头相关字段。
  • 更新session状态为TCP_ESTABLISHED。
  • 调用函数StreamTcpReassembleHandleSegment,这个函数从名字可以看出开始正式处理tcp数据包中可能携带的数据,并做reassembly。

从第四个数据包开始,session状态为TCP_ESTABLISHED,因此调用栈为FlowWorker -> StreamTcp -> StreamTcpPacket -> StreamTcpPacketStateEstablished。这里根据数据包方向的不同选择调用两个不同的函数。

  • HandleEstablishedPacketToServer
  • HandleEstablishedPacketToClient

但无论调用哪个函数,处理逻辑都是一致的。

  • 更新session和其成员client与server的tcp协议头相关字段.
  • 调用函数StreamTcpReassembleHandleSegment。

tcp状态机可以参考此图,suricata中tcp session状态记录与server端状态一致,区别在于TCP_SYN_SENT在实际server端是不存在的,但是suricata中记录此状态。
tcp状态迁移

tcp reassembly

StreamTcpReassembleHandleSegment函数用来处理tcp stream中的数据,这里的处理逻辑区分了suricata的当前模式是IDS还是IPS。

  • IDS模式下。
    • 首先处理对端stream已经缓存的数据包,进行应用层协议识别。函数栈为StreamTcpReassembleHandleSegmentUpdateACK -> StreamTcpReassembleAppLayer -> ReassembleUpdateAppLayer。
    • 然后处理当前packet的数据,加入到本端stream的缓存中。函数为StreamTcpReassembleHandleSegmentHandleData。
  • IPS模式下,或当前packet是PKT_PSEUDO_STREAM_END、RST或进入连接关闭流程。
    • 首先处理当前packet的数据,加入到本端stream的缓存中。函数为StreamTcpReassembleHandleSegmentHandleData。
    • 然后处理本端stream已经缓存的数据包,这里包含了刚刚加入缓存的数据,进行应用层协议识别。函数栈为StreamTcpReassembleAppLayer -> ReassembleUpdateAppLayer。

tcp segment数据重组

数据重组操作位于函数StreamTcpReassembleHandleSegmentHandleData。

  • 检查session的标记STREAMTCP_FLAG_APP_LAYER_DISABLED,和stream的标记STREAMTCP_STREAM_FLAG_NEW_RAW_DISABLED,如果这两个都关闭了,那么没有继续重组的必要了。问题是这两个标记什么时候设置的呢……TODO
  • 检查stream的标记STREAMTCP_STREAM_FLAG_DEPTH_REACHED,以及当前packet是否会导致超出reassembly depth,如果超过则增加该标记(这里的判断代码好像有一点问题,可能导致最后一个数据包直接不处理,而不是截断长度后处理)。这里如果需要处理的数据长度为0,则直接返回,不再插入缓存。
  • 取得一个TcpSegment(使用了一个结构PoolThread,后面介绍),设置其seq序列号和payload_len,payload_len可能是截断后的数据长度。
  • 调用函数StreamTcpReassembleInsertSegment。
    • 调用函数DoInsertSegment将刚才的segment插入到链表中,按seq序号排列。这个函数的返回值区分是segment是否有相互覆盖的情况。
    • 根据上一步的返回值区分是否有覆盖,选择走不同的逻辑处理数据缓存。这里我们只关注简单没有覆盖的场景。
      • 对比数据seq序号与stream->base_seq,确定数据写入在StreamingBuffer中的偏移和长度。拷贝数据内容,写入segment->sbseg。
      • 检查写入的数据后,StreamingBuffer中是否存在空隙,根据实际情况更新StreamingBuffer中的block_list和block_list_tail。StreamingBufferBlock的作用就是标识存在数据的区域,这样就能识别数据间的空隙。

stream数据缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct TcpSession_ {
PoolThreadReserved res;
uint8_t state;
uint8_t queue_len; /**< length of queue list below */
int8_t data_first_seen_dir;
/** track all the tcp flags we've seen */
uint8_t tcp_packet_flags;
/* coccinelle: TcpSession:flags:STREAMTCP_FLAG */
uint16_t flags;
uint32_t reassembly_depth; /**< reassembly depth for the stream */
TcpStream server;
TcpStream client;
TcpStateQueue *queue; /**< list of SYN/ACK candidates */
} TcpSession;
  • res
    PoolThread使用的的id号。
  • state
    会话状态,初始为TCP_NONE。共有以下状态,suricata中的会话状态与会话服务端也就是首个数据包接收端的状态一致。
    • TCP_NONE,
    • TCP_LISTEN,
    • TCP_SYN_SENT,
    • TCP_SYN_RECV,
    • TCP_ESTABLISHED,
    • TCP_FIN_WAIT1,
    • TCP_FIN_WAIT2,
    • TCP_TIME_WAIT,
    • TCP_LAST_ACK,
    • TCP_CLOSE_WAIT,
    • TCP_CLOSING,
    • TCP_CLOSED,
  • queue_len
  • data_first_seen_dir
    首次出现的数据的方向
    • STREAM_TOSERVER
    • STREAM_TOCLIENT
  • tcp_packet_flags
    保存了收到的两个方向数据包tcp头的所有flag,不确定起到什么作用。
  • flags
    tcp会话的标记,以下
    • STREAMTCP_FLAG_MIDSTREAM
    • STREAMTCP_FLAG_MIDSTREAM_ESTABLISHED
    • STREAMTCP_FLAG_MIDSTREAM_SYNACK
    • STREAMTCP_FLAG_TIMESTAMP
    • STREAMTCP_FLAG_SERVER_WSCALE
      server端支持WSCALE(窗口扩大选项)
    • STREAMTCP_FLAG_ASYNC
      标记这是一个异步单向收包的会话。
    • STREAMTCP_FLAG_4WHS
      标记这是一个四次握手建立连接的会话,SYN, SYN, SYN/ACK, ACK。
    • STREAMTCP_FLAG_DETECTION_EVASION_ATTEMPT
    • STREAMTCP_FLAG_CLIENT_SACKOK
      标记client支持SACK
    • STREAMTCP_FLAG_SACKOK
      标记双方均支持SACK
    • STREAMTCP_FLAG_3WHS_CONFIRMED
      三次握手后SERVER发送了ACK包后设置该标记,用于标记握手的绝对完成。
    • STREAMTCP_FLAG_APP_LAYER_DISABLED
    • STREAMTCP_FLAG_BYPASS
  • reassembly_depth
    reassembly支持的最大字节数。
  • server
    记录了由server到client的stream的状态,包括tcp头中的相关数据状态和用于reassembly的数据缓存。
  • client
    记录了由client到server的stream的状态,包括tcp头中的相关数据状态和用于reassembly的数据缓存。
  • queue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
typedef struct TcpStream_ {
uint16_t flags:12; /**< Flag specific to the stream e.g. Timestamp */
/* coccinelle: TcpStream:flags:STREAMTCP_STREAM_FLAG_ */
uint16_t wscale:4; /**< wscale setting in this direction, 4 bits as max val is 15 */
uint8_t os_policy; /**< target based OS policy used for reassembly and handling packets*/
uint8_t tcp_flags; /**< TCP flags seen */

uint32_t isn; /**< initial sequence number */
uint32_t next_seq; /**< next expected sequence number */
uint32_t last_ack; /**< last ack'd sequence number in this stream */
uint32_t next_win; /**< next max seq within window */
uint32_t window; /**< current window setting, after wscale is applied */

uint32_t last_ts; /**< Time stamp (TSVAL) of the last seen packet for this stream*/
uint32_t last_pkt_ts; /**< Time of last seen packet for this stream (needed for PAWS update)
This will be used to validate the last_ts, when connection has been idle for
longer time.(RFC 1323)*/
/* reassembly */
uint32_t base_seq; /**< seq where we are left with reassebly. Matches STREAM_BASE_OFFSET below. */

uint32_t app_progress_rel; /**< app-layer progress relative to STREAM_BASE_OFFSET */
uint32_t raw_progress_rel; /**< raw reassembly progress relative to STREAM_BASE_OFFSET */
uint32_t log_progress_rel; /**< streaming logger progress relative to STREAM_BASE_OFFSET */

StreamingBuffer sb;

TcpSegment *seg_list; /**< list of TCP segments that are not yet (fully) used in reassembly */
TcpSegment *seg_list_tail; /**< Last segment in the reassembled stream seg list*/

StreamTcpSackRecord *sack_head; /**< head of list of SACK records */
StreamTcpSackRecord *sack_tail; /**< tail of list of SACK records */
} TcpStream;
  • flags
    可包含以下标记
    • STREAMTCP_STREAM_FLAG_GAP
      实际运行代码中没有使用该标记。
    • STREAMTCP_STREAM_FLAG_NOREASSEMBLY
      不做重组和应用层分析。这个标记在tcp segment清理阶段可能被设置,前提是存在STREAMTCP_STREAM_FLAG_DEPTH_REACHED(也就是说已经达到了重组的最大深度)或另外两个条件(这两个条件不太确定设置时机,有兴趣可以继续看源码)。
    • STREAMTCP_STREAM_FLAG_KEEPALIVE
      收到了一个keepalive包。发出ack后清除。
    • STREAMTCP_STREAM_FLAG_DEPTH_REACHED
      达到重组深度。在检测、输出后的清理segment阶段,如果stream包含该标记,会再次增加STREAMTCP_STREAM_FLAG_NOREASSEMBLY标记。
    • STREAMTCP_STREAM_FLAG_TRIGGER_RAW
    • STREAMTCP_STREAM_FLAG_TIMESTAMP
      包含时间戳。
    • STREAMTCP_STREAM_FLAG_ZERO_TIMESTAMP
      时间戳值为0。
    • STREAMTCP_STREAM_FLAG_APPPROTO_DETECTION_COMPLETED
      应用层协议解析完成。
    • STREAMTCP_STREAM_FLAG_APPPROTO_DETECTION_SKIPPED
      应用层协议解析跳过。
    • STREAMTCP_STREAM_FLAG_NEW_RAW_DISABLED
    • STREAMTCP_STREAM_FLAG_DISABLE_RAW
  • wscale
    对方的窗口扩大选项因子。在syn和synack包中提供,但syn和synack包的窗口大小不受此选项影响,只对后续数据包有效。
  • os_policy
    此方向在一些特殊情况下数据包的处理策略会因为该项而不同。此项的取值由配置文件host-os-policy决定。
  • tcp_flags
    此方向所有tcp数据包头中flag的或集。
  • isn
    此方向的初始序号。
  • next_seq
    下一个数据包的序号。
  • last_ack
    此方向的最后被ack的序号。
  • next_win
    此方向下一个被允许发送的最大序号,超出将导致对方窗口超出。
  • window
    对方通告的窗口大小。
  • last_ts
    此方向的最后tcp时间戳。
  • last_pkt_ts
    此方向最后的数据包接收时间戳。
  • base_seq
    剩余的需要做reassembly的开始序号。跟sb.stream_offset指向的数据序号应该一直是统一的。
  • app_progress_rel
    相对于sb.stream_offset的数值。看起来是用于应用层协议检测的TODO
  • raw_progress_rel
    看起来是用于特征检测引擎的。TODO
  • log_progress_rel
  • sb
    reassmbly数据缓存。
  • seg_list
    记录了每一个缓存的数据包。
  • seg_list_tail
    同上。
  • sack_head
    用于SACK。
  • sack_tail
    同上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* \brief block of continues data
*/
typedef struct StreamingBufferBlock_ {
// block标记的数据头部相对于整个stream的偏移。
uint64_t offset;
// block标记的数据长度。
uint32_t len;
struct StreamingBufferBlock_ *next;
} StreamingBufferBlock;

typedef struct StreamingBuffer_ {
const StreamingBufferConfig *cfg;

// buf数据相对于整个stream数据的偏移。由于存在AutoSlide函数被调用的可能,也就是当空间不足以放置数据时,buf会保留尾部cfg->buf_slide大小的数据,并向左偏移,也就说是滑动的距离为buf_offset - cfg->buf_slide。这时stream_offset就需要增加滑动的距离,buf_offset变更为cfg->buf_slide。(个人认为如果AutoSlide被调用会导致stream_offset变更,但是app_progress_rel等不会改变,应该是个bug。实际代码运行中,AutoSlide不会被调用,因此bug不会出现)。
// 另外在tcp segment清理阶段,清理不需要的segment时,buf中的数据会做滑动,buf_offset和stream_offset会做调整。stream成员base_seq和app_progress_rel等成员也做相应调整。
uint64_t stream_offset; /**< offset of the start of the memory block */

// stream数据的缓存
uint8_t *buf; /**< memory block for reassembly */
// buf的长度
uint32_t buf_size; /**< size of memory block */
// buf被填充到的位置,也就是使用的字节数
uint32_t buf_offset; /**< how far we are in buf_size */

// StreamingBufferBlock是为了标记stream中接收数据出现了空洞而存在的。比如丢包产生的空洞。
// block记录了实际存在的数据,block之间的部分就是数据空洞。
StreamingBufferBlock *block_list;
StreamingBufferBlock *block_list_tail;
#ifdef DEBUG
uint32_t buf_size_max;
#endif
} StreamingBuffer;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct StreamingBufferSegment_ {
// stream_offset是相对于整个stream的offset。
uint64_t stream_offset;
// StreamingBuffer中的数据长度。
uint32_t segment_len;
} __attribute__((__packed__)) StreamingBufferSegment;

typedef struct TcpSegment_ {
PoolThreadReserved res;
uint16_t payload_len; /**< actual size of the payload */
uint32_t seq;
StreamingBufferSegment sbseg;
struct TcpSegment_ *next;
struct TcpSegment_ *prev;
} TcpSegment;

PoolThread

PoolThread结构体自身很简单,就是一组Pool,每个Pool有一个对应的锁保护多线程并发操作。由于一个PoolThread中可能包含多个Pool,而从一个线程中取得的数据,在归还时可能由另一个线程操作,为了能够归还到原有的那个Pool中,就要求由经由PoolThread分配的数据结构的第一个成员必须是PoolThreadReserved,也就是uint16_t,用于标记这个数据所归属的Pool。也因为这种多线程并发操作的可能,所有的操作都需要锁的保护。这种将一个公用Pool拆解成多个Pool的方式,个人理解理论上能够降低锁操作冲突的可能,但是不能降低锁操作的数量,具体效果可能需要单独测试。
使用时,所有使用线程都共用PoolThread指针,但是分别保存不同的id,这个id也就是该线程使用的Pool在PoolThread中多个Pool的索引值,使用这个索引值定位Pool并取得数据。归还数据时,取数据头部的PoolThreadReserved,这个PoolThreadReserved也就是前文提到的id,用于定位数据所属的Pool,将其归还。

PoolThread结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct PoolThreadElement_ {
SCMutex lock; /**< lock, should have low contention */
Pool *pool; /**< actual pool */
};
// __attribute__((aligned(CLS))); <- VJ: breaks on clang 32bit, segv in PoolThreadTestGrow01

typedef struct PoolThreadElement_ PoolThreadElement;

typedef struct PoolThread_ {
size_t size; /**< size of the array */
PoolThreadElement *array; /**< array of elements */
} PoolThread;

/** per data item reserved data containing the
* thread pool id */
typedef uint16_t PoolThreadReserved;

Pool

Pool结构和使用都很简单,从相关函数可以看到,PoolInit初始化了一个Pool,需要从池子中取得数据时调用PoolGet,使用结束后调用PoolReturn。这个结构减少了内存分配和释放操作,降低内存操作消耗的时间。
alloc_stack保存了已经分配数据内存的PoolBucket(也就是为了在Pool中缓存数据而定义的容器)。
empty_stack保存了没有分配内存数据的PoolBucket。
初始化时,size决定了Pool的max_bucket,也就是这个池子多大可以对外发出的数据的个数,0表示不限制。同时如果这个值大于0,将预先配分相应的PoolBucket放入empty_stack中。
初始化时,prealloc_size决定了预先分配数据的个数,这里的数据使用的内存块由data_buffer指针保存,预分配数据由PoolBucket保存,这里需要的容器由empty_stack链表中取得(如果有的话)或新分配内存。
size和prealloc_size决定了初始化时分配的PoolBucket的数量,后续不会再创建PoolBucket,合理数量的PoolBucket才能达到使用Pool减少内存操作时间的目的。
PoolGet时,优先从alloc_stack中取得数据,并将PoolBucket转入empty_stack。如果alloc_stack为空了,在分配的数据不超过max_buckets的情况下分配内存并返回,这里并不会创建新的PoolBucekt。
PoolReturn时,优先从empty_stack中取得bucket,放入数据并转入alloc_stack。如果empty_stack为空了,释放数据内存或无视这块内存(如果这块内存在预分配的data_buffer中的话,个人觉得这里的操作不是很妥当,可能会造成运行时一定程度的内存增长,虽然增长不会无限上升,但这个增长无助于性能提升)。

Pool相关函数
1
2
3
4
5
6
7
8
9
10
Pool *PoolInit(uint32_t size, uint32_t prealloc_size, uint32_t elt_size,  void *(*Alloc)(void), int (*Init)(void *, void *), void *InitData,  void (*Cleanup)(void *), void (*Free)(void *))

void PoolFree(Pool *);
void PoolPrint(Pool *);
void PoolPrintSaturation(Pool *p);

void *PoolGet(Pool *);
void PoolReturn(Pool *, void *);

void PoolRegisterTests(void);
Pool结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* pool bucket structure */
typedef struct PoolBucket_ {
void *data;
uint8_t flags;
struct PoolBucket_ *next;
} PoolBucket;

/* pool structure */
typedef struct Pool_ {
uint32_t max_buckets;
uint32_t preallocated;
uint32_t allocated; /**< counter of data elements, both currently in
* the pool and outside of it (outstanding) */

uint32_t alloc_stack_size;

PoolBucket *alloc_stack;

PoolBucket *empty_stack;
uint32_t empty_stack_size;

int data_buffer_size;
void *data_buffer;
PoolBucket *pb_buffer;

void *(*Alloc)(void);
int (*Init)(void *, void *);
void *InitData;
void (*Cleanup)(void *);
void (*Free)(void *);

uint32_t elt_size;
uint32_t outstanding; /**< counter of data items 'in use'. Pretty much
* the diff between PoolGet and PoolReturn */
uint32_t max_outstanding; /**< max value of outstanding we saw */
} Pool;