4、输入安全:防止侧信道攻击(时序分析)、防止暴力破解(延迟与锁定)、输入缓冲区的溢出防护
密码输入,是设备的第一道防线。但很多开发者只关注密码存得对不对,却忽略了输入过程本身的安全。我见过不少产品,密码算法再强,输入接口却漏洞百出。
说白了,攻击者根本不需要破解你的加密算法。他只需要盯着你的输入行为,就能把密码偷走。这一章,我们就来聊聊输入安全的三个硬骨头:时序侧信道、暴力破解防御、缓冲区溢出防护。
核心观点:输入安全不是功能,是防御。每一个按键、每一次响应、每一字节内存,都可能成为攻击者的突破口。
4.1 时序侧信道攻击:别让你的比较函数出卖你
时序侧信道攻击,听起来很高端,其实原理很简单。攻击者通过测量密码验证的响应时间,就能推断出密码的每一位。
为什么会这样?因为传统的字符串比较函数(比如 strcmp)一旦遇到不匹配的字符就立即返回。你想想看,如果第一位就错了,返回得就快;如果前三位都对,第四位错了,返回得就慢。攻击者反复测量,就能逐位猜出密码。
我曾经在一个 IoT 门锁项目里发现,用 strcmp 比较密码,响应时间差异达到毫秒级。攻击者用逻辑分析仪测了 100 次,就把 8 位密码还原出来了。嗯,这个坑我踩过,你别再踩了。
解决方案很简单:使用恒定时间比较函数。不管密码对不对,都遍历完所有位再返回结果。
// 不安全的做法:strcmp 会提前返回
if (strcmp(input_password, stored_password) == 0) {
// 验证通过
}
// 安全的做法:恒定时间比较
int constant_time_compare(const char *a, const char *b, size_t len) {
int result = 0;
for (size_t i = 0; i < len; i++) {
result |= a[i] ^ b[i]; // 异或操作,每一位都参与
}
return result; // 0 表示相等,非0表示不等
}
我个人习惯在嵌入式设备上自己实现恒定时间比较函数。标准库的 memcmp 在某些编译器优化下也可能不是恒定时间的。记得关掉编译器的循环优化选项。
4.2 暴力破解防御:延迟与锁定
暴力破解是最原始的 attack 方式,但也是最有效的。攻击者拿着字典或穷举工具,一遍遍地试密码。你想想看,如果你的设备每秒能处理 1000 次验证请求,一个 4 位 PIN 码最多 10000 种组合,10 秒就试完了。
防御手段有两个:延迟和锁定。两者要配合使用。
| 策略 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 固定延迟 | 每次验证后等待 1 秒 | 实现简单 | 攻击者可以并行尝试 |
| 指数退避 | 第 N 次失败后等待 2^N 秒 | 有效阻止自动化攻击 | 用户体验略差 |
| 临时锁定 | 5 次失败后锁定 30 分钟 | 彻底阻断暴力破解 | 可能被恶意锁定 |
| 永久锁定 | 10 次失败后永久锁定 | 最高安全等级 | 需要管理员介入解锁 |
我在项目中遇到过一种情况:攻击者利用多线程同时发起多个验证请求,绕过了单线程的延迟策略。所以,延迟和锁定必须在全局层面实现,不能只针对单个连接。
// 暴力破解防御示例
typedef struct {
uint32_t fail_count;
uint32_t lock_until; // 系统 tick
uint32_t delay_until;
} auth_state_t;
int authenticate(auth_state_t *state, const char *input) {
uint32_t now = get_tick();
// 检查是否在锁定期
if (now < state->lock_until) {
return AUTH_LOCKED;
}
// 检查是否在延迟期
if (now < state->delay_until) {
return AUTH_DELAY;
}
// 验证密码
if (constant_time_compare(input, stored_pwd, PWD_LEN) == 0) {
state->fail_count = 0; // 成功则重置
return AUTH_SUCCESS;
}
// 失败处理
state->fail_count++;
if (state->fail_count >= MAX_FAILS) {
state->lock_until = now + LOCK_DURATION;
} else {
// 指数退避:2^fail_count 秒
state->delay_until = now + (1 << state->fail_count);
}
return AUTH_FAIL;
}
关键点:延迟和锁定的时间要存储在非易失性存储器中。如果设备断电重启后计数器清零,攻击者只需要不断断电重启就能绕过锁定。我见过一个产品就是这样被攻破的。
4.3 输入缓冲区溢出防护
缓冲区溢出,是 C 语言嵌入式开发的经典漏洞。攻击者输入超长字符串,覆盖了栈上的返回地址或函数指针,就能劫持程序执行流。
说白了,就是你的输入缓冲区只有 16 字节,攻击者却塞了 100 字节。多出来的 84 字节就覆盖了栈上的其他数据。
我曾经接手过一个遗留项目,密码输入函数用的是 gets()。你没看错,就是那个已经被 C11 标准废弃的 gets()。攻击者输入 200 个 'A',程序直接崩溃,然后被注入 shellcode。那次教训让我养成了一个习惯:永远不用 gets(),永远手动检查输入长度。
防护措施有三层:
- 固定长度缓冲区:密码缓冲区大小固定,不接受超长输入。
- 输入截断:如果输入超过缓冲区大小,只取前 N 个字符。
- 边界校验:每次写入前检查剩余空间。
#define PWD_BUF_SIZE 16
typedef struct {
char buffer[PWD_BUF_SIZE];
size_t len;
} safe_pwd_t;
// 安全的密码输入函数
int safe_read_password(safe_pwd_t *pwd) {
size_t index = 0;
int ch;
while ((ch = getchar()) != '\n' && ch != EOF) {
if (index < PWD_BUF_SIZE - 1) { // 留一个位置给 '\0'
pwd->buffer[index++] = (char)ch;
} else {
// 超出部分直接丢弃,不写入缓冲区
// 但可以记录日志,用于检测攻击
log_overflow_attempt();
}
}
pwd->buffer[index] = '\0';
pwd->len = index;
// 检查是否被截断(可能是攻击)
if (index == PWD_BUF_SIZE - 1 && ch != '\n') {
return PWD_TRUNCATED; // 输入被截断,需要通知上层
}
return PWD_OK;
}
我个人习惯在缓冲区末尾加一个 canary 值(哨兵)。每次函数返回前检查 canary 是否被改写。如果被改写了,说明发生了缓冲区溢出,立即触发安全响应(比如擦除密钥、重启设备)。
4.4 三种防御的协同
这三种防御不是孤立的。它们需要协同工作,才能构建完整的输入安全体系。
- 时序防御保护验证过程不被侧信道分析。
- 暴力破解防御限制攻击者的尝试频率。
- 缓冲区溢出防护防止输入数据破坏内存结构。
你想想看,如果只做了恒定时间比较,但没做输入长度检查,攻击者照样可以通过缓冲区溢出绕过验证逻辑。反过来,如果只做了缓冲区防护,但没做延迟策略,攻击者每秒试 1000 次密码,迟早能试出来。
总结:输入安全是一个系统工程。时序、频率、内存,三个维度都要守住。我在做安全审计时,会把这三点列为必查项。任何一个缺失,我都会打回重做。