什么?你还不知道Nginx的WAF(防火墙)的如何实现?

718 阅读20分钟

开始之前

  本文的目的是搞懂非业务范围外运维层做那层WAF防护到底是什么?如何处理的、带着这个疑问搜集了一些资料才有了下面这篇文章,当然毕竟不是专业出发,抱着学习的态度来叙述,目的是让大家对于整个WAF流程有个大概的了解。

如果讲的有哪些不妥的之处,请大家多多指教。

前言

  相信大家开发多年、对于Nginx最熟悉不过哪怕是不会配置也知道它的强大之处,那么为什么很多的项目部署都采用的nginx进行搭建呢?它到底有什么魅力?带着这个疑问我们让下看

强大的Nginx

Nginx是一款高性能的开源Web服务器和反向代理服务器

  1. 高性能:Nginx采用事件驱动的架构,能够轻松处理大量并发请求,具有出色的性能表现,能够有效地提升网站的响应速度。
  2. 反向代理:Nginx可以作为反向代理服务器,接收客户端请求并将其转发给后端服务器处理,可以有效地分担后端服务器的负载,提高系统的稳定性和可靠性。
  3. 负载均衡:Nginx支持多种负载均衡算法,可以根据实际情况将请求分发到多个后端服务器上,实现负载均衡,提高系统的吞吐量和并发能力。
  4. 高可靠性:Nginx采用多进程或多线程的模型,当一个进程或线程出现问题时,其他进程或线程可以继续处理请求,提高了系统的可靠性和稳定性。
  5. 热部署:Nginx支持热部署,可以在不中断服务的情况下进行配置文件的修改和软件的升级,提高了系统的可用性。
  6. 强大的模块化扩展性:Nginx具有丰富的第三方模块,可以通过加载这些模块来实现各种功能扩展,如缓存、SSL/TLS支持、HTTP流量控制等。

  注意最后一条是关键,这也就是为什么Nginx强大的原因,好的框架设计能力固然不错但是周边生态火爆才是考量一个框架是否受欢迎的程度,有了这些三方模块的拓展我们可以做的事情就很多了,比如:IP黑白名单、访问频率限制、请求方法过滤、动态模块扩展、等等一系列关于网络拦截和过滤的操作

什么是WAF

  WAF是Web应用程序防火墙(Web Application Firewall)的缩写,是一种位于Web应用程序和客户端之间的安全设备,用于检测和阻止对Web应用程序的恶意攻击。WAF可以通过检查HTTP/HTTPS请求和响应的内容、标头、URL等来识别潜在的攻击,并采取相应的防御措施。

  WAF能够保护应用程序免受常见的网络攻击,如SQL注入、跨站点脚本攻击(XSS)、跨站点请求伪造(CSRF)等。它通过实时监控和分析网络流量,识别出恶意行为并进行拦截,从而保护应用程序和用户数据的安全。可以根据预定义的安全策略进行配置,也可以根据特定应用程序的需求进行自定义配置。它可以提供日志记录、报警和报告功能,帮助管理员及时发现和应对安全威胁。

WAF流程一览

Tips: 谨记,防火墙虽然可以根据自定义规则拦截一些恶意攻击,毕竟不是万能,在业务侧也要做好更精细化兜底的准备

Lua是什么?

  Lua 是巴西里约热内卢天主教大学(Pontifical Catholic University of Rio de Janeiro)里的一个研究小组,由Roberto Ierusalimschy、Waldemar Celes 和 Luiz Henrique de Figueiredo所组成并于1993年开发。是一种轻量小巧的脚本语言,用标准c++语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

应用场景

  1. 游戏开发:Lua 通常用于游戏的脚本编程,许多大型游戏都使用 Lua 来编写游戏逻辑和用户界面。
  2. 嵌入式系统:由于 Lua 的轻量级和高效性,它也常用于嵌入式系统,如路由器、电视、家用电器等。
  3. Web 开发:使用 Lua 和 NginxOpenResty 框架,可以开发高性能的 Web 应用程序。

Lua + Nginx

  Lua脚本在Nginx中被用作扩展其功能和实现复杂的业务逻辑。Nginx通过ngx_lua模块提供了对Lua脚本的支持,使得开发者可以使用Lua语言编写脚本来操作Nginx的各个环节

Tips:使用Lua脚本可能会带来一定的性能开销,因此在编写Lua脚本时需要考虑其效率和处理速度,避免影响整体系统性能。

OpenResty

  OpenResty是一个成熟的 Web 平台,集成了我们增强版的 Nginx核心、增强版的LuaJIT、许多精心编写的 Lua 库、大量高质量的第 3 方 Nginx 模块以及它们的大部分外部依赖项。它旨在帮助开发人员轻松构建可扩展的 Web 应用程序、Web 服务和动态 Web 网关。

  对于初学者来说安装openresty 来说是个不错的选择,下文开始围绕这openresty进行调试,当然你可以进行Nginx的模板加载ngx_lua; 具体的方式以你实际情况而定

本地安装

系统:MacBook

  下面是安装的流程、当然不同的版本安装的方式不同,比如你本地是windows,那么安装的步骤就可以参考

  1. 安装Homebrew:打开终端,执行以下命令安装Homebrew:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  1. 安装OpenResty:在终端中执行以下命令来使用Homebrew安装OpenResty:
brew install openresty
  1. 配置环境变量:OpenResty安装完成后,需要配置环境变量。编辑 ~/.bash_profile 文件并添加以下行:
export PATH=/usr/local/openresty/nginx/sbin:$PATH
  1. 使环境变量生效:在终端中执行以下命令使环境变量生效:
source ~/.bash_profile
  1. 启动OpenResty:在终端中执行以下命令来启动OpenResty:
openresty
  1. 验证安装:打开浏览器,访问 http://localhost,如果看到“Welcome to OpenResty!”的页面,如图所示

7、目录存放位置

// 默认安装的配置文件的位置
/usr/local/etc/openresty  

// 默认存放的html文件的路径:
/usr/local/Cellar/openresty/1.21.4.2_1/nginx/html

因为电脑的差异、可以使用 OpenResty -V 来打印文件目录 Nginx 同理

常用的命令

OpenResty

openresty // 启动openresty
openresty -s stop  //停止openresty
openresty -s reload // 重新加载配置文件
openresty -v // 查看openresty的版本信息:
openresty -t // 检查openresty配置文件语法的正确性:
openresty -c /path/to/nginx.conf // 启动指定配置文件的openresty

Nginx

Nginx // 启动Nginx
Nginx -s stop  //停止Nginx
Nginx -s reload // 重新加载配置文件
Nginx -v // 查看Nginx的版本信息:
Nginx -t // 检查Nginx配置文件语法的正确性
Nginx -c /path/to/nginx.conf // 启动指定配置文件的Nginx

  从上面的指令来看,大家有没有发现其实指令是一样的?他们的关系更像是原生的JavaScriptJQuery的关系,但是基于OpenResty是他已经集成了Lua和一些三方的模板,不用本地搭建nginx在进行编译

Nginx详细配置

路径: /usr/local/etc/openresty/nginx.conf

// nginx.conf [标准版]

