从零开发 RDP 暴力破解防护工具:Win7 兼容约束下的技术选型与踩坑实录
一、项目背景与设计目标
公司内网有 Windows Server 2008 R2 / Win7 老机器,常年被 RDP 暴力破解扫端口(3389 每天几万次 4625 事件)。市面安全软件要么只支持 Win10+,要么是闭源商业产品,要么很大不好用,试用几个没有一个满意的。抱着顺便学习Rust的想法,借助AI写了一个单文件、绿色、免安装的 RDP 暴力破解防护工具。
产品硬性指标(这也是后面所有技术决策的"枷锁"):
- 必须支持 Windows 7 / Server 2008 R2 及以上(内网老系统)
- 单文件绿色部署,不写注册表、不装驱动、不依赖运行时
- GUI 内存占用要小(老机器内存紧张)
- 停止防护 = 完全卸载(不留任何系统残留)
围绕这四条,展开讲设计思路、技术选型和踩过的坑。
二、核心设计思路
1. 双窗口滑动封禁(快攻 + 慢攻)
暴力破解有两个典型节奏:单 IP 短时间内猛攻(10 分钟内 3 次),和慢速试探(24 小时内累计 10 次)。单一阈值要么误封要么漏封。实现上对每个 IP 维护两个独立滑动窗口:
struct FailureRecord {
attempts: Vec<Instant>, // 快攻窗口(分钟级)时间戳
slow_attempts: Vec<Instant>, // 慢攻窗口(小时级)时间戳
cumulative_count: usize, // 绝对累计次数(不清零)
blocked_sent: bool, // 是否已触发封禁
}
每次 4625 事件到达,向两个窗口各推一个时间戳,并按各自窗口长度 retain 过期条目;任一窗口达到阈值即触发封禁。
关键细节:窗口基于事件真实时间而不是处理时间。回放历史事件时,把事件时间回推成 Instant 再入窗,这样重启后补扫的历史攻击能正确参与窗口判断,不会因"处理延迟"而误算。
2. 动态封禁时长(打击反复试探)
被封过一次的 IP 再次攻击,封禁时长应递增,否则攻击者可以等解封再继续。公式:
非永久封禁时长 = 基础时长 + (封禁次数 - 1) × 动态增长时长
fn dynamic_ban_minutes(cfg: &Config, prev_ban_count: u32) -> u64 {
if cfg.ban_type == "permanent" {
0u64 // 0 表示永久
} else {
cfg.ban_duration_minutes
.saturating_add((prev_ban_count as u64).saturating_mul(cfg.ban_growth_minutes))
}
}
封禁次数存在数据库独立 key 里(bancount_<ip>),过期解封后不清零,保证反复攻击时次数持续累计、时长持续增长。
3. 停止 = 绿色卸载
这是"绿色软件"的核心理念:点"停止防护"时,程序主动删除自己添加的所有防火墙规则、清空当前封禁记录(但保留历史统计)。重新开启从零开始,不留任何系统痕迹。因此必须精确记录"哪些规则是我加的"——统一规则名前缀 RDPGuard_Batch / RDPGuard_AllowRDP,停止时按前缀批量清理。
三、Win7 兼容下的技术选型
1. 锁定 rustc 1.77.2
现代 Rust 编译出的 exe 在 Win7 上启动崩溃,因为新版本 std 导入了 Win8+ 才有的 API。排查时用 dumpbin /imports 对比,发现新 toolchain 引入了一堆 Win8+ 符号。方案:锁死 toolchain 版本,并在构建脚本里明确注释"Build with rustc 1.77.2 (no Win8+ API imports)",同时 CRT 静态链接,让 2008 R2 无需安装 VC++ 运行库。
2. GUI 选型:Sciter,而不是 Electron / Tauri / WebView2
这是本软件最关键也最有争议的选型:
| 方案 |
Win7/2008R2 |
体积 |
内存 |
| Electron |
✅ 但超大 |
150MB+ |
100MB+ |
| Tauri/WebView2 |
❌ WebView2 不支持 Win7 |
- |
- |
| Sciter |
✅ 原生 Win7 支持 |
exe 仅 ~11MB |
GUI ~15MB |
Sciter 引擎(4~8MB)直接内嵌进 exe,运行时自动释放,单文件部署。代价是它是老旧的 TIScript 脚本引擎,和标准 JavaScript 有大量语义差异——这也是下文踩坑的重头戏。
3. 外部工具全部 API 化
早期实现用 sc.exe、auditpol、netsh、taskkill 子进程管服务/审核策略/防火墙,后来全部换成 Windows API(SCM API、Audit API、INetFwPolicy2 COM、TerminateProcess),理由后面讲。
四、开发中踩过的坑(重点)
坑 1:TIScript 的 String 是"类型对象",不是函数
GUI 事件列表渲染时偶发崩溃,错误:
[Sciter] Error: Wrong type - Type([type String]), expected instance of function
排查很久(加了分段断点日志定位),最终定位到 HTML 转义函数:
function escHtml(s) {
return (s == null ? "" : String(s)) // ← 这一行
.replace(/&/g, "&") ...;
}
本版 TIScript 中 String 是"类型对象"(typeof String 返回 "Type"),String(x) 对原始字符串/数字正常,但对 slice()/substring() 等字符串方法的返回值(String 类型对象,typeof 仍显示 "string"!)会直接抛类型错误。修复:一律用 "" + s 拼接代替 String(s):
function escHtml(s) {
return (s == null ? "" : "" + s) // 对原始值/数字/String 对象都安全
.replace(/&/g, "&") ...;
}
这个坑隐蔽在:只在列表第一次有数据时才触发(转义函数首次被真实数据调用),空列表时走"暂无记录"分支永远不执行——所以"之前一直好好的,突然就崩了"。
坑 2:typeof 返回值不是 JS 语义
typeof 数字 返回 "integer" / "float",不是 "number"。写 typeof x == "number" 判断数字恒为 false,导致封禁次数永远显示 0。修复:判断数字用 x || 0 或同时判断 "integer"/"float"。
坑 3:全局函数名会被引擎内置遮蔽
esc 这个函数名在 TIScript 全局环境里是只读字符串数组(应该是内置的某个东西),function esc(s) 声明被遮蔽,调用时抛类型错误。改名 escHtml 才绕开。教训:旧版脚本引擎的全局命名空间不干净,自定义函数名要避开常见短名。
坑 4:子进程调用依赖本地化输出
早期用 auditpol /get /r 解析审核策略,输出是 CSV 且按系统语言本地化(中文系统 GBK 编码 + 中文字段名),解析脆弱;sc query 输出也依赖本地化。干脆全部换成 API:审核策略用 AuditQuerySystemPolicy/AuditSetSystemPolicy,服务管理用 SCM API,防火墙用 INetFwPolicy2 COM,强杀进程用 TerminateProcess。不再 spawn 任何外部控制台工具,彻底摆脱本地化和 PATH 依赖。
通过 COM 写 RemoteAddresses = "1.2.3.4" 封禁 IP,再读回来可能变成 "1.2.3.4/255.255.255.255",导致精确匹配失败、解封失效。修复:解析时统一剥离 / 及之后的掩码部分。
坑 6:滑窗清理导致动态封禁永远不生效
cleanup_expired_bans 每分钟把过期的 active 记录从数据库删除,之后该 IP 再次触发封禁走"新封禁"分支,prev_ban_count 取到 0,动态公式被跳过——解封时间永远等于基础时长。修复:新封禁分支也走同一个公式(首次封禁 prev=0,结果不变),保证次数累计生效。
五、核心代码节选
1. 双窗口判定(简化)
// 快攻/慢攻判定核心
record.attempts.push(event_instant);
record.attempts.retain(|t| t.elapsed() <= check_window); // 快攻窗口
record.slow_attempts.push(event_instant);
record.slow_attempts.retain(|t| t.elapsed() <= slow_window); // 慢攻窗口
if !record.blocked_sent && record.attempts.len() >= fast_threshold {
trigger_ban(ip, record.cumulative_count); // 快攻命中
} else if !record.blocked_sent && record.slow_attempts.len() >= slow_threshold {
trigger_ban(ip, record.cumulative_count); // 慢攻命中
}
2. 防火墙批规则管理(封禁 IP 集中写入一条规则)
Windows 防火墙单条规则的地址字符串有长度上限(约 8000 字符),封禁 IP 多了会静默失败。方案:一条批规则装 ~500 个 IP,超长自动溢出到下一条编号规则:
fn block_ip(&self, ip: &str, port: u16) {
let rules = self.read_all_batch_rules(&policy)?; // 读取真实状态,不信任缓存
match rules.last() {
Some((_, name, ips)) => {
let joined = ips.join(",");
if joined.len() + 1 + ip.len() <= MAX_REMOTE_LEN {
set_remote_addresses(name, &format!("{},{}", joined, ip));
} else {
create_block_rule(&batch_rule_name(idx + 1), ip, port); // 溢出新建
}
}
None => create_block_rule(RULE_NAME, ip, port),
}
}
3. 崩溃防护:刷新链永不中断
Sciter 里定时刷新链一旦抛异常就会永久停止(self.timer 不再执行),表现为"按钮点击后列表不再刷新"。所有在刷新链上的代码必须 try/catch,并且先注册下一次定时、再执行刷新:
function scheduleRefresh() {
self.timer(1000, scheduleRefresh); // 先注册,异常也杀不掉链
try { refreshAll(); } catch (e) {}
}
六、总结
这个项目的核心价值不在于"实现了功能",而在于在一堆老旧约束(Win7 兼容、单文件、小体积、绿色卸载)下被迫做出的技术取舍:锁 rustc 版本保兼容、选老引擎 Sciter 换体积、把子进程全部 API 化换健壮性。每个坑背后都是对"为什么这样做"的真实思考——这些恰恰是读文档读不来的经验。若能通过会员申请将把软件首发在吾爱,欢迎交流。