Linux - 通过 Port Knocking 技巧加固 SSH

2025 06 30, Mon

有段时间没有写 post 了,要养娃 >_<

这篇博客主体由我编写,由AI润色。

第一次云主机被爆是很久以前的事情,但是让人印象深刻。今天不讨论爆破的事情,只讨论怎么保护端口。

《加固 Linux》Series:

SSH 的使用场景,现有方案

要保护端口,我们需要考虑 SSH 的使用场景。

  • 交互操作: 直接 ssh 主机,登陆完成交互式操作
  • 自动化任务: 由 ansible / fabric 这样的工具自动化 ssh 登陆,并完成一系列任务
  • sftp: 文件存储,ssh后调用sftp子系统

现有的保护方案:

  • 纯 key 登陆: 基本上完美,除了担心 ssh 本身遭遇 xz-utils 的工具那样的供应链攻击,验证 key 也仍然会消耗资源。另外个别时候需要给其他人提供密码登陆方案,这个时候还是会产生攻击面。
  • fail2ban: 仍然会消耗一些资源。容易误触发。
  • 防火墙限制 IP: 大部分的网络环境无法预知 IP。需要经常更新规则。
  • 修改端口: 比较简单的办法,缺点是有的时候配置文件需要分发给所有的节点。
  • 2fa: 新一点的 Linux 自带 google authenticator pam 模块,可以做二次验证,这个暂时不知道和自动化工具的集成会不会冲突。

这些方案都比较简单好配置,但是也有自己的缺点,就是还是有攻击面。所以引出我们接下来的话题,一个让你的 ssh 没有攻击面的技巧。

Port Knocking

Port Knocking 就是像是对暗号一样,通过一系列动作打开端口,常见的是通过按顺序向特定的端口发送数据包,从而对源地址打开端口。

  sequenceDiagram
    participant 客户端
    participant 服务器

    Note over 客户端,服务器: 服务器初始不允许到 22 端口的 tcp syn
    客户端->>服务器: 访问 22 端口 (TCP SYN)
    服务器-->>客户端: 拒绝连接 (ICMP Reject)

    Note over 客户端,服务器: Port Knocking 开始
    客户端->>服务器: 访问端口 P1
    客户端->>服务器: 访问端口 P2
    Note over 客户端,服务器: ...
    
    Note over 服务器: 服务器接受 knock, 添加临时规则允许 src_ip->dst_ip:22, 并启动计时器
    
    客户端->>服务器: 再次访问 22 端口 (TCP SYN)
    服务器-->>客户端: 接受连接 (TCP SYN/ACK)
    客户端->>服务器: (TCP ACK)
    Note over 客户端,服务器: SSH 连接成功建立

    Note over 服务器: 计时器超时, 服务器删除临时规则, 恢复安全配置
  • 服务器初始不允许到 22 端口的 tcp syn,只允许已经建立连接的连接
    • 客户端访问 22 端口(tcp syn)
    • 被服务器拒绝(icmp reject)
  • port knocking
    • 客户端访问 P1
    • 客户端访问 P2
    • 服务器接受客户端所在的 src1 到 dst:22 的 syn,定时删除这条规则
    • 客户端访问 22 端口
    • 客户端成功建立连接
    • 服务器删除规则,恢复安全配置

在这样的配置下,你的 ssh 端口日常是不暴露给外界的,只有端口打开的几秒你自己可以访问 22。

对于 ssh 来说,在建立连接前执行命令也并不是难事儿,ssh 支持 ProxyCommand,我们只需要实现一个简单的脚本,然后配置给 ssh 即可。

#!/usr/bin/env bash

# 定义敲门的端口序列
# 这个序列必须和服务器防火墙规则里期待的顺序一致
knocking_seq=(123 456 789)

for port in "${knocking_seq[@]}"; do
    # -z 参数表示使用 "zero-I/O" 模式,只建立连接不传输数据
    # 我们只需要向端口发送一个 SYN 包即可
    timeout ${PK_TIMEOUT:-1} ncat -z "$1" "$port"
done

# 敲门结束后,执行真正的 ncat 来建立 SSH 连接
exec ncat "$1" "$2"
Host some-host
  ProxyCommand port-knocking-proxy %h %p

