site logo

Marico's space

差分测试揭示了一致性测试遗漏的问题:开源 eMRTD 库的案例研究

Others 2026-07-08 11:34:40 8

最近折腾了一下电子护照(eMRTD)阅读库的差分测试,踩了几个安全层面上的坑,这篇把问题说清楚。

一致性测试套件告诉你某个库是否通过了自己的测试用例。但它没法回答一个更根本的问题:两个独立实现的库,在收到完全相同的协议流量时,会不会做出同样的安全决策?ICAO Doc 9303 定义了电子护照应该怎么工作,但你在项目中选择的开源库,决定了你的应用实际会遭遇什么。单元测试通过是一回事,互操作测试是另一回事。我们停下来问了自己一个更简单的问题:两个独立实现,收到完全相同的协议流量,会做出相同的安全决策吗?

我们决定动手找答案。方法是一个受控实验,围绕确定性合成协议配置文件展开。不需要护照卡片,没有 NFC 读卡器,也没有射频变量需要追踪。我们用同一个协议交换场景,分别对两个广泛使用的底层 eMRTD 阅读器进行了测试:Go 语言的 gmrtd 和 Java 语言的 JMRTD。

结果算不上惊天动地,但确实揭示了问题。PACE 在初始协商阶段失败了,然后 BAC 接上并成功完成。对于一个只检查是否建立了安全会话的应用来说,没有任何明确指示表明协议发生了降级。两个库在相同的配置下产生了相同的可观察行为。

实验完全可复现。当天下午晚些时候我们重新跑了一遍,得到了相同的结果,有相同的追踪日志和运行元数据支撑。

BAC、PACE,以及为什么要做差分测试

如果你做过 TLS 集成,应该知道协商失败在集成层是什么感觉。eMRTD 的访问控制就是 contactless 芯片上同样形态的问题。

BAC(Basic Access Control,基本访问控制)是老路径:用 MRZ 数据生成会话密钥。PACE(Password Authenticated Connection Establishment,密码认证连接建立)是新文档在认证之前通过 EF.CardAccess 通告的机制,也是审计人员越来越期望终端在支持时优先使用的方案。大多数芯片两者都支持,这是正常的。

有趣的部分是读卡器在 PACE 失败时的行为。栈是停下来报告错误,还是尝试 BAC,然后给你的应用返回一个看起来像干净会话的东西?

一致性测试问的是每个库是否通过了自己的检查清单。差分测试固定芯片行为,问的是每个库告诉应用的是什么。我们关心的是第二个问题。

我们试图了解什么

用大白话说这个假设:如果两个独立实现在看到相同协议流量时,典型的应用调用方应该看到相同的结果吗?

我们刻意把第一版做得小一些。两个库,不是五个。一个合成降级场景:PACE 被通告,第一个 PACE APDU 返回 6FFF,BAC 双向认证仍然可以工作。每个库跑一次冒烟测试。这篇文章是每个栈一个具体案例,不是流行度研究。

实验如何设置

芯片是一个 JSON 配置文件。模拟器在进程内根据那个配置响应 APDU,不需要真实芯片也不需要射频。本次测试中,每个驱动调用真实的库协商路径,对抗相同的字节流。无论返回什么——返回值、会话字段还是 APDU 日志——都会写入一个追踪文件。

我们用的配置叫 pace-then-bac-downgrade。关键字段如下:

{ "id": "TC-AC-01", "condition": "pace_fail_then_bac", "injection": { "pace_fail_on": "first_pace_apdu", "pace_sw": "6FFF" }
}

EF.CardAccess 在认证前通告 PACE(我们的配置通过 card_access_hex 建模这一点)。第一步 PACE 以 6FFF 告终。BAC 仍然完成。这就是整个对抗条件。

我们还给每次运行打分,评估一个简单集成是否能注意到降级。分数 0 意味着调用方代码只需检查"打开了吗?"就永远不会接触到失败。分数 2 意味着调用方不处理失败就无法继续。对于这篇文章,分数 0 就是全部故事。

运行实验

