使用iptables为新机器构建防火墙

当拿到一台刚刚装好系统的机器后,我们需要构建一个相对安全的环境。 许多Linux版本都使用iptables作为默认的防火墙,但是并未做过多的限制。 为了加强机器的安全性,我们需要使用iptables添加一些基本的规则。

Iptables的使用简介

iptables的规则有许多配置项,这些配置项可以组合成各种规则。 每一个网络包经过iptables都会按顺序匹配规则,如果有匹配成功的规则则被过滤或者放行。 本文只需要使用简单的配置项来完成基础的防火墙,这些配置项都非常有用。

-L : 显示当前iptables的规则
-A : 在iptables加入一条规则
-I : 在之前两条规则之间插入一条规则
    example:  -I INPUT 3  (在规则链中的第三个位置插入一条规则)
-v : 提供一条规则的详细信息
-m conntrack : 基于当前的连接状态设定规则
--cstate : 列出当前连接的状态,连接有四种状态:New、Related、Established、Invalid
-p : 规则对应的网络协议,包括:tcp, udp, udplite, icmp, esp, ah, sctp,或者是all
--dport : 链接的目的端口
-j : 当匹配一条规则时采取的措施,目前有四种操作:
    -ACCEPT : 放行
    -REJECT : 过滤,并且通知发包者
    -DROP : 丢弃,不通知发包者
    -LOG : 记录日志,并继续匹配后续规则

添加iptables规则

iptables的使用需要root权限,因此你可以切换为root用户或者使用sudo来获取root权限。

首先查看下iptables当前的规则:

sudo iptables -L

可能会显示出下列信息,每台机器情况各不相同,显示的信息也会有所不同。

Chain INPUT (policy ACCEPT)
target     prot opt source               destination

Chain FORWARD (policy ACCEPT)
target     prot opt source               destination

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination

可能会其他的规则,这时我们可以使用命令来重置iptables让它回到默认的初始状态:

sudo iptables -F

另外,如果你想加快iptables的反应速度,你可以加上命令选项-n。 这个选项会关闭DNS的查找和阻止iptables在规则中反向查找IP信息。 使用的例子:

sudo iptables -L -n

基础防火墙

iptables的初始状态是允许任意进入和发出连接通过,完全没有安全性可言。 因此我们需要先关闭所有的端口,但是不能关闭当前的ssh链接,第一条规则是保持当前的连接状态。

sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

命令解释:

  • -A : 在iptables的规则列表后面加入一条规则
  • INPUT : 这条规则是input规则链的一部分
  • -m conntrack –ctstate ESTABLISHED,RELATED : 是当前链接和与其相关的链接都是允许的。
  • -j ACCEPT : 规则匹配后的操作是保持连接

假设服务器上有两个安全的服务ssh和web,即需要开input的22端口和80端口,我们执行下面两条命令:

sudo iptables -A INPUT -p tcp --dport ssh -j ACCEPT

sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT

命令解释:

  • -p : 链接的网络协议类型,上面两个例子中是tcp
  • –dport : 链接的目的端口, ssh是默认22,如果修改了ssh的端口,请指定端口号

现在我们可以开始关闭所有不安全连接了。 因为这条规则是列表的最后一条规则,所有的链接都会先匹配前面两条规则,因此不会影响前面的设定。 关闭其他连接的命令:

sudo iptables -P INPUT DROP

现在我们的iptables是这样

sudo iptables -L
Chain INPUT (policy DROP)
target     prot opt source               destination         
ACCEPT     all  --  anywhere             anywhere            ctstate RELATED,ESTABLISHED 
ACCEPT     tcp  --  anywhere             anywhere            tcp dpt:ssh 
ACCEPT     tcp  --  anywhere             anywhere            tcp dpt:http 

这样基础防火墙搭建基本完成。 在实际的操作中,我们还要允许服务器的回环访问。 如果直接把规则添加到列表的后面,则会被前一条规则过滤。 现在我们把这条规则添加到列表的第一的位置。

sudo iptables -I INPUT 1 -i lo -j ACCEPT

命令解释:

  • -I INPUT 1 : 把这条规则添加到列表第一的位置
  • lo : 回环

现在基础防火墙完成!你可以查看iptables的详细信息。

sudo iptables -L -v

显示如下:

Chain INPUT (policy DROP 0 packets, 0 bytes)
pkts bytes target     prot opt in     out     source               destination         
   0     0 ACCEPT     all  --  lo     any     anywhere             anywhere            
