Skip to content

PIN 认证与锁定

FT 参考:FT252(NENE2-FT/pinverifylog)——带锁定的 PIN 验证 ATK:FT252——破解者思维攻击测试(ATK-01 到 ATK-12)

6 位 PIN 的暴力破解防护、时序攻击对策以及管理员解锁的实现指南。解说 HMAC-SHA256 哈希存储、恒定时间比较以及尝试次数锁定。

FT192 安全性已验证:VULN-A~L 全部通过 / ATK-01~12 全部通过。

概述

  • 管理员创建 PIN(HMAC-SHA256 哈希存储——不存储明文)
  • 用户验证 PIN(失败次数超过上限则锁定)
  • 管理员解除锁定
  • 尝试历史记录为审计日志

端点

方法路径认证描述
POST/pinsX-Admin-Key创建 PIN
POST/pins/{id}/verify验证 PIN
GET/pins/{id}X-Admin-Key状态确认(剩余尝试次数・锁定期限)
POST/pins/{id}/unlockX-Admin-Key解除锁定
DELETE/pins/{id}X-Admin-Key删除 PIN

数据库设计

sql
CREATE TABLE IF NOT EXISTS pins (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    label        TEXT    NOT NULL,
    pin_hash     TEXT    NOT NULL,        -- HMAC-SHA256(pin, secret)
    attempts     INTEGER NOT NULL DEFAULT 0,
    max_attempts INTEGER NOT NULL DEFAULT 5,
    locked_until TEXT,                    -- ISO 8601 UTC,NULL = 未锁定
    created_at   TEXT    NOT NULL,
    updated_at   TEXT    NOT NULL
);

CREATE TABLE IF NOT EXISTS pin_attempts (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    pin_id       INTEGER NOT NULL,
    success      INTEGER NOT NULL DEFAULT 0,
    attempted_at TEXT    NOT NULL
);

locked_until 以 ISO 8601 字符串存储,通过与当前时间的字符串比较($lockedUntil > $now)判断锁定状态。无转换开销。

HMAC-SHA256 PIN 哈希

PIN 不以明文存储,使用 HMAC-SHA256 进行哈希。混入服务器端密钥($hmacSecret)使 DB 泄露时的暴力破解更加困难:

php
private function hashPin(string $pin): string
{
    return hash_hmac('sha256', $pin, $this->hmacSecret);
}

恒定时间比较(VULN-E / ATK-02)

=== 在字节比较中途会短路,通过时序攻击可以推测正确的哈希。hash_equals() 始终比较所有字节:

php
// ❌ 危险:时序攻击可推测
if ($stored === $provided) { ... }

// ✅ 安全:恒定时间比较
$provided = $this->hashPin($pin);
$success  = hash_equals($pin1->pinHash, $provided);

暴力破解防护(ATK-01)

失败次数达到 max_attempts 时设置 locked_until,此后拒绝所有尝试(包括正确的 PIN),返回 423:

php
public function verify(int $id, string $pin): string
{
    $now  = $this->now();
    $pin1 = $this->findById($id);

    // 1. 在尝试之前先检查锁定
    if ($pin1->isLocked($now)) {
        return 'locked'; // → 423
    }

    // 2. 恒定时间比较
    $provided = $this->hashPin($pin);
    $success  = hash_equals($pin1->pinHash, $provided);

    if ($success) {
        // 成功后重置尝试次数
        $this->resetAttempts($id, $now);
        return 'success'; // → 200
    }

    // 3. 失败:计数增加 → 达到上限时锁定
    $newAttempts = $pin1->attempts + 1;
    $lockedUntil = null;

    if ($newAttempts >= $pin1->maxAttempts) {
        $lockedUntil = $this->lockUntil($now); // 5 分钟后
    }

    $this->incrementAttempts($id, $newAttempts, $lockedUntil, $now);

    return $newAttempts >= $pin1->maxAttempts ? 'locked' : 'wrong'; // → 423 或 401
}

重要:锁定检查必须在尝试之前进行。如果在尝试之后检查,到达锁定状态的最后一次尝试可能会通过。

管理员密钥故障关闭(VULN-H / ATK-03)

