本文首发于个人博客: 网络攻防无处不在:我的RustDesk服务竟成了DDoS的一环 | 灵感空间站 同步过来抛砖引玉
突如其来的欠费
前几日下午,照常在工位上的我突然收到云服务商的欠费通知,不由得警觉起来。笔者在云上只有一个公网流量按量付费的服务器,虽然预留的余额不多,但是服务器上平时也没什么流量,按理不会用的这么快。于是我赶紧登录上去看看是什么情况。
一看费用账单,果真都是公网流量费用,半天消耗了快10GB,一看监控,外网出入带宽居高不下。

但是一时半会还找不到原因,故先紧急止血:临时降低服务器带宽,避免继续快速消耗流量。
初步定位:RustDesk UDP 端口流量激增
遇到这种突发状况,我才发现自己对服务器运维知之甚少,不知道到底是哪个进程、哪个端口消耗了流量,也无从下手,只好赶紧求助运维同事。该说不说不愧是运维大哥,安装好 iftop 工具,三下五除二就定位到如下链路(均已脱敏):
172.16.0.7:21116 => ip-69-33-x-a.iad.megapath.net:25565 1.10Mb 1.25Mb 696Kb
<= 0b 0b 0b
172.16.0.7:21116 => ip-69-33-x-b.iad.megapath.net:25565 1.21Mb 635Kb 605Kb
<= 0b 0b 0b
172.16.0.7:21116 => ip-69-33-x-c.iad.megapath.net:25565 1.03Mb 577Kb 563Kb
<= 0b 0b 0b
iftop 工具中,右侧三列分别为最近2秒、10秒、40秒的平均速率,可以看出,服务器21116端口在不断往 69.33.x.x/24 网段的25565端口发送数据。
21116是 RustDesk 服务端(一个开源的远程桌面服务)hbbs 程序使用到的一个核心端口。RustDesk 服务端部署分为 hbbs 和 hbbr 两个程序,前者用于 ID 注册、心跳、P2P 打洞等核心功能,后者用于 P2P 打洞失败时走服务器中继转发。我通过智能插座 + 电脑通电自启 + 自部署 RustDesk 服务端这套组合,实现了随时随地访问家里电脑的能力。
至于对方的25565端口我可太熟了,Minecraft 服务端的默认端口就是25565。于是我在云安全组中封禁了这个网段的出入口,观察云服务监控,外网出带宽恢复正常了,但是外网入带宽、内网出带宽以及 iftop 监控发现21116端口仍在不断尝试向上述 IP 发包,即使重启 RustDesk 服务也无济于事。
这看起来有些矛盾:既然安全组已经封禁,为什么服务器里还能看到发包?原因是安全组是在云厂商网络出口侧生效。也就是说,包在本机网络栈里已经生成,但在云厂商出口处被丢弃,因此账单侧出站流量恢复正常。
结合前面21116端口与 69.33.x.x/24 网段之间只有出口流量没有入口流量,说明该网段并不是攻击者,而是受害者,没准是一个正在遭受 DDoS 攻击的 Minecraft 服务器(笑)。不过既然出口流量已经封住了,接下来就可以慢慢研究了。
抓包分析:还原攻击链路
接下来,需要搞清楚真正的攻击来源,以及为什么 hbbs 程序会向受害者端口大量发包。询问 ChatGPT 之后,我尝试用 tcpdump 监控 hbbs 程序的网络出入包,发现大量如下特征的链路:
14:57:58.432264 veth90f352c Out IP 194.247.x.x.46403 > 172.18.0.2.21116: UDP, length 548
14:57:58.432393 veth90f352c P IP 172.18.0.2.21116 > 69.33.x.x.25565: UDP, length 643
上文只列举了一个,实际上有大量不同的国外 IP 不断往21116端口发包,这下就解释得通了:并不是 hbbs 程序无故往外网大量发包,而是 多个攻击者向 hbbs 发包,hbbs 再往同一个目标发包。这个特征很符合 UDP 反射放大攻击的特征。
为了了解是 hbbs 中哪个协议被用来进行反射攻击,我继续用 tcpdump 抓包,获取完整 UDP payload:
tcpdump -ni any \
'udp and src host 194.247.x.x and dst port 21116' \
-c 5 -s 0 -vvv -XX
然后将抓到的 payload 贴到 ChatGPT 中分析。不得不说,AI 在逆向/数据分析这块还是太权威了:其思考链中通过浏览 rustdesk-server 源码仓库,查找 protobuf 消息格式,编写代码解码,到最终解析出该 UDP 包对应的 RustDesk 消息如下:
RendezvousMessage {
punch_hole_sent {
socket_addr: <11 bytes> # 解码后为 69.33.x.x:25565
id: "szpontnet"
relay_server: "ymfZoVOLVAjokGhkMbhuA2MJg71BOdqVqfEwEnAwBHn8Q7JyYk..." # 超长随机字符串,约 511 bytes
version: "1.3.0"
}
}
这个过程如果让没有相关经验的我来做,压根一点思路也没有。
解出了包的内容,再去源码里比对逻辑就清晰得多了,根据 rustdesk-server源码 ,PunchHoleSent 消息是客户端 P2P 连接过程中的一环,这个消息会进入 handle_hole_sent 方法,执行以下逻辑:
- 从攻击包里的 socket_addr 解出目标地址
- 构造 PunchHoleResponse
- 把攻击包里的 relay_server 原样复制进去
- 发给 socket_addr 指定的目标
附带 RustDesk 客户端间 P2P 连接的完整过程方便理解(A, B 为客户端,hbbs 为服务端):
- 前提:客户端
A 和 B 已注册到 hbbs
A → hbbs : PunchHoleRequest(A 想连接 B,先发起打洞请求)
hbbs → B : PunchHole(hbbs 将 A 的地址告诉 B)
B → A : UDP punch packet(B 收到 PunchHole,向 A 打洞)
B → hbbs : PunchHoleSent(B 通知 hbbs :B 已经向 A 发起打洞了)
hbbs → A : PunchHoleResponse(hbbs 再通知 A :B已准备好,可以尝试连接 B)
如此一来,真相大白。这是一次基于 UDP 的反射型 DDoS 滥用,攻击者并没有攻破我的服务器,而是利用 RustDesk 服务端协议的安全漏洞,伪造大量 PunchHoleSent 消息,诱导 RustDesk 服务端向伪造的目标地址发送 PunchHoleResponse,间接对目标地址进行 DDoS 攻击。
这类攻击的高明之处,在于只需要少量的 IP 向大量暴露在公网的 RustDesk 服务发送伪造消息,就能利用这些公开服务器对目标地址造成 DDoS攻击;同时 PunchHoleResponse 响应包略大于 PunchHoleSent 请求包(643 / 548 ≈ 1.17),因此也带有一定放大效果。
缓解方案
清楚了攻击链路,接下来就要着手解决方案,防止后续其他攻击者利用这个漏洞攻击别的目标。但是细想下来,这防御措施却并不好做。21116端口承载了 RustDesk 的核心功能,直接关掉这个端口会导致客户端无法注册到服务端,而攻击者利用的又是 RustDesk 标准协议内容,除了包体较大之外,没有能明显区分的特征。
限制入口IP
考虑到攻击方 IP 都是国外的,于是我先使用 iptables + ipset 将 21116/UDP 的访问白名单限制在国内 IP 中。
首先创建 ipset 集合,作为白名单:
ipset create cn_ips hash:net family inet -exist
然后找一个包含所有国内 IP 网段的 CIDR 网段合集,这里使用的是 IPDeny 提供的文件,将其下载到临时目录中:
curl -fsSL https://www.ipdeny.com/ipblocks/data/countries/cn.zone -o /tmp/cn.zone
然后导入刚刚创建的合集,这里使用 awk 将下载下来的 zone 文件预处理为 ipset restore 格式,这样导入比按行切分 zone 文件再一个个 add 进去要快很多:
SET_NAME=cn_ips
ZONE_FILE=/tmp/cn.zone
awk -v set="$SET_NAME" '
/^[[:space:]]*$/ { next }
/^[[:space:]]*#/ { next }
{ print "add " set " " $1 }
' "$ZONE_FILE" | ipset restore -exist
完成之后验证一下导入的数量:
ipset list cn_ips | grep 'Number of entries'
现在将这个 ipset 挂到 iptables 中,首先创建一个规则链,然后将 cn_ips 这个白名单添加到链中:
# 创建规则链
iptables -N RUSTDESK_UDP_GEO 2>/dev/null || true
iptables -F RUSTDESK_UDP_GEO
# 挂载白名单
iptables -A RUSTDESK_UDP_GEO -m set --match-set cn_ips src -j RETURN
# 可选:记录被拦截来源,限速防止刷日志
iptables -A RUSTDESK_UDP_GEO -m limit --limit 5/min --limit-burst 10 \
-j LOG --log-prefix "rustdesk_udp21116_drop: " --log-level 4
# 其他全部丢弃
iptables -A RUSTDESK_UDP_GEO -j DROP
接下来根据服务的部署方式挂载这个规则链,笔者这里是用 Docker 部署的 RustDesk 服务端,故将规则链挂在 DOCKER-USER 前面:
iptables -C DOCKER-USER -p udp --dport 21116 -j RUSTDESK_UDP_GEO 2>/dev/null || \
iptables -I DOCKER-USER 1 -p udp --dport 21116 -j RUSTDESK_UDP_GEO
然后验证规则是否生效:
# 看 DROP 规则的 packet / bytes 是否增长
iptables -L DOCKER-USER -n -v --line-numbers
iptables -L RUSTDESK_UDP_GEO -n -v --line-numbers
# 或者查看日志
dmesg -T | grep rustdesk_udp21116_drop
限制出口速率
为了防止流量被刷,我们还可以限制 21116/UDP 端口的出口速率,这里依旧用 iptables 实现。
依旧先添加一个规则链:
iptables -N RUSTDESK_UDP_OUT_LIMIT 2>/dev/null || true
iptables -F RUSTDESK_UDP_OUT_LIMIT
然后挂上限速规则,将网络包速率限制在15/s,允许突发50个包:
iptables -A RUSTDESK_UDP_OUT_LIMIT \
-m hashlimit \
--hashlimit-name rustdesk_21116_udp_out \
--hashlimit-above 15/second \
--hashlimit-burst 50 \
--hashlimit-mode srcip,srcport \
-j DROP
iptables -A RUSTDESK_UDP_OUT_LIMIT -j RETURN
最后将其挂在 DOCKER-USER 前,限制 21116/UDP 出口方向:
iptables -I DOCKER-USER 1 \
-p udp --sport 21116 \
-j RUSTDESK_UDP_OUT_LIMIT
最后记得 iptables 规则是内存生效的,需要加上持久化和恢复机制,避免重启丢失规则。
复盘总结
需要说明的是,以上规则只是缓解措施,不是根治方案。入口白名单可以减少被国外来源利用的概率,但无法防止国内攻击源;出口限速可以控制最坏影响,但也不能修复协议本身的问题。真正的修复仍然需要 rustdesk-server 在协议层对 PunchHoleSent 增加上下文校验或者身份认证。Github 上已有人提了 issue,可以关注后续进展。
这次事件的整个处理流程相对还是比较稳健的,从调整带宽的紧急止血措施,到安全组封禁目标网段,再到保留现场抓包分析,并实施缓解方案防止后续同类型的攻击,每一步都见效显著。当然,这次事件也带来了几个收获,在此分享一下:
网络攻防无处不在
不要觉得你的网站没什么流量,部署的服务也是私人用途,就可以放松警惕,笔者历来对安全比较上心,仅开放必要的端口,并将各类密钥设置得尽量复杂,但也没想到会在服务器没被攻破的情况下被别人利用,成为了 DDoS 攻击的一环。
这也说明,服务器安全不只是防止被入侵。即使系统没有被攻破,一个正常开放的服务端口,也可能因为协议行为被滥用,成为攻击链路的一部分。
监控告警很重要
必须建立主动监控和告警,这次是因为公网流量把余额花光了触发了欠费通知,我才知道服务器被攻击了,而此时距离攻击开始时间点已经过去小半天了。所谓吃一堑,长一智,过后我立刻补齐了相关监控,包括出口流量异常增高,以及费用异常增高等监控,并设置告警,避免异常情况下无法及时得知。
对自建服务来说,安全组、防火墙、出口限制、流量监控和费用告警都应该作为基础设施的一部分,而不是出事之后才临时补救。
不要预留过多余额(确信)
因为我只预留了个位数的余额在云服务厂商中,所以损失还不算大。如果预留个3位数余额,哪怕是直到攻击结束了我还不知道,只能过后看这月度账单干瞪眼。攻击者没准也是考虑到这一点,才选择7月1日发起攻击。