1289 93442 ACCEPT     all  --  any    any     anywhere             anywhere             ctstate RELATED,ESTABLISHED
   2   212 ACCEPT     tcp  --  any    any     anywhere             anywhere             tcp dpt:ssh
   0     0 ACCEPT     tcp  --  any    any     anywhere             anywhere             tcp dpt:http     

Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 157 packets, 25300 bytes)
pkts bytes target     prot opt in     out     source               destination       

后续你可以根据需要添加更多的规则。

保存iptables的规则

当系统重启后,iptables的规则会清空,因此我们需要保存iptables的规则。

我们可以使用服务iptables-persistent或者是把iptables的规则保存到/etc/iptables/rules.v4文件内。

参考文献

1. <How To Set Up a Firewall Using Iptables on Ubuntu 12.04>

函数调用原理

栈的基本概念

在计算机科学中,栈(stack)是一种特殊的串行数据结构,它的特点是只能允许在链表或数组的一端进行添加数据和删除数据的操作,按照后进先出(LIFO)的原理运作。 可操作的一端称为栈顶(top),栈顶可指向栈的第一个元素或者第一个元素前的空地址,这依赖于栈的实现,在x86_64的机器上栈顶指向第一个元素。 不可操作的一端称为栈底,栈底是栈开始的地址。 栈有两个最重要的操作,添加数据的操作称为push,删除数据的操作称为pop。 高级语言(high-level language)的一个重要特征是引入了过程(procedure)和函数(function)。 函数的原理类似于跳转,即一个函数结束时会将控制权交回调用它的函数。这个特点的高级抽象就是后进先出,因此它的实现需要借助栈。 进程为了实现函数机制,在地址空间中专门开辟了一块栈段。 进程地址空间大致被分为三个区域:代码段、数据段,堆栈段。 这三个段区依次由低地址向高地址排布(x86_64的机器)。如下图:

进程的地址空间

虽然进程的地址空间是从低地址向高地址排布,但是栈段是从高地址向低地址生长的(这一点很重要),即栈底为高地址,栈顶为低地址。  

程序的栈帧

函数调用栈(call stack)使用栈数据结构保存进程中函数调用信息。 函数调用栈位于进程地址空间的栈段。 每个正在运行的函数在栈段都有自己的栈帧,栈帧的排布根据函数的调用关系后进先出,如下图。

程序的栈帧

一个挂起函数的栈帧包括调用函数(也是上一个栈帧)的栈底地址、局部变量、被保存的寄存器、临时变量、调用下一个函数的参数、返回地址。

单个函数的栈帧

切换过程

在x86_64机器上,使用两个寄存器标记当前函数的栈帧,rbprsprbp称为基址寄存器,标记当前栈帧的栈底,rsp可称为栈寄存器,标记当前栈帧的栈顶。 栈帧的切换,也是函数调用的过程就是围绕这两个寄存器实现的。 当函数foo调用bar时: - 1)在新的栈帧中保存旧栈帧的栈底地址%rbp,在此之前 1)’ rsp向下移动一格 - 2)将新栈帧的栈底地址%rsp保存到rbp中,同时使 2)’ rbp指向新栈帧的栈底

新建栈帧流程

新建栈帧过程是通过下面两条汇编代码实现的。

1) push    %rbp
2) mov     %rsp,    %rbp

当函数bar返回foo时: - 3)将栈底指针的地址%rbp保存到rsp中,同时使 3)’ 栈顶指针指向栈底 - 4)旧栈底的地址保存到rbp中,同时使 4)’ rbp指向旧栈底,rsp向上移动一格

恢复栈帧流程

这个过程是通过leave指令实现,它等价于下面的汇编代码。

3) mov     %rbp,     %rsp
4) pop     %rbp 

函数调用涉及到过程的转移,支持过程转移的汇编指令主要是callretcall指令有一个目标操作数,这格操作数是被调用过程的起始的指令地址。 call指令的效果是将返回地址入栈,并跳转到被调函数的起始处。 返回地址是在程序中call指令后面的那条指令地址,当被调函数返回时,控制流会从这个地址继续执行。 ret指令从栈中弹出返回地址,并跳转到这个位置继续执行,此时被调函数结束。

实例演示

现在实现一个程序,在foo函数中调用bar函数,使用GDB来观察函数的栈帧和真实的栈帧切换。

