探索 HNU 无限水卡的可能性
Table of Contents
Table of Contents
郑重声明:本文仅涉及技术讨论,所有内容仅供学术研究与安全测试参考。请勿将文中技术用于任何非法用途。本文不鼓励、不支持任何破坏公共设施或侵犯他人权益的行为。
据大家所知,HNU 大部分宿舍楼的独立卫浴洗澡是不需要刷热水卡的,比如在德智,似乎每一个宿舍楼都有热水器……但笔者所在的天马三区十六栋,以及三区的十栋、十一栋,一区的二栋等部分楼栋例外。这些楼栋的热水余额和校园饭卡余额是分开的,部分楼栋可能单独办一张热水卡,也有一些楼栋是校园卡一卡通就变成了热水卡,余额耗尽后得跑去「天马事务大厅」人工充值。
去充过几次值就知道,这个充值系统非常原始:微信扫码付款,工作人员在电脑的电子账本上登记学号和金额,微信扫码进入“热水充值系统”备注楼栋与房号,然后把卡放到 nfc 刷卡机器上,再让工作人员操控电脑把钱写进卡里。整个流程完全离线,没有任何联网查余额的机制,也没有任何线上充值的机制。由此观察得之,大概率水卡余额数据就只存在于手上这张小小的 M1 卡里。
相比之下,一些比较新的楼栋(比如三区十九栋)热水余额已经和校园饭卡打通了,充值走线上系统,数据存储在校园云端。这种卡就没那么好折腾了:饭卡余额跟数据库实时对账,改本地数据基本是掩耳盗铃。(说到饭卡系统,上次在超算中心上实验课的时候,我用老师提供的数据包偶然抓到过一些可疑的校园卡后台通信报文,之后有机会再分析。)
终于一个发现水卡没钱与想到我已经为热水贡献了快 500 块钱了的无聊夜晚,加上我因换了 iPhone 失去了复制校园卡来刷门禁的功能,我突然冒出一个念头:既然这张校园卡是完全离线的,那数据到底是怎么存的?于是上 PDD 花 40 块钱买了一个「NFC reader 带密钥破解功能」,一个折腾的下午开始了……

