4、输入安全:防止侧信道攻击(时序分析)、防止暴力破解(延迟与锁定)、输入缓冲区的溢出防护

密码输入,是设备的第一道防线。但很多开发者只关注密码存得对不对,却忽略了输入过程本身的安全。我见过不少产品,密码算法再强,输入接口却漏洞百出。

说白了,攻击者根本不需要破解你的加密算法。他只需要盯着你的输入行为,就能把密码偷走。这一章,我们就来聊聊输入安全的三个硬骨头:时序侧信道、暴力破解防御、缓冲区溢出防护。

核心观点:输入安全不是功能,是防御。每一个按键、每一次响应、每一字节内存,都可能成为攻击者的突破口。

输入安全防御体系 密码输入接口 时序侧信道防御 恒定时间比较 暴力破解防御 延迟 + 锁定 缓冲区溢出防护 边界检查 + 截断 memcmp 替代 strcmp 固定循环次数 指数退避延迟 N次失败后锁定 固定长度缓冲区 输入截断 + 校验 三层防御缺一不可,任何一层失守都可能导致密码泄露

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(),永远手动检查输入长度。

防护措施有三层:

  1. 固定长度缓冲区:密码缓冲区大小固定,不接受超长输入。
  2. 输入截断:如果输入超过缓冲区大小,只取前 N 个字符。
  3. 边界校验:每次写入前检查剩余空间。
#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 次密码,迟早能试出来。

总结:输入安全是一个系统工程。时序、频率、内存,三个维度都要守住。我在做安全审计时,会把这三点列为必查项。任何一个缺失,我都会打回重做。