前段时间博客经常性地无法访问,网站宕机。SSH 上去看进程,发现大量 php-fpm 占用系统资源,查看服务器的 Nginx 日志,就知道发生了什么事情。个别 IP “友情”为我的站点扫描漏洞,瞬时并发连接很大。我知道大家也没什么恶意,只是用黑客工具比较兴奋,拿 www.lovelucy.info 练练手而已嘛。但是博主很穷,小站搭建在一个配置并不高的免费 VPS 上,折腾不起,压力很大,结果一不小心让各位搞成 DoS 拒绝服务攻击了,真是惭愧。

有趣的是,除掉一些穷举后台密码的,扫描者一股脑地发请求,大部分却是在找 asp 的漏洞。可是这样好浪费时间啊,尘埃落定的博客实在是用的 wordpress 程序,是 php 平台啊……

GET /mirserver.rar 404
GET /save.asp 404
GET /wwwroot.rar 404
GET /upfile_flash.asp 404
GET /web.rar 404
GET /mirserver3.rar 404
GET /www.rar 404
GET /eWebEditor/admin_login.asp 404
GET /mirserver.zip 404
GET /wwwroot.zip 404
GET /mirserver4.rar 404
GET /newsadmin/ubb/admin_login.asp 404
GET /CmsEditor/admin_login.asp 404
GET /admin/webeditor/admin_login.asp 404

言归正传,扫描漏洞的人目的大多是想做黑链 SEO,给黑掉的站点加上隐藏链接,提高目标网站在搜索引擎中的排名。这背后已经形成产业链了,可惜这种手段收到的效果已经越来越差,Google 早就不给隐藏链接权重了,现在连百度都能检测出恶意外链,这样一来还有什么意义呢?

对于站长来讲,要避免扫描给站点带来的影响,最好是对有关 IP 进行屏蔽。在发现网站宕掉,手工用 iptables 封了几个 IP 后,网站立刻就恢复正常了。

iptables -I INPUT -s 124.115.0.199 -j DROP

但是过了几天又有别的人来扫描,各种 IP 层出不穷,一个个地去封收效甚微。怎么办?

一、使用 Nginx 的 limit_conn 模块

Ngnix 服务器的 limit_conn 模块可以限制单个 IP 的并发连接数,刚了解到它的时候感觉这碉堡了,简直是应用层防火墙了。修改配置文件 nginx.conf

http {
    ...
    limit_zone   ten  $binary_remote_addr  10m;
    limit_conn   ten  10;
    ...
}

这样限制使用 10M 内存来管理 session,同时限制每个 IP 可以同时发起最多 10 个请求。

二、使用 iptables 做连接数控制

iptables 也可以做到限制同一 IP 的瞬间连接数,如果使用 iptables 做并发控制,可以在网络层就把恶意数据包丢弃,理论上讲,效率比 Nginx (应用层)的方式更高一些。

iptables -I INPUT -p tcp --dport 80 -d SERVER_IP -m state --state NEW -m recent --name httpuser --set
 
iptables -A INPUT -m recent --update --name httpuser --seconds 60 --hitcount 9 -j LOG --log-prefix 'HTTP attack: '
 
iptables -A INPUT -m recent --update --name httpuser --seconds 60 --hitcount 9 -j DROP

SERVER_IP 为被攻击的服务器 IP。

  1. 第一行的意思是:-I,将本规则插入到 INPUT 链的最上头。即,所有目标端口是80、目标 IP 是我们机器的IP,的 TCP 连接,在连接建立时,我们就将其列入 httpuser 清单中。
  2. 第二行的意思是:-A,将本规则附在 INPUT 链的最尾端。只要是 60 秒内,同一个来源连续产生 9 个连接请求,我们就对此进行 Log 记录。记录行会以 HTTP attack 开头。 –update 表示规则匹配时会更新 httpuser 列表清单。
  3. 第三行的意思是:-A,将本规则附在 INPUT 链的最尾端。和第二行同样的比对条件,但是本次的动作则是将此连接直接丢弃。

所以,这三行规则表示,我们允许一个客户端 IP,每一分钟内可以向服务器发出 8 个连接请求。

三、使用 Nginx 过滤恶意请求

上面两个方法最大的缺陷在于对数值的拿捏十分困难:配置太松,起不到屏蔽扫描的作用;配置太严格,又可能把正常访问也拒绝了,特别是某些搜索引擎过来抓页面的时候。网络环境差异,这个值该设多少,是没有一个标准答案的。

于是,有外国友人写了一套 Nginx 规则,仅仅对恶意请求进行屏蔽。何为恶意呢?

