DoS(Denial of Service,阻斷服務)攻擊,又稱為洪水攻擊,是常見的網路攻擊手段。利用一台電腦發出大量的連線請求(Request),使目標電腦的網路或系統資源耗盡,使之當機或是無法再回應正常的請求。Nginx是免費開源、穩定高效的Web伺服器,非常廣泛地被使用。Nginx的功能強大,除了可以作反向代理和負載平衡外,也可以用來防禦小規模的DoS、DDoS(Distributed Denial of Service,分散式阻斷服務)攻擊。



Nginx的ngx_http_limit_req_module模組

「ngx_http_limit_req_module」是Nginx預設啟用的模組,可以用來限制同一個IP在每秒內或是每分鐘內的連線次數。使用「ngx_http_limit_req_module」模組時,必須先在Nginx設定檔中的http區塊內撰寫limit_req_zone命令,來建立出用來儲存每個IP連線次數的共享記憶體空間,成為「Zone」。

例如:

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;

以上命令,可以建立出一個名為mylimit的「Zone」,擁有10MiB的大小的共享記憶體空間,為key-value結構。這個key-value結構所用到的「key」,可以是任意未滿64個位元組(32位元環境)或是128個位元組的資料(64位元環境),在此我們將其指定為$binary_remote_addr,也就是請求端的IP位址,如果請求端使用IPv4,則位址會佔用4個位元組;如果請求端使用IPv6,則位址會佔用16個位元組。而每筆key-value資料,在32位元的作業系統中,會使用64個位元組;而在64位元的作業系統中,則會使用128個位元組。也就是說,假設連入64位元伺服器的IP位址都是使用IPv4的話,10MiB的Zone可以儲存10 * 1024 * 1024 / 128 / = 81920筆key-value資料。當這個Zone已經存滿時,如果來了新的來自陌生IP位址的請求,就會把最久不使用的key-value資料從Zone中清除,將新進來的請求所使用的IP位址遞補上去。至於命令中rate設定值的部份,10r/s(10 requests / second)表示來自同一個IP位址的請求,Nginx每秒最多只會執行10個,換句話說100毫秒才會執行一個。至於如果在100毫秒內有兩次以上來自同一IP位址的請求,Nginx的處理方式則要使用其它命令來調整,這邊的limit_req_zone命令只是先規定好一個大方向。

如果想要將rate設定值設小於1r/s,也就是每次請求的間隔要在一秒以上的話,可以改用r/m(requests / minute)單位,來限制每分鐘的請求數量。雖然rate設定值用的單位是r/sr/m但最終都會被換算成「每隔一段時間執行一個請求」,例如10r/s實際上是「100毫秒執行一個請求」,而600r/m實際上也是「100毫秒執行一個請求」。也因此就不要妄想會有r/h(requests / hour)、r/d(requests / day)、r/w(requests / week)等單位了。

另外,Nginx並沒有限制limit_req_zone命令的使用次數,所以也可以建立出許多不同的「Zone」來使用。

總而言之,有了「Zone」之後,我們就可以在使用Nginx設定檔中的http區塊或是其子區塊(如server或location)內,使用limit_req命令,來限制來自同一IP的連線之請求間隔時間。例如:

limit_req zone=mylimit;

以上命令,可以使用mylimit這個「Zone」。如果mylimitrate設定值為10r/s,則來自同一IP的連線之請求間隔時間為100毫秒,如果在100毫秒內就有請求進來,Nginx預設會回傳HTTP的503狀態(代表服務暫時無法使用,Service Temporarily Unavailable)。若想要更改請求過快時,Nginx所回傳的HTTP狀態,可以使用limit_req_status命令。例如:

limit_req_status 444;

以上命令,會使得Nginx在來自同一IP的連線之請求間隔時間過快時,回傳HTTP的444狀態。