php
private function isAdmin(ServerRequestInterface $request): bool
{
    if ($this->adminKey === '') {
        return false; // 空 adminKey 始终拒绝
    }

    $provided = $request->getHeaderLine('X-Admin-Key');

    return $provided !== '' && hash_equals($this->adminKey, $provided);
}

adminKey 为空字符串时无条件返回 false(防止环境变量未设置时成为开放管理员)。

ID 校验(VULN-A / ATK-07)

php
private function resolveId(ServerRequestInterface $request): ?int
{
    $raw = Router::param($request, 'id');

    if ($raw === null || !ctype_digit($raw) || strlen($raw) > 18) {
        return null; // → 422
    }

    $id = (int) $raw;

    return $id > 0 ? $id : null;
}

strlen($raw) > 18 防止 64 位整数溢出(PHP_INT_MAX 是 19 位,留有安全余量)。

PIN 校验(VULN-D)

使用 ctype_digit()。正则表达式(/^[0-9]+$/)可能存在 ReDoS 风险,最坏情况为 O(n²),而 ctype_digit() 是 O(n) 且安全:

php
private function validatePin(mixed $pin): ?string
{
    if (!is_string($pin)) {
        return 'pin must be a string.'; // VULN-G:防止类型混淆
    }

    $len = strlen($pin);
    if ($len < self::MIN_PIN_LEN || $len > self::MAX_PIN_LEN) {
        return 'pin must be between 4 and 8 digits.';
    }

    if (!ctype_digit($pin)) { // O(n),无 ReDoS
        return 'pin must contain only digits.';
    }

    return null;
}

响应设计

PIN 哈希绝对不能包含在响应中。 管理员响应也不例外:

php
public function toAdminArray(): array
{
    return [
        'id'                 => $this->id,
        'label'              => $this->label,
        'attempts'           => $this->attempts,
        'max_attempts'       => $this->maxAttempts,
        'locked_until'       => $this->lockedUntil,
        'remaining_attempts' => $this->remainingAttempts(),
        'created_at'         => $this->createdAt,
        // 不包含 pin_hash
        // 不包含 updated_at(内部信息)
    ];
}

响应示例

json
// POST /pins (201)
{
    "pin": {
        "id": 1,
        "label": "vault",
        "attempts": 0,
        "max_attempts": 5,
        "locked_until": null,
        "remaining_attempts": 5,
        "created_at": "2026-05-26T10:00:00+00:00"
    }
}

// POST /pins/1/verify — 成功 (200)
{ "success": true, "locked": false }

// POST /pins/1/verify — 失败 (401)
{ "success": false, "locked": false }

// POST /pins/1/verify — 已锁定 (423)
{ "success": false, "locked": true, "error": "PIN is locked due to too many failed attempts." }

// POST /pins/1/unlock (200)
{ "unlocked": true }

安全要点(VULN-A~L / ATK-01~12 全部通过)

威胁类别对策
暴力破解ATK-01max_attempts 上限 → locked_until 锁定 5 分钟
时序攻击(PIN)ATK-02 / VULN-Ehash_equals() 恒定时间比较
管理员密钥绕过ATK-03 / VULN-HadminKey = '' → false(故障关闭)
ID 枚举ATK-04不存在的 ID 返回 404(不泄露信息)
SQL 注入(PIN 值)ATK-05 / VULN-Bctype_digit 只允许数字通过 → PDO 预处理语句
SQL 注入(ID)ATK-06 / VULN-Bctype_digit + strlen > 18 守护 → 422
整数溢出ATK-07 / VULN-A / VULN-Jstrlen > 18 守护
锁定绕过ATK-08锁定检查在尝试之前、DB 持久化
解锁后重新攻击ATK-09解锁后 attempts = 0 重置(正常行为)
请求体注入ATK-10 / VULN-I只接受明确字段
管理员密钥时序ATK-11hash_equals() 恒定时间比较
BIDI/Unicode 标签ATK-12 / VULN-Lmb_strlen 长度检查,存储通过 PDO 安全进行
ReDoSVULN-Dctype_digit() O(n),无正则表达式
类型混淆VULN-G!is_string($pin) 检查
max_attempts 溢出VULN-F范围检查 1~20
SSRFVULN-K无外部 HTTP 通信(不适用)
路径遍历VULN-C无文件操作(不适用)

相关指南

基于 MIT 许可证发布。