怎样封禁恶意 ip,避免服务器产生巨额流量费

From 清冽之泉
Jump to navigation Jump to search

有一天早上醒来,收到两条短信,一是说我的服务器流量耗尽,已产生欠费;二是说我的服务器已保护性关停。还好我的流量用尽就自动停机,要是自动续费,可能被吸血一万块,我都没知觉。

我观察了一天,发现恶意 ip 一天就消耗了我 30GB 流量,而我每个月总共才 tm 600G,照它们的吸血速度,我还差 300G 才够他们吸。我都没从我的网站赚到钱,自费购买服务器、域名,花费时间精力写文章,却不但为恶意 ip 打工,还要帮它们缴出站流量费,我又不是傻子。遂决定,封禁恶意 ip。

那怎样封禁恶意 ip,拯救自己的流量费?

所用工具

  • iftop 查看瞬时流量
  • vnstat 按日、按时查看消耗流量:vnstat -h -i eth0
  • robots.txt 爬虫协议
  • iptables 防火墙工具,利用它拒绝恶意 ip 连接:iptables -I INPUT 1 -s "$ip" -j DROP
  • iptables-save 查看已添加的封禁规则
  • awk 利用它获取字段,awk '{print $1}'
  • tail 利用它读取日志尾部内容:tail -n 100 access.log
  • sort 排序日志内容:sort -rn 降序排列,数字最大者靠前,reverse,因为默认 sort -n 会升序排列
  • uniq -c 统计相同行出现的次数,只留下一行,且在该行前加上次数
  • access.log 从访客日志里找线索
  • curl 测试封禁效果。查本机 ip:curl -s https://ifconfig.me
  • grep 过滤结果。统计某 ip 出现次数:tail -n 1000 /var/log/apache2/access.log | grep -c 1.2.3.4
  • wc 统计行数:wc -l

辨别坏蛋

请求数最多

sudo awk '{print $1}' /var/log/apache2/access.log | sort | uniq -c | sort -rn | head -n 30

# 示例输出
    1342 ::1
    416 51.161.86.195
    170 65.108.2.171
    164 37.27.51.140
    151 57.141.16.75
    150 195.201.199.99
    101 194.247.173.99
     82 147.135.214.103
     79 135.181.180.59
     73 122.97.68.77
     71 216.244.66.238
     61 57.141.16.83
     60 57.141.16.71
     60 57.141.16.63

流量用最多

sudo awk '$10 != "-" {print $1, $10}' /var/log/apache2/access.log \
| awk '{bw[$1]+=$2} END {for (ip in bw) printf "%.2f MB %s\n", bw[ip]/(1024*1024), ip}' \
| sort -rn \
| head -n 30

# 示例输出
15.69 MB 3.224.215.150
15.01 MB 3.212.205.90
14.21 MB 18.232.11.247
13.98 MB 34.206.212.24
13.57 MB 3.221.156.96
13.20 MB 3.227.180.70
13.11 MB 34.206.249.188
13.00 MB 34.226.89.140
12.48 MB 3.218.103.254
12.23 MB 18.232.36.1
11.97 MB 34.231.45.47
11.96 MB 54.152.163.42
11.95 MB 52.70.209.13
11.93 MB 3.213.85.234
11.72 MB 3.213.106.226
11.65 MB 52.44.174.136
11.56 MB 54.84.93.8

最频繁页面

sudo awk '$10 != "-" {print $7, $10}' /var/log/apache2/access.log \
| awk '{bw[$1]+=$2} END {for (u in bw) print bw[u], u}' \
| sort -rn \
| head -n 40 \
| while read size url; do
    decoded_url=$(printf '%b' "${url//%/\\x}")
    echo "$size $decoded_url"
  done

