Article Learn

中、小企业如何自建免费的云WAF

Post by netxfly at 2016-10-25 14:07:10

注:本文为“小米安全中心”原创,转载请联系“小米安全中心”

上期回顾:技术分享|利用恶意软件检测服务向服务提供商植入恶意软件(二)

一、概述

WEB攻击是十几年来黑客攻击的主流技术,国内的大厂们早已把WAF作为安全基础设施的标配,市面上也有很多安全厂商提供了WAF产品或云WAF服务。

对于没有自己安全团队,却又饱受sql注入、xss、cc等WEB攻击的中、小企业,对WAF的需求也是非常迫切的。

目前获取WAF的途径有以下几种:

  1. 购买安全厂商的WAF产品

  2. 使用云waf服务,将自己域名的DNS服务器设为云waf厂商提供的,或者将需要接入云waf的域名cname过去

  3. 或者从网上找一些免费或开源的waf使用

  4. 自制WAF

对于收入不错的公司使用收费的产品或服务无可厚非,但是有些公司会因预算、数据私密性(云waf可以捕获所有流量的请求和响应的内容)等原因,不打算使用收费的产品或服务。

这种情况下只能使用免费的waf了,或者按业务需求自制一款适合自己的云WAF。

笔者会通过本文详细阐述如何用一周的时间自制一款简单易用的云WAF,以下为已经完成的云WAF的文档及github地址:

  1. 项目站点:https://waf.xsec.io/

  2. Github地址:https://github.com/xsec-lab

二、云WAF架构设计

物理架构

根据业务场景或需求的不同,WAF也有不同的架构,比如:

  1. 以模块的形式集成到本地WEB容器中,如mod_security、Naxsi

  2. 反向代理模式

  3. 硬件产品WAF

  4. Agent+检测云模式

本文实现的云WAF采用了反向代理模式的架构

  1. waf可以部署一台或者多台服务器中,如果业务规模较大,一台waf的性能已经无法满足业务需求,可以在waf前面使用LVS、haproxy、nginx等搭建负载均衡,通过VIP将前端的请求分发到后端的waf中

  2. 后端的app server为提供正常业务的web server,用户的请求会先经过waf进行过滤,如果是恶意的攻击请求,则会在waf层面阻断,如果是正常的请求才会转发到后端服务器

逻辑架构

x-waf由x-waf本身以及web管理后台x-waf-admin组成,其中:

  1. x-waf基于openresty + lua开发

  2. waf管理后台:采用golang + xorm + macrom开发的,支持二进制的形式部署

x-waf的实现

笔者呆过的2家公司都自主研发过云waf,架构一开始就设计成了适合大规模业务系统的,安装、部署、运维都比较复杂,不方便小企业快速部署,所以在参考了github中现有的开源的几款waf后,重新设计了一款轻量级的。

x-waf的执行流程

openresty默认不会执行lua脚本,需要在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,分配的内存大小为100M
lua_shared_dict limit 100m;
lua_shared_dict badGuys 100m;
# 开启lua代码缓存功能
lua_code_cache on;
# 让nginx在init阶段执行init.lua文件中的lua代码 
init_by_lua_file /usr/local/openresty/nginx/conf/x-waf/init.lua;
# 让nginx在每个http请求的access阶段执行access.lua文件中的lua代码
access_by_lua_file /usr/local/openresty/nginx/conf/x-waf/access.lua;


openresty在init阶段会根据配置文件指定的位置导入json格式的规则到全局的lua table中,不同的规则放在不同的table中,以加快正则匹配的速度

waf = require("waf") 
waf_rules = waf.load_rules()

waf.load_rules会根据配置文件中指定的路径加载读取所有json格式的规则,并加载到不同的table中,然后封装一个get_rule的函数,方便在每个http进来时可以直接从lua table中获取对应类型的规则:

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会将指定文件中的规则按规则名保存到lua table中供waf.get_rule函数在需要的时候获取规则:

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参数的顺序进行过滤,如果匹配到其中任一种就会进行相应的处理(输出提示或跳转后),之后就不会继续判断是否为其他类型的攻击了。

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

对每个请求的每种参数类型的判断都是先获取到参数内容,然后再循环与该类参数的正则规则进行匹配,如果匹配到则认为是攻击请求,以下为对post参数进行过滤的函数:

-- 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 falseend


waf管理后台x-waf-admin的实现

waf的规则是以json格式的字符串,人工维护起来容量出错,另外云waf会有多台waf同时工作,如果人工做waf的后端主机的管理、规则同步与主机配置的同步等这些运维工作的话,非常容易出错或者疏漏,所以有必要提供一个自动化管理、同步配置的管理后台。

waf管理后台的功能需求

  • 方便部署,启动前只需做简单的配置即可,第一次启动时,x-waf-admin会在mysql中生成默认管理员以及默认的waf规则;

  • 用户管理,支持管理员账户的增、改、删;

  • waf规则管理,支持waf规则的增、改、删除以及策略同步到所有waf服务器的功能;

  • 后端站点管理,支持接入waf的站点的增、改、删除,以及单独同步或全部同步接入的后端站点的功能。

程序结构

为了方便部署,x-waf-admin没有采用python、php等需要搭建运行环境或依赖第3方包的语言,而是用可以直接编译为可执行文件的go语言写的,具体的技术栈为go语言 + macron + xorm。

项目结构如下:

$ 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分别为静态资源及模板文件所在的目录

  • routers目录下的为各路由文件

  • setting目录下为配置文件处理的文件

  • server.go为程序入口

规则管理功能的实现

用户管理、后端站点管理与规则管理功能的实现大同小异,都是类似flask、martini、tornado、django等MTV WEB框架的应用,为了减少篇幅,本文只写后端站点管理功能如何实现,完整的代码请参见github

后端站点管理的ORM实现

先用xorm定义site的struct,然后再提供增、改、删、查看等方法,这些方法会被routers模块中的site文件调用:

// 因篇幅太长,省略部分代码,详细代码请查看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 
}


后端站点管理的路由实现

首先import相应的包,然后分别编写以下处理器:

  1. 增加站点的get与post请求的处理器(NewSite、DoNewSite)

  2. 修改站点的get与post请求的处理器(EditSite、DoEditSite)

  3. 根据ID删除站点的get处理器(DelSite)

  4. 同步站点配置的处理器(SyncSite)

  5. 同步站点配置的API的处理器以及根据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获取数据库的配置信息
    sec := setting.Cfg.Section("database")        
    // 连接数据库
    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")
    }             
    // 如果规则为空,则插入默认的初始化规则
    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))


注:本文为“小米安全中心”原创,转载请联系“小米安全中心”。

三、参考资料

  1. https://github.com/unixhot/waf

  2. https://go-macaron.com/

  3. http://gobook.io/read/github.com/go-xorm/manual-zh-CN/

—   联系我们   —

新浪微博

公众号