foo函数的c代码、汇编和地址信息:

foo函数代码

bar函数的c代码、汇编和地址信息:

bar函数代码

在foo函数中,调用bar函数的指令callq指向的地址正是bar函数的起始地址0x400494。 另一个需要注意的地址是函数调用的返回地址0x4004c6。 在foo调用bar后,在bar函数的栈帧中可以看到这个地址。

foo函数的callq将控制流从foo转移到bar函数和bar函数的retq将控制流从bar函数交回给foo函数。

foo函数和bar函数的前两个指令和倒数第二个指令相同,分别是建帧和恢复帧的指令。 foo函数的第三条指令是预分配局部变量指令,会统一预留16字节的空间,此时修改了rsp的值,然后开始分配局部变量的空间。 但是在bar函数中并没有这条指令,因为bar函数是最后调用的简单函数,没有使用rsp。 我们在16行和10行加上断点,运行程序。

断点运行

程序在第16行暂停,此时程序在执行foo函数,我们查看rbprsp的内容,它们指向的是foo的栈帧,并验证栈是从高地址向低地址生长的。

foo的栈信息

然后让程序继续运行,程序在第10行停止,此时程序在执行bar函数,我们查看rbprsp的内容。

bar的栈信息

rbprsp的值已经更新,指向了bar的栈帧。我们查看当前的内存,

内存中的内容

bar栈帧的rbp指向的地址内保存的是foo栈帧的rbp地址,内存块1的值是地址3的值。 我们可以计算出当前foo的栈帧地址为0x7fffffffe4d0 – 0x7fffffffe4c8。 内存块2的值即为调用返回的指令地址。

在第17行加上断点查看bar返回后rbprsp的值。

恢复foo栈帧后的栈信息

此时rbprsp的值和调用前一样了。

小恶作剧

既然知道了函数栈帧的原理,我们可以通过修改返回地址,实现最简单的栈溢出例子。

void foo() {
    int *ret;
    ret = &ret + 2;
    (*ret) += 1;
}

void main() {
    int x;
    x = 0;
    foo();
    x = 1;
    printf("x=%d\n", x);    //output:  x=0
    return;
}

我们通过将局部变量ret地址往高地址移动2个单位可以找到返回地址的地址,然后修改返回地址,程序将跳过x=1这行,直接打印出x等于0。

偏移计算

本文使用的gcc版本为4.4.5 20110214 (Red Hat 4.4.5-6),编译时没有使用优化选项。

参考文献:

1. https://en.wikipedia.org/wiki/Stack_(abstract_data_type)

2. 《深入理解计算机系统》

3. < Smashing The Stack For Fun And Profit>

4. http://www.cnblogs.com/bangerlee/archive/2012/05/22/2508772.html

5. http://blog.chinaunix.net/uid-23069658-id-3981406.html

高精度定时器Hrtimer

上一篇文章介绍了基于tick和时间轮的动态定时器。 虽然时间轮的算法非常高效,但是tick的缺陷影响了动态定时器的精度。 因此在Linux2.6.16加入了高精度定时器 High-resolution kernel timers,简称hrtimer。 hrtimer根据系统的配置和硬件提供更加精确的定时器解决方案。

hrtimer没有使用时间轮对定时器进行管理,而是选用了更加通用、性能稳定的红黑树。 我们知道红黑树查找,插入和删除平均时间复杂度是O(logN)。虽然查找的时间复杂度达不到O(1),但是避免了时间轮的迁移。因此在平均性能上红黑树和时间轮会较为接近。 hrtimer红黑树是在红黑树的基础上做了简单的封装,hrtimer红黑树节点使用数据结构struct timerqueue_node,比rb_node多了一个expires字段,用于记录超时时刻。

struct timerqueue_node {
    struct rb_node node;
    ktime_t expires;
};

hrtimer的数据结构与动态定时器的数据结构类似:

struct hrtimer {
    struct timerqueue_node          node;
    ktime_t                         _softexpires;
    enum hrtimer_restart            (*function)(struct hrtimer *);
    ......
};

hrtimer和动态定时器一样拥有超时计数字段_softexpires和超时后的回调函数。 通过比较当前时间是否大于_softexpires来决定定时器超时,执行回调函数。 在红黑树的node中同样保存了一份expiresnode.expires大于等于_softexpires。 这样做的目的是在高精度模式下,在_softexpiresnode.expires期间设定定时器被触发的时间。

hrtimer管理器结构