背景
观察水表 Logo,可以看到我们学校热水表使用的是新天科技 (Suntront) 出品的非接触式 IC 卡水表(至少我的宿舍是),卡片是 Mifare Classic 1K (S50)。玩过 NFC 折腾的对这个卡片应该不陌生,它的安全性依赖私有的 Crypto-1 算法,但早已被破解,有密钥就能读写任意扇区。
设备方面,我用了 PN532 模块配合 libnfc 1.8.0 来读卡,写卡靠手机上的 NFC 小程序(PN532 对某些国产克隆卡的 Block 1/2 写入有兼容性问题,手机反而更稳定)。入门折腾足够了。
卡片结构一览
漫长地等待暴力密钥破解,然后拿到密钥后 dump 出全卡数据,整体结构如下:
| 扇区 | 用途 |
|---|---|
| 0 | UID + 厂商数据(出厂固化) |
| 1 | 学号 + 卡号等明文信息 |
| 2–12 | 空白/其他用途 |
| 13 | 水卡余额数据(重点在这里) |
| 14–15 | 空白 |
其中 Sector 13 的 Trailer (Block 55) 密钥为 865200361986,KeyA 和 KeyB 相同。拿到这个密钥就能读到余额扇区了。
我的卡片 dump 数据
把我 dump 出来的原始 hex 是这样的,这张卡水卡余额 ¥47.45,mode=1。:
Sector 0 — UID 与厂商区
Block 0 FC A4 96 AC 62 08 04 00 04 47 15 39 CB C2 B5 90 ← UID: FC A4 96 AC
Block 1 02 19 24 08 17 AA 00 AA 31 62 02 24 08 19 4F 31 ← 厂商信息
Block 2 53 70 05 00 D0 BB BE B2 CC F0 00 20 28 07 28 01
Block 3 43 F9 E2 22 08 86 FF 07 80 69 43 F9 E2 22 08 86 ← KeyA=KeyB, 出厂默认
Sector 0 的 Block 0 存储了卡片的 UID(FC A4 96 AC)和厂商信息,这是出厂固化的,无法修改。SAK 08 确认这是一张 Mifare Classic 1K (S50)。
Sector 1 — 学号与卡号(明文)
Block 4 FF 32 30 32 34 30 38 2A 2A 2A 2A 00 02 00 FF FF ← "202408****" (学号)
Block 5 32 31 00 20 20 20 20 20 20 20 20 30 31 FF FF 00 ← "21" "01" (卡号?)
Block 6 10 53 2A 00 00 00 55 38 1F FF FF FF FF FF FF FF
Block 7 A3 93 49 AC E3 01 FF 07 80 69 A3 93 49 AC E3 01 ← KeyA=KeyB
学号居然是以明文 ASCII 存的。202408**** 就这么直接放在 hex 里,没有任何加密或混淆。后面 21 和 01 可能是批次号或卡序号。扇区密钥 A39349ACE301 也直接写在 Trailer 里,KeyA 和 KeyB 相同。
所以为什么有密钥的卡,应用层数据却是完全明文呢?这也太草台班子了……
Sector 13 — 余额数据
Block 52 89 12 00 00 D5 A0 00 01 00 00 02 D1 02 00 F4 DA ← 余额=¥47.45
─────────── ───── ── ── ─────────────── ─────
balance const rsrv mode const checksum
4B LE 2B 1B 1B 6B 2B LE
Block 53 89 12 00 00 D5 A0 00 01 00 00 02 D1 02 00 F4 DA ← 与 Block 52 完全相同!
Block 54 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ← 空白
Block 55 86 52 00 36 19 86 FF 07 80 69 86 52 00 36 19 86 ← Key=865200361986
───────────────────── ─────────────
KeyA Access Bits KeyB
请 AI 把 Block 52 拆开来看:
| 字节偏移 | 原始值 | 含义 |
|---|---|---|
[0] = 0x89 | — | 余额低字节 |
[1] = 0x12 | — | 余额高字节 → 0x1289 = 4745 分 = ¥47.45 |
[2–3] = 0x0000 | — | 余额高位(未使用,金额小) |
[4–5] = D5 A0 | 常量 | 所有样本一致 |
[6] = 0x00 | — | 保留位 |
[7] = 0x01 | mode=1 | 校验算法分支 |
[8–13] | 常量 | 00 00 02 D1 02 00,所有样本一致 |
[14] = 0xF4 | 固定 | mode=1 下的硬编码常量 |
[15] = 0xDA | 校验 | (0x89+0x12+0x00+0x00+0x3F) & 0xFF = 0xDA ✅ |
Block 55 的 Trailer 里,KeyA 和 KeyB 完全相同:865200361986。拿到它,整个 Sector 13 就可以随意读写。
一张卡片的密钥分布
有趣的是,不同扇区的密钥并不统一:
| 扇区 | KeyA | KeyB | 备注 |
|---|---|---|---|
| 0 | 43F9E2220886 | 43F9E2220886 | 出厂默认 |
| 1 | A39349ACE301 | A39349ACE301 | 学号扇区 |
| 2–12 | 各不同 | 各不同 | 其他用途或空白 |
| 13 | 865200361986 | 865200361986 | 余额扇区 |
| 14–15 | FFFFFFFFFFFF | FFFFFFFFFFFF | 空白(出厂默认密钥) |
每个扇区独立控权。即使读到了学号扇区的密钥,也打不开余额扇区。但所有密钥都是 KeyA=KeyB 且基于已知的 M1 漏洞可 dump 出,安全边界一旦被突破就完全透明。
余额存储格式
Sector 13 里 Block 52 和 Block 53 是关键。每次刷卡后,终端会把完全相同的余额数据写入这两个块作为冗余备份。
16 字节的数据块结构如下:
Offset Size Field 说明
─────────────────────────────────────
0–3 4 balance int32 LE,单位为分 (1 = ¥0.01)
4–5 2 constant D5 A0(固定常量)
6 1 reserved 始终为 0x00
7 1 mode 模式标志位 (0/1/2),决定校验算法分支
8–13 6 constant 00 00 02 D1 02 00(固定常量)
14–15 2 checksum uint16 LE
余额用 4 字节小端序存储,单位是分。比如 0x89 0x12 开头的两个字节代表 0x1289 = 4745 分,也就是 ¥47.45。
关键字段是 mode(byte[7]),它决定了终端使用哪套校验算法。我一共观察到三种 mode 值:0x00、0x01、0x02。
样本数据
以下是同一张卡片在不同消费后的 Sector 13 原始数据(Block 52 = Block 53)。mode=1 的算法已经在上文公开,mode=0 和 mode=2 至今没搞明白。如果你有兴趣,不妨拿这些 hex 试试。
| 标签 | 余额 | mode | Block 52/53 原始数据 |
|---|---|---|---|
| 原始卡 | ¥47.45 | 1 | 89120000D5A00001000002D10200F4DA |
| 消费 D | ¥47.54 | 1 | 92120000D5A00001000002D10200F4E3 |
| 手动构造 ¥50 | ¥50.00 | 1 | 88130000D5A00001000002D10200F4DA |
| 消费 A | ¥49.96 | 2 | 84130000D5A00002000002D10200583B |
| 消费 B | ¥49.92 | 2 | 80130000D5A00002000002D10200BC9B |
| 消费 C | ¥48.93 | 0 | 1D130000D5A00000000002D1020064DE |
几点观察供参考:
- 消费 A 和 B 都是 mode=2,byte[0] 差 4(0 x 84 → 0 x 80),校验位却从
3B 58跳到了9B BC。byte[1..7] 完全相同 - 消费 C 是唯一的 mode=0 样本,byte[1]=0 x 13,byte[0]=0 x 1 D
- CRC RevEng 全多项式空间搜索无匹配,排除了标准 CRC-16
- 目前怀疑 mode=2 涉及 LFSR 或查表变换,但两个样本实在不够推导……
如果你看出了什么规律,或者手头有更多 mode≠1 的 dump 数据,非常欢迎在评论区或通过邮件联系我。
校验算法
花了最多时间的就是这块。我收集了若干次自然刷卡后的样本,手动构造数据写回卡片,放到宿舍卫生间的水表上去试。好在最终 mode=1 的算法被我异想天开地猜测出来了……但是 mode 2 和 mode 0 还没有猜出来。
mode=1 校验算法
mode=1 的校验算法其实非常简单:
byte[14] = 0xF4 (硬编码常量!)
byte[15] = (byte[0] + byte[1] + byte[2] + byte[3] + 0x3F) & 0xFF
就是把 4 字节余额全部加起来,取低字节,再加上 0x3F,再取低字节。而 byte[14] 是一个不受余额影响的硬编码 0xF4。
很幸运的是,我第一个手动构造的金额是 ¥50.00(还非常幸运成功了)刷 CUID 卡片里,它的校验位恰好和原始卡片 ¥47.45 的校验位一模一样。当时我还以为校验位跟余额无关,纯粹是固定值,后来加大金额放上去是 error。
问了可能性,AI 直接给出一个很简单的说法: 0x88 + 0x13 = 0x9B 和原始 0x89 + 0x12 = 0x9B 碰巧相等……
验证了几个不同金额:
| 金额 | b[0]+b[1]+b[2]+b[3] | +0x3F | b[15] 预期 | 终端结果 |
|---|---|---|---|---|
| ¥47.45 | 0x9B | 0xDA | 0xDA | ✅ |
| ¥47.54 | 0xA4 | 0xE3 | 0xE3 | ✅ |
| ¥50.00 | 0x9B | 0xDA | 0xDA | ✅ |
| ¥60.00 | 0x87 | 0xC6 | 0xC6 | ✅ |
| ¥655.35 | 0xFE | 0x3D | 0x3D | ✅ |
| ¥999.99 | 0x26 | 0x65 | 0x65 | ✅ |
全部通过,算法确认无误。
但是当时没有改 mode=1,所以测试是否有效,失败了很多次,我一度认为是不是还有什么神奇的算法在里面。然后我第二天起床想到这回事,然后就成功了 ……
mode=2 校验算法(未破解)
两个 mode=2 的样本中,byte[0] 仅差 4(0x84→0x80),但 16 位校验值从 3B 58 跳到了 9B BC,byte[14] 也不再是常量。变化幅度远超线性累加能解释的范围,可能涉及 LFSR 或查表。目前样本太少,没法继续分析。
mode=0 校验算法(样本不足)
我刷卡测试的时候仅有一个数据点,无法推导。
暴力搜索排除了标准 CRC
关于 mode=2,我用 CRC RevEng 跑了一遍全多项式空间,没有匹配的 CRC-16 参数。几次差分测试也排除了 GF(2) 线性变换的可能性。至少不是常见的标准算法,厂商还是动了一点心思的。或者这可能根本不是什么算法,因为 mode 的变动来自于刷卡机的余额修改,我也很难知晓刷卡机到底是依赖什么算法进行余额修改的。
余额上限
但是 mode=1 这个算法足够我们造出来无限水卡了,我想到水表显示上限应该是四位数(xxxx 或者 xx.xx),所以一开始直接刷了一个 9999.99 写进 CUID 卡内。但是提示 ErrL。
似乎刷卡机对卡的余额检测是有上限的(即便不超过它显示的量程范围),所以通过二分测试,这个范围大概就是 5000 元。
对照 Suntront 官方给的说明书,终端错误码含义可能如下:
| 显示 | 含义 |
|---|---|
| 无声/无响应 | 校验位错误 |
| Err A | 密码或区号不一致 |
| Err B | 表号不一致 |
| Err L | 购水数据不一致(含金额超出合理范围) |
| Err H | 卡类型错 |
Err L 的出现说明校验其实已经通过了,只是在业务逻辑层被拦了下来。终端预设了一个合理金额上限,超过 ¥5000 就会拒绝。这个上限是固定的还是可配置的,目前还不太确定。但是 5000 元足够我们整个宿舍每个人洗四年的每天澡了……😂也算是无限了。
关于所谓的「无限水卡」
由于终端写回的 mode 不可控(它可能在刷卡结束后把 mode 改成 0、1 或 2),如果你直接改余额,下次刷卡时如果 mode 变了而你没对应更新校验位,终端会直接报错。
绕过的思路也很直接:既然 mode=1 的算法已经完全透明,每次使用前用 mode=1 ,balance=5000,再构造正确的 checksum 的数据,覆盖 Block 52 和 Block 53 就行。终端验证通过后就会开阀放热水,和普通热水卡没有区别,结束写回的数据(mode 可能变)在下次使用前会被再次覆盖,但是也是属于正常修改。只要金额不超 ¥5000,终端就没有理由拒绝。
且根据新天科技的官方网站说明,这个非接触式水卡的系统的扣费是完全由刷卡终端结算,是离线的过程。但是不排除学校会二次改造、进行清算。
因而再次强调:这只是技术分析层面的讨论。实际中这样做属于违法行为,水表的数据异常也可能会在后台系统中留下痕迹。为了避免你被学校请去喝茶,请不要尝试。
这次的折腾起源于一个非常无聊的好奇心,但完整走下来还是很有意思。从完全不知道卡片里有什么,到能构造出一张终端接受的余额数据。
Mifare Classic 的安全性早已名存实亡,但厂家在应用层加的这一层校验逻辑(虽然也不复杂)还是让逆向过程多了一些趣味。NFC 的领域也好有意思啊,想买 Flipper Zero 的心又悸动了一些。
不过,Hacking for fun,not for money,也是要谨记的原则~