☰
Android NFC门禁卡模拟实战:从读卡原理到HCE代码详解
2026/9/29 2:26:26 网站建设 项目流程

先别急着打开Android Studio。我这次做手机模拟门禁卡,踩了整整一个晚上的坑,最后发现99%的资料都没把最核心的一件事讲透:你的门禁卡是什么卡,决定了你能不能模拟、怎么模拟。

事情的起因很简单,小区换了新门禁,发的是Mifare Classic卡,我这人丢三落四,一周补办了三次卡。干脆想:能不能把门禁卡塞进手机里?NFC都普及这么多年了,按理说这是成熟功能。结果一查资料、一上手才发现,这里面的水比想象中深。搜遍全网,教程不是让你装一堆来路不明的模块,就是甩一句“用NFC卡模拟App就行”,结果装上要么提示不兼容,要么刷开门禁的瞬间被拦在门外——为什么别人的教程就行,你就不行?因为门禁卡的类型、手机NFC协议栈、门禁读卡器的响应方式,三层因素有一层对不上就白搭。

这篇文章把我从上到下的完整实现过程扒给你看,包含了可直接运行的读卡和卡模拟代码,同时把原理边界也掰开揉碎讲清楚。不管你以前有没有接触过Android NFC,按这篇的步骤走,至少能搞清楚“我这卡到底能不能模拟”“卡在哪一步”“代码该怎么写”。

1. 先搞清楚门禁卡是哪一类:读不到卡、模拟失败的根源

很多人在网上问“为什么我的手机识别不了门禁卡”,点开详情一看,大概率是他把门禁卡贴到手机背面,手机连个反应都没有。这不一定是你代码写得不对,而是你手里的卡根本就不是手机NFC能直接交互的那一类。

1.1 门禁卡的三大家族

市面上常见的门禁卡,按通信频率和协议大致分三种:

卡类型工作频率常见形态手机NFC能不能直接读
ID卡(EM4100等)125kHz低频厚卡、大部分老式门禁扣不能,手机NFC芯片只在13.56MHz工作
Mifare Classic(S50/S70)13.56MHz最常见的薄卡、IC门禁卡能,绝大多数安卓手机都支持
CPU卡(ISO 14443-4)13.56MHz银行卡、部分新式门禁卡能,支持ISO-DEP协议就能交互

如果你的门禁卡是125kHz的ID卡,那这条路从一开始就断了。手机NFC芯片只工作在13.56MHz频段,物理层面就匹配不上。这种卡要么继续用实体卡,要么去搞专门的ID卡复制器,手机模拟不了。

最幸运的情况是Mifare Classic,它也是目前小区、写字楼用得最多的一类。不过“能读”和“能模拟”是两回事,后面我会专门讲这个边界。

1.2 用代码给卡片“验明正身”

与其靠猜,不如直接读卡片的ATQA和SAK值。这两个参数是ISO 14443-3协议里卡片在防碰撞阶段回给读卡器的关键信息,决定了卡片的技术类型。你可以写一个最简单的读取器App,把下面这段逻辑跑一遍:

private fun resolveCardType(tag: Tag): String { val techList = tag.techList techList.forEach { Log.d(TAG, "支持技术: $it") } return when { techList.contains("MifareClassic") -> "Mifare Classic (S50/S70)" techList.contains("IsoDep") -> "ISO 14443-4 / CPU卡" techList.contains("NfcA") -> "ISO 14443-3A" techList.contains("NfcB") -> "ISO 14443-3B" techList.contains("NfcF") -> "FeliCa" else -> "未知卡型" } }

tag.techList会列出这张卡支持的所有NFC技术,比如一张典型的Mifare Classic卡会同时包含NfcA和MifareClassic两项。如果只包含NfcA,很可能是Mifare Ultralight或者某些加密卡,解析方式完全不同。

提示:判断卡型的时机必须在NFC连接之前。Tag对象一旦创建,techList就固定了,不会因为你后续connect而变化。