#user  nobody;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;

    server {
        listen       80;
        server_name  localhost;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;

        location / {
            root   html;
            index  index.html index.htm;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

        # proxy the PHP scripts to Apache listening on 127.0.0.1:80
        #
        #location ~ .php$ {
        #    proxy_pass   http://127.0.0.1;
        #}

        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
        #
        #location ~ .php$ {
        #    root           html;
        #    fastcgi_pass   127.0.0.1:9000;
        #    fastcgi_index  index.php;
        #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
        #    include        fastcgi_params;
        #}

        # deny access to .htaccess files, if Apache's document root
        # concurs with nginx's one
        #
        #location ~ /.ht {
        #    deny  all;
        #}
    }


    # another virtual host using mix of IP-, name-, and port-based configuration
    #
    #server {
    #    listen       8000;
    #    listen       somename:8080;
    #    server_name  somename  alias  another.alias;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}


    # HTTPS server
    #
    #server {
    #    listen       443 ssl;
    #    server_name  localhost;

    #    ssl_certificate      cert.pem;
    #    ssl_certificate_key  cert.key;

    #    ssl_session_cache    shared:SSL:1m;
    #    ssl_session_timeout  5m;

    #    ssl_ciphers  HIGH:!aNULL:!MD5;
    #    ssl_prefer_server_ciphers  on;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}

}

Nginx精简版

第一次接触的小白可能比较懵?怎么这么多配置?别急!让我加点注释

worker_process      # 表示工作进程的数量,一般设置为cpu的核数

worker_connections  # 表示每个工作进程的最大连接数

server{}            # 块定义了虚拟主机

    listen          # 监听端口

    server_name     # 监听域名

    location {}     # 是用来为匹配的 URI 进行配置,URI 即语法中的“/uri/”

    location /{}    # 匹配任何查询,因为所有请求都以 / 开头

        root        # 指定对应uri的资源查找路径,这里html为相对路径

        index       # 指定首页index文件的名称,可以配置多个,以空格分开。如有多
                    # 个,按配置顺序查找。

这个时候在把注释全部去掉,是不是大概有点眉目了

// nginx.conf [精简版]

worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       80;
        server_name  localhost;

        location / {
            root   html;
            index  index.html index.htm;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

    }
}

简单的分析一下上面都做了什么?

  • Nginx 监听了 80 端口
  • 域名为 localhost
  • 根路径为 html 文件夹(我的安装路为/usr/local/Cellar/openresty/1.21.4.2_1/nginx/html)
  • 默认 index 文件为 index.html,index.htm
  • 服务器错误重定向到 50x.html 页面。

  知道了上面的配置大概干什么的,那么围绕这整体骨架我们再来加点东西,比如说一些三方的配置:Lua

Lua和Nginx必备配置

注意下面的配置:lua_package_pathinit_by_lua_fileaccess_by_lua_file 更多的可以参考文档

关于nginx中配置

lua_package_path

  这个字段用于设置Lua模块的搜索路径。在Lua中,我们可以通过require函数来引入其他Lua模块,而lua_package_path则指定了这些模块所在的路径。它可以包含一个或多个路径,多个路径之间使用分号进行分隔。例如:

lua_package_path '/path/to/lua/modules/?.lua;/path/to/other/modules/?.lua;';

其中,问号(?)表示通配符,代表任意字符,使得Lua模块可以在指定路径下进行搜索。

init_by_lua_file

  这个字段用于指定一个Lua文件,在Nginx启动过程中会执行该Lua文件。在Nginx启动时,Lua代码可以用于一次性地加载一些全局的配置、初始化函数或者变量等。同时也可以在该阶段进行一些运行环境的检查和准备。例如:

init_by_lua_file '/path/to/init.lua';

在该路径下的init.lua文件中的Lua代码会在Nginx启动时被执行。

access_by_lua_file

  这个字段用于指定一个Lua文件,在处理客户端请求时会执行该Lua文件。对于每个客户端请求,都会调用指定的文件中的Lua代码进行处理。在这个阶段中,可以进行诸如访问控制、鉴权、请求处理等业务逻辑。例如:

access_by_lua_file '/path/to/access.lua';

在该路径下的access.lua文件中的Lua会在每个客户端请求到达时被执行。

  以上的三个字段都可以在httpserverlocation块内部设置相应的指令来配置这些字段,就拿access_by_lua_file 举例来说,如果你在http模块配置,同时在当前的location配置,那么location就会覆盖http模块

一些Lua常用的ngx变量

  所谓 “工欲善其事,必先利其器”,下面这些基础变量必须得知道、这样能让你更快速掌握,下面是简单的介绍一些常用的在Lua中使用的Nginx的变量,详细的可以参考

  • ngx.var.server_name : 获取当前请求的主机名,即服务器接收到的请求中的 "Host" 头部字段的值。
  • ngx.var.request_uri:Lua中用于获取当前请求的URI(Uniform Resource Identifier)的变量。URI是一个包含了主机名、路径和查询参数的字符串,它描述了要访问的资源的位置和标识。
  • ngx.var.http_user_agent :获取当前请求的用户代理信息,即客户端发送请求时的 User-Agent 头部字段的值。
  • ngx.log : 打印log
  • ngx.status: 设置状态码
  • ngx.header: 设置返回的头部信息; ngx.header["Content-Type"] = "text/html; charset=utf-8"
  • ngx.say: 设置返回内容
  • ngx.HTTP_OK : 200 状态码
  • ngx.exit : 会中断当前请求,并将传入的状态码(status)返回给nginx。
  • ...

正则表达式

  在配置nginx的时候由于大部分都是以字符串形式进行匹配,会大量使用正则表达式,这里简单的列举一下方便大家记忆

Tips:正则表达式在各类语言中的语法都相似、不必要专门在某个语言去找正则语法,比如下面列举的这种形式,如果你熟悉JavaScript的话,那么基本都能看懂大部分的指令。

元字符:
.        匹配除换行符以外的任意字符
\w        匹配字母或数字或下划线
\s        匹配任意的空白符
\d        匹配数字
\b        匹配单词的开始或结束
^        匹配字符串的开始
$        匹配字符串的结束

限定符:
*        重复零次或更多次
+        重复一次或更多次
?        重复零次或一次
{n}        重复n次
{n,}        重复n次或更多次
{n,m}        重复n到m次

反义词:
\W        匹配任意不是字母,数字,下划线,汉字的字符
\S        匹配任意不是空白符的字符
\D        匹配任意非数字的字符
\B        匹配不是单词开头或结束的位置
[^x]        匹配除了x以外的任意字符
[^aeiou]        匹配除了aeiou这几个字母以外的任意字符

示例:
用户名 [A-Za-z0-9_-\u4e00-\u9fa5]+
负整数 -[1-9]\d*
正整数 [1-9]\d*