剩下的问题就是如何打开端口了。

服务器实现

由服务提供的 port knocking

有一些服务提供了这样的能力,大都是通过 pcap 绕过防火墙、直接读取网卡上的包,根据规则触发行为。

常见的包括:

  • knockd
  • fwknop

fwknop 甚至可以做一些复杂的配置,比如说配置密码学的验证。有兴趣就自己看 man page 吧。

防火墙驱动

iptables 和 nftables 经过多年发展,自己是可以记录、创建状态的。核心就是每个阶段对满足要求的包记录源地址,并且下个阶段验证这个地址

  • 遇到 P1 时,在 Phase1IPs 中记录 SRC
  • 遇到 P2 时,检查 Phase1IPs 中是否有 SRC,有的话记录到 Phase2IPs 中
  • 遇到 P3 时,检查 Phase2IPs 中是否有 SRC,有的话记录到 Phase3IPs 中
  • 遇到 Pn 时,检查 Phase[n-1]IPs 中是否有 SRC,有的话允许到达 22 端口的包,定时关闭

每个阶段都有自己的超时。

缺点与考量

Port Knocking 并非银弹,它也有一些理论上的缺点和需要注意的边界情况:

  • 对网络波动敏感: 在移动网络等 IP 地址可能频繁变化的环境下,如果在几次敲门之间源 IP 地址发生改变,会导致敲门失败。不过这种情况在几秒的敲门窗口内发生的概率较低。
  • 可被嗅探和重放: 基础的 Port Knocking 实现,其敲门序列是固定的。如果攻击者处在你的网络链路上(例如,在同一个公共 Wi-Fi 下),他们可以嗅探到这个序列,并进行重放攻击来为你自己打开端口。不过即使嗅探到了,没有身份信息也不能登陆,我们仍然可以结合其他方法加固服务器,配置监控检查异常的登陆日志,轮替身份信息和敲门序列。
  • 配置复杂性: 相比于简单地修改端口号,Port Knocking 的配置(尤其是防火墙规则)更复杂,容易出错。但是相对的,没有daemon,只要内核还在就可以工作。

更高级的工具如 fwknop (FireWall KNock OPerator) 通过使用加密和签名的“单包授权”(Single Packet Authorization, SPA)来解决这些问题。它不再发送一个固定的序列,而是发送一个经过加密和签名的单个数据包,包含了源IP、时间戳、所需端口等信息。服务器验证签名和时效性后,才会打开端口,这极大地提高了安全性。

对于个人服务器而言,基础的 Port Knocking 已经能有效抵御绝大多数自动化的扫描和攻击,是一种低成本高效益的安全增强手段。

防火墙实现示例 (nftables)

最后举一个三步敲门的 nftables 实现例子,与上面的客户端脚本相对应。