测试用例是 TC-AC-01。每个库执行一次。我们关心的是 JSON 追踪:run_id、APDU 数组、bac_successobservability_score,以及每个库实际暴露的错误表面。

在线路上,两个阅读器应该讲述相同的芯片故事。MSE:Set AT 启动 PACE;芯片返回 6FFF;GET CHALLENGE 和 EXTERNAL AUTHENTICATE 仍然以 9000 结束。BAC 密文每次运行都不同,因为每个库生成自己的随机数。失败点和降级路径不应该不同。

每个驱动写入 logs/<run_id>.json。在 blog-b10-2026-07 节点,run_id 使用微秒级 UTC 时间加单调序列后缀——模式是 TC-AC-01-{library}-YYYYMMDDTHHMMSS.microsecondsZ-NNNNNN(例如 TC-AC-01-gmrtd-20260707T135005.482913Z-000001)。我们在起草这篇文章的当天下午捕获了冒烟追踪;一位同事在同一个节点的新克隆实验室环境里重新运行了 make smoke,得到了相同结果(两个驱动的 observability_score: 0),但 run_id 值不同,正如预期。

线路上出现了什么

gmrtd

gmrtd 在会话对象上记录了 PACE 失败。它没有让你的服务代码可能当作成功边界的调用失败。

docEx, apduLog, err := reader.ReadDocument(password, atr, ats)
fmt.Printf("ReadDocument err: %v\n", err)
// >>> nil fmt.Printf("PACE err: %v\n", docEx.Session.PaceErr)
// >>> [DoPACE] doApduMseSetAT error: ... MSE:Set AT failed (Status:6fff) fmt.Printf("BAC success: %v\n", docEx.Session.BacResult != nil && docEx.Session.BacResult.Success)
// >>> true

PaceErr 被填充了。APDU 日志显示 6fff 在 BAC 命令之前。err 是 nil。如果你的处理器只检查返回值,你会记录一次成功的读取,但背后是 PACE 失败了,然后继续。

来自我们的 gmrtd 冒烟追踪(logs/TC-AC-01-gmrtd-*.json):

C: 0022c1a412800a04007f0007020204020483010184010d
R: 6fff C: 0084000008
R: 4608f919887022129000 C: 0082000028d7b3b9d55cb313556804d6afedd55db24f0e6389dc7403dbd4d4be510d784711fcafd7dfd68a387628
R: 46b9342a41396cd7d74fff620ba529aa76ed1d79623747d5383fd073b453010e3a6c7e2c59e3ea3e9000

可观察性分数:0。

JMRTD

JMRTD 在库边界上更响亮。它在 6FFF 时抛出 PACEException。生产环境的粘合代码往往更安静。

PACEException paceFailure = null;
try { service.doPACE(bacKey, paceInfo.getObjectIdentifier(), PACEInfo.toParameterSpec(paceInfo.getParameterId()));
} catch (PACEException e) { paceFailure = e; // SW = 0x6fff
}
service.sendSelectApplet(false);
service.doBAC(bacKey);

我们见过足够多次这种模式了,以至于把它当作默认风险而不是边缘情况:捕获,也许在 DEBUG 级别记录,调用 doBAC(),返回 authenticated: true。实验有意用那个包装器建模。

来自我们的 JMRTD 冒烟追踪(logs/TC-AC-01-jmrtd-*.json):

C: 0022c1a40f800a04007f00070202040204830101
R: 6fff C: 00a4040c07a0000002471001
R: 9000 C: 0084000008
R: 4608f919887022129000 C: 008200002892e5c592d2995c4f76a0e0b7f71e8d915f28e27a5a6bc11aa5706ddc781e05cbd3f6534575ebc19b28
R: 46b9342a41396cd798d2cc8b293b4fb78abfd26d4ef5bd5bd8475bf0f290ed59325904a018b7d5419000

相同的芯片。不同的 API 形态。如果应用只问会话是否打开了,同样的漏洞依然存在。用 catch-and-continue 包装器的可观察性分数也是 0。

为什么我们认为集成商应该关心