其它:
[xyz]         字符集合。
[^xyz]         负值字符集合。
[a-z]         字符范围,匹配指定范围内的任意字符。
[^a-z]         负值字符范围,匹配任何不在指定范围内的任意字符。
\b             匹配一个单词边界,也就是指单词和空格间的位置。
\B          匹配非单词边界。
\cx         匹配由x指明的控制字符。
\d             匹配一个数字字符。等价于 [0-9]。
\D             匹配一个非数字字符。等价于 [^0-9]。
? \f         匹配一个换页符。等价于 \x0c 和 \cL。
\n          匹配一个换行符。等价于 \x0a 和 \cJ。
\r             匹配一个回车符。等价于 \x0d 和 \cM。
\s             匹配任何空白字符,包括空格、制表符、换页符等等。等价于[ \f\n\r\t\v]。
\S             匹配任何非空白字符。等价于 [^ \f\n\r\t\v]。
\t          匹配一个制表符。等价于 \x09 和 \cI。
\v          匹配一个垂直制表符。等价于 \x0b 和 \cK。
\w             匹配包括下划线的任何单词字符。等价于’[A-Za-z0-9_]’。
\W             匹配任何非单词字符。等价于 ’[^A-Za-z0-9_]’。

开始配置

目录文件

下面的是默认的配置,主要关心的是/luas 的文件夹、nginx.conf 存放的位置、rules文件夹

├── fastcgi.conf
├── fastcgi.conf.default
├── fastcgi_params
├── fastcgi_params.default
├── koi-utf
├── koi-win
├── logs // 存放log日志文件目录
│   ├── access.log
│   ├── error.log
│   └── info.log  // 本地调试的日志「当然你可以自定义」
├── luas  // 存放lua脚本的全部逻辑代码
│   ├── access.lua // 每次的请求进行拦截的脚本
│   ├── config.lua // 配置脚本
│   ├── init.lua  // 初始化脚本
│   ├── util.lua // 工具函数
│   ├── usertime.lua // 获取时间参数
│   └── waf.lua // core
├── mime.types
├── mime.types.default
├── nginx.conf
├── nginx.conf.default
├── rules   //  防火墙的规则目录
│   ├── args.rule // Args请求参数过滤[规则]
│   ├── blackip.rule //  IP黑名单 [规则]
│   ├── cookie.rule // Cookie过滤 [规则]
│   ├── frequency.rule // 请求频率限制[规则]
│   ├── post.rule // POST数据过滤 [规则]
│   ├── url.rule // Url过滤 [规则]
│   ├── useragent.rule // 浏览器UserAgent过滤[规则]
│   ├── whiteip.rule // IP白名单[规则]
│   └── whiteurl.rule // Url白名单[规则]
├── scgi_params
├── scgi_params.default
├── uwsgi_params
├── uwsgi_params.default
└── win-utf

nginx.conf

注意下面的配置字段都有详细的注释大家可以参考进行配置。

# nginx.conf

worker_processes  1;

# 配置log
error_log  /usr/local/etc/openresty/logs/error.log;
error_log  /usr/local/etc/openresty/logs/info.log  info;

worker_rlimit_nofile 1024;  # 设置每个Worker进程可以打开的文件描述符的数量上限「本地调试打开哦」

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

    # 初始化机制lua的路径
   lua_package_path  "/usr/local/Cellar/openresty/1.21.4.2_1/lualib/?.lua;/usr/local/etc/openresty/luas/?.lua" ;
    # 共享区域,每个worker 可以共享数据「类似于JSLocalStorage」
    lua_shared_dict limit 100m; 

    #开启lua代码缓存功能
    lua_code_cache on;
    lua_regex_cache_max_entries 4096;

    # 加载lua文件
    init_by_lua_file /usr/local/etc/openresty/luas/init.lua;
    access_by_lua_file /usr/local/etc/openresty/luas/access.lua;

    server {
        listen       80;
        # server_name  api api.test.com; 可以配置多个域名
         server_name  api;

        location / {
            root   html;
            index  index.html index.htm;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }

}

设置本地 hosts 文件

通过本地的IP地址进行映射

...
127.0.0.1   api

实现流程

实际实现的流程可以参考下图有个初步的认识

初始化

init.lua

Nginx 在初始化会加载、对应这init_by_lua_file 配置

Tips: 在lua 中 声明local 为局部变量、未声明local 为全局变量

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

access.lua

通过服务器nginx每次请求都会经过当前的access.lua、 对应access_by_lua_file配置

waf.check() 

config.lua

在配置文件中可以进行手动配置关闭或者开启防火墙的某一些功能,具体可以看下面的配置信息

local _M = {
    -- 防火墙开关
    config_waf_enable = "on",
    -- 日志文件存放目录 结尾不带/
    -- config_log_dir = "/var/log/nginx",
    config_log_dir = "/usr/local/etc/openresty/logs",
    -- 规则文件存放目录 结尾不带/
    config_rule_dir = "/usr/local/etc/openresty/rules",

--01假数据时不进行后续流程,非正则匹配
    -- 频率控制开关
    frequency_control_check = "on",
    -- 频率控制中返回空数据内容
    frequency_text = [[{"status":"ok"}]],

--02直接通过不进行后续流程
    -- IP白名单开关
    config_white_ip_check = "on",

--03返回403
    -- IP黑名单开关
    config_black_ip_check = "on",

--04WAF处理:跳转/html/仅日志
    -- UserAgent过滤开关
    config_user_agent_check = "on",

--05直接通过不进行后续流程
    -- URL白名单开关
    config_white_url_check = "on",

--06WAF处理:跳转/html/仅日志
    -- URL过滤开关
    config_url_check = "on",

--07返回403记录limit
    -- CC攻击过滤开关
    config_cc_check = "on",
    -- 设置CC攻击检测依据 攻击阈值/检测时间段
    config_cc_rate = "10/60", -- mock
    -- config_cc_rate = "300/60",

--08WAF处理:跳转/html/仅日志
    -- Cookie过滤开关
    config_cookie_check = "on",

--09WAF处理:跳转/html/仅日志
    -- ARGS请求参数过滤开关
    config_url_args_check = "on",
    
--10WAF处理:跳转/html/仅日志
    -- POST过滤开关
    config_post_check = "on",
    -- 处理方式 redirect/html/jinghuashuiyue  jinghuashuiyue只记录日志
    config_waf_model = "html",
    -- 当配置为redirect时跳转到的URL
    config_waf_redirect_url = "http://www.baidu.com",
    -- bad_guys过期时间
    -- config_expire_time = 600,
    -- 当配置为html时 显示的内容
    config_output_html = [[
    <html>
    <head>
    <meta charset="UTF-8">
    <title>LEPEI WAF</title>
    </head>
      <body>
        <div>
      <div class="table">
        <div>
          <div class="cell">
            您的IP为: %s
          </div>
          <div class="cell">
            已触发WAF规则
          </div>
          <div class="cell">
            实际使用请修改此提示信息
          </div>
        </div>
      </div>
    </div>
      </body>
    </html>
    ]],
}

return _M

waf.lua

核心代码逻辑