# 示例输出
65174752 /load.php?lang=en&modules=ext.SimpleMathJax,SimpleTooltip|jquery,oojs,oojs-ui-core|jquery.client,lengthLimit,textSelection|mediawiki.String,Title,api,base,cldr,cookie,htmlform,jqueryMsg,language,storage,user,util|mediawiki.editfont.styles|mediawiki.libs.pluralruleparser|mediawiki.page.ready|mediawiki.widgets.visibleLengthLimit|oojs-ui-core.icons,styles|oojs-ui.styles.indicators|skins.vector.legacy.js&skin=vector&version=kzx8c
24853627 /load.php?lang=en&modules=codex-search-styles,jquery,oojs,oojs-ui,oojs-ui-core,oojs-ui-toolbars,oojs-ui-widgets,oojs-ui-windows,site|ext.SimpleMathJax,SimpleTooltip|jquery.client,textSelection|mediawiki.String,Title,Uri,api,base,cldr,cookie,diff,experiments,jqueryMsg,language,router,storage,template,user,util|mediawiki.libs.pluralruleparser|mediawiki.page.ready|mediawiki.page.watch.ajax|mediawiki.template.mustache|mobile.init,startup|mobile.pagelist.styles|mobile.pagesummary.styles|oojs-ui-toolbars.icons|oojs-ui-widgets.icons|oojs-ui-windows.icons|skins.minerva.scripts&skin=minerva&version=1b0yp
19195748 /load.php?lang=en&modules=ext.SimpleMathJax,SimpleTooltip|jquery,oojs,oojs-ui,oojs-ui-core,oojs-ui-toolbars,oojs-ui-widgets,oojs-ui-windows,site|jquery.client,textSelection|mediawiki.String,Title,api,base,cldr,cookie,diff,jqueryMsg,language,storage,user,util|mediawiki.editfont.styles|mediawiki.libs.pluralruleparser|mediawiki.page.ready|oojs-ui-toolbars.icons|oojs-ui-widgets.icons|oojs-ui-windows.icons|skins.vector.legacy.js&skin=vector&version=5fbmc
11748862 /load.php?lang=en&modules=ext.visualEditor.core.utils.parsing|ext.visualEditor.desktopArticleTarget.init|ext.visualEditor.progressBarWidget,supportCheck,targetLoader,tempWikitextEditorWidget,track,ve&skin=vector&version=1dqsr

最频繁 UserAgent

sudo awk -F'"' '{print $6}' /var/log/apache2/access.log | sed '/^$/d' | sort | uniq -c | sort -rn | head -n 30

