PAYATHON 2026

Android NFC 支付应用:NTAG213 与 Ultralight C 安全性

支付阿杰

结论

即使配合 AES-256,NTAG213 也不适合把“余额”作为唯一可信数据保存在卡片上。风险不只在于数据能否被读懂,还涉及复制、回滚、重放、伪造、密钥提取和拒绝服务。

更可靠的设计如下:

  • 由后端保存权威余额,NFC tag 只保存账户标识或带认证的短期凭证。
  • 使用 AES-GCM 或“加密 + MAC”,不能只做 AES 加密。
  • 每笔交易使用单调递增计数器,后端拒绝旧计数器,防止余额回滚。
  • 不要把 tag UID 当作秘密或主要密钥材料。
  • 如果系统必须完全离线,并由卡片保存余额,应选用支持强认证、安全消息、内部计数器或 Value File 的安全芯片,例如型号合适且配置正确的 MIFARE DESFire EV 系列或 CIPURSE 产品。

Ultralight C 的原生认证确实比 NTAG213 的 32-bit password 更强,但它不能自动解决余额复制和回滚问题。

1. Ultralight C 为什么通常比 NTAG213 更安全

两者使用的原生安全机制不同。

NTAG213 使用 PWD_AUTH:

  • 密码长度为 32 bit。
  • 认证成功后返回 16-bit PACK。
  • 密码和 PACK 不构成加密通信协议,也不会建立安全信道。
  • 可以配置从某个页面开始要求认证后才能写入,也可以选择是否限制读取。
  • AUTHLIM 可以限制认证失败次数,但攻击者也可能借此锁死合法卡片,造成拒绝服务。

MIFARE Ultralight C 并非“只有 32-bit password”。它支持基于 2-key 3DES 的双向认证,使用 16-byte 密钥。无论密钥空间还是认证协议,通常都明显强于 NTAG213 的四字节密码。

不过,application layer encryption 和 tag 原生认证解决的不是同一类问题:

  • AES 加密主要保护业务数据的保密性。
  • tag 认证用于限制存储页面的读取或写入。
  • 消息认证用于判断数据是否被修改。
  • 交易计数器和后端状态用于阻止复制、回滚与重放。

因此,即使应用层已经加密,Ultralight C 的原生认证仍能提供更强的写入访问控制。但如果业务协议允许攻击者恢复以前复制的合法密文,仅仅换成 Ultralight C 依然无法保证余额正确。

2. AES 加密不能单独防止篡改

“数据看不懂”和“数据不能被修改”是两回事。

某些加密模式具有可塑性。攻击者即使不知道明文和密钥,也可能修改密文,让解密后的一些位产生可预测的变化。即使修改只会生成乱码,只要应用没有验证完整性,就可能错误地接受数据。

应使用带认证加密,例如:

  • AES-GCM
  • AES-CCM
  • ChaCha20-Poly1305

如果运行环境受限,也可以采用 Encrypt-then-MAC:

ciphertext = Encrypt(encKey, nonce, plaintext)
tag = HMAC-SHA-256(macKey, header || nonce || ciphertext)

应用必须先验证 MAC,再解密和处理数据。加密密钥与 MAC 密钥应彼此独立。

AES-GCM 示例

下面的示例展示了基本结构。密钥应来自安全的随机密钥管理系统,不能直接把人类密码转换为字节:

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.security.SecureRandom;

public final class NfcCrypto {
    private static final int NONCE_LENGTH = 12;
    private static final int TAG_LENGTH_BITS = 128;

    public static byte[] encrypt(
            SecretKey key,
            byte[] plaintext,
            byte[] associatedData
    ) throws Exception {
        byte[] nonce = new byte[NONCE_LENGTH];
        SecureRandom.getInstanceStrong().nextBytes(nonce);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(
                Cipher.ENCRYPT_MODE,
                key,
                new GCMParameterSpec(TAG_LENGTH_BITS, nonce)
        );

        if (associatedData != null) {
            cipher.updateAAD(associatedData);
        }

        byte[] ciphertextAndTag = cipher.doFinal(plaintext);

        byte[] output = new byte[nonce.length + ciphertextAndTag.length];
        System.arraycopy(nonce, 0, output, 0, nonce.length);
        System.arraycopy(
                ciphertextAndTag,
                0,
                output,
                nonce.length,
                ciphertextAndTag.length
        );
        return output;
    }
}

每次加密都必须使用新的 nonce。对同一个 AES-GCM 密钥重复使用 nonce 会严重破坏安全性。

协议版本、账户 ID、tag UID 和计数器可以放入 associated data:

AAD = protocolVersion || accountId || tagUid || transactionCounter

这些字段不需要保密,但会受到 GCM authentication tag 的完整性保护。

如果业务密钥确实来自 password,必须使用带随机 salt 和适当成本参数的 KDF,例如 PBKDF2、scrypt 或 Argon2。具体参数需要根据目标设备的性能测试确定,不能只凭“密码很长”来判断强度。

3. 32-bit password 是否足够

32-bit password 最多有:

2^32 = 4,294,967,296