local  config = require ( "config" ) -- 配置防火墙配置「自己编写」  local util = require ( "util" ) -- 工具函数「自己编写」  local utime = require ( "usertime" ) -- 获取毫秒时间戳、微妙时间戳「自己编写」  local json = require ( "cjson" ) -- 通过lua返回JSON对象的函数库「自带功能」  local rulematch = ngx.re. find  -- 用于在字符串中查找匹配正则表达式的子串,并返回其起始位置和结束位置。「Nginx能力」  local unescape = ngx.unescape_uri -- 解码URL编码的字符串,将%xx形式的编码转换为相应的字符。「Nginx能力」 
-- 初始加载的函数、相当于init函数 在init.lua || access.lua 中都有引用
function _M.load_rules()
    _M.RULES = util.get_rules(config.config_rule_dir)

    for k, v in pairs(_M.RULES)
    do
        ngx.log(ngx.INFO, string.format("%s规则载入中...", k))
        for kk, vv in pairs(v)
        do
            ngx.log(ngx.INFO, string.format("编号:%s, 规则:%s", kk, vv))
        end
    end
    return _M.RULES

end

-- 获取RULES字典中指定类型规则列表
function _M.get_rule(rule_file_name)
    return _M.RULES[rule_file_name]
end

...
--[[
    下面讲到的全部内容都会插入到这里进行核心逻辑编写
]]-- 
 
function _M.check()
    if config.config_waf_enable ~= "on" then
        return
    end
    if     _M.frequency_control_check() then
    elseif _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

return _M

重点的方法介绍

  介绍以下这两个方法主要是在之后的WAF中大量使用,提前让大家熟悉一下利于理解接下来的拦截逻辑

ngx.re.find

第一个参数是要搜索的字符串,第二个参数是要匹配的正则表达式,第三个参数是可选的选项。

  1. 查找字符串中是否包含数字:
local str = "This is a text with numbers 12345"
local pattern = "\d+" -- 匹配一个或多个数字

local m, err = ngx.re.find(str, pattern)
if m then
    ngx.say("Pattern found at positions: ", m.start, "-", m["end"], ", value: ", string.sub(str, m.start, m["end"]))
else
    ngx.say("Pattern not found")
end

--[[
输出结果:Pattern found at position: 26-30, value: 12345
]]--
  1. 忽略大小写的匹配:
local str = "This is a Sample_text"
local pattern = "sample"
local options = "i"

local m, err = ngx.re.find(str, pattern, options)
if m then
    ngx.say("Pattern found at positions: ", m.start, "-", m["end"], ", value: ", string.sub(str, m.start, m["end"]))
else
    ngx.say("Pattern not found")
end

--[[
输出结果:Pattern found at position: 11-16, value: Sample
]]--

options参数:

  • "i" :忽略大小写,表示匹配不区分大小写。
  • "j" :启用PCRE_JIT优化,可以提升正则表达式的匹配性能。
  • "s" :将字符串视为单行模式处理,即将换行符也视为普通字符进行匹配。
  • "o" :仅返回第一个匹配结果。

ngx.unescape_uri

ngx.unescape_uri函数用于将URL编码的字符串解码为原始字符串

解码URL中的特殊字符:

local encoded_url = "/%E4%BD%A0%E5%A5%BD/%E4%B8%96%E7%95%8C%21"
local decoded_url = ngx.unescape_uri(encoded_url)

ngx.header["Content-Type"] = "text/html; charset=utf-8"
ngx.say(decoded_url)
ngx.exit(500)

--[[
    输出结果:/你好/世界!
]]--

核心逻辑实现

频率控制开关

nginx配置

在之前的Nginx.conf 加入location /lua 配置


...
http {
    ...
    server {
       ...
        # 新增lua-waf
        location /lua {
            default_type text/html;
            content_by_lua_block {
                ngx.header["Content-Type"] = "text/html; charset=utf-8"
                ngx.say("<h1>Hello World</h1>")
            }
        }
       ... 
    }

}

限制规则

通过配置末位的数字,真数据比例 3 => 30% 末位为返回真数据的百分比,范围0-9其中0为全部返回假数据

注意:rule文件是一种使用Lua编写的脚本文件,用于定义规则、配置和逻辑。虽然一般情况下,.rule文件使用Lua语言编写,但实际上你可以使用其他后缀名来表示相同类型的规则文件,只要在执行时能够正确地解析和执行对应的规则定义即可。

-- frequency.rule 文件

-- 请求频率限制
-- 真数据比例 3 => 30% 末位为返回真数据的百分比,范围0-9其中0为全部返回假数据
-- 只要匹配规则,就返回200,即使url不存在
-- 末尾两位不计入正则表达式,-5
-- 待匹配字符串示例:api-192.168.158.1-/test/index.html
-- 规则示例:api-192.168.158.1-/index.html-0
-- 注意,本规则并非正则匹配,而是字符串比较
-- 
api-127.0.0.1-/lua-3

实现

-- waf.lua 文件

local config = require("config")
local util = require("util")

-- 加入频率控制函数
-- 若返回假数据,将跳过后检查流程
function _M.frequency_control_check()
    if config.frequency_control_check  == 'on' then
        local FREQUENCY_RULE = _M.get_rule('frequency.rule')
        local FREQUENCY_TAG = ngx.var.server_name.."-"..util.get_client_ip().."-"..ngx.var.request_uri
        
        if FREQUENCY_RULE ~= nil then
            for _,rule in pairs(FREQUENCY_RULE) do 
                if rule ~= "" and string.sub(rule,1,-3) == FREQUENCY_TAG then

                    local microsecond = utime.getmillisecond()
                    local microseconds = string.sub(microsecond,1,-3)
       
                    math.randomseed(tonumber(tostring(microseconds):reverse():sub(1,12)))
                    if math.random(0,9) >= tonumber(string.sub(rule,-1,-1)) then
                        ngx.header.content_type = "application/json" 
                        ngx.header.content_length = #config.frequency_text
                        ngx.status = ngx.HTTP_OK
                        ngx.say(config.frequency_text)  
                        ngx.exit(ngx.status) 
                        return true
                    end
                end
            end
        end
        return true
    end
    return false
end

Tips: Lua的随机数

math.randomseed(tostring(microsecond):reverse():sub(1,12)) 这行代码的作用是设置随机数生成器的种子。在Lua中,如果不设置随机数生成器的种子,每次生成的随机数序列都是相同的。

逻辑分析:

  1. 首先判断配置文件中的开关config.frequency_control_check是否为'on',如果是,则继续执行,否则返回false。
  2. 调用_M.get_rule('frequency.rule')函数获取频率规则表格FREQUENCY_RULE,该表格存储了所有的频率规则。
  3. 构建当前请求的标识FREQUENCY_TAG,由当前请求的服务器名、客户端IP和请求URI组成。
  4. 如果FREQUENCY_RULE不为空,则依次遍历每个规则。
  5. 对于每个规则,比较规则中的字符串和请求标识是否相等。如果相等,则继续执行以下步骤,否则继续下一个规则的匹配。
  6. 获取当前的时间戳(毫秒级),并截取前面的部分作为种子数。
  7. 设置随机数生成器的种子,确保每次调用math.random()时生成不同的随机数序列。
  8. 通过比较规则中的最后一位数字和生成的随机数,判断是否触发频率限制。
  9. 如果触发了频率限制,则进行相应的处理,例如记录日志、返回特定内容等。
  10. 最后,返回true表示频率控制检查通过,或返回false表示频率控制检查未通过。