table inet filter {
    set v4_phase1 {
        type ipv4_addr;
        flags timeout;
    }
    set v6_phase1 {
        type ipv6_addr;
        flags timeout;
    }
    set v4_phase2 {
        type ipv4_addr;
        flags timeout;
    }
    set v6_phase2 {
        type ipv6_addr;
        flags timeout;
    }
    set v4_phase3 {
        type ipv4_addr;
        flags timeout;
    }
    set v6_phase3 {
        type ipv6_addr;
        flags timeout;
    }
    set v4_opened {
        type ipv4_addr . inet_service;
        flags timeout;
    }
    set v6_opened {
        type ipv6_addr . inet_service;
        flags timeout;
    }
    chain input {
        type filter hook input priority 0; policy drop;

        ct state invalid counter drop comment "early drop of invalid packets"
        ct state { established, related } counter accept comment "accept all connections related to connections made by us"

        ip protocol icmp counter packets 53 bytes 3252 accept comment "accept all ICMP types"
        meta l4proto ipv6-icmp counter packets 0 bytes 0 accept comment "accept all ICMP types"
        
        ip saddr {127.0.0.0/8, 100.64.0.0/16} counter accept
        tcp dport $allowed_public_tcp_services counter accept
        udp dport $allowed_public_udp_services counter accept

        # 敲门规则
        # 注意: 下面的变量需要在你的 nftables.conf 文件中预先定义, 例如:
        # define knock_port1 = 123
        # define knock_port2 = 456
        # define knock_port3 = 789
        # define knock_timeout = 3s
        # define open_timeout = 15s
        # define guarded_services = { 22, 8022 }

        # 阶段一: 敲响第一个铃铛。
        tcp dport $knock_port1 add @v4_phase1 { ip  saddr timeout $knock_timeout }
        tcp dport $knock_port1 add @v6_phase1 { ip6 saddr timeout $knock_timeout }

        # 阶段二: 如果第一个铃铛还在响,敲响第二个。
        ip  saddr @v4_phase1 tcp dport $knock_port2 add @v4_phase2 { ip  saddr timeout $knock_timeout }
        ip6 saddr @v6_phase1 tcp dport $knock_port2 add @v6_phase2 { ip6 saddr timeout $knock_timeout }

        # 阶段三: 如果第二个铃铛还在响,敲响第三个,准备开门。
        ip  saddr @v4_phase2 tcp dport $knock_port3 add @v4_phase3 { ip  saddr timeout $knock_timeout }
        ip6 saddr @v6_phase2 tcp dport $knock_port3 add @v6_phase3 { ip6 saddr timeout $knock_timeout }

        # 开门: 如果三个铃铛都按顺序响过了,并且客人正要访问受保护的服务,
        # 则记录日志,并将其 IP 和目标端口的组合加入 "opened" 列表,设置一个更长的超时时间。
        tcp dport $guarded_services ip  saddr @v4_phase3 add @v4_opened { ip  saddr . tcp dport timeout $open_timeout } log prefix "[PortKnock] Opened: "
        tcp dport $guarded_services ip6 saddr @v6_phase3 add @v6_opened { ip6 saddr . tcp dport timeout $open_timeout } log prefix "[PortKnock] Opened: "

        # 允许访问: 如果 IP 和端口的组合在 "opened" 列表中,则接受连接。
        ip  saddr . tcp dport @v4_opened counter accept
        ip6 saddr . tcp dport @v6_opened counter accept
        tcp dport $guarded_services reject

        counter comment "count dropped packets"
    }

    chain forward {
        type filter hook forward priority 0; policy drop;

        ct state invalid counter drop comment "early drop of invalid packets"
        ct state { established, related } counter accept comment "accept all connections related to connections made by us"

        ip saddr $trusted_networks counter accept

        counter comment "count dropped packets"
    }
}

注意: 上面的 nftables 规则集只是一个示例。你需要根据自己的需求定义变量 ($knock_port1, $guarded_services 等) 并将其整合到你完整的防火墙配置中。

Windows 下的 ssh

上面我们提到了一个作为 ProxyCommand 的脚本。但是 Windows 下面没有合适的 bash 环境,怎么办呢?

我写了一个简单的 Rust 程序:

use clap::Parser;
use std::io::{self, Read, Write};
use std::net::{TcpStream, ToSocketAddrs, UdpSocket};
use std::str::FromStr;
use std::thread;
use std::time::Duration;

#[derive(Debug, Clone)]
enum Protocol {
    Tcp,
    Udp,
}

#[derive(Debug, Clone)]
struct KnockTarget {
    port: u16,
    protocol: Protocol,
}

impl FromStr for KnockTarget {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let parts: Vec<&str> = s.split('/').collect();
        if parts.len() != 2 {
            return Err("Invalid format. Expected PORT/PROTOCOL".to_string());
        }

        let port = parts[0]
            .parse::<u16>()
            .map_err(|e| format!("Invalid port number: {}", e))?;
        let protocol = match parts[1].to_lowercase().as_str() {
            "tcp" => Protocol::Tcp,
            "udp" => Protocol::Udp,
            _ => return Err("Invalid protocol. Use 'tcp' or 'udp'".to_string()),
        };

