0. 先交代一下背景

手里有吃灰 1C1G 甚至 1C512M 低配公网 VPS 的老哥,应该都对系统日志里的“壮观景象”不陌生:新机器刚开机挂上公网 IP 没半小时,journalctl 和 /var/log/auth.log 就被全网肉鸡的扫网脚本直接灌爆。Mirai 变种扫 SSH 弱密码、自动爬虫全天候遍历 /.env 和 /wp-login.php,网卡软中断和下行带宽天天被白嫖 :sakta_emoji_27:。

社区里经常有兄弟问:“要不要整套开源蜜罐(比如 Cowrie、Hfish 之类)抓抓攻击画像?”

哥们劝你先别急着折腾重型方案。在小内存鸡上跑重型高交互蜜罐,基本属于“没挨过社会的毒打”:

  1. 资源刺客与 OOM 隐患:带 Web 管理面板、伪造完整 Shell 环境与各类复杂服务的容器镜像,常驻内存随随便便吃掉 300MB~500MB。碰到全网高频并发扫描,fork 几个虚拟进程就能把小鸡的物理内存直接干爆触发内核 OOM Killer;
  2. 借刀杀人的跳板风险:高交互蜜罐逻辑复杂、依赖庞大,一旦蜜罐自身容器或虚拟环境出现逃逸漏洞,或者出网规则没焊死,你的 VPS 当场就会沦为攻击者扫别人的跳板肉鸡,机房直接给你拔网线送违规封号大礼包。

今天不整虚的,老哥带大家用几十行原生 Python asyncio 搓一个内存占用 < 20MB 的轻量低交互诱捕 Facade,联动内核态 nftables 动态老化集合 [S2][S4],实现“探针一露鸡脚,当场打进黑洞”的秒级熔断流水线。


1. 诱饵设计哲学:Facade 模式与极简指纹捕获

低交互诱捕的核心是 Facade(门面)模式:我们只在 TCP 握手与协议初始协商层演戏,绝不给攻击者提供任何可执行代码或可下发 payload 的真实执行环境。既白嫖了对方的攻击指纹,又将自身暴露面收敛到极致。

MERMAID · 图表源代码
flowchart TD
    A["公网未过滤恶意扫描流量"] --> B{"内核 nftables 黑名单集合?"}
    B -->|已在 @bad_guys 集合中| C["内核态硬丢弃 (drop)"]
    B -->|首次探测连接| D["轻量 asyncio 诱捕 Facade"]
    D --> E{"协议入口分流"}
    E -->|端口 2222: SSH 探针| F["假握手采集 Client Banner 后立即断开"]
    E -->|端口 8080: HTTP 探针| G["采集物理线序 Wire Order 与恶意 Payload"]
    F --> H["异步触发 nftables set 动态入库"]
    G --> H
    H --> I["写入内核 @bad_guys (timeout 86400s)"]
    I --> J["后续所有端口探测直接进黑洞"]

1.1 SSH 假握手:只取指纹,绝不回车

常规生产环境中,请先把真实 SSH 挪到高位随机端口(配合公钥鉴权),将默认的 22 端口(或通过端口转发映射的假端口)留给诱捕器。

扫描器的 SSH 爆破模块有个铁律:建立 TCP 连接后,服务端先发一行协议版本 Banner(如 SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1\r\n),客户端必须回应其自身的识别串(例如 SSH-2.0-libssh2_1.9.0 或各类 Go/Paramiko 扫描器标识)。

我们的诱捕器只需做到:吐出 Banner -> 读出对端的客户端标识字符串 -> 立即主动关闭 Socket。全程不进入密码交互流程、不分配虚拟终端、不消耗多余内存,甚至不给对方尝试密码的机会,指纹到手直接拉黑。

1.2 HTTP 贪婪采集:保留原始物理线序(Wire Order)

很多自动化探针在扫描漏洞时,会在请求头中夹带特定爬虫库特征。正如之前在协议伪装对抗中总结的,请求头的物理线序(Wire Order)与异常键值排布才是最真实的指纹。诱捕端不需要返回花哨的网页,只需读完第一批数据包、抓出请求行与 Raw Header,返回精炼的 404 Not Found 并掐断连接。


2. 单文件原生 asyncio 诱捕引擎实现

不依赖第三方框架,仅使用 Python 3.8+ 标准库。常驻内存约 14MB~18MB,支撑几十并发探针毫无压力:

python
#!/usr/bin/env python3
import asyncio
import logging
import subprocess

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.FileHandler("/var/log/honeypot.log"), logging.StreamHandler()]
)

NFT_SET_NAME = "bad_guys"
NFT_TIMEOUT = "86400s"  # 封禁 24 小时

def ban_ip_kernel(ip: str):
    """通过 nftables 动态集合注入内核黑名单"""
    cmd = ["nft", "add", "element", "ip", "filter", NFT_SET_NAME, f"{{ {ip} timeout {NFT_TIMEOUT} }}"]
    try:
        subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        logging.info(f"[BANNED] IP {ip} 已打入内核黑洞集")
    except Exception as e:
        logging.error(f"nftables 下发失败: {e}")

