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-GCMAES-CCMChaCha20-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 大致有以下几种选择:
- 使用 password authentication 保护需要更新的用户页面。
- 使用 lock bits 将页面永久设为只读。
- 在应用层验证 AEAD authentication tag 或 MAC。
- 在后端记录版本号或交易计数器,并拒绝旧状态。
- 对高风险操作要求在线授权。
永久只读适合发行后不再变化的凭证,不适合需要反复更新的余额。只要终端需要更新卡片上的余额,就必须保留某种写入能力。
此外,密码学无法阻止持卡人损坏、屏蔽、替换或物理破坏 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 和经过认证的凭证。如果项目目标是实现真正的离线电子钱包,则应选择支持强认证、安全消息和事务文件的安全芯片,并把密钥管理、计数器、断电恢复和后台对账纳入整体设计。
备注:内容仅供参考。
