conntrack中的数据结构详解

说明

用户态conntrack原理分析 中也说到过,对数据结构没有进行详细的分析,本篇文章就是深入到conntrack的消息,一步步解析出IP地址和端口号等等信息.

还是以 conntrack-gofirst commit 为例来分析.

分析

由于是对conntrack的信息进行分析, 因此分析对象就是netfilter的返回包信息.所以我们从parseRawData 来分析

1
2
3
4
5
6
7
func parseRawData(data []byte) *ConntrackFlow {
s := &ConntrackFlow{}
var proto uint8
// First there is the Nfgenmsg header
// consume only the family field
reader := bytes.NewReader(data)
......

data是一个byte数组,通过bytes.NewReader()方法,得到一个Reader对象.

1
2
3
4
5
type Reader struct {
s []byte
i int64 // current reading index
prevRune int // index of previous rune; or < 0
}

当执行完毕reader := bytes.NewReader(data) 之后,此时的data和reader的内容分别如下:

接下来的分析都是基于这个数据,牢记这个数据.

1
2 0 0 0 52 0 1 128 20 0 1 128 8 0 1 0 127 0 0 1 8 0 2 0 127 0 0 1 28 0 2 128 5 0 1 0 6 0 0 0 6 0 2 0 157 128 0 0 6 0 3 0 4 56 0 0 52 0 2 128 20 0 1 128 8 0 1 0 127 0 0 1 8 0 2 0 127 0 0 1 28 0 2 128 5 0 1 0 6 0 0 0 6 0 2 0 4 56 0 0 6 0 3 0 157 128 0 0 8 0 3 0 0 0 1 142 8 0 7 0 0 6 151 126 28 0 9 128 12 0 1 0 0 0 0 0 0 0 0 5 12 0 2 0 0 0 0 0 0 0 2 76 28 0 10 128 12 0 1 0 0 0 0 0 0 0 0 6 12 0 2 0 0 0 0 0 0 0 3 3 48 0 4 128 44 0 1 128 5 0 1 0 3 0 0 0 5 0 2 0 0 0 0 0 5 0 3 0 0 0 0 0 6 0 4 0 42 0 0 0 6 0 5 0 42 0 0 0 8 0 8 0 0 0 0 0 8 0 12 0 138 10 32 24 8 0 11 0 0 0 0 1

reader中的s属性和data内容完全一样,是uint8(byte类型)的数组,len为252,cap为65520. 此时的i(即index)为0,表示没有任何的读取(因为Index为0,说明读取指针没有移动).

READ

接下来程序执行 binary.Read(reader, NativeEndian(), &s.FamilyType), FamilyType 是一个uint8类型的数据.即取第一个元素.前面已经列出了data的数据. [2 0 0 0 52 0 1 128 20 0 1 12.....]

第一个元素是2,对应的是s.FamilyType是2. 由于已经读取了一位,所以reader中的index应该为1.

数据的结果也和我们分析情况一致.

SEEK

接下来,程序执行如下代码:

1
2
3
4
5
6
const (
// backward compatibility with golang 1.6 which does not have io.SeekCurrent
seekCurrent = 1
)
// skip rest of the Netfilter header
reader.Seek(3, seekCurrent)

即,移动4个单位.移动的原因在 用户态conntrack原理分析 中也说到过 是因为在Netlink Data中的前面4个字节一般都是代表nfgenmsg信息. 观察此时的reader的信息. 由于reader调用了Seek()移动了3,所以此时的reader的index为4.

parseNfAttrTL

此时reader的index喂,接下来就是执行parseNfAttrTL(reader) . 此时的reader情况如下所示:

同样一步步来分析整个过程.

1
2
3
4
5
6
7
8
9
10
func parseNfAttrTL(r *bytes.Reader) (isNested bool, attrType, len uint16) {
binary.Read(r, NativeEndian(), &len)
len -= SizeofNfattr

binary.Read(r, NativeEndian(), &attrType)
isNested = (attrType & NLA_F_NESTED) == NLA_F_NESTED
attrType = attrType & (NLA_F_NESTED - 1)

return isNested, attrType, len
}

binary.Read(r, NativeEndian(), &len) 由于len是uint16类型,而Reader中的每个元素是uint8,所以len会读取2个元组,即52,0,所以len的长度是52.reader的i为6.如下所示:

此时reader的状态变为:

程序继续执行, binary.Read(r, NativeEndian(), &attrType).同样由于attrType是uint16,所以读取的数据是1和128.将两者组合成为uint16的数据. 同时NativeEndian()返回的小端.所以1和128的组合方式是:

128转换为二进制是:10000000 , 由于是unit8,转换为uint16,需要填充,最终是 10000000 00000000,1的二进制是 00000000 00000001 ,所以两者的组合就是 10000000 00000000 + 00000000 00000001 = 1000000000000001 ,最终转换为十进制就是32769.同时reader的index也会移动2位,变为8. 实际分析结果与数据一致.

parseNfAttrTL

程序第一次执行完parseNfAttrTL,根据attrType确定是CTA_TUPLE_ORIG,会继续执行parseNfAttrTL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if nested, t, l := parseNfAttrTL(reader); nested {
switch t {
case CTA_TUPLE_ORIG:
if nested, t, _ = parseNfAttrTL(reader); nested && t == CTA_TUPLE_IP {
proto = parseIpTuple(reader, &s.Forward)
}

...................................
func parseNfAttrTL(r *bytes.Reader) (isNested bool, attrType, len uint16) {
binary.Read(r, NativeEndian(), &len)
len -= SizeofNfattr

binary.Read(r, NativeEndian(), &attrType)
isNested = (attrType & NLA_F_NESTED) == NLA_F_NESTED
attrType = attrType & (NLA_F_NESTED - 1)

return isNested, attrType, len
}

此时的reader的index还是为8.data的读取状态是:

同样按照上面的分析方法,len长度变为了16.attrType的值是32769.reader移动4为,所以reader的index值是12.执行完毕之后,最终的data的数据情况如下所示:

parseIpTuple

程序接下来就是执行parseIpTuple(reader, &s.Forward)

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
func parseNfAttrTLV(r *bytes.Reader) (isNested bool, attrType, len uint16, value []byte) {
isNested, attrType, len = parseNfAttrTL(r)

value = make([]byte, len)
binary.Read(r, binary.BigEndian, &value)
return isNested, attrType, len, value
}
func parseNfAttrTL(r *bytes.Reader) (isNested bool, attrType, len uint16) {
binary.Read(r, NativeEndian(), &len)
len -= SizeofNfattr

binary.Read(r, NativeEndian(), &attrType)
isNested = (attrType & NLA_F_NESTED) == NLA_F_NESTED
attrType = attrType & (NLA_F_NESTED - 1)

return isNested, attrType, len
}

// This method parse the ip tuple structure
// The message structure is the following:
// <len, [CTA_IP_V4_SRC|CTA_IP_V6_SRC], 16 bytes for the IP>
// <len, [CTA_IP_V4_DST|CTA_IP_V6_DST], 16 bytes for the IP>
// <len, NLA_F_NESTED|nl.CTA_TUPLE_PROTO, 1 byte for the protocol, 3 bytes of padding>
// <len, CTA_PROTO_SRC_PORT, 2 bytes for the source port, 2 bytes of padding>
// <len, CTA_PROTO_DST_PORT, 2 bytes for the source port, 2 bytes of padding>
func parseIpTuple(reader *bytes.Reader, tpl *ipTuple) uint8 {
for i := 0; i < 2; i++ {
_, t, _, v := parseNfAttrTLV(reader)
switch t {
case CTA_IP_V4_SRC, CTA_IP_V6_SRC:
tpl.SrcIP = v
case CTA_IP_V4_DST, CTA_IP_V6_DST:
tpl.DstIP = v
}
}
// Skip the next 4 bytes nl.NLA_F_NESTED|nl.CTA_TUPLE_PROTO
reader.Seek(4, seekCurrent)
_, t, _, v := parseNfAttrTLV(reader)
if t == CTA_PROTO_NUM {
tpl.Protocol = uint8(v[0])
}
// Skip some padding 3 bytes
reader.Seek(3, seekCurrent)
for i := 0; i < 2; i++ {
_, t, _ := parseNfAttrTL(reader)
switch t {
case CTA_PROTO_SRC_PORT:
parseBERaw16(reader, &tpl.SrcPort)
case CTA_PROTO_DST_PORT:
parseBERaw16(reader, &tpl.DstPort)
}
// Skip some padding 2 byte
reader.Seek(2, seekCurrent)
}
return tpl.Protocol
}

程序首先会调用parseNfAttrTLV(reader),parseNfAttrTLV相比parseNfAttrTL就是多返回了一个对应读取内容的值.由于本质上还是调用的parseNfAttrTL,所以分析方法与上面的分析一致,这里就不做说明了. 此时i已经变为了16.当执行完毕,isNested, attrType, len = parseNfAttrTL(r) 之后,得到isNested为false,attrType为1,len为4.接下来就是执行:

1
2
3
value = make([]byte, len)
binary.Read(r, binary.BigEndian, &value)
return isNested, attrType, len, value

此时reader继续读取数据,由于len为4,所以就会读取4个元素.读取到value中.

读取方式是大端方式读取.value的值是127.0.0.1. 所以当执行完毕数据,以下的值分别变为:

  • isNested false
  • attrType 1
  • len 4
  • value 127.0.0.1
  • reader的index为20

最后程序回到如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const (
CTA_IP_V4_SRC = 1
CTA_IP_V4_DST = 2
CTA_IP_V6_SRC = 3
CTA_IP_V6_DST = 4
)

for i := 0; i < 2; i++ {
_, t, _, v := parseNfAttrTLV(reader)
switch t {
case CTA_IP_V4_SRC, CTA_IP_V6_SRC:
tpl.SrcIP = v
case CTA_IP_V4_DST, CTA_IP_V6_DST:
tpl.DstIP = v
}
}

由于t为1,所以命中CTA_IP_V4_SRC,最后得到tpl.SrcIP为127.0.0.1;同理可以得到tpl.DstIP为127.0.0.1. 之后程序又继续执行如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
// Skip the next 4 bytes  nl.NLA_F_NESTED|nl.CTA_TUPLE_PROTO
reader.Seek(4, seekCurrent)
_, t, _, v := parseNfAttrTLV(reader)
if t == CTA_PROTO_NUM {
tpl.Protocol = uint8(v[0])
}

const (
CTA_PROTO_NUM = 1
CTA_PROTO_SRC_PORT = 2
CTA_PROTO_DST_PORT = 3
)

首先会跳过4个字符,之后同样是调用parseNfAttrTLV函数.此时data的读取情况如下所示:

此时的数据情况是:

  • t:1
  • v:6

最终执行tpl.Protocol = uint8(v[0]), 得到tpl.Protocol为6,即当前协议是一个tcp的协议.
接下来程序执行如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Skip some padding 3 bytes
reader.Seek(3, seekCurrent)
for i := 0; i < 2; i++ {
_, t, _ := parseNfAttrTL(reader)
switch t {
case CTA_PROTO_SRC_PORT:
parseBERaw16(reader, &tpl.SrcPort)
case CTA_PROTO_DST_PORT:
parseBERaw16(reader, &tpl.DstPort)
}
// Skip some padding 2 byte
reader.Seek(2, seekCurrent)
}

程序首先会跳过3个元素,之后同样是调用parseNfAttrTL(reader)方法,获取t. 分析方法同上,最终得到的t为2.进入到 parseBERaw16(reader, &tpl.SrcPort).

1
2
3
func parseBERaw16(r *bytes.Reader, v *uint16) {
binary.Read(r, binary.BigEndian, v)
}

所以SrcPort的值是 10011101 (157的二进制) + 10000000 (128的二进制) = 1001110110000000(40320). 所以SrcPort是40320.

接下来同样是获取DstPort的代码.分析方法一样,最终的结果是: 1000000(4的二进制,补充0000)+111000(56的二进制)= 10000111000(1080),所以DstPort是1080.此时data的状态如下所示:

最后由于conntrack会同时记录网络包的发送信息和预期的返回包信息,当前我们仅仅只是分析了CAT_TUPLE_ORIG,即网络包的发送信息,接下来就是解析预期的返回包信息,解析方法完全与上述分析方法一样,就不做说明了.下图十分清晰明了地说明了各个数据结构.

通过上面的分析,我们发现其实conntrack的连接跟踪表的数据结构还是相当有规律的.整个的数据结构在之前的代码注释也说明了.

1
2
3
4
5
// <len, [CTA_IP_V4_SRC|CTA_IP_V6_SRC], 16 bytes for the IP>
// <len, [CTA_IP_V4_DST|CTA_IP_V6_DST], 16 bytes for the IP>
// <len, NLA_F_NESTED|nl.CTA_TUPLE_PROTO, 1 byte for the protocol, 3 bytes of padding>
// <len, CTA_PROTO_SRC_PORT, 2 bytes for the source port, 2 bytes of padding>
// <len, CTA_PROTO_DST_PORT, 2 bytes for the source port, 2 bytes of padding>

整个的数据解析过程也是按照这个来进行的.

总结

通过对conntrack的整个数据解析过程的分析,对conntrack的数据结构加深了理解,同时也方便我们利用conntrack来记录主机中的网络状态信息.