suricata 4.0.3 tcp reassembly
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 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的作用就是标识存在数据的区域,这样就能识别数据间的空隙。
1 | typedef struct 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 | typedef struct 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
- STREAMTCP_STREAM_FLAG_GAP
- 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 | /** |
1 | typedef struct StreamingBufferSegment_ { |
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,将其归还。
1 | struct PoolThreadElement_ { |
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中的话,个人觉得这里的操作不是很妥当,可能会造成运行时一定程度的内存增长,虽然增长不会无限上升,但这个增长无助于性能提升)。
1 | 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 *)) |
1 | /* pool bucket structure */ |