中、小企業(yè)如何自建免費的云WAF
WEB攻擊是十幾年來黑客攻擊的主流技術,國內(nèi)的大廠們早已把WAF作為安全基礎設施的標配,市面上也有很多安全廠商提供了WAF產(chǎn)品或云WAF服務。
對于沒有自己安全團隊,卻又飽受sql注入、xss、cc等WEB攻擊的中、小企業(yè),對WAF的需求也是非常迫切的。
目前獲取WAF的途徑有以下幾種:
- 購買安全廠商的WAF產(chǎn)品
- 使用云waf服務,將自己域名的DNS服務器設為云waf廠商提供的,或者將需要接入云waf的域名cname過去
- 或者從網(wǎng)上找一些免費或開源的waf使用
- 自制WAF
對于收入不錯的公司使用收費的產(chǎn)品或服務無可厚非,但是有些公司會因預算、數(shù)據(jù)私密性(云waf可以捕獲所有流量的請求和響應的內(nèi)容)等原因,不打算使用收費的產(chǎn)品或服務。
這種情況下只能使用免費的waf了,或者按業(yè)務需求自制一款適合自己的云WAF。
筆者會通過本文詳細闡述如何用一周的時間自制一款簡單易用的云WAF,以下為已經(jīng)完成的云WAF的文檔及github地址:
項目站點:https://waf.xsec.io/
Github地址:https://github.com/xsec-lab
云WAF架構設計
物理架構
根據(jù)業(yè)務場景或需求的不同,WAF也有不同的架構,比如:
以模塊的形式集成到本地WEB容器中,如mod_security、Naxsi
反向代理模式
硬件產(chǎn)品WAF
Agent+檢測云模式
本文實現(xiàn)的云WAF采用了反向代理模式的架構。

waf可以部署一臺或者多臺服務器中,如果業(yè)務規(guī)模較大,一臺waf的性能已經(jīng)無法滿足業(yè)務需求,可以在waf前面使用LVS、haproxy、nginx等搭建負載均衡,通過VIP將前端的請求分發(fā)到后端的waf中。
后端的app server為提供正常業(yè)務的web server,用戶的請求會先經(jīng)過waf進行過濾,如果是惡意的攻擊請求,則會在waf層面阻斷,如果是正常的請求才會轉發(fā)到后端服務器。
邏輯架構