种可能。

对现代安全系统来说,这个空间太小。不过,攻击究竟需要多长时间,不能只看这个数字,还取决于:

  • 攻击者是否只能通过 NFC 在线尝试。
  • 每次认证所需的通信延迟。
  • 是否启用了 AUTHLIM。
  • 攻击者能否从应用、日志、固件或测试设备中取得密码。
  • 密码是否通过 UID 以可预测的方式派生。
  • 系统是否存在旁路、协议实现错误或调试接口泄露。

如果攻击者只能对一张真实 tag 逐次尝试,NFC 通信速度和失败次数限制可能大幅拖慢暴力破解。但这不足以让 32-bit password 成为长期的密码学安全边界。实际攻击通常也不会从遍历全部密码开始。攻击者更可能先逆向 Android 应用、提取固定密钥,或者分析密钥派生算法。

使用 AUTHLIM 时需要谨慎。它能限制在线猜测,但任何拿到卡片的人也可能故意耗尽失败次数,导致卡片无法继续认证。

写保护的可选方式

NTAG213 大致有以下几种选择:

  1. 使用 password authentication 保护需要更新的用户页面。
  2. 使用 lock bits 将页面永久设为只读。
  3. 在应用层验证 AEAD authentication tag 或 MAC。
  4. 在后端记录版本号或交易计数器,并拒绝旧状态。
  5. 对高风险操作要求在线授权。

永久只读适合发行后不再变化的凭证,不适合需要反复更新的余额。只要终端需要更新卡片上的余额,就必须保留某种写入能力。

此外,密码学无法阻止持卡人损坏、屏蔽、替换或物理破坏 tag。格式化或写入垃圾数据属于可以检测、却未必能彻底避免的拒绝服务问题。系统应支持后端挂失和重新发卡。

4. UID 不能充当秘密

任何靠近卡片的读卡器通常都能读取 tag UID。部分仿真设备或特殊 tag 还可以复制或模拟 UID。

因此,在密钥派生中加入 UID,只能辅助“把数据绑定到某个标识”:

key = KDF(masterKey, tagUid || context)

这里真正的秘密是 masterKey,而不是 tagUid。如果 Android 应用中包含可恢复的 masterKey,攻击者可能通过逆向、动态调试或在受控设备上使用 hook 来提取它。

更合适的安排是:

  • 主密钥只保存在后端或受控的安全模块中。
  • 终端通过经过认证的后端接口完成交易。
  • 必须离线时,使用 Android Keystore 保护终端密钥,同时考虑硬件支持、设备完整性和密钥轮换。
  • 不要让所有终端和卡片共享同一个长期固定密钥。
  • 为不同卡片派生独立密钥,并为密钥设置版本号。

Android Keystore 能增加密钥提取的难度,但无法让已经被完全控制的客户端成为绝对可信的环境。

5. 最大风险是复制和余额回滚

假设卡片中保存着一条完全合法的记录:

{
  "accountId": "12345",
  "balance": 100,
  "counter": 10
}

这条记录经过 AES-GCM 加密后成功写入卡片。消费完成后,余额变成 80,计数器变成 11。

攻击者不需要破解 AES。他只需在消费前复制整段合法密文,消费后再把旧密文写回卡片。旧数据的 authentication tag 仍然有效,因为它原本就是由系统生成的。

这就是 rollback attack。加密、MAC 和 NTAG213 password 本身都无法彻底解决这个问题。

在线系统的推荐流程

读取卡片标识
    ↓
终端向后端请求账户状态
    ↓
后端检查卡片状态、终端权限和交易序号
    ↓
后端以原子事务扣款
    ↓
终端只把收据或缓存状态写回卡片

余额必须以数据库记录为准。数据库还应通过原子更新,防止两个终端同时读取旧余额后分别扣款。

卡片上的记录可以包含:

protocolVersion
accountId
transactionCounter
lastTransactionId
optionalCachedBalance
expiresAt
authenticationTag

后端为每张卡保存已经接受的最高 transactionCounter,遇到较小或重复的值时拒绝交易。随机 nonce 无法替代服务端的防重放状态。

完全离线系统

在完全离线的系统中,普通 Type 2 tag 很难安全承担电子钱包的职责。安全芯片需要实际参与交易,而不是被动保存应用生成的密文。例如:

  • 终端与卡片进行双向认证。
  • 每张卡使用独立密钥。
  • 通信使用 secure messaging。
  • 余额通过芯片内部受保护的 Value File 或等价机制更新。
  • 使用芯片维护的计数器或交易状态。
  • 正确处理断电、重复提交和交易中断。
  • 定期与后台对账,并检测重复卡片。

这类需求属于安全智能卡系统,而不是普通的 NDEF 文本读写。

6. Ndef API 与 tag 认证的关系

当前代码只是在 tag 上写入一条 NDEF MIME record:

ndef.connect();
NdefRecord mimeRecord = NdefRecord.createMime(
        "text/plain",
        messageEncrypted.getBytes(Charset.forName("US-ASCII"))
);
ndef.writeNdefMessage(new NdefMessage(mimeRecord));
ndef.close();