演示:

  通过上面的的演示可以看到当末位的数字越小的时候,返回真实数据的比例越小,相反数字越大返回的真实数据的比例越大;

  • 真实内容:<h1>Hello World</h1>
  • 假数据:{"status":"ok"}%

IP白名单开关

限制规则

规则匹配了当前的server-name 和IP地址进行匹配

-- IP白名单
-- 待匹配字符串示例:api-192.168.158.1
-- 规则示例:^api-192.168.158.1$
-- 注意,如果不指定主机头api,将对所有主机生效
-- 
^api-127.0.0.1$

实现

-- waf.lua 文件

local config = require("config")
local util = require("util")

-- 白名单IP检查
-- 匹配字段式样:api-192.168.1.1
function _M.white_ip_check()
    if config.config_white_ip_check == "on" then
        local IP_WHITE_RULE = _M.get_rule('whiteip.rule')
        local WHITE_IP = ngx.var.server_name.."-"..util.get_client_ip()
        
        if IP_WHITE_RULE ~= nil then
            for _, rule in pairs(IP_WHITE_RULE) do
                if rule ~= "" and rulematch(WHITE_IP, rule, "jo") then
                    ngx.log(ngx.INFO, "白名单IP地址::".. WHITE_IP .. '\n\n\n\n')
                    ngx.header["Content-Type"] = "text/html; charset=utf-8"
                    ngx.say("加白成功!")
                    return true
                end
            end
        end
 
    end
end

逻辑分析:

  1. 判断config.config_white_ip_check是否等于"on"来确定是否开启了白名单IP检查功能
  2. 调用_M.get_rule('whiteip.rule')函数获取到名为whiteip.rule的规则。
  3. 通过ngx.var.server_name获取当前服务器名称,并与util.get_client_ip()获取到的客户端IP地址进行拼接,得到一个形如"服务器名称-客户端IP地址"的字符串,赋值给变量WHITE_IP
  4. 遍历IP_WHITE_RULE中的每个规则,如果规则不为空且rulematch(WHITE_IP, rule, "jo")返回true

演示:

IP黑名单开关

了解了白名单的限制、那么实现黑名单逻辑代码原理大同小异,这里就不多赘述

-- 黑名单IP检查
-- 匹配字段式样:api-192.168.1.1
local config = require("config")
local util = require("util")


function _M.black_ip_check() 
    if config.config_black_ip_check == "on" then
        local IP_BLACK_RULE = _M.get_rule('blackip.rule')
        local BLACK_IP = ngx.var.server_name.."-"..util.get_client_ip()

        if IP_BLACK_RULE ~= nil then
            for _, rule in pairs(IP_BLACK_RULE) do
                if rule ~= "" and rulematch(BLACK_IP, rule, "jo") then
                    ngx.header["Content-Type"] = "text/html; charset=utf-8"
                    ngx.say("当前IP已经加入黑名单!")
                    ngx.exit(403)
                    return true
                end
            end
        end
    end
    return false
end

URL白名单开关

同样的方式也可以把url白名单进行过滤

Nginx.conf

在之前的配置文件加上 location 的white_url模拟URL的白名单


...
http {
    ...
    server {
       ...
      # URL白名单开关
      location /white_url {
             default_type text/html;

             content_by_lua_block {
                ngx.say("<h2>URL白名单</h2>")
            }
       }
        ...
    }

}

规则

-- Url白名单
-- 待匹配字符串示例:api-/test/index.html
-- 规则示例:^api-/text/index.html
-- 注意,如果不指定主机头api,将对所有主机生效
-- 

^api-/white_url/index.html

实现

-- 白名单URL
-- 匹配字段式样:api-index.html

local config = require("config")
local rulematch = ngx.re.find

function _M.white_url_check()
    if config.config_white_url_check == "on" then
        local URL_WHITE_RULES = _M.get_rule('whiteurl.rule')
        local REQ_URI = ngx.var.server_name.."-"..ngx.var.request_uri 
        if URL_WHITE_RULES ~= nil then
            for _,rule in pairs(URL_WHITE_RULES) do
                if rule ~= nil and rulematch(REQ_URI, rule, "joi") then
                    ngx.header["Content-Type"] = "text/html; charset=utf-8"
                    ngx.say("命中白名单的URL限制喽!")
                    return true
                end
            end
        end
    end
    return false
end

演示:

UserAgent过滤开关

限制规则

规则匹配了当前的server-name 和IP地址进行匹配

-- 浏览器UserAgent过滤
-- 待匹配字符串示例:api-Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0
-- 规则示例:^api-.*Firefox
-- 注意,如果不指定主机头api,将对所有主机生效
-- 
(Chrome|Firefox)

实现

-- waf.lua 文件

local config = require("config")
local rulematch = ngx.re.find

-- UserAgent检查
-- 匹配字段式样:api-Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0
function _M.user_agent_attack_check()
    local USER_AGENT = ngx.var.http_user_agent 
    local USER_AGENT_RULES = _M.get_rule('useragent.rule')
    if USER_AGENT ~= nil then
        for _,rule in pairs(USER_AGENT_RULES) do
            if rule ~= "" and  rulematch((ngx.var.server_name.."-"..USER_AGENT), rule, "joi") then
                ngx.log(ngx.INFO, "当前的浏览器受限!!".. ngx.HTTP_OK .. '\n\n\n\n')
                ngx.status = ngx.HTTP_OK
                ngx.header["Content-Type"] = "text/html; charset=utf-8"
                ngx.say("当前的浏览器受限!!")
                ngx.exit(ngx.HTTP_OK)
                return true
            end
           
        end
    end
    return false
end

逻辑分析:

  1. 通过ngx.var.http_user_agent获取浏览器的User-Agent信息,并将其赋值给变量USER_AGENT
  2. 使用_M.get_rule('useragent.rule')加载存储浏览器规则的模块(假设为useragent.rule),并将加载的规则对象赋给变量USER_AGENT_RULES
  3. 判断USER_AGENT是否为空。
  4. 使用pairs()遍历USER_AGENT_RULES中的每个规则
  5. 对于每个规则,通过调用rulematch((ngx.var.server_name.."-"..USER_AGENT), rule, "joi")进行规则匹配
  6. 如果没有匹配到任何规则,就直接返回false。

演示

URL过滤开关

限制规则

-- Url过滤
-- 待匹配字符串示例:api-/test/index.html
-- 规则示例:^api-/text/index.html
-- 注意,如果不指定主机头api,将对所有主机生效
-- 
.(git|svn|htaccess|bash_history)
.(bak|inc|old|mdb|sql|backup|java|class|tgz|gz|tar|zip|rar)$
(phpmyadmin|jmx-console|admin-console|jmxinvokerservlet)
java.lang
/(attachments|upimg|images|css|uploadfiles|html|uploads|templets|static|template|data|inc|forumdata|upload|includes|cache|avatar)/(.*).(php|jsp)
.(svn|git|sql|bak)/

实现

local config = require("config")
local rulematch = ngx.re.find

--- URL检查
-- 匹配字段式样:api-index.html
--  触发当前的规则: http://api/index.git || ttp://api/index.bak || ...