2. 手机NFC的模拟能力边界:哪些能App模拟、哪些不能

这是全网教程讲得最含糊的部分,也是我被坑得最惨的部分。先说结论:Android第三方应用通过HCE(Host Card Emulation)没法直接模拟Mifare Classic门禁卡。这不是代码努不努力的问题,是协议栈层面就封死了。

2.1 三种“模拟”方案的真实差异

方案原理适用卡型门槛
系统级卡模拟(厂商ROM)NFC控制器固件直接模拟卡片UID等小米、华为等系统自带“门禁卡”功能依赖手机厂商
全卡模拟(Root+Xposed)拦截NFC驱动层收发,完整模拟卡片数据理论上支持Mifare Classic需要Root,且不同ROM兼容性差
HCE应用层模拟手机系统把NFC控制器收到的APDU转发给App仅支持ISO 14443-4 / CPU卡无需Root,但卡型受限

很多教程让你装“NFC卡模拟”类App,其实走的就是HCE。它模拟出来的是一张“支持ISO-DEP协议的卡”,不是一张“Mifare Classic”。门禁读卡器在防碰撞阶段发现手机回应的防碰撞参数不对,根本不会继续往下走。

2.2 为什么HCE模拟不了Mifare UID

NFC交互分三层:ISO 14443-3的防碰撞/选卡阶段,ISO 14443-4的APDU交换阶段,再往上才是NDEF或者Mifare的块读写。门禁读卡器读Mifare Classic时,第一步是防碰撞,目标是拿到卡片的UID(4字节或7字节)。很多门禁系统只校验UID就开门——市面上大量复制卡就是这么干的,买一张UID可写卡,把原卡UID写进去就完事。

Android提供给第三方应用的HCE,只开放了ISO 14443-4的APDU交换能力。防碰撞阶段由NFC控制器固件处理,第三方应用根本拿不到改UID的入口。所以App层想模拟Mifare UID,结论是做不到。能实现全卡模拟的只有两条路:厂商ROM系统级功能,或者Root后替换NFC协议栈。

那HCE有没有用?有,而且很有用。如果你们门禁用的是CPU卡(带COS的智能卡),读卡器选卡后会发SELECT AID,再走APDU认证流程。HCE正好可以在应用层处理这些APDU,实现一张虚拟CPU卡。第5章的Demo就是围绕这个做的。

3. 工程配置与NFC调用方式:权限、ReaderMode和回调初始化

不管读卡还是模拟,环境配置是第一步。Android的NFC API看起来简单,但有几个细节没处理好,真机调试时会浪费大量时间。

3.1 权限声明与AndroidManifest配置

在AndroidManifest.xml里加上这两行:

<uses-permission android:name="android.permission.NFC" /> <uses-feature android:name="android.hardware.nfc" android:required="true" />

uses-feature带required="true"是告诉应用商店“没有NFC硬件的设备别给我装”。如果你的App还有非NFC用途,可以改成required="false",然后在代码里运行时判断。

3.2 用ReaderMode还是前台NDEF调度

Android读取NFC标签有几种方式,最老土的是在onNewIntent里等NDEF消息,但门禁卡大多不带NDEF数据,这种方案会漏卡。实测下来最稳的是enableReaderMode,它能绕过系统弹窗确认,只要检测到NFC标签就直接回调,对门禁卡这种纯技术卡来说体验好太多。

override fun onResume() { super.onResume() nfcAdapter?.enableReaderMode( this, readerCallback, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null ) } override fun onPause() { super.onPause() nfcAdapter?.disableReaderMode(this) }

FLAG_READER_SKIP_NDEF_CHECK很重要。如果不加,系统检测到NDEF标签时会先用NDEF解析逻辑处理一遍,对Mifare Classic这种非NDEF卡会多出不必要的延迟。

3.3 初始化NfcAdapter与权限校验