async def handle_ssh(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
    peer = writer.get_extra_info("peername")
    ip = peer[0] if peer else "unknown"
    try:
        # 伪装 Ubuntu 官方 OpenSSH Banner
        writer.write(b"SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1\r\n")
        await writer.drain()
        
        # 严格 5 秒超时,防 Slowloris 悬挂
        client_banner = await asyncio.wait_for(reader.readline(), timeout=5.0)
        logging.info(f"[SSH-PROBE] 来自 {ip} | 指纹: {client_banner.strip().decode('latin1', 'ignore')}")
        ban_ip_kernel(ip)
    except Exception:
        pass
    finally:
        writer.close()
        await writer.wait_closed()

async def handle_http(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
    peer = writer.get_extra_info("peername")
    ip = peer[0] if peer else "unknown"
    try:
        # 限制读取前 4KB,防止恶意大报文消耗缓冲区
        raw_request = await asyncio.wait_for(reader.read(4096), timeout=5.0)
        if raw_request:
            first_line = raw_request.split(b"\r\n")[0].decode("latin1", "ignore")
            logging.info(f"[HTTP-PROBE] 来自 {ip} | 探针载荷: {first_line}")
            ban_ip_kernel(ip)
        
        response = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
        writer.write(response)
        await writer.drain()
    except Exception:
        pass
    finally:
        writer.close()
        await writer.wait_closed()

async def main():
    # 生产中可用高位端口或直接监听对外伪装端口
    server_ssh = await asyncio.start_server(handle_ssh, "0.0.0.0", 2222)
    server_http = await asyncio.start_server(handle_http, "0.0.0.0", 8080)
    logging.info("诱捕 Facade 服务已启动,开始捕获恶意探针...")
    async with server_ssh, server_http:
        await asyncio.gather(server_ssh.serve_forever(), server_http.serve_forever())

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass


3. 联动内核黑洞:nftables 动态老化集合实战

早期防御常用 Fail2ban 轮询正则扫日志,再一条条跑 iptables -A INPUT -s ... -j DROP。在 1C1G 小鸡上,这套方案又慢又笨重:Fail2ban 扫日志 CPU 飙高,而 iptables 规则链超过上千条后,每来一个数据包都要线性遍历,软中断直接爆炸。

现代化 Linux 防火墙的首选是 nftables 命名集合(Named Set) [S1][S3]。通过声明 flags timeout,黑名单直接由内核维护过期时钟,到期自动剔除,完全不需要用户态跑定时清理脚本 [S2]:

3.1 创建内核黑名单规则表

bash
# 1. 确保基础 filter 表与 input 链就位 [S1][S3]
sudo nft add table ip filter
sudo nft add chain ip filter input { type filter hook input priority 0 \; policy accept \; }

# 2. 创建带自动超时老化机制的动态集合 [S2][S4]
sudo nft add set ip filter bad_guys '{ type ipv4_addr; flags timeout; timeout 86400s; }'

# 3. 将黑名单封禁规则插入到 input 链的最前列 [S3]
sudo nft insert rule ip filter input ip saddr @bad_guys drop

一旦探针触发了上面的 asyncio 诱捕逻辑,执行 nft add element ip filter bad_guys { <IP> timeout 86400s } 仅需毫秒级 [S2][S4]。在内核态,集合底层是高效哈希或基数树匹配,哪怕里面挂着几万个扫描源,匹配复杂度也是 O(1)O(1) 级别,对 CPU 几乎零负载 [S2][S3]。

通过命令随时可以查验内核当前阻断的残留时间 [S2]:

bash
sudo nft list set ip filter bad_guys

输出中会清晰显示每个被抓 IP 的剩余封禁秒数(如 elements = { 198.51.100.23 timeout 23h58m12s }),一目了然 [S2]。


4. 生产避坑指南:乐子人老哥的四条血泪军规

很多同学搭诱捕容易“图一时爽”,结果上线两天小鸡先瘫了。注意以下四处死穴:

4.1 锁死 Read 超时,严防 Slowloris 慢速挂死

小黑子业务挺熟的,不少网络扫描工具(甚至专门针对轻量蜜罐的脚本)不会一口气发完握手包,而是每隔 10 秒发 1 个字节。如果你的代码写成裸的 await reader.readline(),几百个僵尸连接就能把你的 Linux 文件描述符(FD)和协程队列耗尽。必须像示例中一样焊死 asyncio.wait_for(..., timeout=5.0),超时直接掐断回收。

4.2 严禁原样回显报文(防止沦为反射放大源)

遇到二进制探针或未知 UDP 探针时,千万不要自作聪明地做 Echo(原样反弹)测试!UDP/DNS/NTP 探针经常伪造源 IP(Spoofed IP),如果你把接收到的 Payload 重新包装发回去,你的机器立马就会变成 DDoS 放大反射攻击里的帮凶。

4.3 Logrotate 轮转必须配置,防止单夜爆盘

公网被自动化扫描群盯上时,一天产出几百万条日志稀松平常。1C1G VPS 的系统盘通常只有 10G~20G,若日志没限额,一个周末就能把磁盘写到 100%,导致系统连 SSH 都无法分配缓冲区。

在 /etc/logrotate.d/honeypot 加上硬性轮转限制:

text
/var/log/honeypot.log {
    daily
    rotate 7
    missingok
    notifempty
    compress
    maxsize 50M
}

4.4 集合容量兜底(size 限制)

如果在恶意扫描极其密集的公网段,为了防止动态集合占用过多内核内存,可以在声明 nftables set 时显式指定 size 上限(例如 size 65535)[S2]。当扫描量超过阈值时,结合合理的短寿命周期自动更替,确保防御体系永远处于算力可控的安全区 [S2]。


5. 总结

网络攻防不需要上来就堆重型架构。低配 VPS 防御的核心在于“用最小代价让对方付出最大成本”:

  • 抛弃臃肿高交互容器,单文件原生 asyncio 跑 Facade 假握手;
  • 握手采集完关键特征后立即断连,不吃 CPU,不漏 Shell;
  • 借力内核态 nftables 动态集合 [S2][S4],把后续的所有恶意请求在网卡入口直接丢进黑洞。

露出鸡脚的探针当场掐死,自留地的小鸡自然跑得清爽又安生 :sakta_emoji_30:。

参考资料