当 PACE 失败而 BAC 成功时,栈接受了一个比芯片通告的更弱的机制。你的中间件可能记录 authenticated: true,却永远不会存储 PACE 曾经被尝试并被拒绝。基于会话成功构建的 SIEM 规则不会看到它。策略说"当 EF.CardAccess 提供时使用 PACE",却无法强制执行你的 API 从未暴露的内容。

如果你曾经在供应商代码库里搜索过 doPACE 失败后的 doBAC,你就知道那种感觉了。README 说库支持 PACE。你的包装器可能仍然把 BAC 成功当作任务完成。审计你锁定的版本,而不是营销页面。

一个故意无聊的修复

失败模式是隐式降级。我们关心的修复同样无聊:不要回退,除非回退是一个明确的策略决定。

在 gmrtd 上,这意味着把 PaceErr 暴露在返回路径上,而不是埋在会话结构体里。在 JMRTD 上,这意味着重新抛出 PACEException 而不是 catch-and-continue。用伪代码说规则很简单:如果 PACE 失败了且策略不允许 BAC 回退,就返回错误;不要悄悄打开 BAC 然后宣称成功。

我们在实验仓库里接了一个薄薄的中间件层来展示修复前后的差异。基线驱动得分 0。缓解后的驱动强制调用方处理 PACE 失败。没什么花活,就是把协议结果和事务成功分开。

别处同样的故事

护照是运行这个测试的方便场所,因为开源阅读器存在且规范公开。但模式不是护照特有的。长寿硬件叠加短命软件的地方就会出现这个情况:PIV 卡、国家电子身份证、支付终端,任何安全芯片比你在上个季度锁定的中间件版本存活更久的地方。TLS 密码协商和 SSH KEX 降级跟这个问题是同样的韵脚:到底是哪个机制在跑?

这不是什么

仅限合成追踪,没有射频实验室、没有真实芯片硅片、没有实时光盘撤销列表(CRL)或公钥目录(PKD)基础设施。两个命名的库在一种确定性条件下,冒烟测试深度,锁定的复现提交。我们描述的是行为分歧,不是声称存在已部署的漏洞。

自己跑一遍

这是文章里第一次指向代码仓库。

emrtd-differential-harness

Checkout tag blog-b10-2026-07(commit ef15b10,2026年7月7日)。这个节点描述的就是这篇文章的内容;main 分支可能会推进但不会改变文章中的结论。

git clone https://github.com/kazuru-chidumbwe/emrtd-differential-harness.git
cd emrtd-differential-harness
git checkout blog-b10-2026-07
bash scripts/bootstrap-vendor.sh
export GOTOOLCHAIN=auto
make smoke

你应该得到 JSON 追踪,基线驱动的 observability_score: 0。查看 logs/——每个文件里的 run_id 字段与文件名匹配。在 Ubuntu 24.04 上使用 Go 1.25+(GOTOOLCHAIN=auto)、Java 17 和 Maven,在一个已经引导过供应商的加温实验室虚拟机上,make smoke 在一分钟内完成。第一次冷克隆需要额外时间为 bootstrap-vendor.sh 和从供应商源码编译 JMRTD;预算几分钟而不是几秒。

一个实际的小麻烦:Maven Central 上的 jmrtd-0.5.2 工件对我们的构建来说是个空包。引导脚本从供应商源码编译 JMRTD。第一次需要额外预算几分钟。

MIT 许可。如果你扩展了这项工作,引用你追踪里的 run_id

结语

两个主流开源护照阅读器。一个合成降级。两个都在 PACE 失败后完成了 BAC。两个都对简单调用方保持沉默。

如果这在你的技术栈里有影响,在你发布之前用那个节点重新跑一遍。追踪是公开的。检查只需要几分钟。我们不是要你相信我们的总结——我们是要你自己跑同样的配置并读取你自己的 run_id

进一步阅读:ICAO Doc 9303 · gmrtd · JMRTD · Avoine et al., ePassport protocol survey · BSI TR-03110

仅限合成测试环境。没有使用真实旅行证件。结果适用于 TC-AC-01 冒烟测试深度、版本 blog-b10-2026-07 的 gmrtd 和 JMRTD。