然而,如果我們的Web伺服器是網頁服務的話,直接使用limit_req命令指定一個「Zone」並不是一個適當的作法,因為瀏覽器開啟一個網頁時,會同時對該伺服器發出多個請求,至於請求數量要看網頁是怎麼設計的。因為請求是「同時」發出,會導致只有其中一個請求會被正常處理,而其它緊接在後的請求全都會被當作「間隔過快的」請求,而造成網頁顯示出問題。因此,我們在使用limit_req命令時,還需要指定burst設定值,來讓limit_req命令有能力處理首批比較大量的請求。例如:

limit_req zone=mylimit burst=30;

以上命令,將burst設為30,表示Nginx最多可以暫存30個來自同一個IP位址的請求。如果mylimitrate設定值為10r/s,則會以「100毫秒」執行一個暫存中的請求的速度,來將所有暫存中的請求處理完畢。若新的請求進來時,burst的暫存空間已經滿了,這個新的請求就會立刻被當作是「間隔過快的」請求。因此,這個burst的設定值,最好要大於或等於網站的任意網頁所可能會產生的最大請求量,才不會導致網頁一開就出問題。

但是,只加上burst設定值的limit_req命令還是會有一個很大的問題,那就是排在後面的請求必須要等待前面所有的請求都被執行後才會被執行。如果mylimitrate設定值為10r/s,則第21個請求要等前面20個請求都被執行後,也就是要經過至少2秒鐘之後才會被執行,這樣的話很可能會導致網頁要等待好幾秒才能完全載入好。因此,我們在使用limit_req命令時,除了要指定burst設定值外,通常還要搭配nodelay來使用。例如:

limit_req zone=mylimit burst=30 nodelay;

以上命令,由於多加了nodelay,暫存於burst的請求不需要等待前面的請求即可被執行,但是被執行的請求並不會立刻移出burst的暫存中。如果mylimitrate設定值為10r/s,則會以「100毫秒」移除一個暫存中已執行的請求的速度,逐一將burst的暫存空間清空,使它可以繼續接收新的請求。若burst在當下已沒有空間,新的請求還是會立刻被當作是「間隔過快的」請求。這樣的機制可以讓Nginx有能力處理首批比較大量的請求,同時也能防禦後續短時間內又有大量請求的出現。

當然,對於需要被搜尋引擎搜尋到的網頁來說,我們要儘可能地張開雙手迎接搜尋引擎「爬蟲」(Crawler)的進入。若對搜尋引擎的爬蟲也限制一段時間內的請求數量,對網站的SEO絕對會有不利的影響。因此,這套限制IP的機制,必須還要有白名單功能才可以拿來實際運用在公開的網站上。我們可以改寫一下原本limit_req_zone命令的寫法,搭配geo命令和map命令來使用。

Nginx的geo命令,可以判斷某IP(不指定的話就是請求端的IP)是否在某CIDR或是IP範圍內。寫法如下:

geo $limit {
    default 1;
    10.0.0.0/8 0;
    192.168.18.5-192.168.18.5 0;
}

以上的geo命令,可以判斷請求端的IP,如果就是192.168.18.5或是在10.0.0.0/8這個CIDR下的話,$limit就是0;否則$limit就是1

至於Nginx的map命令,可以判斷某值究竟是什麼值,來輸出另一個值到其它的變數。寫法如下:

map $limit $limit_key {
    0 "";
    1 $binary_remote_addr;
}

以上的map命令,可以判斷$limit的值。當$limit0時,$limit_key就會被設為"";當$limit1時,$limit_key就會被設為請求端的IP位址($binary_remote_addr)。

接著,我們可以將limit_req_zone命令改寫成:

limit_req_zone $limit_key zone=mylimit:10m rate=10r/s;

如此一來,寫在geo命令內的CIDR或是IP範圍就是限制同一IP請求數量機制的白名單了!

至於搜尋引擎的爬蟲所使用的IP究竟有哪些,就要動手去查了。筆者推薦以下這個網站提供的免費資料,因為它有提供CIDR格式,套用起來比較容易~