前段時間博客經常性地無法訪問,網站宕機。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.