nfcAdapter = NfcAdapter.getDefaultAdapter(this) if (nfcAdapter == null) { Log.e(TAG, "设备不支持NFC") return } if (!nfcAdapter!!.isEnabled) { Log.e(TAG, "NFC未开启,请到系统设置打开") }

这两个判断一定要做,别偷懒。没有NFC硬件的设备上,getDefaultAdapter返回的是null,直接调用会闪退。

4. 读卡代码实战:UID、ATQA/SAK和Mifare扇区一次拿走

理清原理后,真正的编码环节就顺畅了。这一节给的是完整可运行的MainActivity,前提是你新建了一个Android工程,包名随意,只要在activity_main.xml里放一个TextView,id设为tv_result就行。

4.1 读取UID、ATQA、SAK与Mifare扇区数据

package com.example.nfcreader import android.nfc.NfcAdapter import android.nfc.Tag import android.nfc.tech.MifareClassic import android.nfc.tech.NfcA import android.os.Bundle import android.util.Log import android.widget.TextView import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { private val TAG = "NfcReader" private var nfcAdapter: NfcAdapter? = null private lateinit var tvResult: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) tvResult = findViewById(R.id.tv_result) nfcAdapter = NfcAdapter.getDefaultAdapter(this) if (nfcAdapter == null) { tvResult.text = "当前设备不支持NFC" return } if (!nfcAdapter!!.isEnabled) { tvResult.text = "NFC未开启" } } override fun onResume() { super.onResume() nfcAdapter?.enableReaderMode( this, readerCallback, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null ) } override fun onPause() { super.onPause() nfcAdapter?.disableReaderMode(this) } private val readerCallback = NfcAdapter.ReaderCallback { tag -> runOnUiThread { tvResult.text = parseTag(tag) } } private fun parseTag(tag: Tag): String { val sb = StringBuilder() sb.append("UID: ").append(tag.id.toHex()).append("\n") sb.append("技术支持: ").append(tag.techList.joinToString()).append("\n") // 读取ATQA和SAK try { val nfcA = NfcA.get(tag) nfcA.connect() sb.append("ATQA: ").append(nfcA.atqa.toHex()).append("\n") sb.append("SAK: ").append(String.format("%02X", nfcA.sak.toInt() and 0xFF)).append("\n") nfcA.close() } catch (e: Exception) { Log.w(TAG, "读取ATQA/SAK失败", e) } // 读取Mifare Classic数据 if (tag.techList.contains("MifareClassic")) { sb.append(readMifareClassic(tag)) } return sb.toString() } private fun readMifareClassic(tag: Tag): String { val sb = StringBuilder() val mfc = MifareClassic.get(tag) try { mfc.connect() sb.append("Mifare Classic 扇区数: ").append(mfc.sectorCount).append("\n") val keys = arrayOf( MifareClassic.KEY_DEFAULT, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY, MifareClassic.KEY_NFC_FORUM ) for (sector in 0 until mfc.sectorCount) { for (key in keys) { if (mfc.authenticateSectorWithKeyA(sector, key)) { val blockCount = mfc.getBlockCountInSector(sector) for (blockIdx in 0 until blockCount) { val blockIndex = mfc.sectorToBlock(sector) + blockIdx try { val data = mfc.readBlock(blockIndex) sb.append(String.format("扇区%02d 块%02d: %s%n", sector, blockIndex, data.toHex())) } catch (e: Exception) { sb.append(String.format("扇区%02d 块%02d: 读取失败%n", sector, blockIndex)) } } break } } } mfc.close() } catch (e: Exception) { sb.append("读取MifareClassic失败: ").append(e.message).append("\n") } return sb.toString() } private fun ByteArray.toHex(): String = joinToString("") { String.format("%02X", it.toInt() and 0xFF) } }

4.2 结果怎么看

拿小区卡跑一遍,你会看到类似这样的输出:

UID: 6A2B3C4D 技术支持: NfcA, MifareClassic ATQA: 0004 SAK: 08 Mifare Classic 扇区数: 16 扇区00 块00: 4D3C2B6A 12345678 9ABCDEF0 11223344

SAK=08是Mifare Classic 1K最常见的值,4字节UID。SAK=18则常见于7字节UID的卡,复制或者模拟时要注意这个差异。门口刷卡的逻辑往往只比对UID,所以读卡阶段第一行那个UID就是很多门禁系统的“钥匙”。

注意:扇区末尾的Trailer块(每个扇区第3块)存的是密钥和访问位,当你手上没有对应Key时读取会直接抛异常。上面代码用了三层常见默认密钥依次尝试,能读的部分都是门禁系统没做加密处理的;读不到也没关系,说明该卡有独立密钥体系,需要专业的卡务工具才能处理。

5. 卡模拟代码实战:HCE模拟一张ISO-DEP门禁卡

读卡只是前半场,后半场的卡模拟才是真正有意思的部分。这一节完整实现一个HostApduService,让手机在门禁读卡器面前呈现为一张“支持ISO 14443-4的CPU卡”。

5.1 HCE服务声明与AID配置

先在res/xml下新建apduservice.xml:

<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android" android:description="@string/service_description" android:requireDeviceUnlock="false"> <aid-group android:description="@string/aid_group_description" android:category="other"> <aid-filter android:name="F222222222" /> </aid-group> </host-apdu-service>

AID是一串偶数位的十六进制数字,F222222222对应5字节的AID。category这里用other而不是payment,因为门禁卡模拟不属于支付场景,避免和支付类HCE冲突。

然后在AndroidManifest.xml的<application>内注册服务:

<service android:name=".MyHostApduService" android:exported="true" android:permission="android.permission.BIND_NFC_SERVICE"> <intent-filter> <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" /> </intent-filter> <meta-data android:name="android.nfc.cardemulation.host.apduservice" android:resource="@xml/apduservice" /> </service>

android:permission="android.permission.BIND_NFC_SERVICE"是系统强制要求,不能省。

5.2 HostApduService实现代码

package com.example.nfchce import android.nfc.cardemulation.HostApduService import android.os.Bundle import android.util.Log class MyHostApduService : HostApduService() { override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray { Log.d("HceService", "收到APDU: ${commandApdu.toHex()}") // 门禁读卡器选卡后,第一步通常是 SELECT AID if (isSelectAidCommand(commandApdu)) { // 返回9000表示成功选中 return byteArrayOf(0x90.toByte(), 0x00) } // 其他APDU根据实际门禁逻辑处理 return byteArrayOf(0x90.toByte(), 0x00) } override fun onDeactivated(reason: Int) { Log.d("HceService", "卡模拟停用,原因: $reason") } private fun isSelectAidCommand(apdu: ByteArray): Boolean { // ISO 7816 SELECT命令格式:00 A4 04 00 [长度] [AID] return apdu.size >= 4 && apdu[0] == 0x00.toByte() && apdu[1] == 0xA4.toByte() && apdu[2] == 0x04.toByte() } private fun ByteArray.toHex(): String = joinToString(" ") { String.format("%02X", it.toInt() and 0xFF) } }

5.3 这个Demo能刷开什么门禁,刷不开什么门禁

先泼盆冷水:这个Demo刷不开绝大多数普通Mifare门禁。因为那些门禁读卡器根本不走ISO 14443-4的AID流程,它们只做Mifare防碰撞和UID校验。

那它能干什么?如果你的门禁系统用的是CPU卡,读卡器会做以下几件事:

  1. 在ISO 14443-4层激活手机;
  2. 发送SELECT AID,试图读取卡片上的应用;
  3. 通过一系列内部认证APDU(比如00 84 00 00 08生成签名)来验证卡内数据。

第2步正是processCommandApdu的切入时机。你只需要按门禁系统的规范,把真实CPU卡的APDU应答逻辑原样实现一遍,手机就能伪装成那张CPU卡。这里有个现实前提:你得先知道目标卡对每条APDU的应答数据。这通常需要一张合法授权可研究的卡片,用IsoDep.transceive()逐条抓取分析。

提示:真实项目里,CPU卡内部往往带有安全认证算法,不是简单回一段固定数据就能过。固定返回只适合做POC和协议调试,量产方案要么拿到卡商授权,要么走系统级SE安全元件方案。

6. 真机实测中的差异、坑与合规提醒

代码归代码,真机环境千奇百怪。我在这个项目里踩过的坑,单独列出来能帮大家少走很多弯路。

6.1 厂商ROM的NFC实现差异

同一个enableReaderMode,在不同手机上表现完全不一样。小米、华为部分机型会优先走系统自带的“门禁卡模拟”逻辑,导致第三方App的ReaderMode偶尔抢不到标签;三星和Pixel则中规中矩,按协议标准来。实测下来:

  • 如果系统有自带NFC门禁卡功能,先用它试试能不能模拟成功,成功率往往高于第三方App;
  • 第三方App如果发现标签“刚贴上就被系统吃掉了”,可以试试点按屏幕唤醒后重新贴卡,或者到系统NFC设置里关掉“优先使用系统读卡器”之类的选项(部分ROM叫“NFC读卡器模式”)。

HCE的AID配置也受ROM影响。个别厂商对category="other"的HCE服务支持有bug,表现为手机贴上去没有反应。这时可以试试把android:requireDeviceUnlock改成true,强制解锁屏幕再刷,或者换一台原生安卓机做对照测试。

6.2 读卡距离、天线位置与协议细节

门禁卡感应区通常在手机背面摄像头附近,不是整个背面都灵敏。读卡时把卡片贴在中上部,缓慢移动找天线位置,比贴正中间更稳。另外NFC的感应距离很短,实测稳定识别距离一般在2-3厘米内,卡片和手机之间不要夹厚壳、卡片或金属物。

还有一个容易忽略的点:某些门禁读卡器只工作在一个固定的防碰撞窗口期。手机HCE从检测到卡到返回响应有一定延迟,如果门禁读卡器超时快,就可能“刷不上”。把手机亮度锁屏时间拉长,提前点亮屏幕贴卡,能有效降低超时概率。

6.3 关于合法使用的提醒

写这类内容必须把丑话说在前面。手机模拟门禁卡,目前最现实的应用场景是备份自己合法持有的门禁卡,比如小区发给你的卡、公司发的工作证,真丢了可以用手机应急开门。未经授权读取或复制他人的门禁卡、绕过管理方门禁系统,属于违规甚至违法行为,本篇文章提供的代码和技术分析仅限于学习研究和自我授权场景。

另外顺带提一句网上流传的所谓“NFC中继”方案,本质是把读卡器端采集的数据无线转发到手机端再应答,这类设备依赖专用硬件和通信链路,不在Android原生开发能力范围内,也不适合在这类技术社区展开,更不建议碰。

6.4 后续扩展思路

到这里,读卡和HCE模拟这条线已经完整跑通了。如果你想继续深入,有几个方向值得玩:

  • 把读到的Mifare转储文件解析成结构化数据,研究访问位和密钥分布;
  • 对接IsoDep.transceive(),抓取CPU卡完整通信日志,做一张“会应答的虚拟卡”;
  • 如果手机支持安全元件(SE),可以研究将卡片数据写入SE的方案,不过这条路通常是厂商定制,普通开发者拿不到完整权限。

从“读卡”到“模拟”,中间最关键的不是代码能力,而是把NFC协议栈和卡片的握手流程搞明白。Android的NFC API封装得已经很友好了,门槛比底层驱动开发低得多,但该懂的协议知识一点也省不了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询