server {
[...]
 
    ## Block SQL injections
    set $block_sql_injections 0;
    if ($query_string ~ "union.*select.*\(") {
        set $block_sql_injections 1;
    }
    if ($query_string ~ "union.*all.*select.*") {
        set $block_sql_injections 1;
    }
    if ($query_string ~ "concat.*\(") {
        set $block_sql_injections 1;
    }
    if ($block_sql_injections = 1) {
        return 403;
    }
 
    ## Block file injections
    set $block_file_injections 0;
    if ($query_string ~ "[a-zA-Z0-9_]=http://") {
        set $block_file_injections 1;
    }
    if ($query_string ~ "[a-zA-Z0-9_]=(\.\.//?)+") {
        set $block_file_injections 1;
    }
    if ($query_string ~ "[a-zA-Z0-9_]=/([a-z0-9_.]//?)+") {
        set $block_file_injections 1;
    }
    if ($block_file_injections = 1) {
        return 403;
    }
 
    ## Block common exploits
    set $block_common_exploits 0;
    if ($query_string ~ "(<|%3C).*script.*(>|%3E)") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "GLOBALS(=|\[|\%[0-9A-Z]{0,2})") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "_REQUEST(=|\[|\%[0-9A-Z]{0,2})") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "proc/self/environ") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "mosConfig_[a-zA-Z_]{1,21}(=|\%3D)") {
        set $block_common_exploits 1;
    }
    if ($query_string ~ "base64_(en|de)code\(.*\)") {
        set $block_common_exploits 1;
    }
    if ($block_common_exploits = 1) {
        return 403;
    }
 
    ## Block spam
    set $block_spam 0;
    if ($query_string ~ "\b(ultram|unicauca|valium|viagra|vicodin|xanax|ypxaieo)\b") {
        set $block_spam 1;
    }
    if ($query_string ~ "\b(erections|hoodia|huronriveracres|impotence|levitra|libido)\b") {
        set $block_spam 1;
    }
    if ($query_string ~ "\b(ambien|blue\spill|cialis|cocaine|ejaculation|erectile)\b") {
        set $block_spam 1;
    }
    if ($query_string ~ "\b(lipitor|phentermin|pro[sz]ac|sandyauer|tramadol|troyhamby)\b") {
        set $block_spam 1;
    }
    if ($block_spam = 1) {
        return 403;
    }
 
    ## Block user agents
    set $block_user_agents 0;
 
    # Don't disable wget if you need it to run cron jobs!
    #if ($http_user_agent ~ "Wget") {
    #    set $block_user_agents 1;
    #}
 
    # Disable Akeeba Remote Control 2.5 and earlier
    if ($http_user_agent ~ "Indy Library") {
        set $block_user_agents 1;
    }
 
    # Common bandwidth hoggers and hacking tools.
    if ($http_user_agent ~ "libwww-perl") {
        set $block_user_agents 1;
    }
    if ($http_user_agent ~ "GetRight") {
        set $block_user_agents 1;
    }
    if ($http_user_agent ~ "GetWeb!") {
        set $block_user_agents 1;
    }
    if ($http_user_agent ~ "Go!Zilla") {
        set $block_user_agents 1;
    }
    if ($http_user_agent ~ "Download Demon") {
        set $block_user_agents 1;
    }
    if ($http_user_agent ~ "Go-Ahead-Got-It") {
        set $block_user_agents 1;
    }
    if ($http_user_agent ~ "TurnitinBot") {
        set $block_user_agents 1;
    }
    if ($http_user_agent ~ "GrabNet") {
        set $block_user_agents 1;
    }
 
    if ($block_user_agents = 1) {
        return 403;
    }
[...]
}

