Linux - 通过 Port Knocking 技巧加固 SSH
有段时间没有写 post 了,要养娃 >_<
这篇博客主体由我编写,由AI润色。
第一次云主机被爆是很久以前的事情,但是让人印象深刻。今天不讨论爆破的事情,只讨论怎么保护端口。
《加固 Linux》Series:
- Harden your installation | 让你的系统更安全
- Linux - 通过 Port Knocking 技巧加固 SSH
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 下完全一致。