1. 程式人生 > >對付簡訊傳送攻擊

對付簡訊傳送攻擊

簡介

自從某公司使用簡訊驗證碼驗證使用者真實性以來,簡訊便逐漸成了公司業務的標配。現在幾乎每家公司的服務都包含了簡訊傳送這一功能。而使用者請求簡訊時,一般還未註冊,所以這個介面是匿名介面(不需要登入)。

於是,壞蛋們就開始搗蛋了。他們通過對軟體抓包,得到使用者的請求訊息,然後模擬使用者對伺服器瘋狂的傳送這一請求,消耗公司的簡訊資源,騷擾無辜手機使用者,甚至造成簡訊通道堵塞,無法發出正常訊息。搞不好還被那些被騷擾者的投訴,封掉公司的簡訊通道,或者被各手機廠商識別為垃圾簡訊從而失去營銷效果,甚至影響正常業務。

今天,我就遇到這麼個搗蛋鬼。他的手裡有大約上千臺肉雞(科普一下:“肉雞”是指那些可以被黑客操控的無辜者的電腦,這些電腦使用者並不知情。之所以可以被操控,可能是因為安裝了有木馬程式的軟體,或者系統存在漏洞被攻破),於是模擬訊息從全國鋪天蓋地而來,伺服器日誌瘋狂刷屏。於是,我們立刻著手處理這件事情。

解決策略

我們想到了兩種策略:

  1. 使用iptables封IP
  2. 使用nginx lua + redis

兩種方案各有好處,如果訪問量不大的話,任選其一即可,如果訪問量極高,就需要酌情考慮了。

我們採取的策略是第二種:

  1. 使用嵌入到nginx中的lua程式對使用者請求(僅限簡訊)+IP進行限定,使其:
    • 在1分鐘內的請求只允許傳送1次簡訊
    • 如果一分鐘內超過1次,但在3次以內,則不傳送簡訊,並給與警告
    • 如果一分鐘內超過3次,則禁止這個IP的任何訪問
  2. 使用redis儲存這些被封IP以及帶時限的介面請求,以免nginx無法定義全域性變數。這樣,nginx就可以實現:
    • 對IP是否允許放行的檢查
    • 令1分鐘前的IP訪問記錄自動過期

剛開始我們也使用了第一種,將這些IP直接加入到iptables中去,從核心層面封掉這些IP,只是我們覺得解封起來比較麻煩,而且無法與我們的軟體整合,尤其是很難實現邏輯整合(區別介面,允許1分鐘1次,3次以上才封IP),遂使用nginx+redis的方案,畢竟lua程式可以寫成我想要的邏輯。

實現