function _M.url_attack_check()
    if config.config_url_check == "on" then
        local URL_RULES = _M.get_rule('url.rule')
        local REQ_URI = ngx.var.server_name.."-"..ngx.var.request_uri
        for _, rule in pairs(URL_RULES) do
            if rule ~= "" and rulematch(REQ_URI, rule, "joi") then
                ngx.header.content_type = "text/html"
                ngx.status = ngx.HTTP_FORBIDDEN
                ngx.say(string.format(config.config_output_html, util.get_client_ip()))
                ngx.exit(ngx.status)
                return true
            end
        end
    end
    return false
end

逻辑分析:

  1. 首先判断 config_url_check 是否为 "on"
  2. 获取 url.rule 规则列表。
  3. 构建请求的完整 URI,将服务器名称和请求 URI 以 "-" 连接。
  4. 遍历规则列表,对每个规则进行匹配检测。
  5. 如果某个规则不为空且匹配成功,则进行以下操作:
    1. 设置响应头内容类型为 "text/html"。
    2. 设置 HTTP 状态码为 ngx.HTTP_FORBIDDEN,表示禁止访问。
    3. 输出配置文件中的 HTML 格式字符串,其中包含客户端 IP 地址。
    4. 终止当前请求处理,使用 ngx.exit 函数。
    5. 返回 true,表示检测到 URL 攻击。
  6. 如果遍历完所有规则后都没有匹配成功,则返回 false,表示未检测到 URL 攻击。

演示:

CC攻击过滤开关

Nginx.conf

...
http {
    ... 
    # 共享区域,每个worker 可以共享数据「类似于JS的LocalStorage」
    lua_shared_dict limit 100m; 
    ...
    
}
... 

限制规则

 --- config.lua
 
 local _M = {
  ...
  --07返回403记录limit
    -- CC攻击过滤开关
    config_cc_check = "on",
    -- 设置CC攻击检测依据 攻击阈值/检测时间段
    config_cc_rate = "10/60", -- mock
    -- config_cc_rate = "300/60", --produce
 }
 

实现

local config = require("config")

-- CC攻击
-- 匹配字段式样:api-192.168.158.1-/index.html
-- 使用共享存储limit

function _M.cc_attack_check()
    if config.config_url_check == "on" then
        -- 对ngx.var.request_uri限制长度为最大40字符 避免key太长
        local ATTACK_URI = string.sub(ngx.var.request_uri,1,40)
        local CC_TOKEN = ngx.var.server_name.."-"..util.get_client_ip() .."-"..ATTACK_URI
        -- ngx.shared.limit 是一个 shared dict 对象,它可以在不同的 Nginx worker 进程之间共享数据。
        local limit = ngx.shared.limit

         -- config_cc_rate = "10/60" ;
        -- CCcount : 10
        -- CCseconds : 60

        local CCcount = tonumber(string.match(config.config_cc_rate, '(.*)/')) 
        local CCseconds = tonumber(string.match(config.config_cc_rate, '/(.*)'))
        local req, _ = limit:get(CC_TOKEN)
        
        if req then
            if req  > CCcount then
                ngx.header["Content-Type"] = "text/html; charset=utf-8"
                ngx.say("<h2>当前的服务器正在遭受CC攻击!</h2>")
                ngx.exit(403)
            else
                limit:incr(CC_TOKEN, 1)  -- 相当于JS的 => i++
            end
        else
            limit:set(CC_TOKEN, 1, CCseconds)
        end

    end
    return false
end

逻辑分析:

  1. 首先判断 config_url_check 是否为 "on"
  2. 通过 string.sub 函数将 ngx.var.request_uri 截取前40个字符,并赋值给 ATTACK_URI 变量,用于限制键的长度。
  3. 构建 CC 攻击令牌 CC_TOKEN,由服务器名称、客户端 IP 地址和截取后的请求 URI 组合而成。
  4. 获取共享字典对象 limit,它用于在不同的 Nginx worker 进程之间共享数据。
  5. 从配置项 config_cc_rate 中分别提取出 CCcountCCseconds,这两个值表示在 CCseconds 秒内最多允许发生 CCcount 次 CC 攻击。
  6. 通过 limit:get 函数获取当前令牌对应的计数值 req
  7. 如果 req 存在,则进行以下判断:
    1. 如果 req 大于 CCcount,说明当前服务器正在遭受 CC 攻击,设置响应头 Content-Type 为 "text/html; charset=utf-8",输出提示信息,并使用 ngx.exit 函数终止当前请求处理,返回 HTTP 403 状态码。
    2. 否则,使用 limit:incr 函数对计数值 req 进行自增操作。
  8. 如果 req 不存在,说明是新的 CC 攻击请求,使用 limit:set 函数将计数值初始化为 1,并设置有效期为 CCseconds 秒。
  9. 最后,返回 false,表示未检测到 CC 攻击。

演示:

设置共享内存区域

lua_shared_dict设置一块共享内存区域,可以被各个worker共享,size 参数接受大小单位,如 k,m

常用方法合集

ngx.shared.limit 是一个共享内存字典对象,下面列举了一些常用的方法:

  • set(key, value, exptime?): 向共享内存字典中设置一个键值对,key为键名,value为键值,exptime可选参数表示过期时间(单位为秒)。
  • get(key): 根据键名获取共享内存字典中的键值。
  • add(key, value, exptime?): 向共享内存字典中添加一个键值对,如果键名已存在,则不进行添加。可选参数exptime表示过期时间(单位为秒)。
  • replace(key, value, exptime?): 替换共享内存字典中指定键的键值,如果键名不存在,则不进行替换。可选参数exptime表示过期时间(单位为秒)。
  • delete(key): 根据键名删除共享内存字典中的键值对。
  • incr(key, value, init?): 将共享内存字典中指定键的键值按指定的步长增加,并返回增加后的值。可选参数init表示如果键名不存在时是否进行初始化,默认为0。
  • flush_all(): 清空共享内存字典中的所有键值对。
举个例子
-- 在nginx配置文件中定义共享内存
http {
    lua_shared_dict limit 10m;
}

-- 在lua代码中使用ngx.shared.limit进行限流
local limit_dict = ngx.shared.limit
local key = "request_limit"
local limit = 100   -- 每秒限制请求数
local window = 1    -- 时间窗口为1秒

-- 获取当前时间戳
local now = ngx.now()
-- 获取窗口开始时间戳
local start_time = ngx.now() - window

-- 获取当前窗口内的请求数
local current_count = limit_dict:get(key)
if not current_count then
    -- 如果共享内存中没有记录,则初始化
    limit_dict:set(key, 1, window)
elseif current_count < limit then
    -- 如果请求数在限制范围内,则自增
    limit_dict:incr(key, 1)
else
    -- 达到限制数量,执行限流操作
    ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
end

Cookie过滤开关

限制规则