调用 Ndef.writeNdefMessage() 并不等于执行 NTAG213 的 PWD_AUTH 或 Ultralight C 的 3DES authentication。配置原生密码保护后,通常需要通过相应的底层技术接口发送芯片命令,例如调用 NfcA.transceive()。具体命令、页面地址和认证顺序必须以准确型号的数据手册为准。

认证状态通常与当前 RF/session connection 相关。如果先通过一个连接完成认证,随后关闭连接并使用 Ndef 重新连接,认证状态未必会保留。不能假定底层认证会与高层 NDEF 写入自动组合。

安全协议更适合使用紧凑的二进制记录,而不是把密文伪装成 text/plain:

NdefRecord record = NdefRecord.createMime(
        "application/vnd.example.wallet",
        binaryPayload
);

NTAG213 的用户存储空间有限。NDEF header、nonce、GCM tag、版本号和业务字段都会占用容量,因此设计前需要计算完整编码后的实际长度。

7. 安全措施清单

Tag layer

  • 为每张 tag 配置不同的认证密钥或密码。
  • 保护配置页和所有业务数据页,不能只保护 NDEF payload 的一部分。
  • 只有在接受永久不可写的情况下才使用 lock bits。
  • 谨慎设置认证失败次数限制。
  • 验证 tag 类型、容量和配置字节,避免把异常 tag 当成正常卡片。
  • 高价值离线业务应使用支持强双向认证和 secure messaging 的安全芯片。
  • 按照具体芯片手册实施密钥更新、访问权限和交易恢复。

Application layer

  • 使用 AES-GCM、AES-CCM 或其他经过审查的 AEAD。
  • 每次加密都生成唯一 nonce。
  • 在验证成功前,不解析、不显示数据,也不执行余额变更。
  • 为记录加入协议版本、密钥版本、计数器和交易 ID。
  • 所有整数都采用固定长度和固定字节序,避免解析歧义。
  • 不把 UID 当作秘密。
  • 不在 APK 中硬编码全局主密钥。
  • 拒绝处理重复、过期或计数器倒退的记录。
  • 数据库扣款必须使用原子事务。
  • 建立挂失、补卡、密钥轮换、审计和异常交易检测机制。

8. “DESFire > Ultralight > NTAG213 > Classic”比较的是什么

这种简单排序很容易造成误解,因为“安全”并不是单一指标。实际评估时,需要分别比较:

  • 认证密钥长度和认证协议。
  • 是否支持双向认证。
  • 是否支持带加密和完整性保护的 secure messaging。
  • 读取、写入和配置区的访问控制粒度。
  • 是否支持每卡独立密钥和密钥轮换。
  • 是否具有硬件计数器、Value File 或事务机制。
  • 是否能够抵抗复制、重放和回滚。
  • 如何处理断电和交易中断。
  • 芯片是否存在已知的密码学攻击或实现漏洞。
  • 密钥是否容易从终端应用中泄露。
  • 系统是否正确部署和配置。

MIFARE Classic 使用的原生 Crypto-1 已经存在公开且实用的攻击,因此不应再用于新的安全支付设计。

Ultralight C 的 3DES authentication 通常比 NTAG213 的 32-bit password 更强,但它仍然不是完整的电子钱包平台。“Ultralight”也是一个产品家族,不能把不同型号的能力混为一谈。

DESFire 同样需要区分具体代际、算法和配置。错误的密钥管理、共享默认密钥或未启用安全消息,都会让强芯片失去意义。

ICODE SLIX 主要用于 ISO/IEC 15693 的远距离识别场景。不能只看产品名称,就认定它在支付方面比另一款 tag 更安全。必须检查具体型号所支持的认证、加密、访问控制和交易能力。

9. NTAG213 能否用于食堂余额

NTAG213 可以用于课堂演示或低风险原型,但需要明确展示它的安全边界。它也可以作为在线账户的标识载体。即使 tag 被复制,后端仍可通过账户状态、交易风控或额外认证来限制损失。

不建议把 NTAG213 上保存的密文余额作为真实资金的唯一依据,原因包括:

  • 32-bit password 的强度有限。
  • UID 可以被读取,也可能被模拟。
  • Android 客户端中的密钥可能被提取。
  • 合法密文可以被完整复制并回滚。
  • tag 可能被改写、锁定或破坏,造成拒绝服务。
  • 普通 NDEF 写入缺少支付交易所需的原子性和防重放机制。

“破解需要多久”没有可信的统一答案。暴力猜测四字节密码的速度会受到 NFC 交互和失败次数限制的影响,但攻击者更可能选择复制旧记录、克隆标识、逆向 APK 或提取终端密钥。这些攻击路径甚至不需要破解 AES-256。

对于大学项目,较合理的方案是由服务器保存余额,tag 只保存随机账户 ID 和经过认证的凭证。如果项目目标是实现真正的离线电子钱包,则应选择支持强认证、安全消息和事务文件的安全芯片,并把密钥管理、计数器、断电恢复和后台对账纳入整体设计。

备注:内容仅供参考。