假如我們傳送簡訊的介面是 /sendSms
在nginx.conf檔案的 server 段內新建一個location塊,使其匹配正則表示式 ~* sendSms ,然後開始寫程式碼吧:

        location ~* sendSms { 
                default_type 'application/json; charset=UTF-8'
; lua_need_request_body on; access_by_lua ' -- 為了lua程式碼可以語法高亮,這裡的內容放在後一個程式碼塊,請自行復制到此處即可 '; proxy_pass http://app_backend; proxy_set_header X-Forwarded-For $remote_addr; proxy_pass_header Origin; }

下面的lua程式碼請複製到上文單引號內:

-- ngx.exit(ngx.OK)
local cjson = require "cjson"
local ip = ngx.var.remote_addr
if ngx.var.http_user_agent ~= nil or ngx.var.http_user_agent == "" then
        local agent = string.lower(ngx.var.http_user_agent)
        -- ngx.say("user agent:", ngx.var.http_user_agent)
        -- ngx.exit(200)
        local sIdx = string.find(agent, "httpclient") or string.find(agent, "java")
        -- ngx.say("sIdx:", sIdx)
        if (sIdx ~= nil) then
                ngx.status = ngx.HTTP_FORBIDDEN
                local msg = "你把硬碟拿過來,我直接把資料庫給你拷貝一份吧,這樣太慢了,我都急死了"
                ngx.say(cjson.encode({code=16, msg=msg, R=cjson.null}))
                ngx.exit(ngx.status)
                return
        end 
end
local redis = require "resty.redis"
local red = redis.new()

red:set_timeout(1000)

local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
        ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
        ngx.say("failed to connect: ", err)
        return
end 

local forbidden, err1 = red:sismember("fbdIP", ip) 
-- ngx.say("forbidden:", forbidden)
if forbidden == 1 then
        ngx.status = ngx.HTTP_FORBIDDEN
        local msg = "你把硬碟拿過來,我直接把資料庫給你拷貝一份吧,這樣太慢了,我都急死了"
        ngx.say(cjson.encode({code=16, msg=msg, R=cjson.null}))
        ngx.exit(ngx.status)
        return
end 

local key = "ip::" .. ip
-- ngx.say("key:", key)
local ttl, err1 = red:ttl(key)
if ttl == -1 then
        red:del(key)
end

local res, err = red:get(key)
red:incr(key)
if (not res) or (res == ngx.null) then
        --[[
        local msg = "failed to get cache"
        ngx.say(cjson.encode({code=16, msg=msg, R=cjson.null}))
        --]]
        red:expire(key, 55) -- 55秒內不允許同一IP超過30次訪問
        ngx.exit(ngx.OK)
elseif tonumber(res) < 1 then
        ngx.exit(ngx.OK)
elseif tonumber(res) >= 1 then
        ngx.status = 200 
        -- ngx.say("redis result is string 1")
        -- local msg = "我們認為你有惡意請求的嫌疑,請不要使用及其程式進行訪問"
        local msg = "慢點,無影手得多累啊"
        ngx.say(cjson.encode({code=16, msg=msg, R=cjson.null}))
       if tonumber(res) > 10 then
                red:sadd("fbdIP", ip)
        end
        ngx.exit(ngx.status)
        return
end

解釋

  • 前面對key的檢查不止使用了get() ,還使用了 ttl(),是因為redis的過期回收策略使用的是一種近似LRU演算法,導致一定概率的不刪除,所以使用ttl進行檢查。

前提

在大家讀到這篇文章的時候,我要順便說一下這個方案適用的前提。如果沒有這些前提,那這個方案對你就不可用,當然,解決思路也許可以有些幫助,如果你善於動手的話,很快也能弄好自己的解決方法。我們的系統滿足以下幾個條件:

  1. 當然是使用nginx做前端代理的web架構了
  2. 使用支援lua的nginx。直接從nginx官網下載按照的nginx是不支援的,需要額外下載lua程式碼,並編譯到nginx中去。或者

建議

由於之前redis有個可以拿到root許可權的漏洞,所以:

  1. 務必要對redis設定訪問密碼
  2. 最好將redis服務的繫結IP限定在內網IP上

redis 的key千萬不能被汙染,否則正常使用者的IP會被誤傷封禁。

工具

在解決這個問題的時候,我們也是用廢了很多腦細胞的,為了節省你的腦細胞,我就免費讓你看看。

怎麼得知壞蛋在攻擊我的伺服器呢?

我的辦法是:
通過將日誌內所有對/sendSms介面的呼叫IP進行統計,找到那些呼叫次數比較多的,比如大於10次的。用這個命令就好了:

grep  "POST /sendSms" logs/ikuaiyue.log | awk '{print $6}' | sed s/IP:// | sort | uniq -c | awk '{print $1 "\t" $2}' | sort -n

然後就會看到這樣的結果:

1       115.205.13.179
2       117.136.40.20
2       117.136.94.44
2       117.59.39.22
122     223.104.10.28

第一列是此IP的呼叫次數,第二列你懂。
好了,現在知道改怎麼辦了吧?

順便說一下,我們的日誌是這個樣子滴:

[2016-05-30 01:25:20.451] [INFO] normal - IP:117.174.26.32 POST /sendSms
[2016-05-30 01:26:17.918] [INFO] normal - IP:117.174.26.32 POST /sendSms
...

稍微解釋下上面的命令:

grep  "POST /sendSms" logs/ikuaiyue.log 
 | awk '{print $6}'       #按空格分割後的第6列(即IP:117.174.26.32)
 | sed s/IP://            #刪除字元"IP:"
 | sort                   #排序
 | uniq -c                #去重,並記下重複數,相當與做了個統計操作
                          #此時重複數字為第一列,IP被放在了第二列.
                          #但此時格式上有個問題:首列數字是右對齊的
 | awk '{print $1 "\t" $2}'  #為了解除其右對齊,重新列印以便,並以tab分隔
 | sort -n                #以首列為依據排序。-n表示當做數字來排列,預設是當做字串的

當你按我的方法設定好了nginx後,怎麼知道有沒有起作用呢?

把剛剛那個命令改改,只輸出最後3000行(具體數字看你的業務繁忙程度了)用做統計:

 tail -n3000 logs/ikuaiyue.log | grep  "POST /sendSms" | awk '{print $6}' | sed s/IP:// | sort | uniq -c | awk '{print $1 "\t" $2}' | sort -n