struct hrtimer_cpu_base {
    struct hrtimer			    *running;
    unsigned int			    cpu;
    unsigned int			    active_bases;
    ktime_t				    expires_next;
    struct hrtimer			*next_timer;
    struct hrtimer_clock_base	clock_base[HRTIMER_MAX_CLOCK_BASES];
    ......
} ____cacheline_aligned;

running字段指向正在执行的hrtimer。 next_timer字段指向第一个超时的hrtimer。 clock_base字段是当前CPU维护的定时器树。 目前每个CPU都维护了若干组定时器树,其中包括单条递增时间HRTIMER_BASE_MONOTONIC,上墙时间HRTIMER_BASE_REALTIME等。

struct hrtimer_clock_base结构体中包含结构struct timerqueue_head active。 相对与红黑树的根节点多一个next字段,指向下一个即将到期或者已经到期的hrtimer的红黑树节点。 当一个定时器到期,执行回调函数前,会在红黑树中找到下一个定时器节点,更新next字段,并删除当前定时器的红黑树节点,调整红黑树。

检查hrtimer函数会遍历每一组定时器树,通过next字段取出下一个定时器,直到定时器还未超时。下面是检查hrtimer函数的核心代码。

static void __hrtimer_run_queues(struct hrtimer_cpu_base *cpu_base, ktime_t now)
{
    struct hrtimer_clock_base *base = cpu_base->clock_base;
    unsigned int active = cpu_base->active_bases;

    for (; active; base++, active >>= 1) {
	    struct timerqueue_node *node;
	    ktime_t basenow;

	    if (!(active & 0x01))
		    continue;

	    basenow = ktime_add(now, base->offset);

	    while ((node = timerqueue_getnext(&base->active))) {
		    struct hrtimer *timer;

		    timer = container_of(node, struct hrtimer, node);
 
		    if (basenow.tv64 < hrtimer_get_softexpires_tv64(timer))
			    break;

		    __run_hrtimer(cpu_base, base, timer, &basenow);
	    }
    }
}

hrtimer有两种精度模式,高精度模式和低精度模式。那么同样的数据设计,如何实现两种精度呢? 这里的关键就是调用检查hrtimer函数的地方。我们来看看两种精度的调用栈。

  • 低精度

    timer_interrupt() -> update_process_times() -> run_local_timers() -> hrtimer_run_queues() -> __hrtimer_run_queues()

  • 高精度

    hrtimer_interrupt() -> __hrtimer_run_queues()

当hrtimer处于低精度模式时,每次irq0上的时间中断,即每次tick事件,调用一次检查hrtimer函数。 tick的频率是1000hz,此时,hrtimer处于低精度模式。 在第一篇文章中,我们提到TSC、HPET都是可以提供纳秒级的时间设备。 当hrtimer处于高精度模式时,Linux把hrtimer_interrupt()绑定到高精度的时间设备,这时就可以提供纳秒级的定时器服务了。 但是频繁的调用检查hrtimer函数会非常消耗机器性能。 为了避免这个缺陷,hrtimer_interrupt()的调用采用了one-shot的模式。每一次调用都会设定下一次调用的时间。

void hrtimer_interrupt(struct clock_event_device *dev)
{
    ....

    delta = ktime_sub(now, entry_time);
    
    if (delta.tv64 > 100 * NSEC_PER_MSEC)
	    expires_next = ktime_add_ns(now, 100 * NSEC_PER_MSEC);
    else
	    expires_next = ktime_add(now, delta);
    tick_program_event(expires_next, 1);
}

hrtimer_interrupt()后半段的代码中会取出下一次超时时间,把这个时间设定为下一次中断调用的时间。

另外并不是所有系统都支持hrtimer高精度定时器。 因此,Linux开始会以低精度的hrtimer运行,然后在每一次的时间中断调用hrtimer_run_queues()时,根据系统状态切换为高精度的定时器。

void hrtimer_run_queues(void)
{
    ...
    if (tick_check_oneshot_change(!hrtimer_is_hres_enabled())) {
	    hrtimer_switch_to_hres();
	    return;
    }

    raw_spin_lock(&cpu_base->lock);
    now = hrtimer_update_base(cpu_base);
    __hrtimer_run_queues(cpu_base, now);
    raw_spin_unlock(&cpu_base->lock);
}

虽然Linux提供了hrtimer高精度的定时器,但是基于tick和时间轮的动态定时器并没有废除。