        Ok(KnockTarget { port, protocol })
    }
}

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
    /// The target to connect to after knocking, e.g., 127.0.0.1:22
    #[arg(required = true)]
    target_address: String,

    /// Port knocking sequence, e.g., --knock 1000/tcp --knock 2000/udp
    #[arg(long, short, value_parser = clap::value_parser!(KnockTarget), required = true, env = "PORT_KNOCKING_SEQ", value_delimiter = ':', num_args = 1..)]
    knock: Vec<KnockTarget>,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    env_logger::builder().target(env_logger::Target::Stderr).init();


    let (target_host, _target_port) = {
        let parts: Vec<&str> = cli.target_address.split(':').collect();
        if parts.len() != 2 {
            return Err("Invalid target address format. Expected HOST:PORT".into());
        }
        (parts[0], parts[1].parse::<u16>()?)
    };

    log::info!("Starting port knocking sequence...");

    for knock_target in &cli.knock {
        let addr = format!("{}:{}", target_host, knock_target.port)
            .to_socket_addrs()?
            .next()
            .ok_or("Could not resolve host")?;
        
        match knock_target.protocol {
            Protocol::Tcp => {
                log::debug!("Knocking on TCP port {}", knock_target.port);
                let timeout = Duration::from_secs(1);
                if TcpStream::connect_timeout(&addr, timeout).is_err() {
                    // We expect errors here, like connection refused or timeout.
                    // The goal is just to hit the port.
                }
            }
            Protocol::Udp => {
                log::debug!("Knocking on UDP port {}", knock_target.port);
                let socket = UdpSocket::bind("0.0.0.0:0")?;
                socket.send_to(&[], addr)?;
            }
        }
    }

    log::debug!("Knocking sequence finished. Connecting to {}...", cli.target_address);

    let mut stream = TcpStream::connect(&cli.target_address)?;
    let mut stream_clone = stream.try_clone()?;

    log::info!("Connection established. Proxying data...");

    let stdin_thread = thread::spawn(move || {
        let mut stdin = io::stdin();
        let mut buffer = [0u8; 1024]; // 1KB buffer
        
        loop {
            match stdin.read(&mut buffer) {
                Ok(0) => break, // EOF
                Ok(n) => {
                    if stream_clone.write_all(&buffer[..n]).is_err() {
                        break;
                    }
                    if stream_clone.flush().is_err() {
                        break;
                    }
                }
                Err(_) => break,
            }
        }
    });

    let stdout_thread = thread::spawn(move || {
        let mut stdout = io::stdout();
        let mut buffer = [0u8; 1024]; // 1KB buffer
        
        loop {
            match stream.read(&mut buffer) {
                Ok(0) => break, // EOF
                Ok(n) => {
                    if stdout.write_all(&buffer[..n]).is_err() {
                        break;
                    }
                    if stdout.flush().is_err() {
                        break;
                    }
                }
                Err(_) => break,
            }
        }
    });

    stdin_thread.join().unwrap();
    stdout_thread.join().unwrap();
    
    Ok(())
}

这个 Rust 程序支持通过命令行参数或环境变量来定义敲门序列。编译后,你可以将它放到 PATH 环境变量包含的目录中,然后在你的 ~/.ssh/config 文件里配置 ProxyCommand

用法一:通过命令行参数

直接在 ProxyCommand 中指定敲门序列,比较直观:

Host some-host-on-windows
  ProxyCommand port-knocking-proxy-rs.exe --knock 123/tcp --knock 456/tcp --knock 789/tcp %h:%p 

用法二:通过环境变量

为了让 ssh 配置更清爽,或者方便在不同脚本中复用,你可以使用环境变量 PORT_KNOCKING_SEQ

首先,在你的 shell 环境中设置环境变量。例如,在 PowerShell 中:

$env:PORT_KNOCKING_SEQ="123/tcp:456/tcp:789/tcp"

(注意:不同的 shell 设置环境变量的方式不同。上面是 PowerShell 的例子。)

然后,你的 ssh 配置就可以简化成这样:

Host some-host-on-windows
  ProxyCommand port-knocking-proxy-rs.exe %h:%p

程序会自动读取 PORT_KNOCKING_SEQ 环境变量。

通过这两种方式,当你在 Windows 上执行 ssh some-host-on-windows 时,SSH 客户端会自动调用这个 Rust 程序先完成敲门,再建立连接,体验和 Linux 下完全一致。