0x01 前言

我博客的静态资源托管在腾讯CDN,得益于前后端分离的架构,CDN服务器可以直接与后端服务器通讯以获取最新的静态文件。

但不能排除后端服务器被爬虫或访客有意识地访问,因为正常情况下是不会有人主动访问后端服务器的,所以可以大胆地假设:所有非CDN服务器的访问都是非法访问。

同时因为托管静态文件的后端服务器为腾讯云的VPS,所以公网流量会产生高昂的费用。

为此,我决定使用fail2ban对非腾讯CDN服务器的IP进行封禁。

如果你没有接触过fail2ban,我建议先阅读以下文章:

0x02 准备

腾讯CDN服务器的IP肯定是无时无刻都在变化的,但有一个比较明显的特征:

上图中所有的IP几乎都是腾讯云CDN的IP,因为cn2这台服务器仅允许CDN服务器访问。然后通过浏览器访问其中一个IP后,会发现:

响应内容是这样的,而响应头是这样的:

虽然特征比较明显,但直接取值,肯定是不行的,可信度会大大地降低。

但腾讯云提供了CDN IP的查询页面:

如果每次都手动检测,也是比较头疼的一件事,毕竟IP众多,另外手动添加黑名单也不靠谱,效率太低。

其实以上说的都是废话,这种事情肯定需要调用API,在这里我依然选择python SDK,因为我只会python。

接口的相关说明请浏览以下页面:

在这里有一点需要注意的:我写这篇文章和撰写脚本的时候,腾讯云有两个版本的SDK:

  • 新版:腾讯云开发者工具套件(SDK)3.0
  • 旧版:QcloudApi

新版仅完成了常用的一小部分服务的API调用功能,所以在这里我依旧使用QcloudApi这个旧版的SDK。

另外,新旧版SDK都是支持python3的,所以我会用python3编写脚本。

在继续往下之前,我们需要先安装相关的python模块:

[root@cn2 ~]# pip3 install qcloudapi-sdk-python

完成后即可继续往下:

0x03 脚本

首先需要定义各种预设的变量:

# 模块
module = 'cdn'

# 接口
action = 'QueryCdnIp'

# 账户信息
secret_id = ''
secret_key = ''

# 定义config字典
config = {
    'Region': 'gz',
    'secretId': secret_id,
    'secretKey': secret_key,
    'method': 'get'
}

# 定义固定参数,传入IP地址
params = {
    'ips': client_ip
}

因为是自定义脚本,所以需要符合fail2ban的相关动作需求:

  • banip
  • unbanip
  • start
  • stop

基本的动作有以上四个。因为需要操作iptables,所以这四个动作有包含相应的iptables命令。

而banip比较特殊,包含校验访客IP是否为腾讯云CDN的相关脚本。

0x03.1 banip

以下为banip动作的相关脚本:

    service = QcloudApi(module, config)
    qcloud_output = service.call(action, params).decode()
    qcloud_output = json.loads(qcloud_output)

# 校验API调用是否正常
    if qcloud_output['code'] == 0:
        pass
    else:
        error_msg = 'pub_error_code: ' + str(qcloud_output['code']) + ' error_msg: ' + qcloud_output['message']
        print(error_msg)
        sys.exit(0)

# 校验API调用是否正常
    if qcloud_output['codeDesc'] == 'Success':
        pass
    else:
        error_msg = 'codeDesc: ' + str(qcloud_output['code']) + ' error_msg: ' + qcloud_output['message']
        print(error_msg)
        sys.exit(0)

# 校验是否为腾讯CDN的IP
    if qcloud_output['data']['list'][0]['platform'] == 'no':
        f2b_cmd_1 = '/usr/sbin/iptables -I f2b-' + jail_name + ' 1 -s ' + client_ip + ' -j DROP'

    else:
        f2b_cmd_1 = '/usr/bin/fail2ban-client set ' + jail_name + ' unbanip ' + client_ip

首先调用腾讯云的API,腾讯云返还的内容如下:

{
  "codeDesc": "Success", 
  "message": "", 
  "data": {
    "last_update_time": 1526393225, 
    "list": [
      {
        "prov_name": "上海", 
        "platform": "yes", 
        "status": "on", 
        "isp_name": "联通", 
        "pid": 200111, 
        "ip": "140.207.120.100"
      }
    ]
  }, 
  "code": 0
}

如果用户参数异常,则code会返还非0数字,所以在调用API后需要校验。

然后需要校验codeDesc的value是否为Success,如果不是,则表示业务侧出现了错误,这个有可能是没有传递正确的IP或传递了空IP。

最后检查platform这个key是否为yes,如果不是,则调用iptables封禁IP。

其实还需要校验status这个key的,如果为off,则表示CDN节点处于停用状态,这时候也应该进行封禁。但目前没有加上这个key的校验,计划在下个版本中进行修正。

最后有两个命令:

f2b_cmd_1 = '/usr/sbin/iptables -I f2b-' + jail_name + ' 1 -s ' + client_ip + ' -j DROP'

f2b_cmd_1 = '/usr/bin/fail2ban-client set ' + jail_name + ' unbanip ' + client_ip

第一行是调用iptables封禁IP的,第二行是将该IP的封禁记录从fail2ban数据库中删除,这个我们放到后面讲。先来解释第一行的iptables命令:

  • /usr/sbin/iptables:可执行二进制文件的绝对路径
  • -I:插入到链的第一行
  • f2b-‘ + jail_name + ‘:以“f2b-”为前缀,加上jail名字组成链的名称
  • 1:指定该规则的序号,默认就是1
  • -s:来源IP
  • -j:采取的动作