-- Cookie过滤
-- 待匹配字符串示例:api-nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; 
--                  kod_user_language=zh_CN; kod_user_online_version=check-at-1523267590; 
--                  kod_name=admin; kod_token=3e016b80ce1e7349ff371324a1c0f996, 
--                  client: 192.168.158.1, server: api, request: "GET /index.html HTTP/1.1", 
--                  host: "192.168.158.139"
-- 规则示例:^api-.*kod_name=admin;
-- 注意,如果不指定主机头api,将对所有主机生效
-- 
../
select.+(from|limit)
(?:(union(.*?)select))
sleep((\s*)(\d*)(\s*))
benchmark((.*),(.*))
base64_decode(
(?:from\W+information_schema\W)
(?:(?:current_)user|database|schema|connection_id)\s*(
(?:etc/\W*passwd)
into(\s+)+(?:dump|out)file\s*
group\s+by.+(
xwork.MethodAccessor
(?:define|eval|file_get_contents|include|require|require_once|shell_exec|phpinfo|system|passthru|preg_\w+|execute|echo|print|print_r|var_dump|(fp)open|alert|showmodaldialog)(
(gopher|doc|php|glob|file|phar|zlib|ftp|ldap|dict|ogg|data):/
java.lang
$_(GET|post|cookie|files|session|env|phplib|GLOBALS|SERVER)[
<(iframe|script|body|img|layer|div|meta|style|base|object|input)
(onmouseover|onerror|onload)=
 

实现

local config = require("config")
local rulematch = ngx.re.find

-- Cookie检查
-- 匹配字段式样:api-nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; 
-- kod_user_language=zh_CN; kod_user_online_version=check-at-1523267590; kod_name=admin; 
-- kod_token=3e016b80ce1e7349ff371324a1c0f996, client: 192.168.158.1, server: api, 
-- request: "GET /index.html HTTP/1.1", host: "192.168.158.139"

function _M.cookie_attack_check()
    if config.config_cookie_check == 'on' then
        local COOKIE_RULES = _M.get_rule('cookie.rule')
        local USER_COOKIE = ngx.var.http_cookie

        if USER_COOKIE ~=nil then
            USER_COOKIE = ngx.var.server_name.."-"..USER_COOKIE
            for _, rule in pairs(COOKIE_RULES) do
                if rule~= "" and rulematch(USER_COOKIE, rule, "joi") then
                    ngx.header["Content-Type"] = "text/html; charset=utf-8"
                    ngx.say("<h2>页面注入的cookie有问题,请注意!</h2>")
                    ngx.exit(403)
                end
            end
        end
    end
    return false
end

逻辑分析:

  1. 它会检查配置项 config.config_cookie_check 的值是否为 'on'
  2. 如果开启了检查,在获取到规则文件 cookie.rule 的内容后,它会获取当前请求中的 http_cookie 字段(即浏览器发送的 Cookies)。
  3. 将当前服务器的名称与用户的 Cookies 进行拼接,并存储在变量 USER_COOKIE 中。这是为了区分不同服务器的 Cookies。比如,如果存在多个服务器共享同一个域名,每个服务器会在 Cookies 前面添加自己的名称。
  4. 它会遍历 COOKIE_RULES 中的每一条规则。对于每一条非空的规则,使用 rulematch(USER_COOKIE, rule, "joi") 函数来检查用户的 Cookies 是否满足规则条件。如果满足条件,表示存在命中的恶意情况。
  5. 如果命中了恶意情况,首先会设置响应头的 Content-Type 为 text/html,并输出一段提示信息 <h2>页面注入的cookie有问题,请注意!</h2>
  6. 最后,通过 ngx.exit(403) 终止请求并返回 403 状态码,表示禁止访问。

演示:

ARGS请求参数过滤开关

限制规则

-- Args请求参数过滤
-- args是转码过的,/index.html?a=3&bb=23检查的是值3或23 /index.html?23模式会被忽略
-- 待匹配字符串示例:api-args
-- 规则示例:^api-/text/index.html
-- 注意,如果不指定主机头api,将对所有主机生效
-- 
../
:$
${
select.+(from|limit)
(?:(union(.*?)select))
sleep((\s*)(\d*)(\s*))
benchmark((.*),(.*))
base64_decode(
(?:from\W+information_schema\W)
(?:(?:current_)user|database|schema|connection_id)\s*(
(?:etc/\W*passwd)
into(\s+)+(?:dump|out)file\s*
group\s+by.+(
xwork.MethodAccessor
(?:define|eval|file_get_contents|include|require|require_once|shell_exec|phpinfo|system|passthru|preg_\w+|execute|echo|print|print_r|var_dump|(fp)open|alert|showmodaldialog)(
xwork.MethodAccessor
(gopher|doc|php|glob|file|phar|zlib|ftp|ldap|dict|ogg|data):/
java.lang
$_(GET|post|cookie|files|session|env|phplib|GLOBALS|SERVER)[
<(iframe|script|body|img|layer|div|meta|style|base|object|input)
(onmouseover|onerror|onload)=

实现

local config = require("config")
local rulematch = ngx.re.find
local unescape = ngx.unescape_uri

-- 请求参数检查
-- 匹配字段式样:api-3
-- ?a   不检查,REQ_ARGS => table;ARGS_DATA => boolean
-- ?a=3 检查的是3

function  _M.url_args_attack_check()
    if config.config_url_args_check == 'on' then
        local ARGS_RULES = _M.get_rule('args.rule')
        for _, rule in pairs(ARGS_RULES) do
            local REQ_ARGS = ngx.req.get_uri_args()
            for key, val in pairs(REQ_ARGS) do
                local ARGS_DATA = {}
                if type(val) == 'table' then
                    ARGS_DATA = table.concat(val, " ")
                else 
                    ARGS_DATA = val
                end

                if ARGS_DATA and type(ARGS_DATA) ~= "boolean" and rule ~= "" and rulematch(unescape(ngx.var.server_name.."-"..ARGS_DATA), rule, "joi") then
                    ngx.header["Content-Type"] = "text/html; charset=utf-8"
                    ngx.say("<h2>传递的参数有问题</h2>")
                    ngx.exit(403)
                end

            end
        end
    end
end

逻辑分析:

  1. 检查配置项 config.config_url_args_check 的值是否为 'on',表示是否开启了对 URL 参数的检查。
  2. 如果开启了检查,在获取到规则文件 args.rule 的内容后,它会遍历 ARGS_RULES 中的每一条规则。
  3. 通过 ngx.req.get_uri_args() 函数获取当前请求中的 URL 参数,并存储在变量 REQ_ARGS 中。
  4. 遍历 REQ_ARGS 中的每一个参数。对于每一个参数,首先会创建一个空的表 ARGS_DATA 用于存储参数的值。
  5. 判断参数的值的类型。如果是一个表(即参数有多个值),则通过 table.concat(val, " ") 将多个值连接成一个字符串,并保存在 ARGS_DATA 中。否则,直接将参数的值赋给 ARGS_DATA
  6. 判断 ARGS_DATA 是否存在、是否为布尔类型、规则是否为空,并使用rulematch(unescape(ngx.var.server_name.."-"..ARGS_DATA), rule, "joi") 函数来检查参数的值是否满足规则条件。如果满足条件,表示存在命中的恶意情况。
  7. 如果命中了恶意情况,首先会设置响应头的 Content-Type 为 text/html,并输出一段提示信息 <h2>传递的参数有问题</h2>
  8. 最后,通过 ngx.exit(403) 终止请求并返回 403 状态码,表示禁止访问。

演示:

POST过滤开关

限制规则

-- Cookie过滤
-- 待匹配字符串示例:api-nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; 
--                  kod_user_language=zh_CN; kod_user_online_version=check-at-1523267590; 
--                  kod_name=admin; kod_token=3e016b80ce1e7349ff371324a1c0f996, 
--                  client: 192.168.158.1, server: api, request: "GET /index.html HTTP/1.1", 
--                  host: "192.168.158.139"
-- 规则示例:^api-.*kod_name=admin;
-- 注意,如果不指定主机头api,将对所有主机生效
-- 
../
select.+(from|limit)
(?:(union(.*?)select))
sleep((\s*)(\d*)(\s*))
benchmark((.*),(.*))
base64_decode(
(?:from\W+information_schema\W)
(?:(?:current_)user|database|schema|connection_id)\s*(
(?:etc/\W*passwd)
into(\s+)+(?:dump|out)file\s*
group\s+by.+(
xwork.MethodAccessor
(?:define|eval|file_get_contents|include|require|require_once|shell_exec|phpinfo|system|passthru|preg_\w+|execute|echo|print|print_r|var_dump|(fp)open|alert|showmodaldialog)(
(gopher|doc|php|glob|file|phar|zlib|ftp|ldap|dict|ogg|data):/
java.lang
$_(GET|post|cookie|files|session|env|phplib|GLOBALS|SERVER)[
<(iframe|script|body|img|layer|div|meta|style|base|object|input)
(onmouseover|onerror|onload)=
 

实现

local config = require("config")
local rulematch = ngx.re.find

-- POST检查
-- 匹配字段式样:api-txt
-- multipart/form-data方式的数据,只要其中带有文件,则不检查
-- application/x-www-form-urlencoded二进制模式发送的文件会被检查
-- 日志中的post_data并非完整的POST数据

function  _M.post_attack_check() 
    if config.config_post_check == 'on' and ngx.req.get_method() ~= "GET" then
        ngx.req.read_body()
        local POST_RULES = _M.get_rule('post.rule')
        local POST_ARGS = ngx.req.get_post_args() or {}
        for _,rule in pairs(POST_RULES) do
            for k, v in pairs(POST_ARGS) do
                local post_data = ""
                if type(v) == 'table' then
                    if type(v[1]) == "boolean" then
                        return false
                    end
                    post_data = ngx.var.server_name.."-"..table.concat(v, ", ")
                elseif type(v) == "boolean" then
                    post_data = ngx.var.server_name.."-"..k
                else
                    post_data = ngx.var.server_name.."-"..v
                end

                if rule ~= "" and rulematch(post_data, rule, "jois") then
                    ngx.header["Content-Type"] = "application/json; charset=utf-8"
                    local obj = {error = true, msg = "请求参数有误!请重新检查哦~"}
                    local jsonStr = json.encode(obj)
                    ngx.say(jsonStr)
                    ngx.exit(403)
                    return true
                end

            end
        end
    end
    return false
end

逻辑分析:

  1. 通过判断配置项 config_post_check 是否为 "on",以及请求方法是否为 GET 方法,来确定是否进行检查。
  2. 若满足条件,则先读取请求体,并获取名为 POST_RULES 的规则列表,以及名为 POST_ARGS 的 POST 参数。
  3. 遍历规则列表 POST_RULES,对于每一个规则,再遍历 POST 参数 POST_ARGS 中的键值对。
  4. 在内层循环中,首先根据 POST 参数的类型,将其拼接成字符串 post_data,用于后续的匹配。
  5. 如果规则不为空且匹配成功(调用 rulematch 函数),则设置响应头的 Content-Type 为 "application/json; charset=utf-8",并返回一个 JSON 格式的错误消息给客户端。然后,使用 ngx.exit(403) 终止请求,并返回 true。
  6. 如果没有匹配到任何规则,最后返回 false。

演示:

工具方法

详细大家看完整个流程对于经常用到的util.lua的方法想要了解,下面罗列一下经常用到的函数

获取IP地址

function _M.get_client_ip()
    local CLIENT_IP = ngx.req.get_headers()["X_real_ip"]
    if CLIENT_IP == nil then
        CLIENT_IP = ngx.req.get_headers()["X_Forwarded_For"]
    end
    if CLIENT_IP == nil then
        CLIENT_IP = ngx.var.remote_addr
    end
    if CLIENT_IP == nil then
        CLIENT_IP = "0.0.0.0"
    end
    -- 判断CLIENT_IP是否为table类型,table类型即获取到多个ip的情况
    if type(CLIENT_IP) == "table" then
        CLIENT_IP = table.concat(CLIENT_IP, ",")
    end
    if type(CLIENT_IP) ~= "string" then
        CLIENT_IP = "0.0.0.0"
    end
    return CLIENT_IP
end

载入规则到本模块RULE_TABLE


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,"r")
        if file_rule_name ~= nil then
            for line in file_rule_name:lines() do
                --在规则文件中可以使用lua模式的注释
                if string.sub( line, 1, 2 ) ~= "--" then
                    -- string.gsub(s, "^%s*(.-)%s*$", "%1") 去除字符串s两端的空格
                    table.insert(t_rule, (string.gsub(line, "^%s*(.-)%s*$", "%1")))
                    ngx.log(ngx.INFO, string.format("规则名称:%s, 值:%s", rule_name, line))
                end
            end
        end
        file_rule_name:close()
        ngx.log(ngx.INFO, string.format("规则文件%s读取完毕!", rule_file))
        _M.RULE_TABLE[rule_name] = t_rule
    end
    return (_M.RULE_TABLE)
end

建立字典 规则类名称:规则文件路径

function _M.get_rule_files(rules_path)
    local rule_files = {}
    for _, file in ipairs(_M.RULE_FILES) do
        if file ~= "" then
            local file_name = rules_path .. '/' .. file
            ngx.log(ngx.DEBUG, string.format("规则:%s, 文件路径:%s", file, file_name))
            rule_files[file] = file_name
        end
    end
    return rule_files
end

有兴趣的可以看考源码看一下

其他

关于打印

在日志打印的过程中,如果当前的对象是nil类型,那么会导致整个函数或者说语句报错,可以采取以下的方法

--[[
  实例如下:
  local limit = ngx.shared.limit
 
  ngx.log(ngx.INFO, "测试打印:".. limit .. '\n\n\n\n') -- error
  ngx.log(ngx.INFO, "测试打印:".. table_to_string(limit) .. '\n\n\n\n') -- success
--]]

function table_to_string(t)
    local result = {}
    for k, v in pairs(t) do
        result[#result + 1] = k .. ": " .. tostring(v)
    end
    return "{" .. table.concat(result, ", ") .. "}\n"
end

本地启动报错:nginx: [warn] 1024 worker_connections exceed open file resource limit: 256

解决:stackoverflow.com/questions/1…

最后

  相信大家阅读了整篇的文章大致对整个Nginx-WAF的防护逻辑有了一定的了解,虽然上面整个流程实现起来不是很难但是在实际的操作中这只是WAF防护的冰山一角,不过能从以上的代码中也能管中窥豹,可见一斑。

好了,如果整个流程能帮助大家的话,欢迎点赞收藏,你的支持是我写作的动力!

参考文档