我们来看上面这个配置文件,它将所有包含类似 union.*select.*\( URL 参数的请求都拒绝,这样来阻止 SQL 注入攻击。又拒绝所有 URL 参数后面有 =http:// 的请求,来防止文件包含漏洞。类似地,屏蔽掉所有有 <|%3C).*script.*(>|%3E 的请求,阻止跨站脚本。还有就是屏蔽一些预定义的 User Agent,拒绝恶意抓站。

回过头来看,这个配置其实不太符合我国国情,比如屏蔽的那些浏览器 UA,并不是扫描中文网站常见的 UA。另外,要在配置文件中定义和枚举所有的恶意行为,是很困难的一件事情。

四、使用脚本定时检测日志

所有对服务器的访问请求都会在 Nginx 日志中记录,这其中也包括那些造成出错的请求。我们能否通过分析日志来确认恶意的访问请求呢?受第二种方法的启发,我们可以监控服务器日志,将请求出错的访问者 IP 放入一个 List 特别观察,在一段时间内如果没有太多的出错,我们就将其从列表中移除,否则,错误太多达到警戒值就调用 iptable 将其禁封。

通过一个脚本就可以完成这项工作。这个脚本与方法二的区别在于,方法二仅仅记录并发连接,只要并发过高,就可以触发禁封,这不够科学。这里我们通过对日志的监控,禁封的只会是给我们造成出错和麻烦的 IP(例如频繁的 404 错误,明显是扫描),搜索引擎过来就不会有问题了。

脚本代码

#!/usr/bin/perl
 
use strict;
use warnings;
 
## 本脚本将会监控 Web 服务器的 log 记录,(例如 Apache 或者 Nginx)
## 并统计同一个 IP 所引发的 HTTP 错误数目。该数值达到用户配置的数量,
## 则使用防火墙对该 IP 进行屏蔽,拒绝其访问。
 
## log 文件路径
  my $log = "/var/log/nginx/access.log";
 
## 一个 IP 触发了多少次错误,我们就将其屏蔽?
  my $errors_block = 10;
 
## 过期时间,超过多少秒没有再见到该 IP 则将其从观察列表中移除?
  my $expire_time = 7200;
 
## 将 IP 从观察列表中移除时,清理多少个错误日志行数?
  my $cleanup_time = 10;
 
## 调试模式 on=1 off=0
  my $debug_mode = 1;
 
## 声明一些内部变量
  my ( $ip, $errors, $time, $newtime, $newerrors );
  my $trigger_count=1;
  my %abusive_ips = ();
 
## 打开日志文件。使用系统的 tail 命令,有效轮询
  open(LOG,"tail --follow=$log |") || die "Failed!\n"; ## For Linux (Ubuntu) systems
  # open(LOG,"tail -f $log |") || die "Failed!\n";       ## For OpenBSD, FreeBSD or Linux systems
 
  while(<LOG>) {
       ## 定义错误代码。这里使用了正则表达式匹配,你可以自行添加一些,
       ## 例如无端访问 .vbs 后缀文件请求,列入屏蔽条件 
       if ($_ =~ m/( 401 | 402 | 403 | 404 | 405 | 406 | 407 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 444 | 500 | 501 | 502 | 503 | 504 | 505 )/)
         {
 
       ## 自定义: 白名单 IP。 不论这些 IP 做了什么,均不屏蔽。
       ## Google 爬虫 IP 段 66.249/16 就是一个好例子。
       ## 为了方便程序员开发测试,内部子网 192.168/16 也不屏蔽。
        if ($_ !~ m/(^66\.249\.\d{1-3}\.d{1-3}|^192\.168\.\d{1-3}\.d{1-3})/)
        {
 
         ## 从日志行中解析出 IP
          $time = time();
          $ip = (split ' ')[0];
 
         ## 若 IP 之前从未出现过,我们需要初始化,以避免出现警告消息
          $abusive_ips{ $ip }{ 'errors' } = 0 if not defined $abusive_ips{ $ip }{ 'errors' };
 
         ## 给这个 IP 增加出错计数,更新时间戳
          $abusive_ips{ $ip }{ 'errors' } = $abusive_ips{ $ip }->{ 'errors' } + 1;
          $abusive_ips{ $ip }{ 'time' } = $time;
 
         ## DEBUG: 输出详细信息
         if ( $debug_mode == 1 ) {
           $newerrors  = $abusive_ips{ $ip }->{ 'errors' };
           $newtime = $abusive_ips{ $ip }->{ 'time' };
           print "unix_time:  $newtime, errors:  $newerrors, ip:  $ip, cleanup_time: $trigger_count\n";
         }
 
         ## 如果该 IP 已经触发 $errors_block 出错数量,调用 system() 函数屏蔽之
          if ($abusive_ips{ $ip }->{ 'errors' } >= $errors_block ) {
 
             ## DEBUG: 输出详细信息
             if ( $debug_mode == 1 ) {
               print "ABUSIVE IP! unix_time:  $newtime, errors:  $newerrors, ip:  $ip, cleanup_time: $trigger_count\n";
             }
 
             ## 自定义: 这里是屏蔽 IP 的 system() 系统调用
             ## 你可以对这个 IP 添加更多的执行操作。例如,我们使用 logger 打印记录到 /var/log/messages 
             ## 注释掉的是 OpenBSD 系统 Pf 防火墙
             system("logger '$ip blocked by calomel abuse detection'; iptables -I INPUT -s $ip -j DROP");
             # system("logger '$ip blocked by calomel abuse detection'; pfctl -t BLOCKTEMP -T add $ip");
 
             ## 当 IP 已经被屏蔽,它就没必要继续留在观察列表中了
             delete($abusive_ips{ $ip });
          }
 
         ## 为后面的清理函数增加触发计数
          $trigger_count++;
 
         ## 清理函数:当触发计数达到 $cleanup_time 我们将所有已经过期的条目从 $abusive_ips 列表中删除
          if ($trigger_count >= $cleanup_time) {
             my $time_current =  time();
 
             ## DEBUG: 输出详细信息
             if ( $debug_mode == 1 ) {
               print "  Clean up... pre-size of hash:  " . keys( %abusive_ips ) . ".\n";
             }
 
              ## 清理我们已经很久没再见到的 IP
               while (($ip, $time) = each(%abusive_ips)){
 
               ## DEBUG: 输出详细信息
               if ( $debug_mode == 1 ) {
                 my $total_time = $time_current - $abusive_ips{ $ip }->{ 'time' };
                 print "    ip: $ip, seconds_last_seen: $total_time, errors:  $newerrors\n";
               }
 
                  ## 如果 IP 未出现的时间已经超过我们设定的过期时间,则将其从列表中移除
                  if ( ($time_current - $abusive_ips{ $ip }->{ 'time' } ) >= $expire_time) {
                       delete($abusive_ips{ $ip });
                  }
               }
 
            ## DEBUG: 输出详细信息
            if ( $debug_mode == 1 ) {
               print "   Clean up.... post-size of hash:  " . keys( %abusive_ips ) . ".\n";
             }
 
             ## 重置清理触发计数
              $trigger_count = 1;
          }
         }
        }
  }
#### EOF ####

保存以上内容到 web_server_abuse_detection.pl,增加可执行权限

chmod +x web_server_abuse_detection.pl

变量解释:
my $log 是要监控的日志路径。日志文件格式是标准的 Apache “common” 或 “combined”。这个脚本可以处理 Apache, Nginx, Lighttpd 甚至 thttpd 的日志,它将会在日志中寻找第一个字符串,即远程连接过来的 IP 地址。例如,这个是 Google 爬虫访问我网站的一条日志 “66.249.72.6 www.lovelucy.info – …”

my $errors_block 是一个客户端所能触发的最大错误数量,超过此值 IP 将被屏蔽。 Web 服务器的错误代码 400-417(如果你用 Nginx 则还包括 444),以及 500-505 都是触发条件。我们默认设置 errors_block 为 10,即如果一个 IP 在 7200 秒 ($expire_time) 以内触发了 10 个错误 ($errors_block),它将被屏蔽。

my $expire_time 是一个 IP 从观察列表中移除的过期时间。默认我们设 7200 秒(2 小时)。请注意,一个 IP 必须在 7200 秒以内没有触发任何错误,我们才会将它从列表中移除。这意味着一个恶意用户用很慢的速率扫描,我们仍可能会将其屏蔽。例如,一个 IP 每一小时访问一次来检测漏洞,第一个小时它就被列入观察列表,在第二个小时出错计数被增加,同时“最后一次见到这个IP” 的时间戳也被更新。普通的入侵检测系统 (IDS) 可能会漏报这样的行为,但这个脚本会在一个较长的时间(2小时)持续跟踪监测一个 IP。在这个 IP 达到触发 10 次错误时,也就是10小时后,我们仍会将其屏蔽。

my $cleanup_time 是触发清理观察列表的出错行数量。清理工作是一个循环,很耗费 CPU 所以没必要每一个错误日志行都去执行。请保证你的清理计数值足够低,从而让旧 IP 能以合适的速率从列表中移除。但是太低又会耗费 CPU,一个恰当的值应该是你的服务器5分钟内所产生的错误日志行数。

my $debug_mode 调试模式,会打印出一些有用的信息。

自定义白名单:注释中已经说过,Google 机器人 IP段 66.249/16 就应该放到白名单里,因为任何别人的网站链接到我们的一个错误的 URL,都会导致搜索引擎抓取失败。开发人员的 IP 也应该放入白名单,因为程序测试也会经常产生失败错误。

自定义系统调用:检测到恶意 IP 后,我们通过系统调用屏蔽之。默认的调用包括 logger 打印消息到 /var/log/messages,并执行 iptables 屏蔽命令。我们也可以添加更多操作,例如触发一个 Nagios 监控警告,给运维人员发 Email,等等。

运行:

设置 my $debug_mode = 0; 脚本即会静默运行。要让它在后台运行,不占用终端,则在命令后加一个 & 符号

./web_server_abuse_detection.pl &

总结

月光博客写过一篇《防止CC攻击的方法》,他说现在 CC 攻击的技术含量低,利用工具和一些 IP 代理,搞个几百个肉鸡,一个初、中级的电脑水平的用户就能够实施攻击。门槛还真低啊。

做人还是要低调一点。

参考链接:
Web Server Abuse Detection
iptables 限制同一 IP 连接数
Nginx: How To Block Exploits, SQL Injections, File Injections, Spam, User Agents, Etc.