# 示例输出
   8257 Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Amazonbot/0.1; +https://developer.amazon.com/support/amazonbot) Chrome/119.0.6045.214 Safari/537.36
   6454 Sogou web spider/4.0(+http://www.sogou.com/docs/help/webmasters.htm#07)
   4369 meta-externalagent/1.1 (+https://developers.facebook.com/docs/sharing/webmasters/crawler)
   1342 Apache/2.4.62 (Debian) OpenSSL/3.0.15 (internal dummy connection)
   1188 Mozilla/5.0 (compatible; MJ12bot/v1.4.8; http://mj12bot.com/)
    654 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3
    324 Mozilla/5.0 (Linux; Android 7.0;) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; PetalBot;+https://webmaster.petalsearch.com/site/petalbot)
    279 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
    251 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
    243 Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0
    234 Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0

明确封禁

以下封禁方法,要多管齐下。

用爬虫协议封

# robots.txt 必须放在根目录,如 sth.com/robots.txt
# 聊胜于无,因为好爬虫不乱来,坏爬虫写了它们也不遵守
User-agent: Amazonbot
Disallow: /

User-agent: SogouSpider
Disallow: /

User-agent: meta-externalagent
Disallow: /

User-agent: *
Disallow:

用脚本封

以下脚本,组合起来才发挥作用。核心观念是,当在某阈值内,过度访问本站,就触发封禁。

以下脚本均需“执行”权限,例如:sudo chmod +x auto_ban.sh。

要注意各脚本的路径,仿写时请自行修改。

把恶意 ip 加入黑名单

#!/bin/bash
# catch_bad_ip.sh
set -euo pipefail

LOG="/var/log/apache2/access.log"
BAN_FILE="/ban/ban.txt"

LINES=1000        # 只看最近多少行日志
THRESHOLD=300     # 命中次数阈值

echo "[$(date '+%F %T')] scanning last $LINES lines of access.log"

# 下列 url 后的 ~ 是 awk 语言中“匹配”的含义
tail -n "$LINES" "$LOG" \
| awk '
{
    ip  = $1
    url = $7

    # 只统计 MediaWiki 高消耗接口
    if (url ~ /(load\.php|api\.php|index\.php)/) {
        count[ip]++
    }
}
END {
    for (ip in count) {
        if (count[ip] > '"$THRESHOLD"') {
            print count[ip], ip
        }
    }
}
' \
| sort -rn \
| while read -r hits ip; do

    # 跳过本地
    [[ "$ip" == "127.0.0.1" || "$ip" == "::1" ]] && continue

    # 已封的跳过
    grep -qw "$ip" "$BAN_FILE" && continue

    echo "# auto catched at $(date '+%F %T') hits=$hits" >> "$BAN_FILE"
    echo "$ip" >> "$BAN_FILE"

    echo "[$(date '+%F %T')] have written $ip ($hits hits) to $BAN_FILE"
done

按黑名单封禁

#!/bin/bash
# ban_bad_ip.sh
set -euo pipefail

BAN_FILE="/ban/ban.txt"
IPT="/usr/sbin/iptables"

# 确保是 root
if [[ $EUID -ne 0 ]]; then
  echo "Must run as root"
  exit 1
fi

# 确保 iptables 存在
if [[ ! -x "$IPT" ]]; then
  echo "iptables not found"
  exit 1
fi

# 遍历 ban 列表
while IFS= read -r ip || [[ -n "$ip" ]]; do
  [[ -z "$ip" || "$ip" =~ ^# ]] && continue

# 这里是核心,封禁前要比对旧规则,若从前加过了,就不要再次加了
# 若 check_ip 是单 ip,就在比较时加 /32,这样能比较出单 ip,如 1.2.3.4
# 若 check_ip 是网段,就不管它直接比较,这样能比较出网段,如 1.2.3.0/24
check_ip="$ip"
  if [[ ! "$ip" =~ / ]]; then
      check_ip="$ip/32";
  else
      check_ip="$ip";
  fi

# 如果规则不存在才插入
  if ! $IPT -C INPUT -s "$check_ip" -j DROP 2>/dev/null; then
    echo "[$(date '+%F %T')] iptables DROP $ip"
    $IPT -I INPUT 1 -s "$ip"  -j DROP
  fi
done < "$BAN_FILE"

清理日志

如果长年累月不关注日志,它可能过大。那么大于 2M 就给它删了。

#!/bin/bash
# clean_log.sh

# 定义文件路径和大小阈值
BAN_LOG="/ban/ban.log"
SIZE_LIMIT=2097152  # 2M in bytes

# 检查 ban.log 文件是否存在并大于 2M
if [ -f "$BAN_LOG" ] && [ $(stat -c%s "$BAN_LOG") -gt $SIZE_LIMIT ]; then
    echo "[$(date '+%F %T')] 清空 ban.log. (大小: $(ls -hl "$BAN_LOG" | awk '{print $5}'))." > "$BAN_LOG"
fi

定时运行

#!/bin/bash
# auto_ban.sh

# 定时运行前,先随机在 60*9 = 540 秒内睡一觉。这样可以制造随机运行效果,免得被恶意 ip 轻易发现规律
# RANDOM 除以 541 取余数,必是 540 以内的某个数,这保证了脚本随机睡眠的时间
# RANDOM 会生成一个0到32767之间的随机整数,这是行规
sleep $((RANDOM % 541))
/ban/catch_bad_ip.sh
/ban/ban_bad_ip.sh
/ban/clean_log.sh

最后,用 crontab -e 安装定时任务,添加 */30 * * * * bash /ban/auto_ban.sh >> /ban/ban.log 2>&1。表示每 30 分钟运行一次 auto_ban.sh。

用配置封

# 屏蔽坏 UA
# 加入 apache conf 文件里
SetEnvIfNoCase User-Agent "Amazonbot/0.1" bad_ua
SetEnvIfNoCase User-Agent "^$|^-$" bad_ua
SetEnvIfNoCase User-Agent "Sogou" bad_ua

# 注意下边 Location 后的 / 表示根目录,不是写错了
<Location />
<RequireAll>
Require all granted
Require not env bad_ua
</RequireAll>
</Location>

然后 sudo systemctl reload apache2 即可。

确认成效

以上是封了,但到底有没有效果呢?

短效

查看短期效果,用命令 vnstat -h -i eth0 即可。

长效

以下脚本可以检验并记录出站流量(入站不收流量费)的长期消耗情况。记得用 crontab -e 安装定时任务,添加 0 * * * * bash /root/log_outbound_hourly.sh,使它每小时运行一次。

#!/usr/bin/env bash
set -euo pipefail

# 文件位置(使用绝对路径,cron 下更可靠)
STATE="$HOME/.outbound_bytes_prev"
LOG="$HOME/save.txt"

# 尝试识别默认出站接口(最常用的方法)
iface=$(ip route 2>/dev/null | awk '/default/ {for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')

# 如果没有默认路由(很少见),退回到把所有非-loopback 接口合并
if [ -z "$iface" ]; then
  sum=0
  for f in /sys/class/net/*; do
    name=$(basename "$f")
    [ "$name" = "lo" ] && continue
    # 排除明显的虚拟/桥接接口(可按需调整)
    case "$name" in
      docker*|veth*|br-*|virbr*|lxcbr*|tun*|tap*|wg* ) continue ;;
    esac
    if [ -r "/sys/class/net/$name/statistics/tx_bytes" ]; then
      v=$(cat "/sys/class/net/$name/statistics/tx_bytes")
      sum=$((sum + v))
    fi
  done
  current=$sum
else
  # 读取该接口的 tx_bytes(累计)
  if [ ! -r "/sys/class/net/$iface/statistics/tx_bytes" ]; then
    echo "Error: can't read tx_bytes for interface '$iface'." >&2
    exit 1
  fi
  current=$(cat "/sys/class/net/$iface/statistics/tx_bytes")
fi

# 如果没有上一次记录:写入 state 然后退出(第一次运行没有可比较的前值)
if [ ! -f "$STATE" ]; then
  echo "$current" > "$STATE"
  # 不写日志——因为无法计算小时差
  exit 0
fi

prev=$(cat "$STATE")

# 计算差值(考虑 counter 重置的简单处理)
if [ "$current" -ge "$prev" ]; then
  diff=$((current - prev))
else
  # 计数器可能在重启或 wrap 后被重置,认为本次差值就是 current(保守处理)
  diff=$current
fi

# 转换为 MB(四舍五入到整数 MB)
mb=$(awk -v b="$diff" 'BEGIN{printf "%d", (b/1024/1024 + 0.5)}')

# 时间段:假设脚本在整点运行(cron 0 * * * *)
end_time=$(date "+%H:00")
start_time=$(date -d "1 hour ago" "+%H:00")

# 追加到日志
printf "%s - %s %sMB\n" "$start_time" "$end_time" "$mb" >> "$LOG"

# 更新 state
echo "$current" > "$STATE"

示例输出:

04:00 - 05:00 80MB
05:00 - 06:00 75MB
06:00 - 07:00 98MB
07:00 - 08:00 96MB
09:00 - 10:00 180MB
10:00 - 11:00 28MB
11:00 - 12:00 92MB
12:00 - 13:00 66MB
13:00 - 14:00 81MB
14:00 - 15:00 100MB
15:00 - 16:00 149MB
16:00 - 17:00 215MB
17:00 - 18:00 90MB
18:00 - 19:00 88MB
19:00 - 20:00 85MB

封禁前一小时 1GB 多流量。封禁后一小时 200 MB 左右,甚至低于 100MB。成效显著。

直观

直接看一下,最近一万条访问,返回码分别是什么。

tail -10000 /var/log/apache2/access.log | awk '{print $9}' | sort | uniq -c | sort -rn

# 示例输出
   4332 200
   3725 403
   1163 404
    418 302
    329 301
     13 304
     13 "-"
      6 400
      1 206

其他知识

URL 中的 % 号特殊字符一般含义如下:

  • %2C = ,(逗号)
  • %2F = /(斜杠)
  • %7C = |(竖线)
  • %3A = :(冒号)
  • %3F = ?(问号)

用 ufw 前先开 ssh 端口,别把自己关门外了。

  • sudo ufw allow ssh
  • sudo ufw allow 80/tcp
  • sudo ufw allow 443/tcp

ufw 适合开端口,iptables 更适合封禁 ip,未来是属于 nftables 的。但我眼前先解决被滥刷的问题,先不研究 nftables 了。