x-waf由x-waf本身以及web管理后臺x-waf-admin組成,其中:
x-waf基于openresty + lua開發(fā)
waf管理后臺:采用golang + xorm + macrom開發(fā)的,支持二進制的形式部署
x-waf的實現(xiàn)
筆者呆過的2家公司都自主研發(fā)過云waf,架構一開始就設計成了適合大規(guī)模業(yè)務系統(tǒng)的,安裝、部署、運維都比較復雜,不方便小企業(yè)快速部署,所以在參考了github中現(xiàn)有的開源的幾款waf后,重新設計了一款輕量級的。
x-waf的執(zhí)行流程
openresty默認不會執(zhí)行l(wèi)ua腳本,需要在nginx.conf中進行配置,如下所示:
- # 指定lua文件的查找路徑
- lua_package_path "/usr/local/openresty/nginx/conf/x-waf/?.lua;/usr/local/lib/lua/?.lua;;";
- # 定義2個lua shared dict變量分別為limit和badGuys,分配的內(nèi)存大小為100M
- lua_shared_dict limit 100m; lua_shared_dict badGuys 100m;
- # 開啟lua代碼緩存功能
- lua_code_cache on;
- # 讓nginx在init階段執(zhí)行init.lua文件中的lua代碼
- init_by_lua_file /usr/local/openresty/nginx/conf/x-waf/init.lua;
- # 讓nginx在每個http請求的access階段執(zhí)行access.lua文件中的lua代碼
- access_by_lua_file /usr/local/openresty/nginx/conf/x-waf/access.lua;
openresty在init階段會根據(jù)配置文件指定的位置導入json格式的規(guī)則到全局的lua table中,不同的規(guī)則放在不同的table中,以加快正則匹配的速度
- waf = require("waf") wafwaf_rules = waf.load_rules()
waf.load_rules會根據(jù)配置文件中指定的路徑加載讀取所有json格式的規(guī)則,并加載到不同的table中,然后封裝一個get_rule的函數(shù),方便在每個http進來時可以直接從lua table中獲取對應類型的規(guī)則:
- local _M = { RULES = {} }
- function _M.load_rules() _M.RULES = util.get_rules(config.config_rule_dir)
- return _M.RULES end
- function _M.get_rule(rule_file_name) ngx.log(ngx.DEBUG, rule_file_name)
- return _M.RULES[rule_file_name] end
util.get_rules會將指定文件中的規(guī)則按規(guī)則名保存到lua table中供waf.get_rule函數(shù)在需要的時候獲取規(guī)則:
- function _M.get_rules(rules_path)
- local rule_files = _M.get_rule_files(rules_path)
- if rule_files == {} then return nil end
- for rule_name, rule_file in pairs(rule_files) do local t_rule = {}
- local file_rule_name = io.open(rule_file)
- local json_rules = file_rule_name:read("*a") file_rule_name:close()
- local table_rules = cjson.decode(json_rules)
- if table_rules ~= nil then
- for _, table_name in pairs(table_rules) do table.insert(t_rule, table_name["RuleItem"]) end end _M.RULE_TABLE[rule_name] = t_rule end
- return(_M.RULE_TABLE) end
每個請求進來時,waf會按ip白名單、ip黑名單、user_agent、是否cc攻擊、url白名單、url黑名單、是否cc攻擊、cookies、get和post參數(shù)的順序進行過濾,如果匹配到其中任一種就會進行相應的處理(輸出提示或跳轉后),之后就不會繼續(xù)判斷是否為其他類型的攻擊了。
- function _M.check()
- if _M.white_ip_check() then elseif _M.black_ip_check() then elseif _M.user_agent_attack_check() then elseif _M.white_url_check() then elseif _M.url_attack_check() then elseif _M.cc_attack_check() then elseif _M.cookie_attack_check() then elseif _M.url_args_attack_check() then elseif _M.post_attack_check() then else return end
- end
對每個請求的每種參數(shù)類型的判斷都是先獲取到參數(shù)內(nèi)容,然后再循環(huán)與該類參數(shù)的正則規(guī)則進行匹配,如果匹配到則認為是攻擊請求,以下為對post參數(shù)進行過濾的函數(shù):
- -- deny post function _M.post_attack_check()
- if config.config_post_check == "on" then ngx.req.read_body() local POST_RULES = _M.get_rule('post.rule')
- for _, rule in pairs(POST_RULES) do local POST_ARGS = ngx.req.get_post_args() or {}
- for _, v in pairs(POST_ARGS) do local post_data = "" if type(v) == "table" then post_data = table.concat(v, ", ")
- else post_data = v
- end if rule ~= "" and rulematch(post_data, rule, "jo") then util.log_record('Deny_USER_POST_DATA', post_data, "-", rule)
- if config.config_waf_enable == "on" then util.waf_output()
- return true end end end end end return false
- end
waf管理后臺x-waf-admin的實現(xiàn)
waf的規(guī)則是以json格式的字符串,人工維護起來容量出錯,另外云waf會有多臺waf同時工作,如果人工做waf的后端主機的管理、規(guī)則同步與主機配置的同步等這些運維工作的話,非常容易出錯或者疏漏,所以有必要提供一個自動化管理、同步配置的管理后臺。
waf管理后臺的功能需求
方便部署,啟動前只需做簡單的配置即可,第一次啟動時,x-waf-admin會在mysql中生成默認管理員以及默認的waf規(guī)則;
用戶管理,支持管理員賬戶的增、改、刪;
waf規(guī)則管理,支持waf規(guī)則的增、改、刪除以及策略同步到所有waf服務器的功能;
后端站點管理,支持接入waf的站點的增、改、刪除,以及單獨同步或全部同步接入的后端站點的功能。
程序結構
為了方便部署,x-waf-admin沒有采用python、php等需要搭建運行環(huán)境或依賴第3方包的語言,而是用可以直接編譯為可執(zhí)行文件的go語言寫的,具體的技術棧為go語言 + macron + xorm。
項目結構如下:
hartnett at hartnett-notebook in /data/code/golang/src/xsec-waf/x-waf-admin (master●) $ tree -L 2 ├── conf │ └── app.ini ├── models │ ├── models.go │ ├── rules.go │ ├── site.go │ └── user.go ├── modules │ └── util ├── public │ ├── css ├── README.md ├── routers │ ├── admin.go │ ├── index.go │ ├── rules.go │ ├── site.go │ └── user.go ├── server ├── server.go ├── setting │ └── setting.go └── templates
conf為配置文件目錄
models目錄下為orm文件
modules為功能模塊組件
public和templates分別為靜態(tài)資源及模板文件所在的目錄
routers目錄下的為各路由文件
setting目錄下為配置文件處理的文件
server.go為程序入口
規(guī)則管理功能的實現(xiàn)
用戶管理、后端站點管理與規(guī)則管理功能的實現(xiàn)大同小異,都是類似flask、martini、tornado、django等MTV WEB框架的應用,為了減少篇幅,本文只寫后端站點管理功能如何實現(xiàn),完整的代碼請參見github。
后端站點管理的ORM實現(xiàn)
先用xorm定義site的struct,然后再提供增、改、刪、查看等方法,這些方法會被routers模塊中的site文件調(diào)用:
- // 因篇幅太長,省略部分代碼,詳細代碼請查看github
- // debuglevel: debug, info, notice, warn, error, crit, alert, emerg
- // ssl: on, off
- type Site struct { Id int64 SiteName string `xorm:"unique"` Port int BackendAddr []string Ssl string `xorm:"varchar(10) notnull default 'off'"` DebugLevel string `xorm:"varchar(10) notnull default 'error'"` LastChange time.Time `xorm:"updated"` Version int `xorm:"version"` // 樂觀鎖
- }
- func ListSite() (sites []Site, err error) { sites = make([]Site, 0) err = Engine.Find(&sites)
- log.Println(err, sites)
- return sites, err }
- func NewSite(siteName string, Port int, BackendAddr []string, SSL string, DebugLevel string) (err error) {
- if SSL == "" { SSL = "off" }
- if DebugLevel == "" { DebugLevel = "error" } _, err = Engine.Insert(&Site{SiteName: siteName, Port: Port, BackendAddr: BackendAddr, Ssl: SSL, DebugLevel: DebugLevel})
- return err }
后端站點管理的路由實現(xiàn)
首先import相應的包,然后分別編寫以下處理器:
增加站點的get與post請求的處理器(NewSite、DoNewSite)
修改站點的get與post請求的處理器(EditSite、DoEditSite)
根據(jù)ID刪除站點的get處理器(DelSite)
同步站點配置的處理器(SyncSite)
同步站點配置的API的處理器以及根據(jù)ID同步站點配置的API的處理器(SyncSiteApi、SyncSiteById)
- // 因篇幅太長,省略部分代碼,詳細代碼請查看github
- func NewSite(ctx *macaron.Context, sess session.Store, x csrf.CSRF) { if sess.Get("uid") != "" { ctx.Data["csrf_token"] = x.GetToken() ctx.HTML(200, "newSite") } else { ctx.Redirect("/login/") } }
- func DoNewSite(ctx *macaron.Context, sess session.Store) {
- if sess.Get("uid") != nil {
- log.Println(sess.Get("uid")) siteName := ctx.Req.Form.Get("sitename") port := ctx.Req.Form.Get("port") Port, _ := strconv.Atoi(port) backaddr := ctx.Req.Form.Get("backendaddr") backendaddr := strings.Split(backaddr, "\r\n") BackendAddr := make([]string, 0)
- for _, v := range backendaddr {
- if v == "" {
- continue } v = strings.TrimSpace(v) BackendAddr = append(BackendAddr, v) } ssl := ctx.Req.Form.Get("ssl") debugLevel := ctx.Req.Form.Get("debuglevel")
- log.Println(siteName, BackendAddr, ssl, debugLevel) models.NewSite(siteName, Port, BackendAddr, ssl, debugLevel) ctx.Redirect("/admin/site/list/") } else { ctx.Redirect("/login/") } }
model的初始化
大家一定注意到了,雖然用了mysql,但是沒有要求在使用前手工去導入建表或插入初始化值的sql腳本,這是為神馬呢?
因為我們使用了ORM,ORM會幫我們自動完成上面所說的操作,如下代碼所示:
- // 因篇幅太長,省略部分代碼,詳細代碼請查看github
- var ( Engine *xorm.Engine err error )
- func init() {
- // 從conf/app.ini獲取數(shù)據(jù)庫的配置信息 sec := setting.Cfg.Section("database")
- // 連接數(shù)據(jù)庫 Engine, err = xorm.NewEngine("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8", sec.Key("USER").String(), sec.Key("PASSWD").String(), sec.Key("HOST").String(), sec.Key("NAME").String()))
- if err != nil {
- log.Panicf("Faild to connect to database, err:%v", err) }
- // 新建site、user和rules表 Engine.Sync2(new(Site)) Engine.Sync2(new(User)) Engine.Sync2(new(Rules))
- // 如果user表為空,則新建一個默認賬戶, ret, err := Engine.IsTableEmpty(new(User))
- if err == nil && ret {
- log.Printf("create new user:%v, password:%v\n", "admin", "x@xsec.io") NewUser("admin", "x@xsec.io") }
- // 如果規(guī)則為空,則插入默認的初始化規(guī)則 ret, err = Engine.IsTableEmpty(new(Rules))
- if err == nil && ret {
- log.Println("Insert default waf rules") Engine.Exec(DefaultRules) } }
配置路由
當ORM、路由處理相關的代碼寫完后就可以在程序入口中配置路由了,將URL與路由處理的控制器對應起來,如下所示:
- // 因篇幅太長,省略部分代碼,詳細代碼請查看github
- m.Group("/admin", func() {
- m.Get("/index/", routers.Admin)
- m.Group("/site/", func() {
- m.Get("", routers.Admin)
- m.Get("/list/", routers.Admin)
- m.Get("/new/", routers.NewSite)
- m.Post("/new/", csrf.Validate, routers.DoNewSite)
- m.Get("/edit/:id", routers.EditSite)
- m.Post("/edit/:id", csrf.Validate, routers.DoEditSite)
- m.Get("/del/:id", routers.DelSite)
- m.Get("/sync/", routers.SyncSite)
- m.Get("/sync/:id", routers.SyncSiteById)
- m.Get("/json/", routers.SiteJSON)
- }) })
- m.Group("/api", func() {
- m.Get("/site/sync/", routers.SyncSiteApi)
- m.Get("/rule/sync/", routers.SyncRuleApi) })
- log.Printf("xsec waf admin %s", setting.AppVer)
- log.Printf("Run mode %s", strings.Title(macaron.Env))
- log.Printf("Server is running on %s", fmt.Sprintf("0.0.0.0:%v", setting.HTTPPort))
- log.Println(http.ListenAndServe(fmt.Sprintf("0.0.0.0:%v", setting.HTTPPort), m))
互動問題
從前有座山,山里有個廟,廟里有個灰帽子小明同學,他有次通過一些不可描述的手段,得到了某個網(wǎng)站的反彈Shell,雖是root權限,但利用方法不穩(wěn)定。
此時小明發(fā)現(xiàn)服務器的內(nèi)網(wǎng)網(wǎng)卡上跑著一個有root權限的redis,但加了密碼,只見小明虎軀一震,頓時有了思路:留個webshell,以后通過webshell來執(zhí)行redis反彈shell的exp。
但當他看完nginx的配置后又菊花一緊,因為這個站點只跑了lua的web應用.。