整合到一起的意思是:在’f2b-‘ + jail_name这个链的第一行插入拒绝来源IP为client_ip的规则。

例如:

/usr/sbin/iptables -I f2b-log-ngx-20x 1 -s 1.1.1.1 -j DROP

0x03.2 unbanip

f2b_cmd_1 = '/usr/sbin/iptables -D f2b-' + jail_name + ' -s ' + client_ip + ' -j DROP'

unbanip的内容比较简单,就是一行iptables调用命令。

0x03.3 start

启动与停止的命令正好相反,启动动作包含以下内容:

    f2b_cmd_1 = '/usr/sbin/iptables -N f2b-' + jail_name
    f2b_cmd_2 = '/usr/sbin/iptables -A f2b-' + jail_name + ' -j RETURN'
    f2b_cmd_3 = '/usr/sbin/iptables -I INPUT -j f2b-' + jail_name

以上命令的含义如下:

  • f2b_cmd_1:建立名称为f2b- + jail_name的自定义链
  • f2b_cmd_2:在f2b- + jail_name链中添加RETURN动作
  • f2b_cmd_3:在INPUT链中添加名称为f2b- + jail_name的默认动作

第一行比较容易理解,就是建立一个名称独一无二的链,用于存放该jail被ban的IP;

第二行得意思是,如果在自定义的链中没有命中规则,则返回珠链,也就是INPUT。因为iptables匹配的顺序是子链—>父链—>缺省,所以通过RETURN就可以将数据包丢给父链,在这里是INPUT。

第三行的意思是接收到子链过来的数据包后,需要进行的动作,如果匹配上链名称,则根据定义的动作进行操作,如果没有指定动作,则按默认的动作进行操作。

执行完上面的三条规则后,iptables会有以下内容:

Chain INPUT (policy ACCEPT 515K packets, 31M bytes)
 pkts bytes target     prot opt in     out     source               destination         
  21M   26G f2b-qcloud-cdn-ip-check  all  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 19M packets, 26G bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain f2b-qcloud-cdn-ip-check (1 references)
 pkts bytes target     prot opt in     out     source               destination         
  21M   26G RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0

在这里就不深入探讨iptables相关的内容了,如果有时间,我会另外撰写文章。

0x03.4 stop

以上是启动的命令,如果重启或停止fail2ban,是需要将被ban的IP一个个拉出来,然后删除自定义的链。所以命令如下:

    f2b_cmd_1 = '/usr/sbin/iptables -D INPUT -j f2b-' + jail_name
    f2b_cmd_2 = '/usr/sbin/iptables -F f2b-' + jail_name
    f2b_cmd_3 = '/usr/sbin/iptables -X f2b-' + jail_name

停止和启动的命令是相反的,首先需要删除INPUT链中相关的规则;然后清空自定义链中的所有规则;最后删除自定义链。

0x03.5 调用

为了方便调用,我将命令组合成列表,然后使用循环进行调用:

f2b_cmd_list = [f2b_cmd_1, f2b_cmd_2, f2b_cmd_3]

for i in f2b_cmd_list:
    if i is None:
        break
    else:
        cmd_output = subprocess.call(i, shell=True)
        print(cmd_output)

如果命令为空,则停止循环,这样可以保证各个命令得以运行。

0x04 fail2ban

准备好脚本后,我们将它放置在一个地方:

/usr/local/shell/qcloud_cdn_ip_check.py

然后建立jail:

[qcloud-cdn-ip-check]
enabled = true
port = http,https
filter = qcloud-cdn-ip-check
logpath = /var/log/nginx/access.log
action = qcloud-cdn-ip-check[name=%(__name__)s]
         %(mta)s-whois[name=%(__name__)s, dest="%(destemail)s"]
maxretry = 2
findtime = 1800
bantime = 86400
ignoreip = 127.0.0.1

再建立一个filter:

[root@cn2 ~]# cat /etc/fail2ban/filter.d/qcloud-cdn-ip-check.conf 
[Definition]
failregex = <HOST> - - \[.*\] \".*\" cdn\.enginx\.cn .* \d*\s\"
ignoreregex =

以上filter仅筛选日志中cdn.enginx.cn这个域名的访客IP。

最后建立action:

[root@cn2 ~]# cat /etc/fail2ban/action.d/qcloud-cdn-ip-check.conf 
[INCLUDES]

[Definition]

actionstart = /usr/bin/python3 /usr/local/shell/qcloud_cdn_ip_check.py start None <name>

actionstop = /usr/bin/python3 /usr/local/shell/qcloud_cdn_ip_check.py stop None <name>

actioncheck = /usr/sbin/iptables -n -L INPUT | grep -q 'f2b-<name>[ \t]'

actionban = /usr/bin/python3 /usr/local/shell/qcloud_cdn_ip_check.py banip <ip> <name>

actionunban = /usr/bin/python3 /usr/local/shell/qcloud_cdn_ip_check.py unbanip <ip> <name>

[Init]

可以看到脚本后面跟着三个参数,方便识别各种动作、传递IP与传递jail名称。

一切准备就绪后,即可启动fail2ban,然后等待恶意访客上门。

0x05 结语

经过一周的使用,效果良好。但该API接口限制每分钟请求不超过一千次,在受到攻击时可能会超过限制,在后期计划建立一个本地json的缓存。

最后,完整的脚本可以在我的私有gitlab中找到: