转自:http://blog.chinaunix.net/uid-23069658-id-3160506.html
(一)洞悉linux下的Netfilter&iptables:什么是Netfilter?
很多人在接触iptables之后就会这么一种感觉:我通过iptables命令配下去的每一条规则,到底是如何生效的呢?内核又是怎么去执行这些规则匹配呢?如果iptables不能满足我当下的需求,那么我是否可以去对其进行扩展呢?这些问题,都是我在接下来的博文中一一和大家分享的话题。这里需要指出:因为Netfilter与IP协议栈是无缝契合的,所以如果你要是有协议栈方面的基础,在阅读本文时一定会感觉轻车熟路。当然,如果没有也没关系,因为我会在关键点就协议栈的入门知识给大家做个普及。只是普及哦,不会详细深入下去的,因为涉及的东西太多了,目前我还正在研究摸索当中呢。好了,废话不多说,进入正题。
备注:我研究的内核版本是2.6.21,iptables的版本1.4.0。
什么是Netfilter?
为了说明这个问题,首先看一个网络通信的基本模型:
在数据的发送过程中,从上至下依次是“加头”的过程,每到达一层数据就被会加上该层的头部;与此同时,接受数据方就是个“剥头”的过程,从网卡收上包来之后,在往协议栈的上层传递过程中依次剥去每层的头部,最终到达用户那儿的就是裸数据了。
那么,“栈”模式底层机制基本就是像下面这个样子:
对于收到的每个数据包,都从“A”点进来,经过路由判决,如果是发送给本机的就经过“B”点,然后往协议栈的上层继续传递;否则,如果该数据包的目的地是不本机,那么就经过“C”点,然后顺着“E”点将该包转发出去。
对于发送的每个数据包,首先也有一个路由判决,以确定该包是从哪个接口出去,然后经过“D”点,最后也是顺着“E”点将该包发送出去。
协议栈那五个关键点A,B,C,D和E就是我们Netfilter大展拳脚的地方了。
Netfilter是Linux 2.4.x引入的一个子系统,它作为一个通用的、抽象的框架,提供一整套的hook函数的管理机制,使得诸如数据包过滤、网络地址转换(NAT)和基于协议类型的连接跟踪成为了可能。Netfilter在内核中位置如下图所示:
这幅图,很直观的反应了用户空间的iptables和内核空间的基于Netfilter的ip_tables模块之间的关系和其通讯方式,以及Netfilter在这其中所扮演的角色。
回到前面讨论的关于协议栈那五个关键点“ABCDE”上来。Netfilter在netfilter_ipv4.h中将这个五个点重新命了个名,如下图所示,意思我就不再解释了,猫叫咪咪而已:
在每个关键点上,有很多已经按照优先级预先注册了的回调函数(后面再说这些函数是什么,干什么用的。有些人喜欢把这些函数称为“钩子函数”,说的是同一个东西)埋伏在这些关键点,形成了一条链。对于每个到来的数据包会依次被那些回调函数“调戏”一番再视情况是将其放行,丢弃还是怎么滴。但是无论如何,这些回调函数最后必须向Netfilter报告一下该数据包的死活情况,因为毕竟每个数据包都是Netfilter从人家协议栈那儿借调过来给兄弟们Happy的,别个再怎么滴也总得“活要见人,死要见尸”吧。每个钩子函数最后必须向Netfilter框架返回下列几个值其中之一:
n NF_ACCEPT 继续正常传输数据报。这个返回值告诉 Netfilter:到目前为止,该数据包还是被接受的并且该数据包应当被递交到网络协议栈的下一个阶段。
n NF_DROP 丢弃该数据报,不再传输。
n NF_STOLEN 模块接管该数据报,告诉Netfilter“忘掉”该数据报。该回调函数将从此开始对数据包的处理,并且Netfilter应当放弃对该数据包做任何的处理。但是,这并不意味着该数据包的资源已经被释放。这个数据包以及它独自的sk_buff数据结构仍然有效,只是回调函数从Netfilter 获取了该数据包的所有权。
n NF_QUEUE 对该数据报进行排队(通常用于将数据报给用户空间的进程进行处理)
n NF_REPEAT 再次调用该回调函数,应当谨慎使用这个值,以免造成死循环。
为了让我们显得更专业些,我们开始做些约定:上面提到的五个关键点后面我们就叫它们为hook点,每个hook点所注册的那些回调函数都将其称为hook函数。
Linux 2.6版内核的Netfilter目前支持IPv4、IPv6以及DECnet等协议栈,这里我们主要研究IPv4协议。关于协议类型,hook点,hook函数,优先级,通过下面这个图给大家做个详细展示:
对于每种类型的协议,数据包都会依次按照hook点的方向进行传输,每个hook点上Netfilter又按照优先级挂了很多hook函数。这些hook函数就是用来处理数据包用的。
Netfilter使用NF_HOOK(include/linux/netfilter.h)宏在协议栈内部切入到Netfilter框架中。相比于2.4版本,2.6版内核在该宏的定义上显得更加灵活一些,定义如下:
#define NF_HOOK(pf, hook, skb, indev, outdev, okfn) \
NF_HOOK_THRESH(pf, hook, skb, indev, outdev, okfn,INT_MIN)
关于宏NF_HOOK各个参数的解释说明:
1) pf:协议族名,Netfilter架构同样可以用于IP层之外,因此这个变量还可以有诸如PF_INET6,PF_DECnet等名字。
2) hook:HOOK点的名字,对于IP层,就是取上面的五个值;
3) skb:不解释;
4) indev:数据包进来的设备,以struct net_device结构表示;
5) outdev:数据包出去的设备,以struct net_device结构表示;
(后面可以看到,以上五个参数将传递给nf_register_hook中注册的处理函数。)
6) okfn:是个函数指针,当所有的该HOOK点的所有登记函数调用完后,转而走此流程。
而NF_HOOK_THRESH又是一个宏:
#define NF_HOOK_THRESH(pf, hook, skb, indev, outdev, okfn, thresh) \
({int __ret; \
if ((__ret=nf_hook_thresh(pf, hook, &(skb), indev, outdev, okfn, thresh, 1)) == 1)\
__ret = (okfn)(skb); \
__ret;})
我们发现NF_HOOK_THRESH宏只增加了一个thresh参数,这个参数就是用来指定通过该宏去遍历钩子函数时的优先级,同时,该宏内部又调用了nf_hook_thresh函数:
static inline int nf_hook_thresh(int pf, unsigned int hook, struct sk_buff **pskb, struct net_device *indev, struct net_device *outdev, int (*okfn)(struct sk_buff *), int thresh, int cond) { if (!cond) return 1; #ifndef CONFIG_NETFILTER_DEBUG if (list_empty(&nf_hooks[pf][hook])) return 1; #endif return nf_hook_slow(pf, hook, pskb, indev, outdev, okfn, thresh); } |
这个函数又只增加了一个参数cond,该参数为0则放弃遍历,并且也不执行okfn函数;为1则执行nf_hook_slow去完成钩子函数okfn的顺序遍历(优先级从小到大依次执行)。
在net/netfilter/core.h文件中定义了一个二维的结构体数组,用来存储不同协议栈钩子点的回调处理函数。
struct list_head nf_hooks[NPROTO][NF_MAX_HOOKS];
其中,行数NPROTO为32,即目前内核所支持的最大协议簇;列数NF_MAX_HOOKS为挂载点的个数,目前在2.6内核中该值为8。nf_hooks数组的最终结构如下图所示。
在include/linux/socket.h中IP协议AF_INET(PF_INET)的序号为2,因此我们就可以得到TCP/IP协议族的钩子函数挂载点为:
PRE_ROUTING: nf_hooks[2][0]
LOCAL_IN: nf_hooks[2][1]
FORWARD: nf_hooks[2][2]
LOCAL_OUT: nf_hooks[2][3]
POST_ROUTING: nf_hooks[2][4]
同时我们看到,在2.6内核的IP协议栈里,从协议栈正常的流程切入到Netfilter框架中,然后顺序、依次去调用每个HOOK点所有的钩子函数的相关操作有如下几处:
1)、net/ipv4/ip_input.c里的ip_rcv函数。该函数主要用来处理网络层的IP报文的入口函数,它到Netfilter框架的切入点为:
NF_HOOK(PF_INET,NF_IP_PRE_ROUTING, skb, dev, NULL,ip_rcv_finish)
根据前面的理解,这句代码意义已经很直观明确了。那就是:如果协议栈当前收到了一个IP报文(PF_INET),那么就把这个报文传到Netfilter的NF_IP_PRE_ROUTING过滤点,去检查[R]在那个过滤点(nf_hooks[2][0])是否已经有人注册了相关的用于处理数据包的钩子函数。如果有,则挨个去遍历链表nf_hooks[2][0]去寻找匹配的match和相应的target,根据返回到Netfilter框架中的值来进一步决定该如何处理该数据包(由钩子模块处理还是交由ip_rcv_finish函数继续处理)。
[R]:刚才说到所谓的“检查”。其核心就是nf_hook_slow()函数。该函数本质上做的事情很简单,根据优先级查找双向链表nf_hooks[][],找到对应的回调函数来处理数据包:
struct list_head **i;
list_for_each_continue_rcu(*i, head) {
struct nf_hook_ops *elem = (struct nf_hook_ops *)*i;
if (hook_thresh > elem->priority)
continue;
verdict = elem->hook(hook, skb, indev, outdev, okfn);
if (verdict != NF_ACCEPT) { … … }
return NF_ACCEPT;
}
上面的代码是net/netfilter/core.c中的nf_iterate()函数的部分核心代码,该函数被nf_hook_slow函数所调用,然后根据其返回值做进一步处理。
2)、net/ipv4/ip_forward.c中的ip_forward函数,它的切入点为:
NF_HOOK(PF_INET,NF_IP_FORWARD, skb, skb->dev, rt->u.dst.dev,ip_forward_finish);
在经过路由抉择后,所有需要本机转发的报文都会交由ip_forward函数进行处理。这里,该函数由NF_IP_FOWARD过滤点切入到Netfilter框架,在nf_hooks[2][2]过滤点执行匹配查找。最后根据返回值来确定ip_forward_finish函数的执行情况。
3)、net/ipv4/ip_output.c中的ip_output函数,它切入Netfilter框架的形式为:
NF_HOOK_COND(PF_INET,NF_IP_POST_ROUTING, skb, NULL, dev,ip_finish_output,
!(IPCB(skb)->flags & IPSKB_REROUTED));
这里我们看到切入点从无条件宏NF_HOOK改成了有条件宏NF_HOOK_COND,调用该宏的条件是:如果协议栈当前所处理的数据包skb中没有重新路由的标记,数据包才会进入Netfilter框架。否则直接调用ip_finish_output函数走协议栈去处理。除此之外,有条件宏和无条件宏再无其他任何差异。
如果需要陷入Netfilter框架则数据包会在nf_hooks[2][4]过滤点去进行匹配查找。
4)、还是在net/ipv4/ip_input.c中的ip_local_deliver函数。该函数处理所有目的地址是本机的数据包,其切入函数为:
NF_HOOK(PF_INET,NF_IP_LOCAL_IN, skb, skb->dev, NULL,ip_local_deliver_finish);
发给本机的数据包,首先全部会去nf_hooks[2][1]过滤点上检测是否有相关数据包的回调处理函数,如果有则执行匹配和动作,最后根据返回值执行ip_local_deliver_finish函数。
5)、net/ipv4/ip_output.c中的ip_push_pending_frames函数。该函数是将IP分片重组成完整的IP报文,然后发送出去。进入Netfilter框架的切入点为:
NF_HOOK(PF_INET,NF_IP_LOCAL_OUT, skb, NULL, skb->dst->dev,dst_output);
对于所有从本机发出去的报文都会首先去Netfilter的nf_hooks[2][3]过滤点去过滤。一般情况下来来说,不管是路由器还是PC中端,很少有人限制自己机器发出去的报文。因为这样做的潜在风险也是显而易见的,往往会因为一些不恰当的设置导致某些服务失效,所以在这个过滤点上拦截数据包的情况非常少。当然也不排除真的有特殊需求的情况。
小节:整个Linux内核中Netfilter框架的HOOK机制可以概括如下:
在数据包流经内核协议栈的整个过程中,在一些已预定义的关键点上PRE_ROUTING、LOCAL_IN、FORWARD、LOCAL_OUT和POST_ROUTING会根据数据包的协议簇PF_INET到这些关键点去查找是否注册有钩子函数。如果没有,则直接返回okfn函数指针所指向的函数继续走协议栈;如果有,则调用nf_hook_slow函数,从而进入到Netfilter框架中去进一步调用已注册在该过滤点下的钩子函数,再根据其返回值来确定是否继续执行由函数指针okfn所指向的函数。
(二)洞悉linux下的Netfilter&iptables:内核中的ip_tables小觑
Netfilter框架为内核模块参与IP层数据包处理提供了很大的方便,内核的防火墙模块(ip_tables)正是通过把自己所编写的一些钩子函数注册到Netfilter所监控的五个关键点(NF_IP_PRE_ROUTING,
从上图我们可以看出,ip_tables模块它是防火墙的核心模块,负责维护防火墙的规则表,通过这些规则,实现防火墙的核心功能。归纳起来,主要有三种功能:包过滤(filter)、NAT以及包处理(mangle)。同进该模块留有与用户空间通讯的接口。如第一篇博文中Netfilter处于内核中位置那副图所描述的情形。
在内核中我们习惯将上述的filter,nat和mangle等称之为模块。连接跟踪conntrack有些特殊,它是NAT模块和状态防火墙的功能基础,其实现机制我们也会在后面详细分析的。
OK,回到开篇的问题,我们来看一下基于Netfilter的防火墙系统到底定义了哪些钩子函数?而这些钩子函数都是分别挂载在哪些hook点的?按照其功能结构划分,我将这些hook函数总结如下:
包过滤子功能:包过滤一共定义了四个hook函数,这四个hook函数本质最后都调用了ipt_do_table()函数。
网络地址转换子功能:该模块也定义了四个hook函数,其中有三个最终也都调用了ip_nat_fn()函数,ip_nat_adjust()有自己另外的功能。
连接跟踪子功能:这里连接跟踪应该称其为一个子系统更合适些。它也定义四个hook函数,其中ip_conntrack_local()最后其实也调用了ip_conntrack_in()函数。
以上便是Linux的防火墙---iptables在内核中定义的所有hook函数。接下来我们再梳理一下这些hook函数分别是被挂载在哪些hook点上的。还是先贴个三维框图,因为我觉得这个图是理解Netfilter内核机制最有效,最直观的方式了,所以屡用不爽!然后,我们拿一把大刀,从协议栈的IPv4点上顺着hook点延伸的方向一刀切下去,就会得到一个平面,如上图所示。前面这些hook函数在这个平面上的分布情况如下所示:
这幅图彻底暴露了ip_tables内核模块中那些hook函数在各个hook点分布情况。与此同时,这个图还告诉了我们很多信息:所有由网卡收上来的数据包率先被ip_conntrack_defrag处理;链接跟踪系统的入口函数以-200的优先级被注册到了PRE_ROUTING和LOCAL_OUT两个hook点上,且其优先级高于mangle操作,NAT和包过滤等其他模块;DNAT可以在PRE_ROUTING和LOCAL_OUT两个hook点来做,SNAT可以在LOCAL_IN和POST_ROUTING两个hook点上。如果你认真研究会发现这个图确实很有用。因为当初为了画这个图我可是两个晚上没睡好觉啊,画出来后还要验证自己的想法,就得一步一步给那些关键的hook点和hook函数分别加上调试打印信息,重新编译内核然后确认这些hook函数确实是按照我所分析的那样被调用的。因为对学术严谨就是对自己负责,一直以来我也都这么坚信的。“没有调查就没发言权”;在我们IT行业,“没有亲自动手做过就更没有发言权”。又扯远了,赶紧收回来。
框架的东西多看些之上从宏观上可以使我们对整个系统的架构和设计有个比较全面的把握,接下来在分析每个细节的时候才会做到心中有数,不至于“盲人摸象”的境地。在本章即将结束之际,我们来看点代码级的东西。我保证只是个简单的入门了解,因为重头戏我打算放到后面,大家也知道分析代码其实是最头疼的,关键还是看自己的心态。
Netfilter的实现方式:
第一篇我们讲了Netfilter的原理,这里我们谈谈其实现机制的问题。
我们回头分析一下那个用于存储不同协议簇在每个hook点上所注册的hook函数链的二维数组 nf_hooks[][],其类型为list_head:
struct list_head nf_hooks[NPROTO][NF_MAX_HOOKS];
list_head{}结构体定义在include/linux/list.h头文件中
struct list_head {
structlist_head *next, *prev;
};
这是Linux内核中处理双向链表的标准方式。当某种类型的数据结构需要被组织成双向链表时,会在该数据结构的第一个字段放置一个list_head{}类型的成员。在后面的使用过程中可以通过强制类型转换来实现双向链表的遍历操作。
在Netfilter中一个非常重要的数据结构是nf_hook_ops{}
struct nf_hook_ops { struct list_head list; /* User fills in from here down. */ nf_hookfn *hook; struct module *owner; int pf; int hooknum; /* Hooks are ordered in ascending priority. */ int priority; }; |
对该结构体中的成员参数做一下解释:
n list:因为在一个HOOK点有可能注册多个钩子函数,因此这个变量用来将某个HOOK点所注册的所有钩子函数组织成一个双向链表;
n hook:该参数是一个指向nf_hookfn类型的函数的指针,由该函数指针所指向的回调函数在该hook被激活时调用【nf_hookfn在后面做解释】;
n owner:表示这个hook是属于哪个模块的
n pf:该hook函数所处理的协议。目前我们主要处理IPv4,所以该参数总是PF_INET;
n hooknum:钩子函数的挂载点,即HOOK点;
n priority:优先级。前面也说过,一个HOOK点可能挂载了多个钩子函数,当Netfilter在这些HOOK点上遍历查找所注册的钩子函数时,这些钩子函数的先后执行顺序便由该参数来制定。
nf_hookfn所定义的回调函数的原型在include/linux/netfilter.h文件中:
typedef unsigned int nf_hookfn(unsigned int hooknum, //HOOK点 struct sk_buff **skb, //不解释 const struct net_device *in, //数据包的网络如接口 const struct net_device *out, //数据包的网络出接口 int (*okfn)(struct sk_buff *)); //后续的处理函数 |
我们可以到,上面这五个参数最后将由NF_HOOK或NF_HOOK_COND宏传递到Netfilter框架中去。
如果要增加新的钩子函数到Netfilter中相应的过滤点,我们要做的工作其实很简单:
1)、编写自己的钩子函数;
2)、实例化一个structnf_hook_ops{}结构,并对其进行适当的填充,第一个参数list并不是用户所关心的,初始化时必须设置成{NULL,NULL};
3)、用nf_register_hook()
这也是最原生的扩展方式。有了上面这个对nf_hook_ops{}及其用法的分析,后面我们再分析其他模块,如filter模块、nat模块时就会不那么难懂了。
内核在网络协议栈的关键点引入NF_HOOK宏,从而搭建起了整个Netfilter框架。但是NF_HOOK宏仅仅只是一个跳转而已,更重要的内容是“内核是如何注册钩子函数的呢?这些钩子函数又是如何被调用的呢?谁来维护和管理这些钩子函数?”
(三)洞悉linux下的Netfilter&iptables:内核中的rule,match和target
作为ipchains的后继者,iptables具有更加优越的特性,良好的可扩展功能、更高的安全性以及更加紧凑、工整、规范的代码风格。
在2.6的内核中默认维护了三张表(其实是四张,还有一个名为raw的表很少被用到,这里不对其进行分析介绍了):filter过滤表,nat地址转换表和mangle数据包修改表,每张表各司其职。我们对这三张表做一下简要说明:
1)、filter表
该表是整个过滤系统中真正起“过滤”作用的地方。所有对数据包的过滤工作都在这个表里进行,也就是说用户如果需要对某种类型的数据包进行过滤拦截,那么最好在这个表中进行操作。filter表会在NF_IP_LOCAL_IN、NF_IP_FORWARD和NF_IP_LOCAL_OUT三个hook点注册钩子函数,也就是说所有配置到filer表中的规则只可能在这三个过滤点上进行设置。
2)、nat表
主要用于DNAT和SNAT和地址伪装等操作。用于修改数据包的源、目的地址。目前版本的内核中nat表监视四个hook点:NF_IP_PRE_ROUTING、NF_IP_LOCAL_IN/OUT、NF_IP_POST_ROUTING。但在真正的实际应用中,我们一般仅需要在nat表的PREROUTING和POSTROUTING点上注册钩子函数。该表有个特性:只有新连接的第一个数据包会经过这个表,随后该连接的所有数据包将按照第一个数据包的处理动作做同样的操作,这种特性是由连接跟踪机制来实现的。
3)、mangle表
该表主要用于对数据包的修改,诸如修改数据包的TOS、TTL等字段。同时该表还会对数据包打上一些特殊的标签以便结合TC等工具,实现诸如Qos等功能。该表监视所有的hook点。在 Netfilter 中规则是顺序存储的,一条rule规则主要包括三个部分:
上面这几个结构体的成员属性基本上已经做到了“见名知意”,而且内核源码也对它们做了充分的注解。这里只对最后一个属性elem做一下说明,其定义为unsignedchar elems[0]。大家可能觉得有些奇怪,怎么定了一个大小为零的数组呢?而且有些面试官曾经就这样的定义还向面试者发问过呢。这种方式定义的数组叫柔性数组,又叫可变长数组。为了不至于冲淡本文主题,这里给出一个关于柔性数组的链接,这位大牛已经将的很清楚了,大家可以去拜读拜读:http://blog.csdn.net/supermegaboy/article/details/4854939。更多详细的内容可以去研读C99标准。
我们将看到内核中大量的在运用柔性数组,包括我们即将要介绍的这两个结构体:这两个双胞胎兄弟一眼望去还以为它们是同一个东西,但事实并非如你所想的那样。其中ipt_entry_match{}表示防火墙规则的匹配部分;ipt_entry_target{}表示防火墙规则的动作处理部分。大家先忽略掉它们左边那条烦人的提示部分union,后面我会详细介绍的,现在请跟我一样尽情地无视它们吧。
这里我们还注意到,这两个家伙分别都拖了一条小尾巴:ipt_match{}和ipt_target{}。对于前面我们提到过的标准匹配,它只会去检查数据包的IP地址,源目的接口,掩码等通用信息,不会用到ipt_entry_match{}。对于以模块形式存在的扩展匹配,如iprange模块,ipp2p模块等,它们就得实现自己的ipt_match{}结构。也就是说,如果你要开发一个新的match模块,那么就必须去实例化一个ipt_match{}结构体对象,并实现该结构体中相应的成员属性(其实主要都是一些些函数指针成员,你必须实现相应的函数体),然后将该ipt_match{}对象挂在你的ipt_entry_match{}结构的match属性里就OK了,就这么简单。
同样的,我们来说一下ipt_target{}结构体。对于target(我这里就不翻译了,那个叫“动作”的翻译太难听了,后面我都用英文表述)也分为标准target和扩展target。标准target就是那些ACCEPT、DROP、REJECT等等之类的处理方式;扩展target就是那些诸如DNAT、SNAT等以模块形式存在的target了。对于标准的target,它是不需要ipt_target{}结构的,即ipt_entry_target{}中的target属性为NULL;而对于我们自己扩展target是需要我们自己手工去实现ipt_target{}对象,并完成相关回调函数的编写。对于ipt_target{}结构体中target回调函数的编写有一点要注意:该函数必须向Netfilter框架返回IPT_CONTINUE、或者诸如NF_ACCEPT、NF_DROP之类的值。开发细节我会在后续动手实践章节一一向大家说明。
结构体ipt_entry_match{}定义在include/linux/netfilter/x_tables.h文件中。
结构体ipt_entry_target{}也定义在include/linux/netfilter/x_tables.h文件中。
结构体ipt_match{}定义在include/linux/netfilter/ip_tables.h文件中。
结构体 ipt_target{}也定义在include/linux/netfilter/ip_tables.h文件中。匹配match:
上面我们说过,match分为两种:基本match,又叫标准match;扩展match。
n 标准match:
标准匹配主要用于匹配由structipt_ip{}所定义的数据包的特征项。标准匹配的内核数据结构就是我们上面所看到的ipt_match{}定义在include/linux/netfilter/ip_tables.h。在所有的表中我们最后真正所用到的match结构为ipt_entry_match{},它和ipt_match{}的关系我也将其画出来了,如上所示。
既然说到这里,那我就再啰嗦一点。对于ipt_entry_match{}的结构大家可能也留意到了它内部结构有个union成员,同时它还区分了user和kernel两种情况。我们刚刚在上面所讨论的ipt_match{}结构是内核中用来表示match的数据类型,在用户空间我们用的是iptables_match{}结构(定义在iptables.tar.gz源码包中的include/iptables.h头文件中)来表示match的。
也就是说,内核空间和用户空间在注册和维护match时使用的是各自的match结构,ipt_match{}和iptables_match{},但是当某个具体的match被应用到防火墙规则里时,它们两个必须统一成ipt_entry_match{},这才是防火墙规则中真正用到的match结构。
至于iptables_match{}和ipt_match{}是如何完美地统一到ipt_entry_match{}结构中的,我们在后面再来详细分析。
n 扩展match:
扩展match通常以插件或模块的形式存在。当我们在用户空间通过iptables命令设置规则时如果用到了-m ‘name’ 参数时,那么此时‘name就是一个扩展匹配模块。前面我们也说过,如果你需要开发一个新的match模块,那么就必须去实例化一个ipt_match{}结构体对象,并实现其中的重要回调函数(如match()函数),最后通过xt_register_match()接口将你的ipt_match{}对象注册到Netfilter中去就可以了。实战篇我们讲解如何开发一个新match的全过程。
动作target:
根据上面的两幅图我们可以看到ipt_entry_match{}和ipt_entry_target{}的结构基本如出一辙,那么它们也就存在着很多非常相似的地方了。 在所有的表中关于target的使用,我们既可以用ipt_standard_target{}又可以用ipt_entry_target{},这是为什么呢?后面再解释。这两个结构体的关系如下图所示:怎么样很简单吧,就多了一个verdict变量而已。说了半天,那么target到底是用来干什么的呢?说白了,target主要用来处理:当某条规则中的所有match都被数据包匹配后该执行什么样的动作来处理这个报文,最后将处理后结果通过verdict值返回给Netfilter框架。
同样的target也分内核空间和用户空间两种结构。在内核空间中,我们所说的target由ipt_target{}表示,定义在include/linux/netfilter/ip_tables.h文件中;在用户空间中,所使用的是iptables_target{}结构,该结构定义在iptables源码包里的iptables.h头文件中。同样地,ipt_target{}和iptables_target{}最后也完美地统一到了ipt_entry_target{}里。
iptables的-j参数后面即可跟诸如ACCEPT、DROP这些动作,也可以跟一条用户自定义链表的名字。我们都知道iptables中的规则链(chain)其实就是某个hook点上所有规则的集合。除了系统内建的链外,用户还可以创建自定义的新链。在iptables中,同一个链里的规则是顺序存放的。内建链的最后一条规则的target是链的policy策略,而用户自定义链中是没有policy这么一说。用户自定义链的最后一条规则的target是NF_RETURN,遍历过程将返回原来的链中。当然,规则中的target也可以指定跳转到某个用户创建的自定义链上,这时这条规则的target就是ipt_standard_target{}类型,并且这个target的verdict值大于0。如果在用户自定义链上没有找到任何匹配的规则的话,遍历过程将返回到原来调用这条用户自定链的链里去匹配下一条规则。
这里还需要注意一点:target也分为标准target和扩展target,前面简单提过一些。它和标准的match以及扩展match还是有些区别:
标准的target,即ipt_standard_target{}里可以根据verdict的值再划分为内建的动作或者跳转到自定义链中。简单了说,标准的target就是内核内建的一些处理动作或其延伸。
扩展的target,则完全是由用户定义的处理动作。如果ipt_target.target()函数是空的,那就是标准target,因为它不需要用户再去提供新的target函数了;反之,如果有target函数那就是扩展的target。
如果我们要开发自己的target,那么也只需要实例化一个ipt_target{}对象,并填充其内部相关的回调函数,然后调用xt_register_target()将其注册到Netfilter框架即可。
规则rule:
其实每张table表中最后真正用于表示其内部所有规则的结构体是ipt_standard{},定义在include/linux/netfilter_ipv4/ip_tables.h文件中。最后在我们几张表里,如filter表,nat表里真正用的规则结构为ipt_standard{}。它和我们前面介绍的ipt_entry{}的关系如下:
(四)洞悉linux下的Netfilter&iptables:包过滤子系统iptable_filter
今天我们讨论一下防火墙的数据包过滤模块iptable_filter的设计原理及其实现方式。
内核中将filter模块被组织成了一个独立的模块
整个filter模块就一百多行代码,但要将其理解清楚还是需要一些功夫。我们首先来看一下filter模块是如何将自己的钩子函数注册到netfilter所管辖的几个hook点的。
static int __init iptable_filter_init(void) { int ret; if (forward < 0 || forward > NF_MAX_VERDICT) { printk("iptables forward must be 0 or 1\n"); return -EINVAL; } /* Entry 1 is the FORWARD hook */ initial_table.entries[1].target.verdict = -forward - 1;
/* Register table */ ret = ipt_register_table(&packet_filter, &initial_table.repl); if (ret < 0) return ret;
/* Register hooks */ ret = nf_register_hooks(ipt_ops, ARRAY_SIZE(ipt_ops)); if (ret < 0) goto cleanup_table; return ret;
cleanup_table: ipt_unregister_table(&packet_filter); return ret; } |
这里我只看关键部分,根据上面的代码我们已经知道。filter模块初始化时先调用ipt_register_table向Netfilter完成filter过滤表的注册,然后调用ipt_register_hooks完成自己钩子函数的注册,就这么简单。至于这两个注册的动作分别都做了哪些东西,我们接下来详细探究一下。
注册过滤表:ipt_register_table(&packet_filter,&initial_table.repl);
Netfilter在内核中为防火墙系统维护了一个结构体,该结构体中存储的是内核中当前可用的所有match,target和table,它们都是以双向链表的形式被组织起来的。这个全局的结构体变量staticstruct xt_af *xt定义在net/netfilter/x_tables.c当中,其结构为:
struct xt_af { struct mutex mutex; struct list_head match; //每个match模块都会被注册到这里 struct list_head target; //每个target模块都会被注册到这里 struct list_head tables; //每张表都被被注册到这里 struct mutex compat_mutex; }; |
其中xt变量是在net/netfilter/x_tables.c文件中的xt_init()函数中被分配存储空间并完成初始化的,xt分配的大小以当前内核所能支持的协议簇的数量有关,其代码如下:
每注册一张表,就会根据该表所属的协议簇,找到其对应的xt[]成员,然后在其tables双向链表中挂上该表结构即完成了表的注册。接下来我们再看一下Netfilter是如何定义内核中所认识的“表”结构的。
关于表结构,内核中有两个结构体xt_table{}和xt_table_info{}来表示“表”的信息。
structipt_table{}的结构体类型定义在
struct xt_table //其中#defineipt_table xt_table { struct list_head list; char name[XT_TABLE_MAXNAMELEN]; //表的名字 unsigned int valid_hooks; //该表所检测的HOOK点 rwlock_t lock; //读写锁 void *private; //描述表的具体属性,如表的size,表中的规则数等 struct module *me; //如果要设计成模块,则为THIS_MODULE;否则为NULL int af; //协议簇 ,如PF_INET(或PF_INET) }; |
而每张表中真正和规则相关的信息,则由该结构的的private属性来指向。从2.6.18版内核开始,该变量被改成了void*类型,目的是方便日后对其进行扩充需要。通常情况下,private都指向一个xt_table_info{}类型的结构体变量。
structxt_table_info{}的结构体类型定义在< include/linux/netfilter/x_tables.h >中。
struct xt_table_info { unsigned int size; //表的大小,即占用的内存空间 unsigned int number; //表中的规则数 unsigned int initial_entries; //初始的规则数,用于模块计数
/* 记录所影响的HOOK的规则入口相对于下面的entries变量的偏移量*/ unsigned int hook_entry[NF_IP_NUMHOOKS]; /* 与hook_entry相对应的规则表上限偏移量,当无规则录入时,相应的hook_entry和underflow均为0 */ unsigned int underflow[NF_IP_NUMHOOKS]; char *entries[NR_CPUS]; }; |
我们发现ipt_register_table()函数还有一个输入参数:initial_table。根据其名称不难推断出它里面存储的就是我们用于初始化表的一些原始数据,该变量的结构虽然不复杂,但又引入了几个其他的数据结构,如下:
static struct { struct ipt_replace repl; struct ipt_standard entries[3]; struct ipt_error term; } initial_table; |
在注册过滤表时我们只用到了该结构中的struct ipt_replace repl成员,其他成员我们暂时先不介绍,主要来看一下这个repl是个神马东东。
ipt_replace{}结构体的定义在include/linux/netfilter_ipv4/ip_tables.h文件中。其内容如下:
struct ipt_replace { char name[IPT_TABLE_MAXNAMELEN]; //表的名字 unsigned int valid_hooks; //所影响的HOOK点 unsigned int num_entries; //表中的规则数目 unsigned int size; //新规则所占用存储空间的大小
unsigned int hook_entry[NF_IP_NUMHOOKS]; //进入HOOK的入口点 unsigned int underflow[NF_IP_NUMHOOKS]; /* Underflow points. */
/* 这个结构不同于ipt_table_info之处在于它还要保存旧的规则信息*/ /* Number of counters (must be equal to current number of entries). */ unsigned int num_counters; /* The old entries' counters. */ struct xt_counters __user *counters;
/* The entries (hang off end: not really an array). */ struct ipt_entry entries[0]; }; |
之所以要设计ipt_replace{}这个结构体,是因为在1.4.0版的iptables中有规则替换这个功能,它可以用一个新的规则替换掉指定位置上的已存在的现有规则(关于iptables命令行工具的详细用法请参见man手册或iptables指南)。最后我们来看一下initial_table.repl的长相:
initial_table.repl= { "filter",FILTER_VALID_HOOKS, 4, sizeof(struct ipt_standard) * 3 + sizeof(struct ipt_error), { [NF_IP_LOCAL_IN] = 0, [NF_IP_FORWARD] = sizeof(struct ipt_standard), [NF_IP_LOCAL_OUT] = sizeof(struct ipt_standard) * 2 }, { [NF_IP_LOCAL_IN] = 0, [NF_IP_FORWARD] = sizeof(struct ipt_standard), [NF_IP_LOCAL_OUT] = sizeof(struct ipt_standard) * 2 }, 0, NULL, { } }; |
根据上面的初始化代码,我们就可以弄明白initial_table.repl成员的意思了:
"filter"表从"FILTER_VALID_HOOKS"这些hook点介入Netfilter框架,并且filter表初始化时有"4"条规则链,每个HOOK点(对应用户空间的“规则链”)初始化成一条链,最后以一条“错误的规则”表示结束,filter表占(sizeof(struct ipt_standard) * 3+sizeof(struct ipt_error))字节的存储空间,每个hook点的入口规则如代码所示。 因为初始化模块时不存在旧的表,因此后面两个个参数依次为0、NULL都表示“空”的意思。最后一个柔性数组struct ipt_entry entries[0]中保存了默认的那四条规则。
由此我们可以知道,filter表初始化时其规则的分布如下图所示:
我们继续往下走。什么?你说还有个ipt_error?记性真好,不过请尽情地无视吧,目前讲了也没用。那你还记得我们现在正在讨论的是什么主题吗?忘了吧,我再重申一下:我们目前正在讨论iptables内核中的filter数据包过滤模块是如何被注册到Netfilter中去的!!
有了上面这些基础知识我们再分析ipt_register_table(&packet_filter,&initial_table.repl)函数就容易多了,该函数定义在net/ipv4/netfilter/ip_tables.c中:
int ipt_register_table(struct xt_table *table, const struct ipt_replace *repl) { int ret; struct xt_table_info *newinfo; static struct xt_table_info bootstrap = { 0, 0, 0, { 0 }, { 0 }, { } }; void *loc_cpu_entry; newinfo = xt_alloc_table_info(repl->size); //为filter表申请存储空间 if (!newinfo) return -ENOMEM;
//将filter表中的规则入口地址赋值给loc_cpu_entry loc_cpu_entry = newinfo->entries[raw_smp_processor_id()]; //将repl中的所有规则,全部拷贝到newinfo->entries[]中 memcpy(loc_cpu_entry, repl->entries, repl->size); /*translate_table函数将由newinfo所表示的table的各个规则进行边界检查,然后对于newinfo所指的xt_talbe_info结构中的hook_entries和underflows赋予正确的值,最后将表项向其他cpu拷贝*/ ret = translate_table(table->name, table->valid_hooks, newinfo, loc_cpu_entry, repl->size, repl->num_entries, repl->hook_entry, repl->underflow); if (ret != 0) { xt_free_table_info(newinfo); return ret; } //这才是真正注册我们filter表的地方 ret = xt_register_table(table, &bootstrap,newinfo); if (ret != 0) { xt_free_table_info(newinfo); return ret; } return 0; } |
在该函数中我们发现点有意思的东西:还记得前面我们在定义packet_filter时是什么情况不? packet_filter中没对其private成员进行初始化,那么这个工作自然而然的就留给了xt_register_table()函数来完成,它也定义在x_tables.c文件中,它主要完成两件事:
1)、将由newinfo参数所存储的表里面关于规则的基本信息结构体xt_table_info{}变量赋给由table参数所表示的packet_filter{}的private成员变量;
2)、根据packet_filter的协议号af,将filter表挂到变量xt中tables成员变量所表示的双向链表里。
最后我们回顾一下ipt_register_table(&packet_filter,&initial_table.repl)的初始化流程:
简而言之ipt_register_table()所做的事情就是从模板initial_table变量的repl成员里取出初始化数据,然后申请一块内存并用repl里的值来初始化它,之后将这块内存的首地址赋给packet_filter表的private成员,最后将packet_filter挂载到xt[2].tables的双向链表中。
注册钩子函数:nf_register_hooks(ipt_ops,ARRAY_SIZE(ipt_ops));
在第二篇博文中我们已经简单了解nf_hook_ops{}结构了,而且我们也知道该结构在整个Netfilter框架中的具有相当重要的作用。当我们要向Netfilter注册我们自己的钩子函数时,一般的思路都是去实例化一个nf_hook_ops{}对象,然后通过nf_register_hook()接口其将其注册到Netfilter中即可。当然filter模块无外乎也是用这种方式来实现自己的吧,那么接下来我们来研究一下filter模块注册钩子函数的流程。
首先,我们看到它也实例化了一个nf_hook_ops{}对象——ipt_ops,代码如下所示:
static struct nf_hook_ops ipt_ops[] = { { .hook = ipt_hook, .owner = THIS_MODULE, .pf = PF_INET, .hooknum = NF_IP_LOCAL_IN, .priority = NF_IP_PRI_FILTER, }, { .hook = ipt_hook, .owner = THIS_MODULE, .pf = PF_INET, .hooknum = NF_IP_FORWARD, .priority = NF_IP_PRI_FILTER, }, { .hook = ipt_local_out_hook, .owner = THIS_MODULE, .pf = PF_INET, .hooknum = NF_IP_LOCAL_OUT, .priority = NF_IP_PRI_FILTER, }, }; |
对上面这种定义的代码我们现在应该已经很清楚其意义了:iptables的filter包过滤模块在Netfilter框架的NF_IP_LOCAL_IN和NF_IP_FORWARD两个hook点以NF_IP_PRI_FILTER(0)优先级注册了钩子函数ipt_hook(),同时在NF_IP_LOCAL_OUT过滤点也以同样的优先级注册了钩子函数ipt_local_out_hook()。
然后,在nf_register_hooks()函数内部通过循环调用nf_register_hook()接口来完成所有nf_hook_ops{}对象的注册任务。在nf_register_hook()函数里所执行的操作就是一个双向链表的查找和插入,没啥难度。考大家一个问题,测试一下你看博客的认真和专心程度:filter模块所定义的这些hook函数是被注册到哪里去了呢?
=================================华丽丽的分割线================================
想不起的话可以去复习一下第一篇博文结尾部分的内容,不过我知道大多数人都懒的翻回去了。好吧,我再强调一遍:所有的hook函数最终都被注册到一个全局的二维的链表结构体数组struct list_headnf_hooks[NPROTO][NF_MAX_HOOKS]里了。一维表示协议号,二维表示hook点。
还记得我们给过滤模块所有hook函数所划的分类图么:
目前只出现了ipt_hook和ipt_local_out_hook,不过这四个函数本质上最后都调用了ipt_do_table()函数,而该函数也是包过滤的核心了。
数据包过滤的原理:
根据前面我们的分析可知,ipt_do_table()函数是最终完成包过滤功能的这一点现在已经非常肯定了,该函数定义在net/ipv4/netfilter/ip_tables.c文件中。实际上,90%的包过滤函数最终都调用了该接口,它可以说是iptables包过滤功能的核心部分。在分析该函数之前,我们把前几章中所有的相关数据结构再梳理一遍,目的是为了在分析该函数时达到心中有数。
我们前面提到过的核心数据结构有initial_table、ipt_replace、ipt_table、ipt_table_info、ipt_entry、ipt_standard、ipt_match、ipt_entry_match、ipt_target、ipt_entry_target,这里暂时没有涉及到对用户空间的相应的数据结构的讨论。以上这些数据结构之间的关系如下:
我们还是先看一下ipt_do_table()函数的整体流程图:
我们分析一下整个ipt_do_table()函数执行的过程:
对某个hook点注册的所有钩子函数,当数据包到达该hook点后,该钩子函数便会被激活,从而开始对数据包进行处理。我们说过:规则就是“一组匹配+一个动作”,而一组规则又组成了所谓的“表”,因此,每条规则都属于唯一的一张表。前面我们知道,每张表都对不同的几个HOOK点进行了监听,而且这些表的优先级是不相同的,我们在用户空间里去配置iptables规则的时候恰恰也是必须指定链名和表名,在用户空间HOOK点就被抽象成了“链”的概念,例如:
iptables –A INPUT –p tcp –s !192.168.10.0/24 –j DROP
这就表示我们在filter表的NF_IP_LOCAL_IN这个HOOK点上增加了一个过滤规则。当数据包到达LOCAL_IN这个HOOK点时,那么它就有机会被注册在这个点的所有钩子函数处理,按照注册时候的优先级来。因为表在注册时都已确定了优先级,而一个表中可能有数条规则,因此,当数据包到达某个HOOK点后。优先级最高的表(优先级的值越小表示其优先程度越高)中的所有规则被匹配完之后才能轮到下一个次高优先级的表中的所有规则开始匹配(如果数据包还在的话)。
所以,我们在ipt_do_table()中看到,首先就是要获取表名,因为表名和优先级在某种程度上来说是一致的。获取表之后,紧接着就要获取表中的规则的起始地址。然后用依次按顺序去比较当前正在处理的这个数据包是否和某条规则中的所有过滤项相匹配。如果匹配,就用那条规则里的动作target来处理包,完了之后返回;如果不匹配,当该表中所有的规则都被检查完了之后,该数据包就转入下一个次高优先级的过滤表中去继续执行此操作。依次类推,直到最后包被处理或者被返回到协议栈中继续传输。
(五)洞悉linux下的Netfilter&iptables:如何理解连接跟踪机制?【上】
如何理解Netfilter中的连接跟踪机制?
本篇我打算以一个问句开头,因为在知识探索的道路上只有多问然后充分调动起思考的机器才能让自己走得更远。连接跟踪定义很简单:用来记录和跟踪连接的状态。
问:为什么又需要连接跟踪功能呢?
答:因为它是状态防火墙和NAT的实现基础。
OK,算是明白了。Neftiler为了实现基于数据连接状态侦测的状态防火墙功能和NAT地址转换功能才开发出了连接跟踪这套机制。那就意思是说:如果编译内核时开启了连接跟踪选项,那么Linux系统就会为它收到的每个数据包维持一个连接状态用于记录这条数据连接的状态。接下来我们就来研究一下Netfilter的连接跟踪的设计思想和实现方式。
之前有一副图,我们可以很明确的看到:用于实现 连接跟踪入口的hook函数以较高的优先级分别被注册到了netfitler的NF_IP_PRE_ROUTING和NF_IP_LOCAL_OUT两个hook点上;用于实现 连接跟踪出口的hook函数以非常低的优先级分别被注册到了netfilter的NF_IP_LOCAL_IN和NF_IP_POST_ROUTING两个hook点上。其实PRE_ROUTING和LOCAL_OUT点可以看作是整个netfilter的入口,而POST_ROUTING和LOCAL_IN可以看作是其出口。在只考虑连接跟踪的情况下,一个数据包无外乎有以下三种流程可以走:
一、发送给本机的数据包
流程:PRE_ROUTING----LOCAL_IN---本地进程
二、需要本机转发的数据包
流程:PRE_ROUTING---FORWARD---POST_ROUTING---外出
三、从本机发出的数据包
流程:LOCAL_OUT----POST_ROUTING---外出
我们都知道在INET层用于表示数据包的结构是大名鼎鼎的sk_buff{}(后面简称skb),如果你不幸的没听说过这个东东,那么我强烈的建议你先补一下网络协议栈的基础知识再继续阅读这篇文章。在skb中有个成员指针nfct,类型是struct nf_conntrack{},该结构定义在include/linux/skbuff.h文件中。该结构记录了连接记录被公开应用的计数,也方便其他地方对连接跟踪的引用。连接跟踪在实际应用中一般都通过强制类型转换将nfct转换成指向ip_conntrack{}类型(定义在include/linux/netfilter_ipv4/ip_conntrack.h里)来获取一个数据包所属连接跟踪的状态信息的。即:Neftilter框架用ip_conntrack{}来记录一个数据包与其连接的状态关系。
同时在include/linux/netfilter_ipv4/ip_conntrack.h文件中还提供了一个非常有用的接口:struct ip_conntrack *ip_conntrack_get(skb,ctinfo)用于获取一个skb的nfct指针,从而得知该数据包的连接状态和该连接状态的相关信息ctinfo。从连接跟踪的角度来看,这个ctinfo表示了每个数据包的几种连接状态:
l IP_CT_ESTABLISHED
Packet是一个已建连接的一部分,在其初始方向。
l IP_CT_RELATED
Packet属于一个已建连接的相关连接,在其初始方向。
l IP_CT_NEW
Packet试图建立新的连接
l IP_CT_ESTABLISHED+IP_CT_IS_REPLY
Packet是一个已建连接的一部分,在其响应方向。
l IP_CT_RELATED+IP_CT_IS_REPLY
Packet属于一个已建连接的相关连接,在其响应方向。
在连接跟踪内部,收到的每个skb首先被转换成一个ip_conntrack_tuple{}结构,也就是说ip_conntrack_tuple{}结构才是连接跟踪系统所“认识”的数据包。那么skb和ip_conntrack_tuple{}结构之间是如何转换的呢?这个问题没有一个统一的答案,与具体的协议息息相关。例如,对于TCP/UDP协议,根据“源、目的IP+源、目的端口”再加序列号就可以唯一的标识一个数据包了;对于ICMP协议,根据“源、目的IP+类型+代号”再加序列号才可以唯一确定一个ICMP报文等等。对于诸如像FTP这种应用层的“活动”协议来说情况就更复杂了。本文不试图去分析某种具体协议的连接跟踪实现,而是探究连接跟踪的设计原理和其工作流程,使大家掌握连接跟踪的精髓。因为现在Linux内核更新的太快的都到3.4.x,变化之大啊。就算是2.6.22和2.6.21在连接跟踪这块还是有些区别呢。一旦大家理解了连接跟踪的设计思想,掌握了其神韵,它再怎么也万变不离其宗,再看具体的代码实现时就不会犯迷糊了。俗话说“授人一鱼,不如授人一渔”,我们教给大家的是方法。有了方法再加上自己的勤学苦练,那就成了技能,最后可以使得大家在为自己的协议开发连接跟踪功能时心里有数。这也是我写这个系列博文的初衷和目的。与君共勉。
在开始分析连接跟踪之前,我们还是站在统帅的角度来俯视一下整个连接跟踪的布局。这里我先用比较粗略的精简流程图为大家做个展示,目的是方便大家理解,好入门。当然,我的理解可能还有不太准确的地方,还请大牛们帮小弟指正。
我还是重申一下:连接跟踪分入口和出口两个点。 谨记:入口时创建连接跟踪记录,出口时将该记录加入到连接跟踪表中。我们分别来看看。入口:
整个入口的流程简述如下:对于每个到来的skb,连接跟踪都将其转换成一个tuple结构,然后用该tuple去查连接跟踪表。如果该类型的数据包没有被跟踪过,将为其在连接跟踪的hash表里建立一个连接记录项,对于已经跟踪过了的数据包则不用此操作。紧接着,调用该报文所属协议的连接跟踪模块的所提供的packet()回调函数,最后根据状态改变连接跟踪记录的状态。
出口:
整个出口的流程简述如下:对于每个即将离开Netfilter框架的数据包,如果用于处理该协议类型报文的连接跟踪模块提供了helper函数,那么该数据包首先会被helper函数处理,然后才去判断,如果该报文已经被跟踪过了,那么其所属连接的状态,决定该包是该被丢弃、或是返回协议栈继续传输,又或者将其加入到连接跟踪表中。
连接跟踪的协议管理:
我们前面曾说过,不同协议其连接跟踪的实现是不相同的。每种协议如果要开发自己的连接跟踪模块,那么它首先必须实例化一个ip_conntrack_protocol{}结构体类型的变量,对其进行必要的填充,然后调用ip_conntrack_protocol_register()函数将该结构进行注册,其实就是根据协议类型将其设置到全局数组ip_ct_protos[]中的相应位置上。
ip_ct_protos变量里保存连接跟踪系统当前可以处理的所有协议,协议号作为数组唯一的下标,如下图所示。
结构体ip_conntrack_protocol{}中的每个成员,内核源码已经做了很详细的注释了,这里我就不一一解释了,在实际开发过程中我们用到了哪些函数再具体分析。
连接跟踪的辅助模块:
Netfilter的连接跟踪为我们提供了一个非常有用的功能模块:helper。该模块可以使我们以很小的代价来完成对连接跟踪功能的扩展。这种应用场景需求一般是,当一个数据包即将离开Netfilter框架之前,我们可以对数据包再做一些最后的处理。从前面的图我们也可以看出来,helper模块以较低优先级被注册到了Netfilter的LOCAL_OUT和POST_ROUTING两个hook点上。
每一个辅助模块都是一个ip_conntrack_helper{}结构体类型的对象。也就是说,如果你所开发的协议需要连接跟踪辅助模块来完成一些工作的话,那么你必须也去实例化一个ip_conntrack_helper{}对象,对其进行填充,最后调用ip_conntrack_helper_register{}函数将你的辅助模块注册到全局变量helpers里,该结构是个双向链表,里面保存了当前已经注册到连接跟踪系统里的所有协议的辅助模块。
全局helpers变量的定义和初始化在net/netfilter/nf_conntrack_helper.c文件中完成的。
最后,我们的helpers变量所表示的双向链表一般都是像下图所示的这样子:
由此我们基本上就可以知道,注册在Netfilter框架里LOCAL_OUT和POST_ROUTING两个hook点上ip_conntrack_help()回调函数所做的事情基本也就很清晰了:那就是通过依次遍历helpers链表,然后调用每个ip_conntrack_helper{}对象的help()函数。
期望连接:
Netfilter的连接跟踪为支持诸如FTP这样的“活动”连接提供了一个叫做“期望连接”的机制。我们都知道FTP协议服务端用21端口做命令传输通道,主动模式下服务器用20端口做数据传输通道;被动模式下服务器随机开一个高于1024的端口,然后客户端来连接这个端口开始数据传输。也就是说无论主、被动,都需要两条连接:命令通道的连接和数据通道的连接。连接跟踪在处理这种应用场景时提出了一个“期望连接”的概念,即一条数据连接和另外一条数据连接是相关的,然后对于这种有“相关性”的连接给出自己的解决方案。我们说过,本文不打算分析某种具体协议连接跟踪的实现。接下来我们就来谈谈期望连接。
每条期望连接都用一个ip_conntrack_expect{}结构体类型的对象来表示,所有的期望连接存储在由全局变量ip_conntrack_expect_list所指向的双向链表中,该链表的结构一般如下:
结构体ip_conntrack_expect{}中的成员及其意义在内核源码中也做了充分的注释,这里我就不逐一介绍了,等到需要的时候再详细探讨。
连接跟踪表:
说了半天终于到我们连接跟踪表抛头露面的时候了。连接跟踪表是一个用于记录所有数据包连接信息的hash散列表,其实连接跟踪表就是一个以数据包的hash值组成的一个双向循环链表数组,每条链表中的每个节点都是ip_conntrack_tuple_hash{}类型的一个对象。连接跟踪表是由一个全局的双向链表指针变量ip_conntrack_hash[]来表示。为了使我们更容易理解ip_conntrack_hash[]这个双向循环链表的数组,我们将前面提到的几个重要的目前还未介绍的结构ip_conntrack_tuple{}、ip_conntrack{}和ip_conntrack_tuple_hash{}分别介绍一下。
我们可以看到ip_conntrack_tuple_hash{}仅仅是对ip_conntrack_tuple{}的封装而已,将其组织成了一个双向链表结构。因此,在理解层面上我们可以认为它们是同一个东西。
在分析ip_conntrack{}结构时,我们将前面所有和其相关的数据结构都列出来,方便大家对其理解和记忆。
该图可是说是连接跟踪部分的数据核心,接下来我们来详细说说ip_conntrack{}结构中相关成员的意义。
l ct_general:该结构记录了连接记录被公开应用的计数,也方便其他地方对连接跟踪的引用。
l status:数据包连接的状态,是一个比特位图。
l timeout:不同协议的每条连接都有默认超时时间,如果在超过了该时间且没有属于某条连接的数据包来刷新该连接跟踪记录,那么会调用这种协议类型提供的超时函数。
l counters:该成员只有在编译内核时打开了CONFIG_IP_NF_CT_ACCT开完才会存在,代表某条连接所记录的字节数和包数。
l master:该成员指向另外一个ip_conntrack{}。一般用于期望连接场景。即如果当前连接是另外某条连接的期望连接的话,那么该成员就指向那条我们所属的主连接。
l helper:如果某种协议提供了扩展模块,就通过该成员来调用扩展模块的功能函数。
l proto:该结构是ip_conntrack_proto{}类型,和我们前面曾介绍过的用于存储不同协议连接跟踪的ip_conntrack_protocol{}结构不要混淆了。前者是个枚举类型,后者是个结构体类型。这里的proto表示不同协议为了实现其自身的连接跟踪功能而需要的一些额外参数信息。目前这个枚举类型如下:
如果将来你的协议在实现连接跟踪时也需要一些额外数据,那么可以对该结构进行扩充。
l help:该成员代表不同的应用为了实现其自身的连接跟踪功能而需要的一些额外参数信息,也是个枚举类型的ip_conntrack_help{}结构,和我们前面刚介绍过的结构体类型ip_conntrack_helpers{}容易混淆。ip_conntrack_proto{}是为协议层需要而存在的,而ip_conntrack_help{}是为应用层需要而存在。
l tuplehash:该结构是个ip_conntrack_tuple_hash{}类型的数组,大小为2。tuplehash[0]表示一条数据流“初始”方向上的连接情况,tuplehash[1]表示该数据流“应答”方向的响应情况,见上图所示。
到目前为止,我们已经了解了连接跟踪设计思想和其工作机制:连接跟踪是Netfilter提供的一套基础框架,不同的协议可以根据其自身协议的特殊性在连接跟踪机制的指导和约束下来开发本协议的连接跟踪功能,最后将其交给连接跟踪机制来统一管理。
(六)洞悉linux下的Netfilter&iptables:如何理解连接跟踪机制?【中】
Netfilter连接跟踪的详细流程
上一篇我们了解了连接跟踪的基本框架和大概流程,本篇我们着重分析一下,数据包在连接跟踪系统里的旅程,以达到对连接跟踪运行原理深入理解的目的。
连接跟踪机制在Netfilter框架里所注册的hook函数一共就五个:ip_conntrack_defrag()、ip_conntrack_in()、ip_conntrack_local()、ip_conntrack_help()
和ip_confirm()。前几篇博文中我们知道ip_conntrack_local()最终还是调用了ip_conntrack_in()。这五个hook函数及其挂载点,想必现在大家应该也已经烂熟于心了,如果记不起来请看【上】篇博文。
在连接跟踪的入口处主要有三个函数在工作:ip_conntrack_defrag()、ip_conntrack_in()、ip_conntrack_local();在出口处就两个:ip_conntrack_help()和ip_confirm()。
接下来的事情就变得非常奇妙,请大家将自己当作一个需要转发的数据包,且是一条新的连接。然后跟随我去连接跟踪里耍一圈吧。在进入连接跟踪之前,我需要警告大家:连接跟踪虽然不会改变数据包本身,但是它可能会将数据包丢弃。
ip_conntrack_defrag()
当我们初到连接跟踪门口的时候,是这位小生来招待我们。这个函数主要是完成IP报文分片的重新组装,将属于一个IP报文的多个分片重组成一个真正的报文。关于IP分片,大家可以去阅读《TCP/IP详解卷1》了解一点基础,至于IP分片是如何被重新组装一个完整的IP报文也不是我们的重心,这里不展开讲。该函数也向我们透露了一个秘密,那就是连接跟踪只跟踪完整的IP报文,不对IP分片进行跟踪,所有的IP分片都必须被还原成原始报文,才能进入连接跟踪系统。
ip_conntrack_in()
该函数的核心是resolve_normal_ct()函数所做的事情,其执行流程如下所示:在接下来的分析中,需要大家对上一篇文章提到的几个数据结构:
ip_conntrack{}、ip_conntrack_tuple{}、ip_conntrack_tuple_hash{}和ip_conntrack_protocol{}以及它们的关系必须弄得很清楚,你才能彻底地读懂resolve_normal_ct()函数是干什么。最好手头再有一份2.6.21的内核源码,然后打开source insight来对照着阅读效果会更棒!
第一步:ip_conntrack_in()函数首先根据数据包skb的协议号,在全局数组ip_ct_protos[]中查找某种协议(如TCP,UDP或ICMP等)所注册的连接跟踪处理模块ip_conntrack_protocol{},如下所示。
在结构中,具体的协议必须提供将属于它自己的数据包skb转换成ip_conntrack_tuple{}结构的回调函数pkt_to_tuple()和invert_tuple(),用于处理新连接的new()函数等等。
第二步:找到对应的协议的处理单元proto后,便调用该协议提供的错误校验函数(如果该协议提供的话)error来对skb进行合法性校验。
第三步:调用resolve_normal_ct()函数。该函数的重要性不言而喻,它承担连接跟踪入口处剩下的所有工作。该函数根据skb中相关信息,调用协议提供的pkt_to_tuple()函数生成一个ip_conntrack_tuple{}结构体对象tuple。然后用该tuple去查找连接跟踪表,看它是否属于某个tuple_hash{}链。请注意, 一条连接跟踪由两条ip_conntrack_tuple_hash{}链构成,一“去”一“回”,参见上一篇博文末尾部分的讲解。为了使大家更直观地理解连接跟踪表,我将画出来,如下图,就是个双向链表的数组而已。如果找到了该tuple所属于的tuple_hash链表,则返回该链表的地址;如果没找到,表明该类型的数据包没有被跟踪,那么我们首先必须建立一个ip_conntrack{}结构的实例,即创建一个连接记录项。
然后,计算tuple的应答repl_tuple,对这个ip_conntrack{}对象做一番必要的初始化后,其中还包括,将我们计算出来的tuple和其反向tuple的地址赋给连接跟踪ip_conntrack里的tuplehash[IP_CT_DIR_ORIGINAL]和tuplehash[IP_CT_DIR_REPLY]。
最后,把ip_conntrack->tuplehash[IP_CT_DIR_ORIGINAL]的地址返回。这恰恰是一条连接跟踪记录初始方向链表的地址。Netfilter中有一条链表unconfirmed,里面保存了所有目前还没有收到过确认报文的连接跟踪记录,然后我们的ip_conntrack->tuplehash[IP_CT_DIR_ORIGINAL]就会被添加到unconfirmed链表中。
第四步:调用协议所提供的packet()函数,该函数承担着最后向Netfilter框架返回值的使命,如果数据包不是连接中有效的部分,返回-1,否则返回NF_ACCEPT。也就是说,如果你要为自己的协议开发连接跟踪功能,那么在实例化一个ip_conntrack_protocol{}对象时必须对该结构中的packet()函数做仔细设计。
虽然我不逐行解释代码,只分析原理,但有一句代码还是要提一下。
resolve_normal_ct()函数中有一行ct = tuplehash_to_ctrack(h)的代码,参见源代码。其中h是已存在的或新建立的ip_conntrack_tuple_hash{}对象,ct是ip_conntrack{}类型的指针。不要误以为这一句代码的是在创建ct对象,因为创建的工作在init_conntrack()函数中已经完成。本行代码的意思是根据ip_conntrack{}结构体中tuplehash[IP_CT_DIR_ORIGINAL]成员的地址,反过来计算其所在的结构体ip_conntrack{}对象的首地址,请大家注意。
大家也看到ip_conntrack_in()函数只是创建了用于保存连接跟踪记录的ip_conntrack{}对象而已,并完成了对其相关属性的填充和状态的设置等工作。简单来说,我们这个数据包目前已经拿到连接跟踪系统办法的“绿卡”ip_conntrack{}了,但是还没有盖章生效。
ip_conntrack_help()
大家只要把我前面关于钩子函数在五个HOOK点所挂载情况的那张图记住,就明白ip_conntrack_help()函数在其所注册的hook点的位置了。当我们这个数据包所属的协议在其提供的连接跟踪模块时已经提供了ip_conntrack_helper{}模块,或是别人针对我们这种协议类型的数据包提供了扩展的功能模块,那么接下来的事儿就很简单了:
首先,判断数据包是否拿到“绿卡”,即连接跟踪是否为该类型协议的包生成了连接跟踪记录项ip_conntrack{};
其次,该数据包所属的连接状态不属于一个已建连接的相关连接,在其响应方向。
两个条件都成立,就用该helper模块提供的help()函数去处理我们这个数据包skb。最后,这个help()函数也必须向Netfilter框架返回NF_ACCEPT或NF_DROP等值。任意一个条件不成立则ip_conntrack_help()函数直接返回NF_ACCEPT,我们这个数据包继续传输。
ip_confirm()
该函数是我们离开Netfilter时遇到的最后一个家伙了,如果我们这个数据包已经拿到了“绿卡”ip_conntrack{},并且我们这个数据包所属的连接还没收到过确认报文,并且该连接还未失效。然后,我们这个ip_confirm()函数要做的事就是:
拿到连接跟踪为该数据包生成ip_conntrack{}对象,根据连接“来”、“去”方向tuple计算其hash值,然后在连接跟踪表ip_conntrack_hash[]见上图中查找是否已存在该tuple。如果已存在,该函数最后返回NF_DROP;如果不存在,则将该连接“来”、“去”方向tuple插入到连接跟踪表ip_conntrack_hash[]里,并向Netfilter框架返回NF_ACCEPT。之所以要再最后才将连接跟踪记录加入连接跟踪表是考虑到数据包可能被过滤掉。
至此,我们本次旅行就圆满结束了。这里我们只分析了转发报文的情况。发送给本机的报文流程与此一致,而对于所有从本机发送出去的报文,其流程上唯一的区别就是在调用ip_conntrack_in()的地方换成了ip_conntrack_local()函数。前面说过,ip_conntrack_local()里面其实调用的还是ip_conntrack_in()。ip_conntrack_local()里只是增加了一个特性:那就是对于从本机发出的小数据包不进行连接跟踪。
(七)洞悉linux下的Netfilter&iptables:如何理解连接跟踪机制?【下】
连接跟踪系统的初始化流程分析
有了前面的知识,我们再分析连接跟踪系统的初始化ip_conntrack_standalone_init()函数就太容易不过了。还是先上ip_conntrack_standalone_init()函数的流程图:
该函数的核心上图已经标出来了“初始化连接跟踪系统”和“注册连接跟踪的hook函数”。其他两块这里简单做个普及,不展开讲。至少让大家明白连接跟踪为什么需要两中文件系统。
1、 procfs(/proc文件系统)
这是一个虚拟的文件系统,通常挂载在/proc,允许内核以文件的形式向用户空间输出内部信息。该目录下的所有文件并没有实际存在在磁盘里,但可以通过cat、more或>shell重定向予以写入,这些文件甚至可以像普通文件那样指定其读写权限。创建这些文件的内核组件可以说明任何一个文件可以由谁读取或写入。但是:用户不能在/proc目录下新增,移除文件或目录。
2、 sysctl(/proc/sys目录)
此接口允许用户空间读取或修改内核变量的值。不能用此接口对每个内核变量进行操作:内核应该明确指出哪些变量从此接口对用户空间是可见的。从用户空间,你可以用两种方式访问sysctl输出的变量:sysctl系统调用接口;procfs。当内核支持procfs文件系统时,会在/proc中增加一个特殊目录(/proc/sys),为每个由sysctl所输出的内核变量引入一个文件,我们通过对这些文件的读写操作就可以影响到内核里该变量的值了。
除此之外还有一种sysfs文件系统,这里就不介绍了,如果你感兴趣可以去研读《Linux设备驱动程序》一书的详细讲解。
那么回到我们连接跟踪系统里来,由此我们可以知道:连接跟踪系统向用户空间输出一些内核变量,方便用户对连接跟踪的某些特性加以灵活控制,如改变最大连接跟踪数、修改TCP、UDP或ICMP协议的连接跟踪超时是时限等等。
注意一点:/proc/sys目录下的任何一个文件名,对应着内核中有一个一模一样同名的内核变量。例如,我的系统中该目录是如下这个样子:ip_conntrack_init()函数
该函数承担了连接跟踪系统初始化的绝大部分工作,其流程我们也画出来了,大家可以对照源码来一步一步分析。
第一步:连接跟踪的表大小跟系统内存相关,而最大连接跟踪数和连接跟踪表容量的关系是:最大连接跟踪数=8×连接跟踪表容量。代码中是这样的:
ip_conntrack_max = 8 ×ip_conntrack_htable_size;那么从上面的图我们可以看出来,我们可以通过手工修改/proc/sys/net/ipv4/netfilter目录下同名的ip_conntrack_max文件即可动态修改连接系统的最大连接跟踪数了。
第二步:注册Netfilter所用的sockopt,先不讲,以后再说。只要知道是这里注册的就行了。
第三步:为连接跟踪hash表ip_conntrack_hash分配内存并进行初始化。并创建连接跟踪和期望连接跟踪的高速缓存。
第四步:将TCP、UDP和ICMP协议的连接跟踪协议体,根据不同协议的协议号,注册到全局数组ip_ct_protos[]中,如下所示:最后再做一些善后工作,例如注册DROP这个target所需的功能函数,为其他诸如NAT这样的模块所需的参数ip_conntrack_untracked做初始化,关于这个参数我们在NAT模块中再详细讨论它。
这样,我们连接跟踪系统的初始化工作就算彻底完成了。有了前几篇关于连接跟踪的基础知识,再看代码是不是有种神清气爽,豁然开朗的感觉。
至于连接跟踪系统所提供的那五个hook函数的注册,我想现在的你应该连都不用看就知道它所做的事情了吧。
(八)洞悉linux下的Netfilter&iptables:状态防火墙
基于连接跟踪机制的状态防火墙的设计与实现
连接跟踪本身并没有实现什么具体功能,它为状态防火墙和NAT提供了基础框架。前面几章节我们也看到:从连接跟踪的职责来看,它只是完成了数据包从“个性”到“共性”抽象的约定,即它的核心工作是如何针对不同协议报文而定义一个通用的“连接”的概念出来,具体的实现由不同协议自身根据其报文特殊性的实际情况来提供。那么连接跟踪的主要工作其实可以总结为:入口处,收到一个数据包后,计算其hash值,然后根据hash值查找连接跟踪表,如果没找到连接跟踪记录,就为其创建一个连接跟踪项;如果找到了,则返回该连接跟踪项。出口处,根据实际情况决定该数据包是被还给协议栈继续传递还是直接被丢弃。
我们先看一下iptables指南中关于用户空间中数据包的四种状态及其解释:
状态 |
解释 |
NEW |
NEW说明这个包是我们看到的第一个包。意思就是,这是conntrack模块看到的某个连接第一个包,它即将被匹配了。比如,我们看到一个SYN包,是我们所留意的连接的第一个包,就要匹配它。第一个包也可能不是SYN包,但它仍会被认为是NEW状态。这样做有时会导致一些问题,但对某些情况是有非常大的帮助的。例如,在我们想恢复某条从其他的防火墙丢失的连接时,或者某个连接已经超时,但实际上并未关闭时。 |
ESTABLISHED |
ESTABLISHED已经注意到两个方向上的数据传输,而且会继续匹配这个连接的包。处于ESTABLISHED状态的连接是非常容易理解的。只要发送并接到应答,连接就是ESTABLISHED的了。一个连接要从NEW变为ESTABLISHED,只需要接到应答包即可,不管这个包是发往防火墙的,还是要由防火墙转发的。ICMP的错误和重定向等信息包也被看作是ESTABLISHED,只要它们是我们所发出的信息的应答。 |
RELATED |
RELATED是个比较麻烦的状态。当一个连接和某个已处于ESTABLISHED状态的连接有关系时,就被认为是RELATE的了。换句话说,一个连接要想是RELATED的,首先要有一个ESTABLISHED的连接。这个ESTABLISHED连接再产生一个主连接之外的连接,这个新的连接就是RELATED的了,当然前提是conntrack模块要能理解RELATED。ftp是个很好的例子,FTP-data 连接就是和FTP-control有RELATED的。还有其他的例子,比如,通过IRC的DCC连接。有了这个状态,ICMP应答、FTP传输、DCC等才能穿过防火墙正常工作。注意,大部分还有一些UDP协议都依赖这个机制。这些协议是很复杂的,它们把连接信息放在数据包里,并且要求这些信息能被正确理解。 |
INVALID |
INVALID说明数据包不能被识别属于哪个连接或没有任何状态。有几个原因可以产生这种情况,比如,内存溢出,收到不知属于哪个连接的ICMP 错误信息。一般地,我们DROP这个状态的任何东西。 |
认真体会这个表格所表达意思对我们理解状态防火墙的机制和实现有很大的帮助。我们以最常见的TCP、UDP和ICMP协议为例来分析,因为他们最常见。对于TCP/UDP来说,我们可以用“源/目的IP+源/目的端口”唯一的标识一条连接;因为ICMP没有端口的概念,因此对ICMP而言,其“连接”的表示方法为“源/目的IP+类型+代码+ID”。因此,你就可以明白,如果你有一种不同于目前所有协议的新协议要为其开发连接跟踪功能,那么你必须定以一个可以唯一标识该报文的规格,这是必须的。
接下来我就抛砖引玉,分析一下NEW、ESTABLISHED、RELATED和INVALID几种状态内核中的变迁过程。
依旧在ip_conntrack_in()函数中,只不过我们这次的侧重点不同。由于该报文是某条连接的第一个数据包,ip_conntrack_find_get()函数中根据该数据包的tuple在连接跟踪表ip_conntrack_hash中肯定找不到对应的连接跟踪记录,然后重任就交给了init_conntrack()函数:
如果连接跟踪数已满,或没有足够的内存时,均会返回错误。否则,将新连接跟踪记录的引用计数置为1,设置连接跟踪记录“初始”和“应答”方向的tuple链,同时还设置了连接跟踪记录被销毁和超时的回调处理函数destroy_conntrack()和death_by_timeout()等。
至此,我们新的连接记录ip_conntrack{}就华丽丽滴诞生了。每种协议必须对其“新连接记录项”提供一个名为new()的回调函数。该函数的主要作用就是针对不同协议,什么样的报文才被称为“new”状态必须由每种协议自身去考虑和实现。具体我就不深入分析了,大家只要知道这里有这么一出戏就可以了,感兴趣的朋友可以去研究研究。当然,这需要对协议字段和意义有比较透彻清晰的了解才能完全弄明白别人为什么要那么设计。毕竟我们不是去为TCP、UDP或ICMP开发连接跟踪,开源界的信条就是“永远不要重复发明车轮”。如果你想深入研究现有的东西,目的只有一个:那就是学习别人的优点和长处,要有重点,有主次的去学习,不然会让自己很累不说,还会打击求知的积极性和动力。
闲话不都说,我们继续往下分析。如果该数据包所属的协议集提供了helper接口,那么将其挂到conntrack->helper的回调接口上。最后,将该连接跟踪项“初始”方向的tuple链添加到一条名为unconfirmed的全局链表中,该链表里存储的都是截止到目前为止还未曾收到“应答”方向数据包的连接跟踪记录。
费了老半天劲儿,状态防火墙终于出来和大家见面了:
在ip_ct_get_tuple()函数里初始化时tuple.dst.dir就被设置为了IP_CT_DIR_ORIGINAL,因为我们讨论的就是NEW状态的连接,tuple.dst.dir字段到目前为止还未被改变过,与此同时,ip_conntrack.status位图自从被创建之日起经过memset()操作后就一直为全0状态,才有最后的skb->nfctinfo=*ctinfo=IP_CT_NEW和skb->nfct = &ip_conntrack->ct_general。
继续回到ip_conntrack_in()函数里,此时调用协议所提供的回调packet()函数。在博文六中我们曾提及过,该函数承担着数据包生死存亡的使命。这里我们有必要注意一下packet()函数最后给Netfilter框架返回值的一些细节:
-1,其实就是-NF_ACCEPT,意思是:连接跟踪出错了,该数据包不是有效连接的一部分,Netfilter不要再对这类报文做跟踪了,调用前面的回调函数destroy()清除已经为其设置的连接跟踪项记录项,释放资源。最后向Netfilter框架返回ACCEPT,让该数据包继续传输。
0,就是NF_DROP,返回给Netfilter框架的也是该值,那么这数据包就挂在这里了。
1,就是NF_ACCEPT,同样,该数据包已经被正确跟踪了,通知Netfilter框架继续传输该数据包。
对于像TCP这样非常复杂的协议才用到了NF_DROP操作,像UDP、ICMP、GRE、SCTP等协议都没有到,但不排除你的项目中使用NF_DROP的情形。
在连接跟踪的出口处的ip_conntrack_confirm()函数中,如果已经为该数据包skb创建了连接跟踪记录ip_conntrack{}(即skb->nfct有值),则做如下处理:
如果该连接还没有收到回复报文----明显如此;
如果该连接没有挂掉----毫无疑问。
因为是新连接,因此在全局链表数组ip_conntrack_hash[]就没有记录该连接“初始”和“应答”方向的tuplehash链。然后,紧接着我把该连接初始方向的tuplehash链ip_conntrack->tuplehash[IP_CT_DIR_ORIGINAL].list从 unconfirmed链表上卸下来。并且,将该连接初始和应答方向的tuplehash链表根据其各自的hash之加入到ip_conntrack_hash[]里,最后启动连接跟踪老化时间定时器,修改引用计数,将ip_conntrack.status状态位图更新为 IPS_CONFIRMED_BIT,并向Netfilter框架返回NF_ACCEPT。数据包离开Netfilter继续在协议栈中传递。针对于ESTABLISHED状态的理解:
每个ip_conntrack{}结构末尾有两条tuplehash链,分别代表“初始”和“应答”方向的数据流向,如下图所示:
如果一条连接进入ESTABLISHED,那么的前一状态一定是NEW。因此,我们继续前面的分析分析过程,当我们的连接跟踪记录收到了其对应的响应报文后的处理流程。注意前面刚分析过的:对于新连接的状态位图status已经被设置成IPS_CONFIRMED_BIT了。
继续在ip_conntrack_in()函数,所以数据包从skb到tuple的生成过程中,初始化时都有tuple->dst.dir=IP_CT_DIR_ORIGINAL;那么tuple->dst.dir是何时被改变状态的呢?这就牵扯到一种很重要的通信机制netlink。连接跟踪框架还为连接记录的跃迁改变定义了一些事件处理和通知机制,而这目前不是本文的重点。在入口处,虽然从连接跟踪表中找到了该tuple所属的连接跟踪记录项,但在过滤表中该报文有可能会被丢弃,因此不应该急于改变回应报文所属的连接跟踪记录的状态,回应报文也有skb->nfctinfo = *ctinfo=IP_CT_NEW,该数据包所属的连接跟踪记录保存在skb->nfct里。在出口处,函数ip_conntrack_confirm()中,由于在NEW状态时,连接跟踪记录项中status=IPS_CONFIRMED_BIT了,因此这里对于响应报文,不会重复执行函数__ip_conntrack_confirm()。
紧接着,当“回应”报文被连接跟踪框架看到后,它会调用ip_ct_deliver_cached_events()函数,以某种具体的事件通过netlink机制来通知ip_conntrack_netlink.c文件中的ctnetlink_parse_tuple()函数将初始方向的tuple->dst.dir=IP_CT_DIR_REPLY。这里理解起来稍微有点抽象,不过还是提醒大家抓重点思路,我们后面这几章内容相对来说比前面几章要稍微复杂些,这也是能力提升必须要经历的过程。
至此,该连接跟踪记录ip_conntrack{}的数据我们来欣赏一下:如果该连接后续一个初始方向的数据包又到达了,那么在resolve_normal_ct()函数中,便会执行设置skb->nfctinfo=*ctinfo =IP_CT_ESTABLISHED + IP_CT_IS_REPLY和set_reply=1,然后退出到ip_conntrack_in()里,将上图中ip_conntrack{}结构体里的status成员属性由原来的IPS_CONFIRMED_BIT设置为IPS_SEEN_REPLY_BIT,并通过函数ip_conntrack_event_cache()触发一个netlink状态改变事件。最后,在ip_conntrack_confirm()里也只出发netlink事件而已。紧接着,第二个应答方向的报文也到达了,和上面的处理动作一样。
针对于RELATED状态的理解:
很多文章都从FTP协议的角度来剖析这个状态,确实FTP也是最能体现RELATED特性的协议。假如有个报文属于某条已经处于ESTABLISHED状态的连接,我们来看看状态防火墙是如何来识别这种情况。
依然在resolve_normal_ct()函数中,执行到init_conntrack()里面时,通过数据包相对应的tuple即可在全局链表ip_conntrack_expect_list里找到该连接所属的主连接。然后将我们这条RELATED连接记录的status=IPS_EXPECTED_BIT,并建立我们RELATED连接和它所属的主连接之间的对应关系conntrack->master= exp->master,同样将其挂载到unconfirmed链里。返回到resolve_normal_ct()里,为数据包设置状态值skb->nfctinfo = *ctinfo=IP_CT_RELATED。当数据包将要离开时,在ip_conntrack_confirm()函数中也会将其加入连接跟踪表,并设置status为IPS_CONFIRMED_BIT。剩下的流程就和前面我们讨论的是一样了,唯一却别的地方在于属于RELATED的连接跟踪,其master指向了它所属的主连接跟踪记录项。
INVALID状态压根儿就没找着,汗。跟俺玩躲猫猫,哥还不鸟你捏。。。
本篇的知识点相对来说总体来说比较抽象,内容比较多,实现上也较为复杂,我在省略了其状态跃迁流程情况下都还写了这么多东西,很多地方研究的其实都不是很深入,只能感慨Netfilter的博大。
(九)洞悉linux下的Netfilter&iptables:网络地址转换原理之DNAT
网络地址转换:NAT
Netfitler为NAT在内核中维护了一张名为nat的表,用来处理所有和地址映射相关的操作。诸如filter、nat、mangle抑或raw这些在用户空间所认为的“表”的概念,在内核中有的是以模块的形式存在,如filter;有的是以子系统方式存在的,如nat,但它们都具有“表”的性质。因此,内核在处理它们时有很大一部操作都是相同的,例如表的初始化数据、表的注册、钩子函数的注册等等。关于NAT表的初始化模板数据和表的注册流程并不是本文的重点,大家可以对照第四篇博文中filter表的相关分析来研究。本文还是侧重于从整体上对整个NAT子系统的设计思想和原理进行,当然,有时间我还是会简单和大家分析一NAT表的东西。因为最近确实太忙了,本来想着在四月份结束这个系列,无奈一转眼就晃到了五月份,做IT的娃,都不容易啊!
通过前面的几篇文章我们已经知道,NAT的设计是独立于连接跟踪系统的,即连接跟踪是NAT的基础框架,我们还了解到连接跟踪不会修改数据包,它只是负责维护数据包和其所属的业务会话或数据连接状态的相关信息而已。连接跟踪最终是被iptables模块所使用的,它所定义的那些状态信息如NEW、ESTABLISHED、RELEATED等等,NAT统统不用关心。
根据前面的hook函数挂载图我们可以清晰的知道,对于那些需要被本机转发的数据包,注册在NF_IP_PRE_ROUTING点的ip_nat_in ()函数完成对其目的地址转换的操作,注册在NF_IP_POST_ROUTING点的ip_nat_out()函数完成源地址转换任务。如果在编译Linux内核源码时打开了CONFIG_IP_NF_NAT_LOCAL选项,则注册在NF_IP_LOCAL_OUT和NF_IP_LOCAL_IN点的hook函数就会工作,最常见的用法的是用NF_IP_LOCAL_OUT点的ip_nat_local_fn()函数来改变本机发出报文的目的地址。至于注册在NF_IP_LOCAL_IN点的ip_nat_fn()函数一般不会起作用,只是当数据包到达该HOOK点后会例行被调用一下。因为,NAT的所有规则只可能被配置到nat表的PREROUTING、POSTROUTING和OUTPUT三个点上,一般也很少有人去修改那些路由给本机的报文的源地址。
NAT的分类如下图所示:相信大家在看iptables用户指南都见过这么一句解释:
只有每条连接的第一个数据包才会经过nat表,而属于该连接的后续数据包会按照第一个数据包则会按照第一个报所执行的动作进行处理,不再经过nat表。Netfilter为什么要做这个限制?有什么好处?它又是如何实现的?我们在接下来的分析中,将一一和大家探讨这些问题。
在ip_nat_rule.c文件中定义了nat表的初始化数据模板nat_table,及相应的target实体:SNAT和DNAT,并将其挂在到全局xt[PF_INET].target链表中。关于NAT所注册的几个hook函数,其调用关系我们在前几篇博文中也见过:
因此,我们的核心就集中在ip_nat_in()上。也就是说,当我们弄明白了ip_nat_fn()函数,你就差不多已经掌握了nat的精髓。ip_nat_in()函数定义定在ip_nat_standalone.c文件里。连接跟踪作为NAT的基础,而建立在连接跟踪基础上的状态防火墙同样服务于NAT系统。
关于ip_nat_fn()函数我们还是先梳理整体流程,以便大家对其有一个宏观整体的把握,然后我们再来分析其实现细节。这里需要大家对连接跟踪的状态跃迁有一定了了解。从流程图可以看出,牵扯到的几个关键函数都土黄色标注出来了。ip_nat_setup_info()函数主要是完成对数据包的连接跟踪记录ip_conntrack对象中相应成员的修改和替换,而manip_pkt()中才是真正对skb里面源/目的地址,端口以及数据包的校验和字段修改的地方。
目的地址转换:DNAT
DNAT主要适用于将内部私有地址的服务发布到公网的情形。情形如下:服务器上架设了Web服务,其私有地址是B,代理防火墙服务器有一个公网地址A。想在需要通过A来访问B上的Web服务,此时就需要DNAT出马才行。根据前面的流程图,我们马上杀入内核。
当client通过Internet访问公网地址A时,通过配置在防火墙上的DNAT将其映射到了对于私网内服务器B的访问。接下来我们就分析一下当在client和server的交互过程中架设在防火墙上NAT是如何工作。
还是看一下hook函数在内核中的挂载分布图。
在PREROUTING点当一个skb被连接跟踪过后,那么skb->ctinfo和skb->nfct两个字段均被设置了值。在接下来的分析中,对那些梢枝末节的代码我们都将不予理睬。盗个图:
这里牵扯到一个变量ip_conntrack_untracked,之前我们见过,但是还没讨论过。该变量定义在ip_conntrack_core.c文件里,并在ip_conntrack_init()函数进行部分初始化:
atomic_set(&ip_conntrack_untracked.ct_general.use, 1); set_bit(IPS_CONFIRMED_BIT, &ip_conntrack_untracked.status); |
同时,在ip_nat_core.c文件里的ip_nat_init()函数中又有如下设置:
ip_conntrack_untracked.status |= IPS_NAT_DONE_MASK; |
该变量又是何意呢?我们知道iptables维护的其实是四张表,有一张raw不是很常用。该表以-300的优先级在PREROUTING和LOCAL_OUT点注册了ipt_hook函数,其优先级要高于连接跟踪。当每个数据包到达raw表时skb->nfct字段缺省都被设置成了ip_conntrack_untracked,所以当该skb还没被连接踪的话,其skb->nfct就一直是ip_conntrack_untracked。对于没有被连接跟踪处理过的skb是不能进行NAT的,因此遇到这种情况代码中直接返回ACCEPT。
从上面的流程图可以看出,无论是alloc_null_binding_confirmed()、alloc_null_binding()还是ip_nat_rule_find()函数其本质上最终都调用了ip_nat_setup_info()函数。
ip_nat_setup_info()函数:
该函数中主要完成了对连接跟踪记录ip_conntrack.status字段的设置,同时根据可能配置在nat表中的DNAT规则对连接跟踪记录里的响应tuple进行修改,最后将该ip_conntrack实例挂载到全局双向链表bysource里。
在连接跟踪系统里根据skb的源/目的IP分别已经构建生成初始tuple和响应tuple,我们通过一个简单的示意图来回顾一下其流程,并加深对ip_nat_setup_info()函数执行过程的理解。在图1中,根据skb的源、目的IP生成了其连接跟踪记录的初始和响应tuple;
在图2中,以初始tuple为输入数据,根据DNAT规则来修改将要被改变的地址。这里是当我们访问目的地址是A的公网地址时,DNAT规则将其改成对私网地址B的访问。然后,计算被DNAT之后的数据包新的响应。最后用新的响应tuple替换ip_conntrack实例中旧的响应tuple,因为数据包的目的地址已经被改变了,所以其响应tuple也必须跟着变。
在图3中,会根据初始tuple计算一个hash值出来,然后以ip_conntrack结构中的nat.info字段会被组织成一个双向链表,将其插入到全局链表bysource里。
最后,将ip_conntrack.status字段的IPS_DST_NAT和IPS_DST_NAT_DONE_BIT位均置为1。
这里必须明确一点:在ip_nat_setup_info()函数中仅仅是对ip_conntrack结构实例中相关字段进行了设置,并没有修改原始数据包skb里的源、目的IP或任何端口。
ip_nat_packet()函数的核心是调用manip_pkt()函数:
在manip_pkt()里主要完成对数据包skb结构中源/目的IP和源/目的端口的修改,并且修改了IP字段的校验和。从mainip_pkt()函数中返回就回到了ip_nat_in()函数中(节选):
ret = ip_nat_fn(hooknum, pskb, in, out, okfn); if (ret != NF_DROP && ret != NF_STOLEN&& daddr != (*pskb)->nh.iph->daddr) { dst_release((*pskb)->dst); (*pskb)->dst = NULL; } return ret; |
正常情况下返回值ret一般都为NF_ACCEPT,因此会执行if条件语句,清除skb原来的路由信息,然后在后面协议栈的ip_rcv_finish()函数中重新计算该数据包的路由。
在数据包即将离开NAT框架时,还有一个名为ip_nat_adjust()的函数。参见hook函数的挂载示意图。该函数主要是对那些执行了NAT的数据包的序列号进行适当的调整。如果调整出错,则丢弃该skb;否则,将skb继续向后传递,即将到达连接跟踪的出口ip_confirm()。至于,ip_confirm()函数的功能说明我们在连接跟踪章节已经深入讨论了,想不起来的童鞋可以回头复习一下连接跟踪的知识点。
前面我们仅分析了从client发出的第一个请求报文到server服务器时,防火墙的处理工作。紧接着我们顺着前面的思路继续分析,当server收到该数据包后回应时防火墙的处理情况。
server收到数据包时,该skb的源地址(记为X)从未变化,但目的地址被防火墙从A改成了B。server在响应这个请求时,它发出的回应报文目的地址是X,源地址是自己的私有地址B。大家注意到这个源、目的地址刚好匹配被DNAT之后的那个响应tuple。
当该回应报文到达防火墙后,首先是被连接跟踪系统处理。显而易见,在全局的连接跟踪表ip_conntrack_hash[]中肯定可以找到这个tuple所属的连接跟踪记录ip_conntrack实例。关于状态的变迁参见博文八。
然后,该回应报文到达NAT框架的ip_nat_in()函数,流程和前面一样,但处理方式肯定不同。我们还是先看一下截止到目前为止,这条连接跟踪结构图: 直接跳到ip_nat_packet()函数里,当netlink通知机制将连接跟踪状态由NEW变为REPLY后,此时dir=1,那么根据初始tuple求出原来的响应tuple:源地址为A,目的之为X。此时,server的响应报文,源地址为私有网段B,目的地址为X。路由寻址是以目的地址为依据,防火墙上有直接到client的路由,所以响应报文是可以被client正确收到的。但是,但可是,蛋炒西红柿,对于UDP来说client收到这样的回复没有任何问题的,但是对于TCPU而言确实不行的。这就引出我们接下来将要讨论的SNAT。
(十)洞悉linux下的Netfilter&iptables:网络地址转换原理之SNAT
源地址转换:SNAT
SNAT主要应用于下列场景:这种情况下,我们只有一个公网地址A,而又有三台主机需要同时上网,这时就需要SNAT了。它的主要作用是将那些由私网发来的数据包skb的源地址改成防火墙的公网地址A,这是因为目的主机在响应源地址为私网地址的数据包时,私网地址不能在网络上路由的缘故。
SNAT仅可以在LOCAL_OUT和POSTROUTING点生效,这也说明了为什么用户空间的iptables命令在配置SNAT规则时只能配置到nat表的OUTPUT和POSTROUTING链的缘由。
我们现在假设的情形是:其他四个HOOK点都没配任何规则,且内置链的缺省处理策略为ACCEPT,然后在防火墙上配置了一条SNAT规则,私网地址B1要访问公网地址C的情况。
和前面DNAT类似,当第一个由B1发往C的数据包到达POSTROUTING点时,在连接跟踪阶段就已经为该连接建立好了ip_conntrack实例并进行过适当的初始化。SNAT主要是在ip_nat_out()函数中完成,而该函数本质上也调用了ip_nat_fn(),所以流程和DNAT一样,但执行的操作有所差别。
还是先回顾一下ip_nat_fn()的流程图:注意,当执行ip_nat_out()函数时,该skb已经被正确路由过了。此时,在ip_nat_fn()里执行的是ip_nat_rule_find()分支,然后进入ip_nat_setup_info()函数中。
接下来我们简单说一下get_unique_tuple()函数。
如果属于某条连接的数据包之前已经被执行过NAT了,则其连接跟踪记录会被添加到bysource链表中。对于SNAT操作,如果是第一个数据包,其流程和DNAT非常相似,在函数find_best_ips_proto()中完成对临时tuple的源地址的修改。然后,以修改后的tuple计算其响应tuple,最终用该响应tuple替换掉连接跟踪记录中原来的响应tuple。替换的结果是:此时,连接跟踪记录中响应tuple的源地址已经被替换成防火墙的公网地址A了。等会儿当服务器C回应时,它所发出的报文目的地址就是A,这样防火墙就可以正确接收到服务器C的回应报文。最后,会根据该连接跟踪记录实例的初始tuple来计算一个hash值,然后将其插入到bysource里,并对ip_conntrack.status状态进行适当设置。
和DNAT类似,最后也是在manip_pkt()函数中完成对skb源地址、IP校验和进行修改。对skb里端口的修改也是在manip_pkt()里完成的。
OK,关于SNAT我们第一阶段的分析就完成了,接下来我们来看一下当服务器C收到这个请求报文后,在对其响应的后续流程里防火墙是如何实现所谓的自动De-SNAT功能。
服务器的响应报文其源地址为自己的公网地址C,目的地址为防火墙的公网地址A。当该响应报文到达防火墙后,连接跟踪系统可以准确地识别该数据包所欲的连接跟踪记录,因为存在一个源地址是C,目的地址是A的响应tuple(如上图所示)与其匹配。该响应数据包还是会先到达PREROUTING点的ip_nat_in()函数,因为没有配置任何DNAT规则,同时ct.status字段又设置了IPS_SRC_NAT和IPS_SRC_NAT_DONE_BIT标志位,所以在进入到ip_nat_in()函数的ip_nat_fn()里时就直接调用ip_nat_packet()接口。
在ip_nat_packet()中以连接跟踪记录的初始tuple计算原来旧的响应tuple:源地址是C,目的地址是B1。因为在PREROUTING点上要做DNAT,所以此时skb中的目的地址A就被改成了原来的响应tuple中的目的地址B1了。后面的流程和DNAT完全一样。
当该响应报文来到POSTROUTING点时,又调用ip_nat_out(),和前面分析SNAT一阶段时的流程一样。
现在我们就明白了,De-SNAT功能是基于manip_pkt()函数实现的。在结尾之际,我们来解释一下上篇博文中关于NAT的一段描述:“只有每条连接的第一个数据包才会经过nat表,而属于该连接的后续数据包会按照第一个数据包则会按照第一个报所执行的动作进行处理,不再经过nat表”。
从用户空间的iptables规则来看,每条规则都有一个counter计数器,该计数器记录的是被该规则成功匹配了数据包的数目。而内核中对该计数器的修改是在ipt_do_table()函数。从ip_nat_fn()函数的执行流程可以看出,当连接跟踪记录项被设置了IPS_SRC_NAT_DONE_BIT状态位,或者连接跟踪的状态不再是IP_CT_NEW状态时,ipt_do_table()函数就不再被调用了,反应在用户空间所看到的直观现象就是,nat表的规则计数器不再增长了。
基于连接跟踪的NAT,其特殊之处就在于初始和响应tuple不再一致了,而这也是NAT得以正确运行的关键所在。通过如下这张图,让大家再复习一下NAT的初始和响应tuple之间的关系:至此,Linux中Netfilter框架下的nat子系统我们就全部学习完了。用了十个章节基本将Netfilter框架的各个功能子模块、架构、原理、流程等作了初步简单的分析了解。由于知识有限,本人的分析难免存在疏漏,还请各位大侠不吝指正。在接下来后续的文章中,主要探讨以下主题:
1、 用户空间的iptables是怎样识别传递给它的每个参数?
2、 通过iptables配置的每条规则是如何进到内核里的?它们又有什么关系?
3、 内核是如何判断数据包到底匹配还是不匹配某条具体规则?
4、 如何自己动手扩展iptables的功能模块?
(十一)洞悉linux下的Netfilter&iptables:iptables命令行工具源码解析【上】
预备知识:
part1: 初见getopt_long()
在分析iptables源码时,作为命令解析的核心函数getopt_long()不得不提。随便百度或google搜索关于该函数的介绍有很多例子和解释,这里我只举一例,目的是让大家了解传递给iptables命令的每个参数是如何被正确识别并处理的。
getopt_long(int argc,char * constargv[],const char *optstring,const struct option *longopts,int *longindex)
参数说明:
argc和argv来自main函数的输入;
optstring:表示可以接受的参数。可以是下列值:1.单个字符,表示选项;2.单个字符后接一个冒号“:”表示该选项后必须跟一个参数。参数紧跟在选项后或者以空格隔开,该参数的指针赋给optarg。3.单个字符后跟两个冒号,表示该选项后必须跟一个参数。参数必须紧跟在选项后不能以空格隔开。该参数的指针赋给optarg。(这个特性是GNU的扩展)。例如,"a:b:cd",表示可以接受的参数选项是a,b,c,d,其中a和b参数后面跟有参数值。
longopts:是一个结构类型,描述如下:
struct option {
const char *name; //name表示的是长参数名
int has_arg; //0-无参;1-一定要有;2-可有可无
int *flag; //用来决定,getopt_long()的返回值到底是什么。 //如果flag是null,则函数会返回与该项option匹配的val值。
int val; //和flag联合决定返回值
}
在iptables的do_command()命令解析函数中,见到最多的就是optarg和optind。
optarg: 如果字符串optstring中某个选项后面需要跟参数,该参数值一般保存在optarg中;
optind: 该参数比较费神,输入参数列表不同,其取值也不一样。
说了半天估计大家都快晕了,还是通过例子来说明这两个值随着输入参数的不同其变化情况吧。
#include #include char *l_opt_arg; char* const short_options = "nb:ls:"; struct option long_options[] = { { "name", 0, NULL, 'n' }, { "bf_name", 1, NULL, 'b' }, { "love", 0, NULL, 'l' }, { "speed", 1, NULL, 's' }, { 0, 0, 0, 0}, };
int main(int argc, char *argv[]) { int c; printf("init otpind=%d\n",optind); while((c = getopt_long (argc, argv, short_options, long_options, NULL)) != -1) { printf("option=%c,optind=%d,optarg=%s\n",c,optind,optarg); printf("args[%d]=%s\n",optind,argv[optind]); switch (c) { case 'n': printf("My name is XL.\n"); break; case 'b': printf("His name is ST.\n"); break; case 'l': printf("Our love is ok!\n"); break; case 's': printf("SHit of son.\n"); break; } } return 0; } |
该测试程序接受的可选参数为-n -b -l -s,其中b和s选项后面要跟值。
如果我们执行./test -n -b boy -l -s son
optind依次取值为1、2、4、5、7。默认值就是1,当解析-n时,因为发现-n不需要值,所以当调用了getopt_long后,optind自动加1,指向-b选项。而-b是需要参数的,那么跟在-b后面的一定是它的值,所以当解析-b时,optind自动跳到下一个选项-l所在位置。同样-s也需要跟参数,那么当解析-l时optind就自动跳到-s所在的位置了,即5。
如果我们执行./test -n --b=boy -l -s son
这样的格式,optind依次为1、2、3、4、6。大家基本已经可以看出些眉目了吧。因为-b参数用长参格式加等号的赋值方式,所以optind的移动稍微有些变化。但它终归可以正确识别传给它的所有命令行参数及其格式。
如果我们执行./test -nl -b boy -s son
optind依次取值1、1、2、4、6。第一个选项是nl组合项,而且这两个选项都不需要跟参数。
关于getopt_long函数的更多用法参见man帮助手册。自己再对上面这个程序摸索摸索体会要更深刻些。
part2:iptables的自动加载模块原理
无论是match还是target,在用户空间都有其对应的so库文件,关于动态库大家可以参阅读我的另一篇博文《Linux系统中“动态库”和“静态库”那点事儿》。这里我们注意到一点的就是无论是诸如libxt_tcp.c这样的协议模块,还是libxt_limit.c这样的match模块,又抑或libipt_REJECT.c这样的target模块,每个模块中都有一个名为_init()的函数。为什么我们的自己平时开发so库时,怎么没见过这个家伙?大家可能会有这疑问。接下来我们就来跟您抽丝剥茧,步步深入,看看它到底是何方妖孽。
iptables在加载动态库时用的是dlopen()函数,在这篇博文中我有介绍。_init()定义在xtables.h中,是共享库用来初始化全局变量和对象用的,其定义如下:
#define _init__attribute__((constructor)) my_init
用__attribute__((constructor))来定义的函数,表示函数是构造函数,在main执行之前被调用;相应的用__attribute__((destructor))析构函数,在main退出时执行。void _init(void)就相当于是__attribute__((constructor)) _INIT(void),其实不管函数名定义成什么都会被执行到。
在iptables中当我们调用dlopen函数来加载动态库时,率先执行每个动态库里的_init()函数,而该函数要么是将该match注册到全局链表xtables_matches里,或者是将target注册到全局链表xtables_targets中。
iptables的命令解析流程
这里我们仅以ipv4协议为例进行分析。iptables-1.4.0.tar.gz源代码中,iptables命令的入口文件为iptables-standalone.c,其中主函数为main或者iptables_main。主函数中,所作的事情也很明了,其流程如下:当前,用户空间的iptables工具的绝大多数模块都是以动态共享库so的形式。使用动态库的优点也是显而易见的:编译出来的iptables命令比较小,动态库方式使得对于iptables的扩充非常方便。如果你非要去研究一下init_extensions函数的话,那么可以在iptables源码包的extensions/Makefile文件里找点思路。这里,我不会对其进行分析。
命令行参数解析do_command()【位于iptable.c文件中】
该函数是iptables用于解析用户输入参数的核心接口函数,其函数原型为:
int do_command(int argc, char *argv[],char **table, iptc_handle_t *handle);
argc和argv是由用户传递过来的命令行参数;
table所操作的表名,对应命令行就是-t参数后面的值,如果用户没有指定-t参数时,默认为filter表;
handle这个结构比较重要,它用于保存从内核返回的由table所指定的表的所有信息,后续对表及其其中的规则操作时都用的该变量;
前面我们在分析netfilter的时候提到过,用户空间和内核空间在表示match以及target时采用了不同的结构体定义。用户空间的match结构体定义为:
struct xtables_match #defineiptables_target xtables_target { struct xtables_match *next; … void (*help)(void); /* Initialize the match. */ void (*init)(struct xt_entry_match *m); … /* Ignore these men behind the curtain: */ unsigned int option_offset; struct xt_entry_match *m; #内核中的match结构 unsigned int mflags; … }; |
该结构是iptables在加载它所支持的所有match模块的时候所用到的结构体,例如time匹配模块、iprange匹配模块等。也就是说,如果你要开发自己的用户空间match的话,那么你必须实例化上面这样一个结构体对象,然后实现它相应的方法,诸如init、help、parse等等。
真正用在我们所配置的iptables规则里的匹配条件,是由下列类型表示:
struct xtables_rule_match #defineiptables_rule_match xtables_rule_match { struct xtables_rule_match *next; struct xtables_match *match; unsigned int completed; }; |
可以看到,xtables_rule_match是将xtables_match组织成了一个链表而已。这也正和我们的意愿,因为一条规则里有可能会有多个match条件,而在解析的时候我们只要将我们规则里所用的match通过一个指针指向iptables目前所支持的那个模块,在后面的使用过程中就可以直接调用那个match模块里的所有函数了。这样即提高的访问效率,又节约了系统内存空间。
同样的,用户空间的target也类似,留给大家自己去研究。
iptables最常用的命令格式无非就是显示帮助信息,或者操作规则,例如:
【帮助信息格式】
iptables [-[m|j|p] name ] -h 显示名为name的match模块(m)、target模块(j)或协议(p)的详细帮助信息。
OK,我们以下面的规则为例,和大家探讨一下iptables对其的解析流程。
iptables –A INPUT –i eth0 –p tcp --syn –s 10.0.0.0/8–d 10.1.28.184 –j ACCEPT
在博文三中,我们知道内核中用于表示一条规则的数据结构是struct ipt_entry{}类型,那么iptables对于输入给它的所有参数最终也要变成这样的格式。而我们在阅读iptables源码时发现,它确实在do_command()函数开始部分定义了一个struct ipt_entry fw;后面当iptables解析传递给它的输入参数时,主要做的事情,就是对该结构体相关成员变量的初始化填充。闲话不多说,let's rock。
(1)、命令控制解析:-A INPUT
iptables –A INPUT –ieth0 –p tcp --syn –s 10.0.0.0/8 –d 10.1.28.184 –jACCEPT
对于“ADRILFZNXEP”这些控制命令来说,其核心处理函数为add_command()函数。
该函数主要将命令行的控制参数解析出来,然后赋值给一个位图变量command,该变量的每一位bit表示一种操作。add_command()的函数原型定义如下(iptables.c):
staticvoidadd_command(unsignedint *cmd, const int newcmd, const int othercmds, int invert)
参数说明:
cmd:用于保存控制参数解析结果的位图标志变量;
newcmd:用户所输入的控制变量,是一些预定义的宏,定义在iptables.c文件中,如下:
#define CMD_NONE 0x0000U #define CMD_INSERT 0x0001U #define CMD_DELETE 0x0002U #define CMD_DELETE_NUM 0x0004U #define CMD_REPLACE 0x0008U #define CMD_APPEND 0x0010U #define CMD_LIST 0x0020U #define CMD_FLUSH 0x0040U #define CMD_ZERO 0x0080U #define CMD_NEW_CHAIN 0x0100U #define CMD_DELETE_CHAIN 0x0200U #define CMD_SET_POLICY 0x0400U #define CMD_RENAME_CHAIN 0x0800U |
othercmd:在上面这11个控制参数中,只有CMD_ZERO需要辅助额外参数,因为从iptables -Z chainname的执行结果来看,它最后还会输出清空后的链的实际情况。因此,当用户的iptables命令中有-Z参数时,cmd默认的会被附加一个CMD_LIST特性。其他10个控制参数时,othercmd参数均为CMD_NONE。
invert:表示命令中是否有取反标志“!”。因为这11个控制参数是没有取反操作的,因此,这个值均为FALSE(即0)。
当解析完iptables -A INPUT … 后,command=0x0010U,chain=“INPUT”。然后将invert=FALSE,重新进入while循环,解析剩下的参数。
(2)、解析接口:-i eth0
iptables –A INPUT –i eth0 –p tcp --syn –s 10.0.0.0/8–d 10.1.28.184 –j ACCEPT
注意前面讲解的关于getopt_long()函数在执行过程中两个关键参数的值及其变化情况。当解析接口的时候optarg=“eth0”,optind=indexof(-p)。
check_inverse(optarg, &invert, &optind, argc);函数用于判断接口是否有取反标志,如果有取反标志,则将invert=TRUE,同时optind++,然后它指向后面的接口名,并返回TRUE;如果没有,则直接返回FALSE。
在接下来执行set_option(&options,OPT_VIANAMEIN, &fw.ip.invflags,invert);同样的,options也是一个位图标志变量,其取值分别如下(定义在iptables.c文件中):
#define OPT_NONE 0x00000U #define OPT_NUMERIC 0x00001U #define OPT_SOURCE 0x00002U #define OPT_DESTINATION 0x00004U #define OPT_PROTOCOL 0x00008U #define OPT_JUMP 0x00010U #define OPT_VERBOSE 0x00020U #define OPT_EXPANDED 0x00040U #define OPT_VIANAMEIN 0x00080U #define OPT_VIANAMEOUT 0x00100U #define OPT_FRAGMENT 0x00200U #define OPT_LINENUMBERS 0x00400U #define OPT_COUNTERS 0x00800U #define NUMBER_OF_OPT 12 |
然后根据check_inverse()函数解析出来的invert的值来设置fw.ip.invflags相应的标志位,该值也是个位图标志变量,其可取的值由全局数组inverse_for_options[]来限定(iptables.c):
static int inverse_for_options[NUMBER_OF_OPT] = { /* -n */ 0, /* -s */ IPT_INV_SRCIP, #这六个宏均定义在ip_tables.h文件中 /* -d */ IPT_INV_DSTIP, /* -p */ IPT_INV_PROTO, /* -j */ 0, /* -v */ 0, /* -x */ 0, /* -i */ IPT_INV_VIA_IN, /* -o */ IPT_INV_VIA_OUT, /* -f */ IPT_INV_FRAG, /*--line*/ 0, /* -c */ 0, }; |
执行parse_interface(argv[optind-1],fw.ip.iniface,fw.ip.iniface_mask);将接口名称赋值给fw.ip.iniface,然后再设置该接口的mask。如果接口中没有正则匹配表达式(即“+”),则mask=0xFFFFFFFF。细心的朋友到这里可能就有疑问了:接口名不是保存在optarg中么,为什么要通过argv[optind-1]来获取呢?我们简单分析对比一下:
如果是“-i eth0”,那么optarg和argv[optind-1]的值相同,大家可以通过前面我给的那个demo例子去验证一下;
如果是“-i ! eth0”,情况就不一样了。注意看代码,此时optarg=“!”,而arg[optind-1]才是真正的接口名“eth0”。
(3)、解析协议字段:-p tcp
iptables –A INPUT–i eth0–p tcp --syn –s 10.0.0.0/8–d 10.1.28.184 –j ACCEPT
check_inverse(optarg,&invert,&optind, argc); 检查协议字段是否有取反标志
set_option(&options,OPT_PROTOCOL,&fw.ip.invflags,invert); 根据invert的值来设置options和fw.ip.invflags。这和前面的接口解析是类似的。
然后,将协议名称解析成对应的协议号,例如ICMP=1,TCP=6,UDP=17等等。
fw.ip.proto = parse_protocol(protocol);
因为iptables在-p参数后面支持数字格式的协议描述,因此parse_protocol()函数首先尝试去解析数字字符串,将其转换成一个0-255之间的整数。如果转换成功,则将转换结果赋值给fw.ip.proto。如果转换失败,首先检查-p后面的参数是不是“all”。如果是则直接返回,否则调用getprotobyname()函数从/etc/protocols中去解析。这里getprotobyname函数主要根据传递给它的协议名返回一个struct protoent{}结构体的对象(详见man手册)。解析成功则返回;否则,在用户自定义的结构体数组chain_protos[]中去解析,其定义如下:
static const struct pprot chain_protos[] = { { "tcp", IPPROTO_TCP }, { "udp", IPPROTO_UDP }, { "udplite", IPPROTO_UDPLITE }, { "icmp", IPPROTO_ICMP }, { "esp", IPPROTO_ESP }, { "ah", IPPROTO_AH }, { "sctp", IPPROTO_SCTP }, { "all", 0 }, }; |
if(fw.ip.proto == 0&& (fw.ip.invflags & IPT_INV_PROTO))
exit_error(PARAMETER_PROBLEM,"rulewould never match protocol");
如果协议类型为“all”并且协议字段-p后面还有取反标志,即-p! all,表示不匹配任何协议。这样的规则是没有任何意义的,iptables也不允许这样的规则存在,因此会给出错误提示信息并退出。
(4)、解析tcp协议模块的具体控制参数:--syn
iptables –A INPUT–i eth0 –p tcp--syn –s 10.0.0.0/8–d 10.1.28.184 –j ACCEPT
针对于--syn符号,会跳转到switch语句的default处执行。因为目前还没有解析到target,因此target=NULL。命令行中没有-m,因此matches=NULL,matchp=NULL,m=NULL。
if (m == NULL&&protocol&&
(!find_proto(protocol,DONT_LOAD,options&OPT_NUMERIC, NULL)
|| (find_proto(protocol,DONT_LOAD,options&OPT_NUMERIC, NULL)
&& (proto_used == 0))
)
&& (m = find_proto(protocol, TRY_LOAD,options&OPT_NUMERIC,&matches))) {
这个逻辑条件判断已经很清晰了:
如果命令行中没有-m,但是有-p,并且find_proto执行失败或者执行成功且协议本身还没有被用过proto_used=0,最后我们试图去加载so库之后再去执行find_proto。当第三执行find_proto函数时,会运行如下的代码部分,因为我们这次是以TRY_LOAD方式执行的:
#ifndef NO_SHARED_LIBS if (!ptr && tryload != DONT_LOAD && tryload != DURING_LOAD) { char path[strlen(lib_dir) + sizeof("/.so") + strlen(afinfo.libprefix) + strlen(name)]; sprintf(path, "%s/libxt_%s.so", lib_dir, name); if (dlopen(path, RTLD_NOW) != NULL) /* Found library. If it didn't register itself, maybe they specified target as match. */ ptr = find_match(name, DONT_LOAD, NULL); |
以上代码会将我们的…/libxt_tcp.so库加载到当前进程的运行空间中,并导出相关环境变量,此时tcp的模块在执行dlopen时就已经被挂到xtables_matches链表中了。最后再在find_match()函数(find_proto()函数的内部其实就是调的find_match()而已)里递归的调用一次自己。
第二次递归调用自己时,首先会申请一块大小为structxtables_match{}的内存空间由变量clone来指向,并将tcp.so模块中的信息保存其中,并设置clone->mflags = 0。然后再申请一块大小为structxtables_rule_match{}大小的内存空间,由变量newentry来保存,将tcp的so模块的信息赋给结构体的相关成员变量。
for (i = matches; *i; i = &(*i)->next) { #不会执行这个for循环 printf("i=%s\n",(i==NULL?"NULL":i)); if (strcmp(name, (*i)->match->name) == 0) (*i)->completed = 1; } newentry->match = ptr; //就是前面的clone所指向的地址空间。 newentry->completed = 0; newentry->next = NULL; *i = newentry; #因为matches是个二级指针,因此这里的*i即*matches=newentry return ptr; #ptr目前就保存了和tcp模块所有相关的内容,ptr最后返回去会赋给 下面的变量m |
然后回到do_command()中继续执行:
/* Try loading protocol */ size_t size;
proto_used = 1;
printf("Ready to load %s's match\n",protocol);
size = IPT_ALIGN(sizeof(struct ipt_entry_match))+m->size;
m->m = fw_calloc(1, size); #为内核态的xt_entry_match结构分配存储空间 m->m->u.match_size = size; #整个tcp_match的大小 strcpy(m->m->u.user.name,m->name); set_revision(m->m->u.user.name,m->revision); if (m->init != NULL) m->init(m->m);#调用tcp_init函数初始化内核中的match结构,主要是将xt_entry_match尾部的data数组进行初始化。对TCP来说就是将源、目的端口置为0xFFFF。这并不是重点。
opts = merge_options(opts,m->extra_opts, &m->option_offset); #重点是merge_options操作,将tcp_opts中的数据合并到全局变量opts中去 optind--; continue; #前面说过optind指向当前参数下一个紧挨着的参数的下标。目前只是完成了解析--syn的初始化工作,还并没有对--syn进行解析,因此需要optind--,然后开始解析--syn |
然后程序继续执行while循环,这次依然进入default段进行处理,并进入if(!target||…
只不过这次matches已经不为NULL,因此matchp就可以取到值,matchep即指向了tcp模块。将解析的结果赋给fw结构体的相应成员,并将代表tcp模块的iptables_match赋给m。
if (!target|| !(target->parse(c - target->option_offset,argv, invert,&target->tflags,&fw, &target->t))) { for (matchp = matches; matchp; matchp = matchp->next) { if (matchp->completed) continue; #调用tcp模块的parse函数,即tcp_parse if (matchp->match->parse(c - matchp->match->option_offset,argv, invert, &matchp->match->mflags,&fw, &matchp->match->m)) break; }
m = matchp ? matchp->match : NULL;
if(m==NULL &&…) #就不会再执行这里了 … … |
至此,对--syn的解析就已经完成了。
(5)、解析源、目的地址:-s10.0.0.0/8 -d 10.1.28.184
iptables –A INPUT–i eth0 –p tcp--syn –s10.0.0.0/8 –d10.1.28.184 –j ACCEPT
解析源地址: check_inverse(optarg, &invert, &optind, argc); set_option(&options,OPT_SOURCE, &fw.ip.invflags,invert); shostnetworkmask = argv[optind-1]; #暂存源地址,后面要做进一步分析x.x.x.x/xx
解析目的地址: check_inverse(optarg, &invert, &optind, argc); set_option(&options,OPT_DESTINATION, &fw.ip.invflags,invert); dhostnetworkmask = argv[optind-1]; #暂存目的地址,后面要做进一步分析x.x.x.x/xx |
(6)、解析target:-j ACCEPT
iptables –A INPUT–i eth0 –p tcp --syn–s 10.0.0.0/8 –d 10.1.28.184 –j ACCEPT
首先判断target字串是否合法,jumpto= parse_target(optarg);
然后在xtables_targets全局链表里查找相应的target。因为目前只有标准target,因此最后加载libxt_standard.so库,对应的文件为libxt_standard.c。
static struct xtables_targetstandard_target = { .family = AF_INET, .name = "standard", .version = IPTABLES_VERSION, .size = XT_ALIGN(sizeof(int)), .userspacesize = XT_ALIGN(sizeof(int)), .help = standard_help, .parse = standard_parse, }; |
我们可以看到标准target(诸如ACCEPT、DROP、RETURN、QUEUE等)是没有init函数和extra_opts变量的。因此,要做的操作只有下面几个:
if (target) { size_t size;
size = IPT_ALIGN(sizeof(struct ipt_entry_target))+ target->size;
target->t = fw_calloc(1, size); #为内核中的xt_entry_target分配存储空间 target->t->u.target_size = size; strcpy(target->t->u.user.name, jumpto); set_revision(target->t->u.user.name,target->revision);
#以下操作均不执行。因为target->init和target->extra_ops都为NULL if (target->init != NULL) target->init(target->t); opts = merge_options(opts, target->extra_opts, &target->option_offset); } |
至此,对用户的命令行输入的参数就算全部解析完成了,其中:
并完成了对struct ipt_entry{}中struct ipt_ip{}结构体成员的初始化,即对fw.ip的初始化。
(7)、参数和合法性检查
如果是“ADRI”操作但是没有指定源目的地址,默认将其置为全网段0.0.0.0/0。然后,设置源目的掩码fw.ip.smsk和fw.ip.dmsk。
检查command和options的匹配性generic_opt_check(command, options)。它们的相关性由一个二维数组commands_v_options[][]来限定:至此,所有的解析、校验工作都已完成。接下来我们将要探究,iptables如何与内核交互的问题。
(十二)洞悉linux下的Netfilter&iptables:iptables命令行工具源码解析【下】
iptables用户空间和内核空间的交互
iptables目前已经支持IPv4和IPv6两个版本了,因此它在实现上也需要同时兼容这两个版本。iptables-1.4.0在这方面做了很好的设计,主要是由libiptc库来实现。libiptc是iptables control library的简称,是Netfilter的一个编程接口,通常被用来显示、操作(查询、修改、添加和删除)netfilter的规则和策略等。使用libipq库和ip_queue模块,几乎可以实现任何在内核中所实现的功能。
libiptc库位于iptables源码包里的libiptc目录下,共六个文件还是比较容易理解。我们都知道,运行在用户上下文环境中的代码是可以阻塞的,这样,便可以使用消息队列和 UNIX 域套接字来实现内核态与用户态的通信。但这些方法的数据传输效率较低,Linux 内核提供 copy_from_user()/copy_to_user() 函数来实现内核态与用户态数据的拷贝,但这两个函数会引发阻塞,所以不能用在硬、软中断中。一般将这两个特殊拷贝函数用在类似于系统调用一类的函数中,此类函数在使用中往往"穿梭"于内核态与用户态。此类方法的工作原理为:
其中相关的系统调用是需要用户自行编写并载入内核。一般情况都是,内核模块注册一组设置套接字选项的函数使得用户空间进程可以调用此组函数对内核态数据进行读写。我们的libiptc库正是基于这种方式实现了用户空间和内核空间数据的交换。
为了后面便于理解,这里我们简单了解一下在socket编程中经常要接触的两个函数:
int setsockopt(intsockfd, int proto, intcmd, void *data, intdatalen)
intgetsockopt(intsockfd, intproto, int cmd, void *data, intdatalen)
这个两个函数用来控制相关socket文件描述符的一些选项值,如设置(获取)接受或发送缓冲区的大小、设置(获取)接受或发送超时值、允许(禁止)重用本地端口和地址等等。
参数说明:
sockfd:为socket的文件描述符;
proto:sock协议,IP RAW的就用SOL_SOCKET/SOL_IP等,TCP/UDP socket的可用SOL_SOCKET/SOL_IP/SOL_TCP/SOL_UDP等,即高层的socket是都可以使用低层socket的命令字 的;
cmd:操作命令字,由自己定义,一般用于扩充;
data:数据缓冲区起始位置指针,set操作时是将缓冲区数据写入内核,get的时候是将内核中的数据读入该缓冲区;
datalen:参数data中的数据长度。
我们可以通过扩充新的命令字(即前面的cmd字段)来实现特殊应用程序的内核与用户空间的数据交换,内核实现新的sockopt命令字有两类:一类是添加完整的新的协议后引入;一类是在原有协议命令集的基础上增加新的命令字。以netfilter为例,它就是在原有的基础上扩展命令字,实现了内核与用户空间的数据交换。Netfilter新定义的命令字如下:
setsockopt新增命令字:
#define IPT_SO_SET_REPLACE //设置规则
#defineIPT_SO_SET_ADD_COUNTERS //加入计数器
getsockopt新增命令字;
#defineIPT_SO_GET_INFO //获取ipt_info
#defineIPT_SO_GET_ENTRIES //获取规则
#define IPT_SO_GET_REVISION_MATCH //获取match
#defineIPT_SO_GET_REVISION_TARGET //获取target
一个标准的setsockopt()操作的调用流程如下:
在ip_setsockopt调用时,如果发现是一个没有定义的协议,并且判断现在这个optname是否为netfilter所设置,如果是则调用netfilter所设置的特殊处理函数,于是加入netfilter对sockopt特殊处理后,新的流程如下:
netfitler对于会实例化一些struct nf_sockopt_ops{}对象,然后通过nf_register_sockopt()将其注册到全局链表nf_sockopts里。
struct nf_sockopt_ops { struct list_head list; int pf;
/* Non-inclusive ranges: use 0/0/NULL to never get called. */ int set_optmin; int set_optmax; int (*set)(struct sock *sk, int optval, void __user *user, unsigned int len); int (*compat_set)(struct sock *sk, int optval,void __user *user, unsigned int len);
int get_optmin; int get_optmax; int (*get)(struct sock *sk, int optval, void __user *user, int *len); int (*compat_get)(struct sock *sk, int optval,void __user *user, int *len);
/* Number of users inside set() or get(). */ unsigned int use; struct task_struct *cleanup_task; }; |
继续回到libiptc中。libiptc库中的所有函数均以“iptc_”开头,主要有下面一些接口(节选自libiptc.h):
typedef struct iptc_handle *iptc_handle_t;
/* Does this chain exist? */ int iptc_is_chain(const char *chain, const iptc_handle_t handle);
/* Take a snapshot of the rules. Returns NULL on error. */ iptc_handle_t iptc_init(const char *tablename);
/* Cleanup after iptc_init(). */ void iptc_free(iptc_handle_t *h);
/* Iterator functions to run through the chains. Returns NULL at end. */ const char *iptc_first_chain(iptc_handle_t *handle); const char *iptc_next_chain(iptc_handle_t *handle);
/* Get first rule in the given chain: NULL for empty chain. */ const struct ipt_entry *iptc_first_rule(const char *chain,iptc_handle_t *handle); /* Returns NULL when rules run out. */ const struct ipt_entry *iptc_next_rule(const struct ipt_entry *prev,iptc_handle_t *handle);
/* Returns a pointer to the target name of this entry. */ const char *iptc_get_target(const struct ipt_entry *e,iptc_handle_t *handle);
/* Is this a built-in chain? */ int iptc_builtin(const char *chain, const iptc_handle_t handle);
int iptc_append_entry(const ipt_chainlabel chain, const struct ipt_entry *e, iptc_handle_t *handle);
/* Zeroes the counters in a chain. */ int iptc_zero_entries(const ipt_chainlabel chain,iptc_handle_t *handle);
/* Creates a new chain. */ int iptc_create_chain(const ipt_chainlabel chain,iptc_handle_t *handle);
/* Makes the actual changes. */ int iptc_commit(iptc_handle_t *handle);
|
上面这些接口都是为IPv4定义了,同样的IPv6的接口均定义在libip6tc.h头文件中,都以“ip6tc_”开头(如此说来,IPv4的头文件应该叫libip4tc.h才比较合适)。然后在libip4tc.c和libip6tc.c文件中分别通过宏定义的形式将IPv4和IPv6对外的接口均统一成“TC_”开头的宏,并在libiptc.c中实现这些宏即可。如下图所示:
这里我们看到iptables-v4和iptables-v6都和谐地统一到了libiptc.c中,后面我们分析的时候只要分析这些相关的宏定义的实现即可。
在继续往下分析之前我们先看一下STRUCT_TC_HANDLE这个比较拉风的结构体,它用于存储我们和内核所需要交换的数据。说的通俗一些,就是从内核中取出的表的信息会存储到该结构体类型的变量中;当我们向内核提交iptables变更时,也需要一个该结构体类型的变量用于存储我们所要提交的数据。(定义在ip_tables.h头文件中)
适用于当getsockopt的参数为IPT_SO_GET_INFO,用于从内核读取表信息 struct ipt_getinfo #define STRUCT_GETINFO struct ipt_getinfo { /* Which table: caller fills this in. */ #从内核取出的表信息会存储在该结构体中 char name[IPT_TABLE_MAXNAMELEN]; /* Kernel fills these in. */ unsigned int valid_hooks; /* Which hook entry points are valid: bitmask */ unsigned int hook_entry[NF_IP_NUMHOOKS]; // Hook entry points: one per netfilter hook. unsigned int underflow[NF_IP_NUMHOOKS]; /* Underflow points. */ unsigned int num_entries; /* Number of entries */ unsigned int size; /* Size of entries. */ }; |
还有一个成员entries用保存表中的所有规则信息,每条规则都是一个ipt_entry的实例:
/* The argument to IPT_SO_GET_ENTRIES. */ struct ipt_get_entries { /* Which table: user fills this in. */ char name[IPT_TABLE_MAXNAMELEN]; unsigned int size; /* User fills this in: total entry size. */
struct ipt_entry entrytable[0]; /*内核里表示规则的结构,参见博文三. */ }; |
(一)、从内核获取数据:iptc_init()
都说“磨刀不误砍柴工”,接下来我们继续上一篇中do_command()函数里剩下的部分。*handle =iptc_init(*table); 这里即根据表名table去从内核中获取该表的自身信息和表中的所有规则。关于表自身的一些信息存储在handle->info成员里;表中所有规则的信息保存在handle->entries成员里。
如果handle获取失败,则尝试加载完内核中相应的ko模块后再次执行iptc_init()函数。
然后,针对“ADRI”操作需要做一些合法性检查,诸如-o选项不能用在PREROUTING和INPUT链中、-i选项不能用在POSTROUTING和OUTPUT链中。
if (target && iptc_is_chain(jumpto, *handle)) { fprintf(stderr,"Warning: using chain %s, not extension\n",jumpto); if (target->t) free(target->t);
printf("Target is a chain,but we have gotten a target,then free it!\n");
target = NULL; } |
如果-j XXX 后面的XXX是一条用户自定义规则链,但是之前却解析出了标准target,那么需要将target的空间释放掉。很明显,目前我们的-j ACCEPT不会执行到这里。
if (!target #如果没有指定target。同样,我们的规则也不会执行到这里 && (strlen(jumpto) == 0|| iptc_is_chain(jumpto, *handle)) #或者target是一条链或为空 ) { size_t size; … … } |
因为我们的target为ACCEPT,已经被完全正确解析,即target!=NULL。后面我们会执行else条件分子如下的代码:
e =generate_entry(&fw, matches, target->t);
用于生成一条iptables的规则,它首先会为e去申请一块大小n*match+target的空间,其中n为用户输入的命令行中的match个数,target为最后的动作。这里很明显,我们的命令只有一个tcp的match,target是标准target,即ACCEPT。将已经解析的fw赋给e,并对结构体e中其他的成员进行初始化,然后将相应的match和target的数据拷贝到e中对应的成员中。
size = sizeof(struct ipt_entry); for (matchp = matches; matchp; matchp = matchp->next) size += matchp->match->m->u.match_size;
e = fw_malloc(size + target->u.target_size); *e = *fw; e->target_offset = size; e->next_offset = size + target->u.target_size;
size = 0; for (matchp = matches; matchp; matchp = matchp->next) { memcpy(e->elems + size, matchp->match->m, matchp->match->m->u.match_size); size += matchp->match->m->u.match_size; } memcpy(e->elems + size, target, target->u.target_size); |
最后所生成的规则e,其内存结构如下图所示:
这里再联系我们对内核中netfilter的分析就很容易理解了,一旦我们获取一条规则ipt_entry的首地址,那么我们能通过target_offset很快获得这条规则的target地址,同时也可以通过next_offset获得下一条ipt_entry规则的起始地址,很方便我们到时候做数据包匹配的操作。
紧接着就是对解析出来的command命令进行具体操作,这里我们是-A命令,因此最后command命令就是CMD_APPEND,这里则执行append_entry()函数。
ret = append_entry(chain, #链名,这里为INPUT
e, #将用户的命令解析出来的最终的规则对象
nsaddrs, #-s 后面源地址的个数
saddrs, #用于保存源地址的数组
ndaddrs, #-d 后面的目的地址的个数
daddrs, #用于保存目的地址的数组
options&OPT_VERBOSE,#iptables命令是否有-v参数
handle #从内核中取出来的规则表信息
);
在append_entry内部调用了iptc_append_entry(chain, fw, handle),其实就是由宏即TC_APPEND_ENTRY所表示的那个函数。该函数内部有两个值得注意的结构体类型struct chain_head{}和struct rule_head{},分别用于保存我们所要操作的链以及链中的规则:
struct chain_head { struct list_head list; char name[TABLE_MAXNAMELEN]; unsigned int hooknum; /* hook number+1 if builtin */ unsigned int references; /* 有多少-j 指定了我们的名字 */ int verdict; /* verdict if builtin */ STRUCT_COUNTERS counters; /* per-chain counters */ struct counter_map counter_map; unsigned int num_rules; /* 本链中的规则数*/ struct list_head rules; /* 本链中所有规则的入口点 */
unsigned int index; /* index (needed for jump resolval) */ unsigned int head_offset; /* offset in rule blob */ unsigned int foot_index; /* index (needed for counter_map) */ unsigned int foot_offset; /* offset in rule blob */ };
struct rule_head { struct list_head list; struct chain_head *chain; struct counter_map counter_map; unsigned int index; /* index (needed for counter_map) */ unsigned int offset; /* offset in rule blob */
enum iptcc_rule_type type; struct chain_head *jump; /* jump target, if IPTCC_R_JUMP */
unsigned int size; /* size of entry data */ STRUCT_ENTRY entry[0]; #真正的规则入口点 sizeof计算时不会包含这个字段 }; |
TC_APPEND_ENTRY的函数实现:
int TC_APPEND_ENTRY(const IPT_CHAINLABEL chain, const STRUCT_ENTRY *e, TC_HANDLE_T *handle) #注意:这里的handle是个二级指针 { struct chain_head *c; struct rule_head *r;
iptc_fn = TC_APPEND_ENTRY; if (!(c = iptcc_find_label(chain, *handle))) { #根据链名查找真正的链地址赋给c,此时c就指向了INPUT链的内存, #包括INPUT中的所有规则和它的policy等 DEBUGP("unable to find chain `%s'\n", chain); errno = ENOENT; return 0; }
if (!(r = iptcc_alloc_rule(c, e->next_offset))) { #ipt_entry的next_offset即指明了下一条规则的起始地址,同时这个值也说明了本条规则所占了存储空间的大小。这里所申请的空间大小=sizeof(rule_head)+当前规则所占的空间大小。 DEBUGP("unable to allocate rule for chain `%s'\n", chain); errno = ENOMEM; return 0; }
memcpy(r->entry, e, e->next_offset); #把规则拷贝到柔性数组entry中去 r->counter_map.maptype = COUNTER_MAP_SET;
if (!iptcc_map_target(*handle, r)) { #主要是设置规则r的target,后面分析。 DEBUGP("unable to map target of rule for chain `%s'\n", chain); free(r); return 0; }
list_add_tail(&r->list, &c->rules); #将新规则r添加在链c的末尾 c->num_rules++; #同时将链中的规则计数增加
set_changed(*handle); #因为INPUT链中的规则已经被改变,则handle->changed=1; return 1; } |
接下来分析一下设置target时其函数内部流程:
static int iptcc_map_target(const TC_HANDLE_T handle, struct rule_head *r) { STRUCT_ENTRY *e = r->entry; #取规则的起始地址 STRUCT_ENTRY_TARGET *t = GET_TARGET(e); #取规则的target
/* Maybe it's empty (=> fall through) */ if (strcmp(t->u.user.name, "") == 0) { #如果没有指定target,则将规则类型设为“全放行” r->type = IPTCC_R_FALLTHROUGH; return 1; }
/* Maybe it's a standard target name... */ #因为都是标准target,因此将target中用户空间的user.name都置为空,设置verdict, #并将rule_head中的type字段为IPTCC_R_STANDARD else if (strcmp(t->u.user.name, LABEL_ACCEPT) == 0) return iptcc_standard_map(r, -NF_ACCEPT - 1); else if (strcmp(t->u.user.name, LABEL_DROP) == 0) return iptcc_standard_map(r, -NF_DROP - 1); else if (strcmp(t->u.user.name, LABEL_QUEUE) == 0) return iptcc_standard_map(r, -NF_QUEUE - 1); else if (strcmp(t->u.user.name, LABEL_RETURN) == 0) return iptcc_standard_map(r, RETURN); else if (TC_BUILTIN(t->u.user.name, handle)) { /* Can't jump to builtins. */ errno = EINVAL; return 0; } else { /* 如果跳转的目标是一条用户自定义链,则执行下列操作*/ struct chain_head *c; DEBUGP("trying to find chain `%s': ", t->u.user.name); c = iptcc_find_label(t->u.user.name, handle); #找到要跳转的目的链的入口地址 if (c) { DEBUGP_C("found!\n"); r->type = IPTCC_R_JUMP; #将rule_head结构的type字段置为“跳转” r->jump = c; #跳转的目标为t->u.user.name所指示的链 c->references++; #跳转到的目的链因此而被引用了一次,则计数器++ return 1; } DEBUGP_C("not found :(\n"); }
/* 如果不是用户自定义链,它一定一个用户自定义开发的target模块,比如SNAT、LOG等。If not, kernel will reject... */ /* memset to all 0 for your memcmp convenience: don't clear version */ memset(t->u.user.name + strlen(t->u.user.name), 0, FUNCTION_MAXNAMELEN - 1 - strlen(t->u.user.name)); r->type = IPTCC_R_MODULE; #比如SNAT,LOG等会执行到这里 set_changed(handle); return 1; } |
在append_entry()函数最后,将执行的执行结果返回给ret,1表示成功;0表示失败。然后在做一下善后清理工作,如果命令行中有-v则将内核中表的快照dump一份详细信息出来显示给用户看:
if (verbose >1)
dump_entries(*handle);
clear_rule_matches(&matches); //释放matches所占的存储空间
由struct ipt_entry e;所存储的规则信息已经被提交给了handle对象对应的成员,因此将e所占的存储空间也释放:
if (e != NULL) {
free(e);
e= NULL;
}
将全局变量opts复位,初始化时opts=original_opts。因为在解析--syn时tcp的解析参数被加进来了:
static structoption original_opts[] = {
{"append", 1, NULL, 'A' },
{"delete", 1, NULL, 'D' },
……
}
至此,do_command()函数的执行就算全部完成了。
(二)、向内核提交变更:iptc_commit()
执行完do_command()解析完命令行参数后,用户所作的变更仅被提交给了handle这个结构体变量,这个变量里的所有数据在执行iptc_commit()函数前都驻留在内存里。因此,在iptables-standalone.c里有如下的代码语句:
ret = do_command(argc, argv, &table, &handle); if (ret) ret = iptc_commit(&handle); |
当do_command()执行成功后才会去执行iptc_commit()函数,将handle里的数据提交给Netfilter内核。
iptc_commit()的实现函数为intTC_COMMIT(TC_HANDLE_T *handle),我们只分析IPv4的情形,因此专注于libiptc.c文件中该函数的实现。
在TC_COMMIT()函数中,又出现了我们在分析Netfilter中filter表时所见到的一些重要结构体STRUCT_REPLACE *repl;STRUCT_COUNTERS_INFO *newcounters;还有前面出现的struct chain_head *c;结构体。
new_number= iptcc_compile_table_prep(*handle,&new_size);
iptcc_compile_table_prep()该函数主要做的工作包含几个方面:
a.初始化handle里每个struct chain_head{}结构体成员中的head_offset、foot_index和foot_offset。
b.对每个链(struct chain_head{})中的每条规则,再分别计算它们的offset和index。
c.计算handle所指示的表中所有规则所占的存储空间的大小new_size,以及规则的总条数new_number。
接下来,为指针repl;申请存储空间,所申请的大小为sizeof(struct ipt_replace)+new_size。因为struct ipt_replace{}结构的末尾有一个柔性数组struct ipt_entry entries[0]; 它是不计入sizeof的计算结果的。因此,iptables的所有规则实际上是存储在struct ipt_entry entries[0]柔性数组中的,这里所有规则所占大小已经得到:new_size。
因为,每条规则entry都一个计数器,用来记录该规则处理了多少数据包,注意结构体STRUCT_COUNTERS_INFO{}的末尾也有一个柔性数组struct xt_counters counters[0];其中struct xt_counters{}才是真正的用于统计数据包的计数器。
然后开始初始化repl结构:
strcpy(repl->name, (*handle)->info.name); repl->num_entries = new_number; repl->size = new_size;
repl->num_counters = (*handle)->info.num_entries; repl->valid_hooks = (*handle)->info.valid_hooks; |
紧接着对repl结构体中剩下的成员进行初始化,hook_entry[]、underflow[]等。对于用户自定义链,其末尾的target.verdict=RETURN。
setsockopt(sockfd,TC_IPPROTO, SO_SET_REPLACE, repl,sizeof(*repl) +repl->size);
会触发内核去执行前面我们看到的do_ipt_set_ctl()函数,如下:
static struct nf_sockopt_ops ipt_sockopts = { .pf = PF_INET, .set_optmin = IPT_BASE_CTL, .set_optmax = IPT_SO_SET_MAX+1, .set = do_ipt_set_ctl, .get_optmin = IPT_BASE_CTL, .get_optmax = IPT_SO_GET_MAX+1, .get = do_ipt_get_ctl, }; |
在do_ipt_set_ctl()中其核心还是执行do_replace()函数:
static int do_replace(void __user *user, unsigned int len) { int ret; struct ipt_replace tmp; struct xt_table_info *newinfo; void *loc_cpu_entry;
if (copy_from_user(&tmp, user, sizeof(tmp)) != 0) return -EFAULT;
/* Hack: Causes ipchains to give correct error msg --RR */ if (len != sizeof(tmp) + tmp.size) return -ENOPROTOOPT; … … } |
其中copy_from_user()负责将用户空间的repl变量中的内容拷贝到内核中的tmp中去。然后设置规则计数器newcounters,通过setsockopt系统调用将newcounters设置到内核:
setsockopt(sockfd,TC_IPPROTO, SO_SET_ADD_COUNTERS, newcounters, counterlen);
此时,在do_ipt_set_ctl()中执行的是do_add_counters()函数。至此,iptables用户空间的所有代码流程就算分析完了。命令:
iptables –A INPUT –i eth0 –p tcp --syn–s 10.0.0.0/8 –d 10.1.28.184 –j ACCEPT
即被设置到内核的Netfilter规则中去了。
(十三)洞悉linux下的Netfilter&iptables:为防火墙增添功能模块【实战】
为netfilter/iptables增添新功能模块:ipp2p
一个防火墙功能模块包含两部分:内核空间的ko模块和用户空间的so模块。如下:
而且文件的命令都非常有讲究。例如我们有个模块名叫AAA,那么内核中该模块的文件名一般为ipt_AAA.c和ipt_AAA.h;对应的用户空间模块叫libipt_AAA.c。今天我通过简单的向防火墙添加ipp2p扩展功能模块的例子,向大家展示一下相关操作和注意事项。
下载ipp2p源码:http://www.ipp2p.org/
最新版的ipp2p-0.8.2.tar.gz支持的内核2.6.17,iptables支持1.3.1。不过这个不影响,我们稍对其源文件进行修改就可以适应我们2.6.21平台了。修改后的源码从“ ipp2p-0.8.2.zip”下载。
下载后对其解压,然后进到ipp2p目录下。接下来有两种方式来编译模块:
1、 直接在该目录下执行make。(备注:该目录下的Makefile我已经改过了)
执行完make后ipt_ipp2p.ko和libipt_ipp2p.so就都生成了。
chmod xipt_ipp2p.ko
2、 依次按下列步骤来操作:
仍然是在ipp2p-0.8.2解压目录里,我系统中如下:
然后将每个文件拷贝到对应的目录下:
在我机器上,内核源码在/usr/src/linux-2.6.21,iptables源码在/usr/src/iptables-1.4.0目录下。
接下来修改/usr/src/linux-2.6.21/net/ipv4/netfilter/ipt_ipp2p.c文件:
同样修改/usr/src/iptables-1.4.0/extensions/libipt_ipp2p.c文件如下:
接换到内核源码目录/usr/src/linux-2.6.21目录下
先备份net/ipv4/netfilter/目录下原来的Makefile文件,然后照着下面自己写个新的Makefile。如下所示:
ipt_ipp2p.ko就已经编译出来了,加上执行权限后将其拷贝到/lib/modules/2.6.21目录里。
最后编译用户空间的so模块,进入到/usr/src/iptables-1.4.0:
因为我系统中有两个版本的iptables 1.3.5和1.4.0,其中1.3.5的库文件位于/lib/iptables目录;1.4.0我将其放置在/lib/iptables-1.4.0/iptables目录。
最后我们来使用一下ipp2p模块,执行iptables -m ipp2p -h应该可以看到下列提示:加载ipp2p内核模块:
insmod/lib/modules/2.6.21/kernel/net/ipv4/netfilter/ipt_ipp2p.ko
总结,虽然方法一干净利落,犀利快捷,可是不利于我们理解netfilter/iptables代码的组织架构。方法二虽然麻烦,但学的知识更多些。大家各取所需吧。
网上还流传另外一种通过补丁包的方式来为iptables扩展功能模块,这里我简单提一下,因为那玩意儿更easy。一般是到ftp://ftp.netfilter.org/pub/patch-o-matic-ng/下载对应的补丁文件,在本地将其解压后进入补丁文件夹,一般执行:./runme ipp2p,然后根据提示输入一些配置路径就OK了。它会自动帮你完成文件的拷贝,Makefile的修改,很方便。感兴趣的朋友可以去试一下。
(十四)洞悉linux下的Netfilter&iptables:开发一个match模块【实战】
自己开发一个match模块
今天我们来写一个很简单的match来和大家分享如何为iptables开发扩展功能模块。这个模块是根据IP报文中有效载荷字段的长度来对其进行匹配,支持固定包大小,也支持一个区间范围的的数据包,在用户空间的用法是:
iptables-A FORWARD -m pktsize --size XX[:YY] -j DROP
这条规则在FORWARD链上对于大小为XX(或大小介于XX和YY之间)的数据包进行匹配,数据包的长度不包括IP头部的长度。为了简单起见,这个模块没有处理“!”情况,因为只是阐述开发过程。
OK,下面我们开始动手吧。我们这个模块名字叫pktsize,所以内核中该模块对应的文件是ipt_pktsize.h和ipt_pktsize.c;用户空间的文件名为libipt_pktsize.c。
我们先来定义头文件,因为这个匹配模块功能很单一,所以设计它的数据结构主要包含两部分,如下:
#ifndef __IPT_PKTSIZE_H #define __IPT_PKTSIZE_H
#define PKTSIZE_VERSION "0.1" //我们自己定义的用户保存规则中指定的数据包大小的结构体 struct ipt_pktsize_info { u_int32_t min_pktsize,max_pktsize; //数据包的最小和最大字节数(不包括IP头) };
#endif //__IPT_EXLENGTH_H |
一、用户空间的开发
我们知道用户空间的match是用structiptables_match{}结构表示的,所以我们需要去实例化一个该对象,然后对其关键成员进行初始化赋值。一般情况我们需要实现parse函数、help函数、final_check函数、print和save函数就已经可以满足基本要求了。我们先把整体代码框架搭起来:
#include #include #include #include #include #include #include #include
static void help(void) { //Todo: your code }
/*用于解析命令行参数的回调函数; 如果成功则返回true */ static int parse(int c, char **argv, int invert, unsigned int *flags, const void *entry, struct ipt_entry_match **match) { return 1; }
static void final_check(unsigned int flags) { //Todo: your code }
static void print(const void *ip, const struct ipt_entry_match *match, int numeric) { //Todo: your code }
static void save(const void *ip, const struct ipt_entry_match *match) { //Todo: your code }
static struct iptables_match pktsize= { .next = NULL, .name = "pktsize", .version = IPTABLES_VERSION, .size = IPT_ALIGN(sizeof(struct ipt_pktsize_info)), .userspacesize = IPT_ALIGN(sizeof(struct ipt_pktsize_info)), .help = &help, .parse = &parse, .final_check = &final_check, .print = &print, .save = &save };
void _init(void) { register_match(&pktsize); } |
下面我们分别来实现这些回调函数,并对其做简单解释:
help()函数:当我们在命令输入iptables-m pktsize -h时用于显示该模块用法的帮助信息,所以很简单,你想怎么提示用户都可以:
static void help(void) { printf( "pktsize v%s options:\n" " --size size[:size] Match packet size against value or range\n" "\nExamples:\n" " iptables -A FORWARD -m pktsize --size 65 -j DROP\n" " iptables -A FORWARD -m pktsize --size 80:120 -j DROP\n" , PKTSIZE_VERSION); } |
print()函数:该函数是用于打印用户的输入参数的,因为其他地方也有可能会输出规则参数,所以我们将其封装成一个子函数__print()供其他人来调用,如下:
static void __print(struct ipt_pktsize_info * info){ if (info->max_pktsize == info->min_pktsize) printf("%u ", info->min_pktsize); else printf("%u:%u ", info->min_pktsize, info->max_pktsize); }
static void print(const void *ip, const struct ipt_entry_match *match, int numeric) { printf("size "); __print((struct ipt_pktsize_info *)match->data); } |
从命令行终端输入的数据包大小的规则参数“XX:YY”其实最终是在ipt_entry_match结构体的data成员里保存着的,关于该结构体参见博文三的图解。
save()函数:该函数和print类似:
static void save(const void *ip, const struct ipt_entry_match *match) { printf("--size "); __print((struct ipt_pktsize_info *)match->data); } |
final_check()函数:如果你的模块有些长参数格式是必须的,那么当用户调用了你的模块但又没进一步制定必须参数时,一般在这个函数里做校验限制。如,我的模块带了一个必须按参数--size ,而且后面必须跟数值,所以该函数内容如下:
static void final_check(unsigned int flags) { if (!flags) exit_error(PARAMETER_PROBLEM, "\npktsize-parameter problem: for pktsize usage type: iptables -m pktsize --help\n"); } |
parse()函数:该函数是我们的核心,参数的解析最终是在该函数中完成的。因为我们用到长参数格式,所以必须引入一个结构体struct option{},我们在博文十三中已经见过,不清楚原理和用法的童鞋可以回头复习一下。
这里我们的模块只有一个扩展参数,所以该结构非常简单,如果你有多个,则必须一一处理:
static struct option opts[] = { { "size", 1, NULL, '1' }, {0} }; //并且还要将该结构体对象赋给:pktsize.extra_opts= opts; //解析参数的具体函数单独出来,会使得parse()函数的结构很优美 /* 我们的输入参数的可能格式如下: xx 指定数据包大小 XX :XX 范围是0~XX YY: 范围是YY~65535 xx:YY 范围是XX~YY */ static void parse_pkts(const char* s,struct ipt_pktsize_info *info){ char* buff,*cp; buff = strdup(s);
if(NULL == (cp=strchr(buff,':'))){ info->min_pktsize = info->max_pktsize = strtol(buff,NULL,0); }else{ *cp = '\0'; cp++;
info->min_pktsize = strtol(buff,NULL,0); info->max_pktsize = (cp[0]? strtol(cp,NULL,0):0xFFFF); }
free(buff);
if (info->min_pktsize > info->max_pktsize) exit_error(PARAMETER_PROBLEM, "pktsize min. range value `%u' greater than max. " "range value `%u'", info->min_pktsize, info->max_pktsize); }
static int parse(int c, char **argv, int invert, unsigned int *flags, const void *entry, struct ipt_entry_match **match) { struct ipt_pktsize_info *info = (struct ipt_pktsize_info *)(*match)->data; switch(c){ case '1': if (*flags) exit_error(PARAMETER_PROBLEM, "size: `--size' may only be " "specified once"); parse_pkts(argv[optind-1], info); *flags = 1; break; default: return 0; } return 1; } |
该文件的最终版本从“ libipt_pktsize.zip ”下载。
用户空间要用的libipt_pktsize.so的源代码我们就算编写完成了,迫不及待的去试一下吧。当前,我的iptables确实不认识pktsize模块。
我将libipt_pktsize.c拷贝到/usr/src/iptables-1.4.0/ extensions目录下,并修改该目录下的Makefile文:
然后在/usr/src/iptables-1.4.0/目录下单独执行一次make命令,最后将extensions/目录下编译出来的libipt_pktsize.so拷贝到iptables的库目录里,例如/lib/iptables-1.4.0/iptables。
此时,当我们再在命令行执行一次iptables -m pktsize -h时,在末尾处可以看到如下的信息:就证明我们的模块已经被iptables正确识别并成功加载了。
一、内核空间的开发
同样的,开发内核的Netfilter模块时,我们还是先搭其框架:
#include #include #include #include #include #include
MODULE_AUTHOR("Koorey Wung MODULE_DESCRIPTION("iptables pkt size range match module."); MODULE_LICENSE("GPL");
static int match(const struct sk_buff *skb, const struct net_device *in, const struct net_device *out, const struct xt_match *match, const void *matchinfo, int offset, unsigned int protoff, int *hotdrop) { return 1; }
static struct ipt_match pktsize_match = { .name = "test", .family = AF_INET, .match = match, .matchsize = sizeof(struct ipt_pktsize_info), .destroy = NULL, .me = THIS_MODULE, };
static int __init init(void) { return xt_register_match(&pktsize_match); }
static void __exit fini(void) { xt_unregister_match(&pktsize_match); }
module_init(init); module_exit(fini); |
通过前面几篇博文我们已经知道,内核中用structipt_match{}结构来表示一个match模块。我们要开发match的内核部分时,也必须去实例化一个struct ipt_match{}对象,然后对其进行必要的初始化设置,最后通过xt_register_match()将其注册到xt[AF_INET].match全局链表中就OK了,就这么简单。
我们这里例子非常简单,只实现最关键的核心函数:match()函数。不过这已经满足我们需求了,我们的match函数做的事情也很simple,就是计算数据包的有效载荷:
static int match(const struct sk_buff *skb, const struct net_device *in, const struct net_device *out, const struct xt_match *match, const void *matchinfo, int offset, unsigned int protoff, int *hotdrop) { const struct ipt_pktsize_info *info = matchinfo; const struct iphdr *iph = skb->nh.iph;
int pkttruesize = ntohs(iph->tot_len)-(iph->ihl*4);
if(pkttruesize>=info->min_pktsize && pkttruesize <=info->max_pktsize){ return 1; } else{ return 0; } return 1; } |
但有一点需要明确,如果数据包匹配了match函数返回1;否则返回0.
该文件的最终版本从“ ipt_pktsize.zip ”下载。
至此,我们的pktsize模块的内核部分就算开发完了,接下将其编译成ipt_pktsize.ko放到系统目录中去。详细参见博文十三,我系统执行了如下步骤:当我们的模块已经被内核认亲后,那感觉真的是无以言表啊。废话不多说,我们赶紧执行一条规则看看:
曾经有个哥们说他在使用owner模块时出现了同样的问题,这会不会是由于同样的原因导致的呢?如果你是严格遵循我的教程来的,那么这里我要说一定就是:这个问题是我特意留出的。细心的童鞋回头看代码时应该很容易找出问题了。原因:
这里有一点要提醒大家注意,内核中的模块名和用户空间的模块名必须一致。这里我们将pktsize_mach.name改为“pktsize”,重新编译,然后将其拷贝。在重新执行insmod前,先执行rmmod ipt_pktsize将原来的模块卸载掉,最后再次执行那条规则:
今天通过这个简单的例子,向大家示范一下为Netfitler/iptables开发功能模块的方法。整体来说还是比较简单,当然要写出更有意义,更高效的模块需要对协议栈、TCP/IP原理、网络编程等有较好的基础才行。
(十五)洞悉linux下的Netfilter&iptables:开发自己的hook函数【实战】(上)
向Netfilter中注册自己的hook函数
数据包在协议栈中传递时会经过不同的HOOK点,而每个HOOK点上又被Netfilter预先注册了一系列hook回调函数,当每个清纯的数据包到达这些点后会被这些可恶hook函数轮番调戏一番。有时候我们就在想,只让系统自带的这些恶棍来快活,我自己能不能也make一个hook出来和它们同流合污呢?答案是肯定的。
我们来回顾一下目前系统中已经注册了的hook函数可分为以下几类:
它们在协议栈中位置如下:
首先我们心里要非常清楚的知道我们将要开发的这个hook函数位于哪个HOOK点的什么级别,它的前后分别是哪些函数,这一点很重要,因为遇到问题时至少心里有个谱。
我们今天讲的这个hook函数功能很简单,主要是向大家展示开发流程和方法。细节性的东西还需要每个人日积月累的修炼才行。
要注册一个hook函数需要用到nf_register_hook()或者nf_register_hooks()系统API和一个structnf_hook_ops{}类型的结构体对象。最简单的hook函数如下:
#include #include #include #include #include #include #include #include #include
MODULE_LICENSE("GPL"); MODULE_AUTHOR("koorey KING"); MODULE_DESCRIPTION("My hook test");
static int pktcnt = 0; //我们自己定义的hook回调函数,丢弃每第5×n(n=1,2,3,4…)个ICMP报文。 static unsigned int myhook_func(unsigned int hooknum, struct sk_buff **skb, const struct net_device *in, const struct net_device *out, int (*okfn)(struct sk_buff *)) { const struct iphdr *iph = (*skb)->nh.iph; if(iph->protocol == 1){ atomic_inc(&pktcnt); if(pktcnt%5 == 0){ printk(KERN_INFO "%d: drop an ICMP pkt to %u.%u.%u.%u !\n", pktcnt,NIPQUAD(iph->daddr)); return NF_DROP; } } return NF_ACCEPT; }
static struct nf_hook_ops nfho={ .hook = myhook_func, //我们自己的hook回调处理函数 .owner = THIS_MODULE, .pf = PF_INET, .hooknum = NF_IP_LOCAL_OUT, //挂载在本地出口处 .priority = NF_IP_PRI_FIRST, //优先级最高 };
static int __init myhook_init(void) { return nf_register_hook(&nfho); }
static void __exit myhook_fini(void) { nf_unregister_hook(&nfho); }
module_init(myhook_init); module_exit(myhook_fini); |
我们在LOCAL_OUT这个HOOK点上,以最高优先级NF_IP_PRI_FIRST注册了一个名为myhook_func()的函数。从本机发出的所有数据包从协议栈进入Netfilter框架时,最先都会被该函数所看到,然后我们在这里就可以“胡作非为”了。
这个模块最后会被编译成名为myhook.ko的驱动模块,然后用insmod来将其加载。具体操作流程如下:可以看到,我们自己的hook函数已经成功run起来了。我们可能不仅局限于做这么简单一个hook,没什么意义,也没啥成就感。况且这种hook压根儿就没有存在的价值,因为我们完全可以通过iptables来配置相应的规则而达到同样的目的。
OK,那我们就改造一下刚写的这个hook。让它实现的功能是:每收到5个ICMP报文就向指定的IP地址发送一个UDP报文。由于这个功能的开发牵扯到内核协议栈编程,关于协议栈部分打算在以后的系列博文中详细阐述。这里仅做个简单的普及入门就可以了。
我们要实现的功能是从内核中发一个报文,这完全不同于之前在用户层通过socket套接字编程的模式。
Godbach兄的文章http://blog.chinaunix.net/u/33048/showart_2043789.html,以及内核版的精华帖《教你修改以及重构skb》都是非常经典的参考文章。不太明白的童鞋可以去拜读一下:http://linux.chinaunix.net/bbs/thread-1152885-1-4.html。
再重申一下我们的目标,每收到5个ICMP报文就向指定IP(例如118.6.24.132)发送一个UDP报文。这里我是在内核里自己去DIY一个新的skb出来,构造数据包和发送数据包的过程如下:
#define ETH "eth0" //接口名称 #define SIP "192.168.6.130" //接口的IP地址 #define DIP "118.6.24.132" //要发送UDP报文的目的IP地址 #define SPORT 39804 //源端口 #define DPORT 6980 //目的端口 unsigned char SMAC[ETH_ALEN] = {0x00,0x0C,0x29,0x33,0x2C,0x3C}; //eth0网卡地址 unsigned char DMAC[ETH_ALEN] = {0x00,0x50,0x56,0xF4,0x8B,0xB3}; //默认网关的网卡地址
static int build_and_xmit_udp(char * eth, u_char * smac, u_char * dmac, u_char * pkt, int pkt_len,u_long sip, u_long dip, u_short sport, u_short dport) { struct sk_buff * skb = NULL; struct net_device * dev = NULL; struct ethhdr * ethdr = NULL; struct iphdr * iph = NULL; struct udphdr * udph = NULL; u_char * pdata = NULL;
if(NULL == smac || NULL == dmac) goto out;
if(NULL == (dev= dev_get_by_name(eth))) goto out; //通过alloc_skb()来为一个新的skb申请内存结构 skb = alloc_skb(pkt_len + sizeof(struct iphdr) + sizeof(struct udphdr) + LL_RESERVED_SPACE(dev), GFP_ATOMIC);
if(NULL == skb) goto out; skb_reserve(skb, LL_RESERVED_SPACE(dev));
skb->dev = dev; skb->pkt_type = PACKET_OTHERHOST; skb->protocol = __constant_htons(ETH_P_IP); skb->ip_summed = CHECKSUM_NONE; skb->priority = 0;
skb->nh.iph = (struct iphdr*)skb_put(skb, sizeof(struct iphdr)); skb->h.uh = (struct udphdr*)skb_put(skb, sizeof(struct udphdr));
pdata = skb_put(skb, pkt_len); //预留给上层用于数据填充的接口 { if(NULL != pkt) memcpy(pdata, pkt, pkt_len); }
//“从上往下”填充skb结构,依次是UDP层--IP层--MAC层 udph = (struct udphdr *)skb->h.uh; memset(udph, 0, sizeof(struct udphdr)); udph->source = sport; udph->dest = dport; skb->csum = 0; udph->len = htons(sizeof(struct udphdr)+pkt_len); udph->check = 0; //填充IP层 iph = (struct iphdr*)skb->nh.iph; iph->version = 4; iph->ihl = sizeof(struct iphdr)>>2; iph->frag_off = 0; iph->protocol = IPPROTO_UDP; iph->tos = 0; iph->daddr = dip; iph->saddr = sip; iph->ttl = 0x40; iph->tot_len = __constant_htons(skb->len); iph->check = 0; iph->check = ip_fast_csum((unsigned char *)iph,iph->ihl);
skb->csum = skb_checksum(skb, iph->ihl*4, skb->len - iph->ihl * 4, 0); udph->check = csum_tcpudp_magic(sip, dip, skb->len - iph->ihl * 4, IPPROTO_UDP, skb->csum); //填充MAC层 skb->mac.raw = skb_push(skb, 14); ethdr = (struct ethhdr *)skb->mac.raw; memcpy(ethdr->h_dest, dmac, ETH_ALEN); memcpy(ethdr->h_source, smac, ETH_ALEN); ethdr->h_proto = __constant_htons(ETH_P_IP); //调用dev_queue_xmit()发送报文 if(0 > dev_queue_xmit(skb)) goto out;
out: if(NULL != skb) { dev_put (dev); kfree_skb (skb); } return(NF_ACCEPT); } |
上面这部分代码看不懂没关系,因为它需要比较熟练的内核协议栈编程知识,大家可以从整体上对其有个感性的把握就可以了。后面如果有时间我会再写个TCP/IP内核协议栈分析的系列文章,虽然CU上有很多大牛已经在写了,但每个人的收获不一样,和大家分享也是学习的另一种形式。好了,闲话不多说。我们这个hook的最终版本在“ myhook.zip ”下载。
接下来,激动人心的时刻又到了,我们来验证一下我们的hook函数是否可以按预期一样地进行工作。编译和加载流程如前面所述。我们为上层应用层往UDP报文中填充数据预留了接口,所以我们可以以如下的形式来调用build_and_xmit_udp()接口:
build_and_xmit_udp(ETH,SMAC,DMAC,”hello”,5,in_aton(SIP),in_aton(DIP),htons(SPORT),htons(DPORT));
通过wireshark抓包来验证一下是不是每收到5个ICMP报文就往118.6.24.132地址发送一个内容仅有“hello”字符串的UDP报文:
经过这么一番“改造”,我们自定义这个hook函数算是有点特色了。至此我们今天的内容就全部讲完了。估计有些人可能觉得还少了点什么,有没有悟性比较高的童鞋提出几点质疑来?没错,就是我们这个hook里设置的IP地址是固定的,包括MAC地址、源和目的端口以及发送的内容。用户空间我们根本没法对这些属性进行操作,骤然间,这个模块的可操作性和易用性大打折扣。那么我们到底如何才能从用户空间来操作这个新注册的hook呢?
(十六)洞悉linux下的Netfilter&iptables:开发自己的hook函数【实战】(下)
从用户空间来操作内核中Netfilter框架里自定义的HOOK函数
本文承上一篇博客。主要是和大家探讨一下如何从用户空间操作我已经注册到Netfilter中的自定义hook函数。有些童鞋可能就纳闷,难道iptables不能操作到么?如果我们需要让iptables操作我们在Netfilter框架中做过的扩展,那么最有效最直接的办法就是开发一个match或者target。但我们现在注册的这个hook很明显和iptables命令行工具没多大关系,你要让iptables来管理这不是强人所难么。当然如果你一要这么干的话,办法肯定是有的,但者不属于本文所要讨论的范畴。
说到内核空间与用户空间的通信,完全可以作为一个专题来讲,以后有机会把这方面总结一下写出来和大家分享。今天我们要用的方法就是借鉴了iptables和内核的通信方式,即采用getsockopt/setsockopt来实现用户空间和内核空间的交互。
还是继续上一篇的练习代码,我们在它的基础上继续修改润色。和注册hook函数时的操作非常类似,我们首先要实例化一个struct nf_sockopt_ops{}结构体对象,然后用Linux提供的nf_register_sockopt()函数来将该对象注册到全局双向链表nf_sockopts中去,当我们在用户空间调用g(s)etsockopt时经过层层系统调用,最后就会在nf_sockopts链表中找到我们已经注册的响应函数。关于g(s)etsockopt的执行流程,感兴趣的童鞋可以回头看一下博文十二里的详细讲解。罗嗦的这么多,大家都耐烦了吧。OK,我们赶紧动手。
… //添加必要的头文件 #include #include #include
//增加我们自定义的扩充命令字 #define SOCKET_OPT_BASE 128 #define SOCKET_OPT_SETTARGET (SOCKET_OPT_BASE) #define SOCKET_OPT_GETTARGET (SOCKET_OPT_BASE) #define SOCKET_OPT_MAX (SOCKET_OPT_BASE +1)
#define ETH "eth0" //interface name #define SIP "192.168.6.130" //#define DIP "118.6.24.132" //把原来这个注释掉,现在看它越看越不顺眼。 … #define ADDRLEN 16 //IP地址的长度16字节,格式一般为xxx.xxx.xxx.xxx\0 static char dstIP[ADDRLEN]={0}; //这个就是我们要操作的目的IP。
//为了醒目,我将两个接口分别在不同的函数中来实现。 static int recv_cmd(struct sock *sk,int cmd, void __user *user,unsigned int len) { int ret = 0; if(cmd == SOCKET_OPT_SETTARGET) { memset(dstIP,0,ADDRLEN); if(0!=(ret = copy_from_user(dstIP,user,len)) { printk("error: can not copy data from userspace\n"); return -1; } printk("The target IP from User: %s \n",dstIP); } return ret; }
static int send_cmd(struct sock *sk,int cmd, void __user *user,int *len) { int ret = 0; if(cmd == SOCKET_OPT_GETTARGET) { if(0!=(ret = copy_to_user(user,dstIP,ADDRLEN))) { printk("error: can not copy data to userspace\n"); return -1; } printk("The target IP to User: %s \n",dstIP); } return ret; }
static struct nf_sockopt_ops my_sockops = { .pf = PF_INET, .set_optmin = SOCKET_OPT_SETTARGET, .set_optmax = SOCKET_OPT_MAX, .set = recv_cmd, .get_optmin = SOCKET_OPT_GETTARGET, .get_optmax = SOCKET_OPT_MAX, .get = send_cmd }; … static int index=1; static unsigned int hook_func(unsigned int hooknum, struct sk_buff **skb, const struct net_device *in, const struct net_device *out, int (*okfn)(struct sk_buff *)) { const struct iphdr *iph = (*skb)->nh.iph; int ret = NF_ACCEPT;
if(iph->protocol == 1){ atomic_inc(&pktcnt); if(pktcnt%5 == 0) { //简单校验一下IP地址的合法性 if(strcmp(dstIP,””) !=0 && strcmp(dstIP,”0.0.0.0”)!=0) { printk(KERN_INFO "Sending the %d udp pkt !\n",index++); ret = build_and_xmit_udp(ETH,SMAC,DMAC,"hello",5, in_aton(SIP),in_aton(dstIP), htons(SPORT),htons(DPORT)); }else{ printk(“Have %d tims:target IP illegal.Nothing to do!\n”,pktcnt/5); } } } return ret; } … static int __init myhook_init(void) { nf_register_sockopt(&my_sockops); //注册我们的sockops对象 return nf_register_hook(&nfho); }
static void __exit myhook_fini(void) { nf_unregister_hook(&nfho); nf_unregister_sockopt(&my_sockops); //注销我们的sockops对象 } |
以上代码的着色部分,需要大家格外留意。因为是示例,所以合法性校验及错误处理的几个地方就是简单象征性地照顾了一下。内核部分的改动就弄完了,接下来我们继续写用户空间的代码usermyhook.c,如下:
#include #include #include #include #include #include
#define SOCKET_OPS_BASE 128 #define SOCKET_OPT_SETTARGET (SOCKET_OPS_BASE) #define SOCKET_OPT_GETTARGET (SOCKET_OPS_BASE) #define SOCKET_OPT_MAX (SOCKET_OPS_BASE +1)
int main(int argc,char** argv) { int sockfd,len,ret; char targetIP[16]={0};
if(0>(sockfd = socket(AF_INET,SOCK_RAW,IPPROTO_RAW))) { printf("can not create a socket\n"); return -1; }
//仅为示范而生。未作输入合法性和参数合法性校验。 if('s' == *argv[1]) { len = strlen(argv[2])+1; ret = setsockopt(sockfd,IPPROTO_IP,SOCKET_OPT_SETTARGET,argv[2],len); if(0 != ret) { printf("setsockopt error: - %d : %s\n",errno,strerror(errno)); return -1; } printf("setsockopt: ret=%d, wanted IP=%s\n",ret,argv[2]); }else{ len = sizeof(char)*16; ret = getsockopt(sockfd,IPPROTO_IP,SOCKET_OPT_GETTARGET,targetIP,&len); if(0 != ret) { printf("getsockopt error: - %d : %s\n",errno,strerror(errno)); return -1; } printf("getsockopt: ret=%d,gotten IP=%s\n",ret,targetIP); } close(sockfd); return 0; } |
将该文件编译:gcc -oumhook usermyhook.c
把重新编译出来的myhook.ko模块加入内核,然后用我们编译出来的umhook工具来动态指定我们要往哪个IP地址发送UDP报文。该工具的用法:
./umhook“s” “182.134.150.6” //设置目的IP
./umhook “g” //获取目的IP
当我们的myhook.ko模块刚加载时内核中的目的IP地址dstIP={0},所以在探测到第五个ICMP报文时并没有发送UDP报文;紧接着,我们用./umhook“s” “123.4.5.6”设置目的IP地址为“123.4.5.6”之后,内核探测到这次改变,打印出“The target IP from User:123.4.5.6”的提示信息;然后,在第10个ICMP报文被探测到后发送了第一条UDP到我们所配置的目的地址,抓包工具也有证实;之后,我们又将目的IP改为“123.4.5.7”,内核打印:“The target IP from User:123.4.5.7”在第15,20个ICMP报文被探测到后又发了两条UDP报文到新IP地址;最后,用./umhook“s” “”命令将目的IP清除掉。整个过程十分流程自然,而我们的心情也无比的愉悦。
后记:
整个Netfilter系列从清明节开始陆陆续续一直写到端午前夕,也算是对自己有个交代了,另外也总结出来和大家分享一下自己的心得和收获。看到网上经常有人问学习Netfilter有什么好资料或教程,其实个人觉得,Netfilter是无缝嵌入到协议栈里的。如果你想了解它的基本原理那么就需要一点协议栈知识就足够,如果你想为它做开发,那么在掌握了内核编程的基础上,还需要对协议栈的实现有相当深厚的底蕴才可以。由于本人也是刚接触Netfilter不久,学识浅薄,分析难免有所疏漏的地方的还请各位高手和大侠为小弟指正。