仓库的验货终端发出”哔”的一声。这是提示单据上的QR码已经读取成功。读取到的字符串会直接传给库存系统,单据被引当,出货指示随即确定。既然QR码有纠错功能,就算有些脏污,也能返回正确的值 ── 建立在这一前提上的系统,绝不罕见。
前半句是对的。QR码在设计上就能在有污损或破损的情况下恢复数据,从纠错级别L到H,可以恢复约7%到约30%的码字。1 问题出在后半句 ──「所以返回的值就是正确的」这一推论。
本文将基于实际生成的QR码样本图像,以及两种解码器的实测结果,整理纠错通过也不保证值正确的原因,以及业务系统一侧应当验证哪些内容。文中出现的QR码全部都是实物。凡是能读取的,都可以直接用手边的QR读码器确认(其中也包含用来展示”无法读取”的样本,会在相应位置注明)。我们还准备了可以在浏览器上切换 jsQR 与 OpenCV.js 来试读的QR码读取对比工具。本文的实测结果都可以在该工具上重现。
1. 先说结论
- 纠错是”恢复”,而不是”验证”。规格本身写明,受损的模块会”被误解码为另一个明显有效但不同的码字”。2
- 如果是随机污损,多半会变成”读不出来”。实测9,700次中,返回错误值的情况为0次。
- 但如果损伤集中在一处,就必定会变成另一个值。26个码字中只要有7个出错,
004873就会被读成104873,两个互不相干的解码器给出了完全相同的错误结果。 - 纠错范围之外,还存在更简单的陷阱。分割QR码、字符编码、画面内的其他QR码 ── 这些都可能在不报错的情况下发生。有些解码器会给出警告或抛出异常,但具体表现取决于实现,确实存在不会报错的组合。
- 因此,读取到的值应当作为未经验证的输入来处理。基本做法是分三段接收:形式检查 → 校验位 → 业务验证。
2. 样本 ── 看起来几乎相同的两张QR码
首先请看实物。下面这两张QR码都能无错误地读取出来。
试读前提示一句:iOS 标准相机可能不会有反应。如果读不出来,请改用QR读码器App 试试(原因写在下方的注记中)。
- A(上) →
NO:20260725-004873 - B(下) →
NO:20260725-104873
由于是实物,可以直接用手边的读码器试读。这个单据编号采用「订单日期8位 + 流水号5位 + 校验位1位」的体系,发生变化的是流水号部分 ── 00487 变成了 10487,指向了一张相差1万号的完全不同的单据。
iOS 标准相机可能不会有反应。标准相机的设计会优先处理像URL这种可以直接”打开”的内容,对于本文这种纯字符串的QR码,有时什么反应都不会有。改用QR读码器App就能读取。同一张图像,却因读取方实现不同而表现各异 ── 这正好让读者在第一个样本上,就亲身体验了本文的主题。
B与A的差异,仅在于从左数第10~14列、一条纵向带状区域内的31个模块。这相当于码字区域208个模块中的15%。
而关键在于,解码器对这两张图都不会返回错误。甚至连”已进行纠错”的提示都没有。从应用程序的角度看,两者都是同样成功的读取。
需要说明一点,这种损伤是刻意构造出来的,偶然形成这种情况的概率并不高。具体的构造方法,以及它与现实污损之间的差距,将在第4节讨论。
3. 为什么会发生 ── 纠错机制的内部原理
QR码的纠错采用里德-所罗门码,以码字(以8比特为单位)为单位工作。本文使用的版本1・纠错级别M(21×21模块)的构成如下。2
| 总码字数 | 数据码字 | 纠错码字 | 规格规定的纠错能力 |
|---|---|---|---|
| 26 | 16 | 10 | 4个码字 |
引人注目的是最后一列。如果有10个纠错码字,按里德-所罗门码的原理最多可以纠正5个。然而规格所规定的纠错能力却是4。相差的这1个,是被特意留作防误读码字 p(版本1-M中 p = 2)。规格表13的脚注也明确写道:”为了降低误读概率,将纠错能力设定为小于纠错码字数的一半”。2
也就是说,规格本身就是在”纠错可能给出错误的值”这一前提下设计的。同一节中还写道:
由于QR码是矩阵符号,使模块由暗变明(或由明变暗)的缺陷,会导致对应的符号字符被误解码为一个明显有效但不同的码字。2
原因就在于纠错原理本身。里德-所罗门解码所做的,是在接收到的模式的一定距离(即纠错能力)范围内,寻找是否存在某个码字。找到就将其作为答案返回,找不到就以”读不出来”收场。它并不是那种”无论损坏到什么程度都会找出最接近的码字”的机制。
由于这一特性,结果会分成两种。随机损伤会分散到远离任何码字的位置,因此大多数情况下会归结为”找不到=读不出来”。真正危险的是,损伤恰好落入另一个码字的邻近范围内的情况。这时解码器会判定那个码字才是正确答案并将其返回。返回的码字本身在内部是完全自洽的,因此无法从中判断出它是错误的。
4. 纠正到什么程度,从哪里开始变得危险
从这里开始是实测内容。先说结论:危险的不是污损的”量”,而是”位置”。
在看数字之前,先整理一下本章的实验条件。这些是你在自己动手复现实验时的前提条件。
| 项目 | 内容 |
|---|---|
| 目标符号 | 版本1-M / 21×21(26个码字 = 数据16 + 纠错10) |
| 生成方式 | segno 1.6.6 / Python 3.11 |
| 解码器1 | OpenCV 5.0.0 cv2.QRCodeDetector(Python 3.11) |
| 解码器2 | jsQR 1.4.0(Node.js 22) |
| 施加损伤的方式(4.1节) | 从码字区域208个模块中随机选取并翻转/按码字为单位随机破坏/对整个符号施加均匀的模糊・噪声・降低对比度 |
| 施加损伤的方式(4.2节) | 对逼近目标值B的码字组合进行穷举 |
| 试验次数 | 模块翻转3,900次(每个水平300次)、码字破坏1,800次(每个水平200次)+超出纠错能力的追加试验4,000次、画质劣化1,000次(每种条件200次)。4.2节为792种・495种全数穷举 |
| 判定标准 | 将解码器返回值分类为:与期望值一致则为”正确读取”,得不到值则为”读不出来”,返回非空的其他值则为”错误值” |
包括第5节样本在内的全文实验环境,汇总在文末的”验证环境”中。
4.1. 随机污损会归结为”读不出来”
针对版本1-M的符号,从码字区域208个模块中随机选取若干个进行翻转,并对结果进行了分类(每个水平300次,共计3,900次)。
上方翻转了6个模块,下方翻转了9个模块。上方能够正确读取,下方则完全无法读取。3个模块的差异,人眼几乎无法察觉。分界线出现在肉眼看不出来的地方。
| 翻转的模块数 | 正确读取 | 读不出来 | 错误值 |
|---|---|---|---|
| 0~5 | 1,799 | 1 | 0 |
| 6 | 131 | 169 | 0 |
| 7 | 32 | 268 | 0 |
| 8 | 11 | 289 | 0 |
| 9~12 | 0 | 1,200 | 0 |
能否读取的分界线出现在5个到6个之间,9个以上则全军覆没。而且没有出现一件错误值。无法完全纠正的损伤会归结为”读不出来”,这是一条朴素的好消息。jsQR的数字也几乎相同(0~5的1,800件全部正确,6个以上的结果与OpenCV相差不超过1件)。
从码字的角度来观察同样的现象,分界线会更加清晰(每个水平200次,共计1,800次)。
| 破坏的码字数 | 正确读取 | 读不出来 | 错误值 |
|---|---|---|---|
| 0~5 | 1,194 | 6 | 0 |
| 6~8 | 0 | 600 | 0 |
纠错一直进行到了5个码字。如前一节所述,规格规定的纠错能力是4,剩下的那1个原本是留作”只检测、不纠正”的余量。jsQR也把0~5的1,200件全部正确读取,两种实现都把那份余量用在了纠错上。规格为防止误读而设置的余量,在实现层面是靠不住的。
对于明显超出纠错能力的破坏(6个码字与8个码字)也各追加试验了2,000次,错误值依然是0件,全部以”读不出来”收场。
相机拍摄导致的画质劣化也表现出同样的趋势。对整个符号施加均匀的模糊・噪声・降低对比度后的结果如下(每种条件200次,共计1,000次)。
| 模糊 σ | 噪声 σ | 对比度 | 正确读取 | 读不出来 | 错误值 |
|---|---|---|---|---|---|
| 0 | 0 | 1.00 | 200 | 0 | 0 |
| 1.5 | 10 | 0.90 | 183 | 17 | 0 |
| 3.0 | 20 | 0.70 | 1 | 199 | 0 |
| 4.5 | 30 | 0.50 | 0 | 200 | 0 |
| 6.0 | 40 | 0.35 | 0 | 200 | 0 |
结果要么是”能正确读取”,要么是”读不出来”,没有中间状态。随着劣化程度加剧,正确率会下降,但减少的部分全部转化为读取失败。
不过,这仅限于均匀劣化的情况。实际的手抖是有方向性的,斜向拍摄或光照不均只会破坏图像的一部分。正如接下来要看到的,真正危险的是损伤的偏向性,因此请不要泛化地认为”只要是画质问题就不会误读”。
4.2. 位置不好时,必定会变成另一个值
第2节中的样本B,正是刻意构造出的这种”偏向性损伤”。
将 NO:20260725-004873(A)与 NO:20260725-104873(B)的26个码字排列比较,差异出现在12处 ── 2个数据码字,以及被其牵连而改变的10个纠错码字。
在这12处之中,只要把其中7处改为B的值,得到的模式就会与A相距7个码字、与B相距5个码字。如果纠错能力为5,解码器就会把这个模式解释为”B有5处受损”,并将其纠正为B。
| 条件 | 试验组合数 | 被误读为B的次数 |
|---|---|---|
| 7个码字改为B(与B的距离为5) | 792种 | 792种(100%) |
| 8个码字改为B(与B的距离为4) | 495种 | 495种(100%) |
无论哪种组合都无一例外地被误读了。下面一行与B的距离为4,也就是在规格所规定的纠错能力范围之内。即便是尊重防误读码字 p 的实现,只要损伤再多出1个码字,就会得到相同的结果。p 只是降低了概率,并没有真正防止误读。
第2节展示的B,是这些组合中损伤集中在一条纵向带状区域内的情况(31个模块)。若加以最小化,还可以减少到23个模块。OpenCV 5.0.0与jsQR 1.4.0这两个互不相关的实现,都返回了 NO:20260725-104873。
4.3. 该如何看待这一差异
老实说,这种损伤在随机情况下很难发生。用杂乱无章的污损做了9,700次试验,误读为0件。这并不是”明天就会发生”的事情。
即便如此,仍有3个理由不能忽视。
- 现实中的损伤并非随机。折痕会沿直线延伸,搬运造成的摩擦会集中在同一条边上,打印头的堵塞则会形成纵向条纹。本文所用的带状损伤,正是这类”位置存在偏向的损伤”的一个例子。不过B包含白→黑17个模块、黑→白14个模块两个方向的变化,因此仅靠油墨减少这种不良无法再现。两个方向同时发生的情况,见于折痕阴影导致二值化判定偏移、污损与褪色叠加、上方被部分贴上了另一张标签等场景。
- 扫描次数不是一个数量级。单次发生的概率就算可以忽略不计,在一天要读取数万次的现场,情况就不一样了。而且误读并不会报错,也就不会留下记录,最终只会被当作原因不明的盘点差异处理掉。
- 能够被人为构造出来,也就意味着别人同样能够构造出来。本文的样本模式,是先确定目标值,再机械地构造出来的。对于价签、优惠券这类存在篡改动机的QR码,这就会成为一种攻击手段。
5. 与纠错无关的”读到了,但不对”
在实际业务中,更常踩到的其实是这一类问题。即便纠错完美地发挥了作用,它依然会发生,甚至根本不是概率问题。
5.1. 只读取分割QR码的第一张
QR码具备将较长的数据拆分成多个符号、由读取方进行拼接的机制(Structured Append,分割QR码)。下面是把 NO:20260725-004873/LOT:AB-77/QTY:120/EXP:20270131 拆分成3张后,其中的第一张。
外观上就是一张普通的QR码,没有任何线索表明它是三张中的一张。单独读取这张图,OpenCV会返回:
NO:20260725-00487
没有错误,没有警告。这只是末尾的校验位 3 掉了而已,是一个完全说得过去的单据编号。第二张、第三张分别是 3/LOT:AB-77/QTY: 和 120/EXP:20270131。把同一张图像交给jsQR,返回的是空字符串。没有考虑分割QR码的应用程序碰巧只扫到第一张时会发生什么,取决于具体的解码器。
在业务中会这样表现出来。如果画面上是用前方一致或 LIKE 来检索单据编号,那么末尾少了1位的 NO:20260725-00487 会照样命中原来的单据,由于”读取成功了,单据也查出来了”,没有人会察觉到异常。只要没有按固定位数进行检查,这个不完整的片段就会一路作为正常的读取结果流转下去。
5.2. 字符编码与ECI
下面是把 部品番号 東-004873 用Shift_JIS编码、且不指定ECI写入的QR码。
这也是实物。用手边的读码器读取,会得到 部品番号 東-004873,或是乱码字符串,或者什么都读不出来 ── 由此可以知道你所用的读码器采用的是哪种解释。
用QR码读取对比工具读取这张图像,会并排显示jsQR取出的原始字节序列,以及分别用UTF-8 / Shift_JIS / EUC-JP等重新解释后的结果。同一段字节序列会因字符编码不同而变成完全不同的内容,这一点当场就能确认。
下面是用同样的内容,改变字符编码和ECI指定生成后,交给两种解码器读取的结果。这4种条件的符号,全部都收录在工具的样本中。不过表中的数值是用Python的 cv2 测得的,只有第3行的结果与浏览器版不同(紧接在这张表之后会详细说明)。由于工具能确认的是浏览器版的行为,要重现第3行”因异常而失败”的情况,需要用Python的 cv2。
| 生成条件 | OpenCV 5.0.0 | jsQR 1.4.0 |
|---|---|---|
| Shift_JIS / 无ECI | 将乱码字符串作为成功返回 | 空字符串 |
| UTF-8 / 无ECI | 部品番号 東-004873 |
部品番号 東-004873 |
| Shift_JIS / 有ECI | 发出警告,解码失败 | 空字符串 |
| UTF-8 / 有ECI | 部品番号 東-004873 |
部品番号 東-004873 |
第1行是最糟糕的情况。OpenCV把字节序列解释为Latin-1,在没有报错的情况下返回了 \x95\x94\x95i... 这样一串损坏的字符串。从应用程序的角度看这是一次正常的读取,如果就这样写入数据库,就会产生一条乱码记录。
在业务中会这样表现出来。入库记录的品名栏中登记了乱码字符串,第二天就会收到”用那个品号搜索不到记录”的咨询。由于重新读取同一张标签也会得到相同的值,现场会报告”系统的搜索出问题了”,直到查明原因出在读取侧的字符编码上之前,双方会一直反复沟通。
这并不是OpenCV的缺陷,而是符合现行规格所规定的默认解释(ISO/IEC 8859-1)的行为。3 偏离规格的一方,是不指定ECI就写入Shift_JIS的那一侧。
第3行同样不容忽视。明明正确指定了ECI(用于明示字符编码的机制),OpenCV却给出了 QR: ECI is not supported properly 的警告,无法将返回值按UTF-8解释而以异常告终。忠实遵循规格制作的QR码反而读不出来这种反转,是真实发生的。
而且,即使是同一个OpenCV,第3行的结果也会因语言绑定不同而改变。上表是Python的 cv2 的结果,但把同一张图像交给浏览器版(opencv.js 5.0.0),则不会抛出异常,而是把 ���i��� ��-004873 这种满是替换字符的字符串作为成功返回。这是因为Emscripten在把 std::string 转换为UTF-8时,会把非法字节替换为U+FFFD,而不是抛出异常。版本和图像都相同,改变的只是调用侧使用的语言。能靠异常察觉问题的Python版反而算好的,浏览器版会一边宣称”读取成功”一边返回损坏的值。这一点可以在工具的样本中实际确认。
5.3. 画面内存在多个QR码
单据上印有多个QR码,相邻箱子上的标签进入了取景范围 ── 这是常见的情形。我们把3个QR码横向排列进行了试验。
首先,对于这张图像,OpenCV的单个读取API什么都没有返回。但这并不保证“只要存在多个就会被拒绝”。单个读取API的文档只说明它用于检测并解码单个QR码,并没有写明会拒绝多张,根据排布方式的不同,也有可能返回其中某一个。请不要用单个读取API有无返回值来代替对多个QR码的检测。
那么改用多个读取API又会怎样呢 ── 这里的情况相当棘手。
直接使用上图(从左到右为 NO: / ITEM: / LOT:),只改变像素数来读取的结果如下。这相当于实际扫描仪在与目标物的距离或相机分辨率发生变化时的情况。
| 图像宽度 | 返回顺序 |
|---|---|
| 1,001px | NO: / LOT: / ITEM: |
| 1,502px | 什么都不返回 |
| 2,002px | NO: / LOT: / ITEM: |
| 3,003px | NO: / ITEM: / LOT: |
| 4,004px | LOT: / NO: / ITEM: |
明明是同一张图像,仅仅因为分辨率不同,返回的顺序就会变化。既不是从左到右,也不是按大小排序,甚至还存在读不出来的分辨率。顺序是由检测算法的内部机制决定的,既然规格上没有规定,就会出现这种情况。
也就是说,那种认定”第一个肯定是单据编号”而使用索引0的代码,即便今天碰巧能正常工作,明天只要相机拉近一点,就会抓到另一个QR码。这是一类难以复现、也难以追查原因的故障。
在业务中会这样表现出来。在验货台上,目标单据与相邻箱子的标签同时进入取景范围。采用索引0的应用程序会引当到相邻的单据,出货指示也随之确定在那一份上。作业人员自认为把相机对准了正确的标签,因此直到出货后收到”收到的商品不对”的反馈之前,没有人会察觉。
接收方需要依据内容而非顺序来做选择。用多个读取API把所有结果都取出来,只采用前缀与格式都一致的那一个,若符合条件的结果为0个或2个以上则报错 ── 这才是安全的写法。
5.4. 内容本身也未必正确
存在一些图像处理原理上根本无法检测到的层面。印刷源头的数据本身就是错的、更换标签前的旧标签还残留在箱子上、混入了别的交易对象的标签、标签被复制了。
QR码只能告诉你”上面写了什么”。至于”这是否正确”、”是否由本公司发出”,只能靠接收方自己去确认。
在业务中会这样表现出来。重复利用的箱子侧面残留着上一次的标签,读取到的是那张标签而不是顶面的新标签。由于这个值本身完全正确,形式检查、校验位、主数据比对全部都能通过,货物会被发往上一次的出货地。唯独这一层,任何针对读取值的检查都无法捕捉到。
6. 如何处理接收到的值
对策可以归结为一句话:在纠错之外,自行叠加验证。
| 阶段 | 验证内容 | 能够捕获的问题 |
|---|---|---|
| 1. 形式检查 | 长度・字符类型・分隔符・前缀的完全一致 | 读取到其他QR码、分割QR码的片段、乱码 |
| 2. 自我验证 | 校验位 | 能确实检测出1个字符的变化。多个字符的变化可能会漏检 |
| 3. 业务验证 | 主数据比对,以及是否与当前应处理的对象一致 | 旧标签、其他公司的标签、单据张冠李戴 |
示例中的单据编号是 NO: + 订单日期8位 + 流水号5位 + 校验位1位,末尾采用了与GS1相同的模10・权重3方式。第2节中的误读结果 NO:20260725-104873 会在这一步被拦下 ── 因为 2026072510487 正确的校验位是 0,与标签上的 3 不一致。
写代码之前,请先确定好字符编码方面的前提。下面的实现假定单据编号只由ASCII数字构成,校验位是用”字符 − ‘0’“来计算的。如果全角数字,或是5.2节中看到的那种乱码字节序列被送到这一步计算,结果就没有意义了。因此形式检查的正则表达式没有用 \d,而是用 [0-9] 来写,让顺序变成不允许ASCII以外的数字到达校验位计算这一步。先确定前提,再由形式检查来保证这个前提,计算放在最后 ── 这个顺序一旦被打乱,后面的检查就会全部落空。
using System.Linq;
using System.Text.RegularExpressions;
public sealed record ScanOutcome(bool Accepted, string? SlipNo, string Reason);
public static class SlipScanValidator
{
// NO: + 订单日期8位 + '-' + 流水号5位 + 校验位1位
// 结尾用 \z 而不是 $。.NET 的 $ 也会匹配末尾换行符之前的位置,
// 因此会放过 "NO:20260725-004873\n"。
// 数字用 [0-9] 而不是 \d。.NET 的 \d 会匹配全角数字等Unicode范围内的
// 各种数字,但后续的校验位计算是以ASCII为前提的
private static readonly Regex Format =
new(@"\ANO:(?<date>[0-9]{8})-(?<seq>[0-9]{5})(?<cd>[0-9])\z", RegexOptions.Compiled);
public static ScanOutcome Validate(string? raw, ISlipRepository repo)
{
// 1. 不要把"读取失败"当成"空的成功"。
// 解码器把失败表示为 null / 空字符串 / 异常中的哪一种,因实现而异
if (string.IsNullOrEmpty(raw))
return new(false, null, "未能读取。请重新扫描");
// 2. 形式检查。不是前方一致,而是连长度都包含在内的完全一致。
// 分割QR码的片段 "NO:20260725-00487" 会在这里被拦下
var m = Format.Match(raw);
if (!m.Success)
return new(false, null, $"不是单据QR码的格式({Describe(raw)})");
// 3. 自我验证。如果因误纠错导致数字出错,会在这里被捕获
var body = m.Groups["date"].Value + m.Groups["seq"].Value;
if (Modulus10Weight3(body) != m.Groups["cd"].Value[0] - '0')
return new(false, null, "校验位不一致。请检查标签是否有污损");
// 4. 业务验证。是否真实存在,以及当前是否处于可以处理的状态
var slip = repo.Find(raw);
if (slip is null)
return new(false, null, "没有对应的单据");
if (slip.Status != SlipStatus.WaitingForShipment)
return new(false, null, $"该单据当前状态为「{slip.Status}」,不是出货对象");
return new(true, raw, "OK");
}
// 与GS1相同的模10・权重3方式。从右端开始依次乘以 3,1,3,1... 的权重
private static int Modulus10Weight3(string body)
{
var sum = 0;
for (var i = 0; i < body.Length; i++)
{
var weight = (body.Length - i) % 2 == 1 ? 3 : 1;
sum += (body[i] - '0') * weight;
}
return (10 - sum % 10) % 10;
}
// 读取值属于外部输入。在输出到画面或日志之前,只保留可打印的ASCII字符。
// char.IsControl 只会过滤掉 Unicode 的 Control(Cc),会放过 U+202E(改写为
// 从右到左书写)之类的 Format(Cf)。要用许可名单而不是排除名单来编写
private static string Describe(string raw)
{
var kept = raw.Where(c => c >= ' ' && c <= '~').Take(40).ToArray();
if (kept.Length == 0) return "(无法显示的字符串)";
var safe = new string(kept);
return kept.Length < raw.Length ? safe + "…(已移除部分字符)" : safe;
}
}
关于校验位设计本身,我们在《业务系统的编码设计与校验位》一文中已经整理了算式与选型方法。在本文的语境下,这项检查的价值在于:它不仅对人的输入错误有效,对机器的误读同样有效。
这三段验证防不住的问题
即便三段验证都做齐了,仍然有3个漏洞。每一个都可以通过”把验证设计再深入一层”来堵上,请一并掌握。
校验位会漏掉多个字符的变化。模10・权重3方式能够确实捕获的是单个字符的错误。事实上,2026072500487 与 2026072517487 的校验位都是 3,因此 NO:20260725-174873 会直接穿过第二段验证。由于误纠错所改变的未必只是1个字符,第三段验证不能省略。
不要让主数据比对止步于”确认是否存在”。上面代码中的 repo.Find() 与状态检查,只确认了”某处存在一张可用的单据”这一点。如果作业人员读取的是相邻箱子的标签,那张标签同样满足形式、校验位、待出货状态的所有条件,就会照样通过。读取到的值,需要与当前应当处理的对象进行比对 ── 是否与拣货单上的下一条一致,是否关联着已扫描的容器ID,出货地是否与正在作业的班次相同。具体要比对什么因业务而异,唯独这一部分无法写成通用代码。
重复处理无法靠验证来防止。如果两台终端几乎同时读取了同一张标签,两边都会先确认”待出货”状态再更新,结果两者都会通过。防止重复处理是执行层的工作 ── 要么把 UPDATE ... WHERE status = '待出货' 这类带条件的状态迁移做成一次原子操作,要么用幂等键来吸收重复执行。验证只是入口处的判断,不能替代排他控制。
“事故”与”攻击”是两回事
到目前为止的这三段验证,防的是事故。因污损造成的误读、分割QR码的漏读、乱码、混入旧标签 ── 这些无恶意的错误,靠它们就能挡住。
但另一方面,在存在篡改动机的用途(价签、优惠券、门票、支付)中,它们起不到防御作用。攻击者可以让格式合规、重新计算校验位,随意制作出指向另一个真实存在的编号的QR码。由于主数据比对只看是否存在,会被直接放行。
如果持有QR码就意味着价值或权限,那就需要让值本身具备真实性。可以把值做成服务器颁发的、无法猜测的令牌(足够长度的随机数),使其无法从一个编号推测出另一个编号;也可以在负载中附加带密钥的MAC或电子签名,由接收方用密钥进行验证。无论哪种方式,都需要在服务器端管理已使用状态,以防止复制品被重复使用。
不过,仅凭真实性无法阻止”调包”。把正牌QR码从便宜商品上撕下来贴到贵重商品上,令牌和签名依然是真的。在可以更换的价签这种载体会被重复使用的场景下,已使用判定同样不起作用。这里同样要靠与第三段验证相同的思路来应对:通过值以外的途径确认这个QR码是否属于眼前这个对象。具体方法包括:用其他手段获取商品一侧的识别信息进行比对、与交易上下文(收银明细、入场时段)进行核对、用撕下即破损的标签实现物理绑定等。
无论是校验位还是主数据比对,都无法对真实性做出任何保证。而真实性本身,也无法保证这个值就属于眼前这个对象。关键在于,不要试图用同一套机制来同时兼顾”防误读”、”防伪造”、”防调包”。
7. 运维层面需要事先决定的事项
有些部分光靠代码是堵不住的。
- 在QR码下方务必并排印上人可读的字符串。这与GS1所规定的HRI(Human Readable Interpretation,人可读表示)的思路是一致的。4 当怀疑发生误读时,就会留有一种人工核对的手段。在实际运营中,这往往是唯一的发现手段。条码整体的现场运用,已经在《GS1 条码标准的基础与现场运用注意事项》一文中整理过。
- 事先确定”读不出来”时的处理流程。重新扫描次数的上限、回退到手工录入的路径、以及审批人。如果这里含糊不清,现场就会朝着”不断变换角度反复尝试直到能读出来为止”这种会提高误读概率的方向发展。
- 把被拦下的值记录到日志中。如果集中出现在特定标签或特定终端上,就能及早发现打印机或扫描仪的故障。不过不要把原始字符串原样写入面向行的日志。包含换行符或控制字符的值,会伪造日志行或破坏显示。应当把原始内容限定长度并转义后,保存到结构化日志的字段或数据库的列中,供人阅读的行只输出经过无害化处理的表示形式 ── 这种分离要从一开始就纳入设计。条件允许的话,也应保留读取时的图像。
- 不使用分割QR码。如果数据放不下,要么提高版本号,要么让QR码中只放识别符,其余内容从主数据中查询。后一种方式还能把标签做得更小,并且具备”内容的修正不需要重新发行标签”这一优点。
- 字符编码问题,用”不放入非ASCII字符”来解决。业务用途的QR码应控制在ASCII范围内。字节模式在没有ECI指定的情况下不会声明字符编码,而默认解释会随规格版本不同而变化。3 仅仅用UTF-8写入并不能获得互操作性,用不同解释方式的扫描仪读取会出现乱码。如果无论如何都要放入非ASCII字符,按规格来说正确做法是带ECI指定地声明UTF-8,但正如5.2节所示,现实中确实存在对ECI处理不完善的实现,因此终究免不了要在目标机型上进行实机确认。
- 在不可逆操作之前插入一次确认。对于出货确定、库存扣减、收款核销这类撤销成本很高的操作,应当把根据读取值查到的品名或金额显示在画面上,让人来核实。因为误读的值本身看起来往往是合理的,但放在业务语境中,很多时候会显得不自然。
验证要做到什么程度,取决于出错时造成的损失大小。
| 用途 | 形式检查 | 校验位 | 主数据比对 | 人工确认 |
|---|---|---|---|---|
| 公司内部场所・货架编号 | 必需 | 可选 | 推荐 | 不需要 |
| 出入库・盘点 | 必需 | 推荐 | 必需 | 不需要 |
| 出货确定・库存扣减 | 必需 | 必需 | 必需 | 推荐 |
| 开票・收款核销 | 必需 | 必需 | 必需 | 必需 |
| 防止药品・危险品张冠李戴 | 必需 | 必需 | 必需 | 必需 |
8. 总结
QR码的纠错,是一种从印刷出来的图案中恢复原始码字的机制。这项工作它完成得很出色。实测中,面对随机污损与均匀画质劣化,在可以纠正的范围内都正确读出了值,在无法纠正的范围内也老老实实地放弃了读取。
但这与应用程序接收到的字符串在业务上是否正确,是两回事。根据损伤的落点不同,纠错生效的结果可能会给出另一个同样有效的值。至于分割QR码、字符编码、画面内的其他QR码,则属于纠错范围之外的问题,甚至根本不是概率问题。
规格本身写明”会被误解码为一个明显有效但不同的码字”,并且特意留出防误读的码字,这一点简明地体现了这种结构。即便做到这一步,也只能降低概率而已 ── 这就是规格能达到的极限。剩下的部分,只能由接收到这个值的应用程序来填补。
读取到的值,是来自外部的未经验证的输入。把它当作从键盘敲入的字符串一样对待 ── 我认为这才是与QR码正确相处的方式。
亲手试验自家QR码的3个步骤
到目前为止的内容,都可以直接用贵公司的标签来验证。
- 生成。用手边的QR码生成工具,把一个实际运用中格式的值制作成QR码(本文的样本使用segno 1.6.6生成)。首先确认在完好无损的状态下能够读取。
- 加上损伤。用图像编辑软件画出一条纵向条带,模拟折痕或打印头堵塞的效果。重要的是偏向性而不是数量,所以不要把噪声稀薄地散布在整体上,而要把它集中在狭窄的范围内。
- 用两种解码器比较。把图像交给QR码读取对比工具,比较jsQR与OpenCV.js的结果。诸如只有一方返回值、两边返回了相同的值但与原值不同这类现象,都可以当场确认。
如果两种解码器给出的结果出现分歧,那个条件就是贵公司的验证设计中必须妥善处理的条件。提前在桌面上试验一次”我们现场的扫描仪没问题吧”,是值得花这个功夫的。
验证环境
用于观察纠错行为的第2~4节统一使用版本1-M(21×21模块)。第5节的样本会根据数据量的不同而使用不同的版本,因此按小节分别标注。
| 项目 | 内容 |
|---|---|
| 解码器1 | OpenCV 5.0.0 cv2.QRCodeDetector |
| 解码器2 | jsQR 1.4.0(Node.js 22) |
| 生成方式 | segno 1.6.6 / Python 3.11 |
| 第2~4节的符号 | 版本1-M / 21×21(26个码字 = 数据16 + 纠错10) |
| 5.1节(分割QR码) | 3张均为版本1-M / 21×21 |
| 5.2节(字符编码与ECI) | Shift_JIS为版本2-Q,UTF-8为版本2-M(均为25×25)。因为日语的18~23字节放不进版本1-M的数据16个码字中 |
| 5.3节(多个QR码) | NO: 与 ITEM: 为版本1-M,LOT:AB-77 因数据较短、segno会提高纠错级别,故为版本1-H(均为21×21) |
-
株式会社デンソーウェーブ, 关于纠错功能|QRcode.com ↩
-
ISO/IEC 18004 Information technology — Automatic identification and data capture techniques — QR code bar code symbology specification。纠错能力公式
e + 2t ≦ d - p、防误读码字p的取值,以及”被误解码为一个明显有效但不同的码字”这一表述,均出自 8.5.1 Error correction capacity;版本1-M的(26,16,4)及脚注”为降低误读概率,将纠错能力设定为小于纠错码字数的一半”出自 Table 13(引用基于2000年版)。现行版本为ISO/IEC 18004:2024。 ↩ ↩2 ↩3 ↩4 -
字节模式在没有ECI指定时的默认解释,会随规格版本不同而变化。ISO/IEC 18004:2000 的 8.3.1 规定”The default interpretation for QR Code is ECI 000020 representing the JIS8 and Shift JIS character sets”,但2006年版(QR Code 2005)之后,默认变为 ECI 000003,即 ISO/IEC 8859-1。依赖未被声明的默认值,也就意味着仅仅因为规格版本不同,解释就可能发生变化。 ↩ ↩2
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
业务系统的编码设计 ── 商品编码・客户编码的确定方法与校验位
确定商品编码・客户编码等业务系统编码体系的实践指南。整理了有意义编码与无意义流水号的判断表、JAN・Luhn等校验位算法及C#实现、Excel开头零丢失的应对方法,直至位数溢出与迁移。
多线程实务最佳实践 .NET 篇 ── 在增加线程之前应先确定的事
针对 .NET/C# 整理「立了线程之后,偶尔崩溃・卡死」的防范设计准则。内容涵盖不自行创建线程而改用 Task、减少共享可变状态、锁的纪律、基于 CancellationToken 的停止设计,直至 UI 线程的处理方式。
如何理解 Windows 的会话隔离 ── Session 0・RDP・多用户同时运行
整理 Windows 应用程序开发者容易混淆的「会话」概念。说明服务无法显示 UI 的 Session 0 隔离原因、RDP 连接时会话的行为、命名对象的会话隔离,以及共享 PC・RDS 环境中常见的设计失误,从实务角度解说。
Windows 的进程间通信该怎么选 ── 命名管道 / TCP / gRPC / 共享内存 / COM 判断表
整理 Windows 应用程序之间应该如何选择通信方式。用判断表整理命名管道、本地 TCP、gRPC、共享内存、文件对接、COM 各自的优势与陷阱,并从实务角度说明 UI+服务分离・32bit/64bit 桥接・权限边界等常见架构,以及命名管道的实现示例。
Windows应用的数据存储位置怎么选 ── SQLite / JSON / 注册表 / Access 判断表
Windows桌面应用的数据该存在哪里、用什么格式保存?本文整理AppData/ProgramData的使用区分,以及SQLite、JSON文件、注册表、Access(.accdb)各自的优势与陷阱,并附判断表,从实务角度讲解防止数据损坏与位数问题等注意事项。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
支持包含常驻处理、设备联动、运行日志与可维护结构的 Windows 桌面应用程序。
技术咨询 & 设计评审
协助梳理设计方向、架构边界、生命周期责任,以及既有 Windows 资产的处理方式。
常见问题
汇总了咨询这一主题时常见的问题。
- 把纠错级别设为H,就能防止误读吗?
- 不能。提高级别可以增加能够纠正的污损量,但「纠错生效导致变成另一个值」这一现象本身并不会消失。里德-所罗门解码的动作是「在接收到的模式的纠错能力范围内寻找是否存在码字」,因此如果损伤恰好落入另一个码字的邻近范围,就会把那个码字作为正确答案返回(相距很远的损伤则会以"读不出来"收场)。ISO/IEC 18004对此明确写明"会被误解码为一个明显有效但不同的码字",并另外留出了防误读用的码字,但这同样只是降低概率的措施,不是保证。能够捕获误读的,只有接收到这个值的应用程序一方。
- 实际上,QR码的误读会以多高的频率发生?
- 如果是随机污损,基本不会发生。本文的实测中,随机翻转模块的3,900次试验,以及随机破坏码字的5,800次试验,全部都没有出现返回错误值的情况。无法完全纠正的损伤会归结为"读不出来"。不过,现实中的损伤并非随机,而是像折痕、摩擦、打印头堵塞那样在位置上存在偏向性。此外,分割QR码的漏读、多个QR码的张冠李戴,甚至根本不是概率问题,只要条件具备就每次都会发生。
- QR码明明有纠错,还需要校验位吗?
- 需要。两者守护的层面不同。纠错处理的是符号内部的一致性,而且它能保证的恢复范围仅限于纠错能力之内。超出这个范围的损伤,有可能被"恢复"为另一个同样有效的码字。而校验位检查的是「应用程序接收到的字符串,是否在编码体系上成立」。它能确实捕获1个字符的变化,但当多个字符同时变化时会有漏检。模10的检查值只有10种可能,本文中也举出了2位数变化后校验位仍然一致的例子。因此,请把校验位看作能挡住大部分误读的一层,最终的判断则交给主数据比对与业务上的核对。它的另一个优点是,手工录入、转抄、从其他渠道导入的数据,也能用同一套检查来守护。
- 手机相机App都能读出来了,那这个值应该是正确的吧?
- 「读出来了」这件事,意味的只是「解码器返回了一个非空字符串」,对其内容是否正确没有任何说明。失败的返回方式也因实现而异,异常、空字符串、null都有可能,因此把"没有抛出异常"当作成功的判断依据同样危险。本文的实测中,针对同一张图像,OpenCV与jsQR给出不同结果的例子有好几处。对于用Shift_JIS写入日语的QR码,一方把乱码字符串作为成功返回,另一方返回的是空字符串。分割QR码的第一张,结果同样出现了分歧。读没读出来,对于值是否正确没有任何说明。
- 业务中最好不要使用分割QR码(Structured Append)吗?
- 如果没有特别理由,避免使用比较稳妥。分割QR码是把数据拆分到多个符号中、由读取方收集并拼接起来的机制,但当不支持该机制的解码器只读到第一张时,具体表现取决于实现。本文的实测中,OpenCV把末尾缺失的单据编号在没有报错的情况下返回,而jsQR返回的是空字符串。既然存在会把不完整的片段当作看似合理的值放行的实现,那么只要没有考虑到分割QR码的场景,就需要有能够拦下不完整结果的验证。如果数据放不下,提高QR码的版本,或者把编码缩短、改为通过主数据引用,会更安全。