先看结论与判断条件
- Java 字符串、JNI jchar 序列、Modified UTF-8 字节和外部标准 UTF-8 是四种边界对象,接口名和结构字段必须写明编码与长度单位。
- GetStringLength 返回 UTF-16 code unit 数,GetStringUTFLength 返回 Modified UTF-8 字节数;两者不能替换,更不能混入缓冲区容量计算。
- Modified UTF-8 用特殊双字节表示 U+0000,并以代理 code unit 表示补充平面字符;普通 UTF-8 解码器与 strlen 都不能替代契约校验。
- 外部标准 UTF-8 先用严格解码器转换为受控 Unicode 或 UTF-16,再创建 Java 字符串;不把任意网络或文件字节直接传给 NewStringUTF。
- GetStringUTFChars、GetStringChars 等借用结果必须在所有成功、失败和异常分支调用对应 Release API,且不得跨线程、跨回调或长期缓存。
- 设备回归应覆盖空串、嵌入 U+0000、BMP、补充字符、未配对代理项、非法序列和边界长度,并把故障与同一 ABI、候选构建和调用路径绑定。
先给每个字符串边界标注编码和单位
JNI 字符串故障常被笼统描述为乱码或截断,但根因通常是把不同表示当成同一种字节串。Java String 在 JNI 边界以 UTF-16 code unit 观察,jchar 也是 code unit;GetStringUTFChars 返回的是 JNI Modified UTF-8,而网络协议、JSON、文件和多数服务端接口通常约定标准 UTF-8。接口只写 char pointer 或 length,调用者便容易拿错编码器和长度函数。
契约应在类型名、参数名和结构字段中直接写明表示,例如 utf16Units、mutf8Bytes、utf8Bytes、byteLength 和 codeUnitLength。函数说明还要写出输入是否允许嵌入 U+0000、未配对代理项、替换字符和空值,失败时返回状态还是抛出 Java 异常。不要让一个 int length 在不同路径里有时表示 Java 字符数、有时表示字节数。
选择转换 API 前先问数据来自哪里。Java 业务字符串进入 Native 计算,可以使用 UTF-16 视图或 Modified UTF-8 视图;外部标准 UTF-8 进入 Java,应由明确的严格 UTF-8 解码器处理,再构造 Java 字符串。若 Native 库自己的协议固定为标准 UTF-8,JNI 适配层负责转换,不能要求业务库猜测输入到底是哪一种 UTF。
| 对象 | 表示 | 长度单位 | 典型用途 |
|---|---|---|---|
| Java String | UTF-16 code unit 序列 | code unit | Java 业务文本 |
| jchar 缓冲 | 无符号 UTF-16 单元 | jchar 数 | 精确读取 Java 内容 |
| JNI UTF 字节 | Modified UTF-8 | 字节 | JNI UTF 系列 API |
| 外部文本 | 协议声明的标准 UTF-8 | 字节 | 网络、JSON 与文件 |
| C 字符串 | 约定编码加终止符 | 依 API 而定 | 仅受控库接口 |
| 显示字符 | Unicode 标量或字形 | 不可由单一 int 代替 | 用户界面与截断策略 |
Modified UTF-8 不能当作普通 UTF-8
Android JNI tips 明确区分 Modified UTF-8 与标准 UTF-8。Modified UTF-8 为 JNI 的字符串 API 提供以零字节结尾的字节表示,同时把 Java 的 UTF-16 code unit 映射为特定字节序列。最关键的差异是 U+0000 不作为中间零字节出现,而使用双字节表示;这使 C 风格终止符仍可标记缓冲区结尾,但不表示普通 UTF-8 工具能够无条件处理内容。
补充平面字符在 Java UTF-16 中由代理对表示,Modified UTF-8 会分别编码这些代理 code unit,因此字节形态与标准 UTF-8 的四字节序列不同。把 Modified UTF-8 交给严格标准 UTF-8 解码器,或把标准 UTF-8 的四字节序列直接交给只接受 Modified UTF-8 的入口,都可能产生拒绝、替换或错误结果。接口评审必须明确转换发生在哪一层。
Modified UTF-8 不是为了替代网络编码。JNI 适配层可以临时使用它与 JNI UTF 系列 API 交互,但持久化、网络传输和跨语言协议仍按各自规范处理。若业务没有以字节处理 Java 字符串的必要,直接使用 UTF-16 视图通常更容易保持 code unit 完整;是否转换为标准 UTF-8,应由目标库的公开契约决定,而不是为了方便使用 char pointer。
| 场景 | Modified UTF-8 | 标准 UTF-8 | 工程动作 |
|---|---|---|---|
| U+0000 | 特殊双字节表示 | 单个零字节 | 禁止混用 strlen 语义 |
| 补充字符 | 代理单元分别编码 | 一个四字节序列 | 明确转换器 |
| 终止符 | 缓冲末尾零字节 | 协议通常按长度 | 分开内容与终止符 |
| 未配对代理项 | 可按 code unit 表示 | 不属于 Unicode 标量 | 制定拒绝或保留策略 |
| 网络传输 | 不作为默认协议编码 | 按协议常见约定 | 先做标准解码 |
| JNI API | UTF 系列使用 | 不能直接假定兼容 | 按 API 文档选择 |
长度函数必须和目标表示配套
GetStringLength 给出 UTF-16 code unit 数量,GetStringUTFLength 给出 Modified UTF-8 表示所需的字节数量。一个 BMP code point 可能占一个 UTF-16 单元,补充字符占一对代理单元;Modified UTF-8 的字节数量又取决于每个单元的值。把 GetStringLength 当成字节容量,会在非 ASCII、U+0000 或补充字符时低估缓冲区。
调用区域读取 API 时,输入的 start 和 len 通常仍围绕 Java UTF-16 code unit 范围,而输出缓冲区容量按目标字节表示计算。Android JNI tips 还提醒 GetStringUTFRegion 的结果不保证自动添加零终止符。若调用方随后使用 printf 风格或 strlen 风格接口,就可能越过有效范围。更稳妥的结构同时保存 data、byteLength、capacity 和 encoding,显示和日志使用显式长度。
溢出检查必须早于分配和加终止符。先验证长度返回值可以安全转换为 size_t,再检查 byteLength 加终止空间是否溢出,并设置业务允许的最大输入。空字符串是有效输入,不应与 null jstring 混为一谈;null 是否允许由接口契约决定。任一长度不一致都返回明确错误,不进入部分转换或静默截断。
| 长度来源 | 单位 | 可用于 | 不能用于 |
|---|---|---|---|
| GetStringLength | UTF-16 code unit | 范围与 jchar 数量 | Modified UTF-8 容量 |
| GetStringUTFLength | Modified UTF-8 字节 | JNI UTF 缓冲容量 | 用户可见字符数 |
| strlen | 首个零字节前字节 | 已知兼容 C 字符串 | Java 字符串长度 |
| 容器 size | 元素数或字节数 | 同类型边界 | 跨编码直接复用 |
| 显示计数 | 产品定义 | 界面限制 | Native 缓冲分配 |
| capacity | 已分配字节 | 边界检查 | 实际内容长度 |
嵌入空字符要进入测试而不是被静默吞掉
Java 字符串可以在中间包含 U+0000。GetStringUTFChars 的 Modified UTF-8 表示不会把该字符编码为中间零字节,因此 JNI 返回的缓冲区仍可用末尾零字节终止。但一旦把内容转换为标准 UTF-8,U+0000 就会成为实际零字节;若下游调用 strlen、strcpy 或只接受 C 字符串的 API,后半段会被忽略或产生协议歧义。
因此 JNI 适配层向业务库传递文本时,优先使用 pointer 加显式 byteLength 的接口。若第三方 API 只能接受零终止 C 字符串,契约必须决定 U+0000 是拒绝、转义还是走其他通道,不能悄悄截断。日志也不能用百分号 s 直接记录不受控缓冲;除了截断风险,生产日志还不应包含用户文本或凭据。
测试夹具应把前置、中间和尾部 U+0000 与普通 ASCII、中文、组合字符和补充字符放在同一矩阵,分别核对 UTF-16 code unit 数、Modified UTF-8 字节、标准 UTF-8 字节和业务往返结果。测试用字符串使用专门生成的公开安全内容,不复制客户输入。任何一层采用替换或拒绝策略,都在回执中写明,而不是把返回非空当作成功。
| 目标接口 | 推荐传递 | 空字符策略 | 失败信号 |
|---|---|---|---|
| JNI UTF API | Modified UTF-8 加配对长度 | 按 JNI 表示 | JNI 异常或空返回 |
| UTF-16 Native API | jchar 加 code unit 数 | 保留 U+0000 | 显式状态 |
| 标准 UTF-8 API | 字节加长度 | 保留零字节 | 长度不一致 |
| 纯 C 字符串 API | 仅受控输入 | 拒绝或转义 | 禁止静默截断 |
| 日志 API | 有限元数据 | 不记录正文 | 字段门禁 |
| 持久化协议 | 按协议编码 | 协议明确 | 解析失败拒绝 |
代理项与非法字节需要明确的拒绝策略
Java String 能保存 UTF-16 code unit 序列,序列中可能出现未配对代理项。它们不是独立 Unicode 标量,转换到严格标准 UTF-8 时不能被当作普通字符直接编码。JNI 适配层要先决定业务是否要求 Unicode 标量有效:面向网络、JSON 或文本数据库的路径通常应拒绝或按明确替换策略处理;需要保持 Java code unit 的内部路径可以使用 UTF-16,并把边界写清。
外部标准 UTF-8 字节也可能包含过短、过长、截断、非法 continuation 或不允许的标量。不要把任意字节直接传给 NewStringUTF,寄希望于运行时修复。Android JNI tips 提到 CheckJNI 能发现部分 Modified UTF-8 错误,但开发诊断不是生产输入验证器。外部数据先经过严格标准 UTF-8 解码,失败时返回字段错误;成功后再由受控 Unicode 表示创建 Java 字符串。
替换字符不是无害默认值。身份、文件名、签名原文、协议字段或业务主键若在解码时替换非法输入,可能让显示、比较和存储看到不同值。面向自然语言展示的路径可以选择替换,但要记录发生过 loss,并禁止该结果进入标识或授权判断。本文不提供利用非法编码的攻击步骤,只给出防御性拒绝和测试方法。
| 输入问题 | 显示文本 | 身份或协议字段 | 审计状态 |
|---|---|---|---|
| 未配对高代理 | 拒绝或显式替换 | 拒绝 | 记录 invalid_surrogate |
| 未配对低代理 | 拒绝或显式替换 | 拒绝 | 记录 invalid_surrogate |
| 截断 UTF-8 | 拒绝 | 拒绝 | 记录 truncated_sequence |
| 过长编码 | 严格解码拒绝 | 拒绝 | 记录 noncanonical_encoding |
| 非法 continuation | 拒绝 | 拒绝 | 记录 invalid_continuation |
| 合法 U+0000 | 按长度保留 | 按协议决定 | 记录 policy 路径 |
借用指针、异常和释放必须形成一条控制流
GetStringUTFChars 或 GetStringChars 取得的结果具有明确的 JNI 生命周期,调用方在使用后要调用对应 Release API。不要缓存到全局变量、交给延迟回调、跨线程长期持有,或在 Java 字符串生命周期之外继续使用。即使实现返回了直接指针而不是副本,调用者也不应依赖这一细节;isCopy 只用于观察,不能改变释放责任。
控制流要覆盖所有出口。取得指针后,长度校验失败、下游转换失败、Native 异常、Java 异常待处理和提前返回都必须走同一清理段。释放完成后再向 Java 报告错误;如果 JNI 调用已经产生待处理异常,后续可调用的 JNI API 受到限制,适配层应按既定异常策略退出,而不是继续做大段业务处理。
对高频短字符串,可以评估区域 API 或栈上受限缓冲,但优化必须建立在正确长度和异常语义上。Android JNI tips 提供减少封送成本和局部引用管理等建议,不能据此宣称某条路径一定更快。目标项目需要用同一候选构建测量,并检查优化后所有释放、异常和 U+0000 路径。本文没有性能数据,不给出百分比或固定阈值。
| 阶段 | 可能失败 | 必须保留的状态 | 清理责任 |
|---|---|---|---|
| 取得 jstring | null 不符合契约 | 参数状态 | 不产生借用 |
| 取得 chars | 返回空或待处理异常 | 是否已借用 | 仅成功后释放 |
| 读取长度 | 单位或转换溢出 | 指针与来源 | 进入统一清理 |
| 下游转换 | 非法编码或容量不足 | 错误类别 | 释放后返回 |
| 创建结果 | 分配或 Java 异常 | 异常状态 | 释放临时资源 |
| 完成 | 无 | 释放回执 | 不得缓存指针 |
兼容问题要和编码契约分层定位
Android NDK common problems 覆盖 API level、缺失符号、STL、异常类型和依赖装载等常见故障。字符串乱码或截断出现时,应先用同一输入在 JNI 边界核对 UTF-16 单元、Modified UTF-8 字节、目标标准 UTF-8 字节和长度单位,再排查 ABI、运行库或加固变换。否则团队可能把一个稳定的编码错误误报为 SO 加固兼容问题。
Android ABIs 说明不同 ABI 具有各自调用约定、寄存器、对齐和设备支持范围。字符串表示规则不应随 ABI 改变,但缓冲区类型、size_t 宽度、第三方库编译选项和异常路径可能在不同产物上暴露不同问题。测试报告绑定最终 ABI、构建版本和输入夹具;声明支持某个 ABI 或编译通过,不能证明字符串往返正确。
Android NDK stable APIs 处理 minSdk 与 Native API 可用性,不是字符串编码规范。若转换器或 ICU 类能力通过动态 API 选择实现,仍需验证每个 API level 使用的是哪个路径、失败是否一致以及是否存在静默替换。没有目标设备回执时,公开结论只保留方法与边界,不声称兼容、性能或防护效果。
- 先复核输入编码、长度单位和往返字节,再进入 ABI 或加固排查
- 每个 ABI 使用同一公开安全 Unicode 夹具执行设备回归
- 第三方转换库、编译选项和错误策略随候选构建归档
- 动态 API 回退记录实际路径,禁止静默改变替换策略
- CheckJNI 作为开发诊断补充,不替代生产严格解码
- 无项目证据的兼容、性能、攻击阻断和客户结论保持未证实
用脱敏用例验证 Modified UTF-8 往返和非法序列
下面的 Python 示例读取 JSON 用例,输入为十六进制 Modified UTF-8 字节、期望的 UTF-16 code unit 和 accept 或 reject。解码器拒绝内容中的零字节、截断序列、错误 continuation、过长编码和四字节标准 UTF-8;它允许 Modified UTF-8 对 U+0000 的特殊表示,并把三字节代理单元保留为 UTF-16 code unit。accept 用例还执行重新编码和字节一致性检查。
代码只验证脱敏 fixture 的字节契约,不调用 JNI,也不能证明 GetStringUTFChars 已释放、Java 异常处理正确或目标 SO 兼容。生产测试还需要在设备端调用真实 Java 与 Native 适配层,对比 GetStringLength、GetStringUTFLength、区域 API、null、空串和清理回执。用例不包含客户文本、凭据、包名、地址、内网信息或攻击链。
可同时参阅本站关于 JNI pending exception 与整数宽度转换的技术说明,分别处理异常状态和长度跨类型边界;当前页面只回答字符串编码契约。准备具体 SO 的字符串回归时,可整理脱敏 API 表、编码说明、错误策略、释放路径、ABI 矩阵和测试 fixture,再从御盾中央平台提交申请;页面框架会统一提供本站内链和中央行动按钮。
- accept 用例必须解码为指定 UTF-16 code unit 并逐字节往返
- U+0000 只接受 Modified UTF-8 的特殊双字节表示
- 截断、错误 continuation、过长编码和四字节序列进入 reject
- 代理 code unit 保持原值,业务层另行决定标量有效性
- fixture 通过后仍验证真实 JNI 长度、异常和 Release 路径
- 所有测试数据使用公开安全 Unicode 内容并绑定候选构建
from pathlib import Path
import json
import sys
if len(sys.argv) != 2:
raise SystemExit("usage: mutf8_gate.py cases.json")
path = Path(sys.argv[1])
if not path.is_file():
raise SystemExit("case file is missing")
try:
cases = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise SystemExit("case file is not valid JSON") from exc
if not isinstance(cases, list) or not cases:
raise SystemExit("cases must be a nonempty list")
def decode_mutf8(data):
units = []
index = 0
while index < len(data):
first = data[index]
if 1 <= first <= 0x7F:
units.append(first)
index += 1
elif index + 1 < len(data) and first & 0xE0 == 0xC0:
second = data[index + 1]
if second & 0xC0 != 0x80:
raise ValueError("invalid continuation")
value = ((first & 0x1F) << 6) | (second & 0x3F)
if value == 0:
if first != 0xC0 or second != 0x80:
raise ValueError("invalid null encoding")
elif value < 0x80:
raise ValueError("overlong two-byte sequence")
units.append(value)
index += 2
elif index + 2 < len(data) and first & 0xF0 == 0xE0:
second, third = data[index + 1], data[index + 2]
if second & 0xC0 != 0x80 or third & 0xC0 != 0x80:
raise ValueError("invalid continuation")
value = ((first & 0x0F) << 12) | ((second & 0x3F) << 6) | (third & 0x3F)
if value < 0x800:
raise ValueError("overlong three-byte sequence")
units.append(value)
index += 3
else:
raise ValueError("invalid or unsupported byte sequence")
return units
def encode_mutf8(units):
output = bytearray()
for unit in units:
if not isinstance(unit, int) or not 0 <= unit <= 0xFFFF:
raise ValueError("invalid UTF-16 code unit")
if unit == 0:
output.extend((0xC0, 0x80))
elif unit <= 0x7F:
output.append(unit)
elif unit <= 0x7FF:
output.extend((0xC0 | unit >> 6, 0x80 | unit & 0x3F))
else:
output.extend((0xE0 | unit >> 12, 0x80 | unit >> 6 & 0x3F, 0x80 | unit & 0x3F))
return bytes(output)
for number, case in enumerate(cases, 1):
if not isinstance(case, dict) or case.get("expect") not in {"accept", "reject"}:
raise SystemExit(f"case {number} has an invalid contract")
try:
data = bytes.fromhex(str(case.get("mutf8Hex", "")))
units = decode_mutf8(data)
if case["expect"] == "reject":
raise SystemExit(f"case {number} unexpectedly decoded")
if units != case.get("utf16Units") or encode_mutf8(units) != data:
raise SystemExit(f"case {number} failed round-trip validation")
except ValueError as exc:
if case["expect"] != "reject":
raise SystemExit(f"case {number} failed: {exc}") from exc
print(f"validated_cases={len(cases)}")事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| JNI 的 UTF 字符串 API 使用 Modified UTF-8,而 Java 字符串可按 UTF-16 code unit 读取。 | Android JNI tips 说明 jstring、GetStringChars、GetStringUTFChars、Modified UTF-8 和相关长度与释放注意事项。 | 平台建议不能证明目标适配层的长度、异常和释放路径已经实现正确。 |
| 高于 minSdk 的 Native API 可用性需要版本化处理,不能无条件静态调用。 | Android NDK stable APIs 说明 minSdk 与 Native API 可用性的关系及动态访问方向。 | API 可用性不是字符串编码规范,也不能证明动态转换回退在所有设备一致。 |
| Native 兼容排查需要区分 API level、缺失符号、STL、异常类型和依赖装载等问题。 | Android NDK common problems 汇总这些常见失败类别与排查方向。 | 通用故障清单不能替代同一输入、ABI、候选构建和设备路径的字符串回归。 |
| 不同 Android ABI 有各自调用约定、寄存器、对齐和设备支持范围。 | Android ABIs 说明 Android 支持 ABI 的架构特征与调用约定。 | 声明支持某个 ABI 不等于该 ABI 的字符串适配产物已经构建、打包和运行验证。 |
| 依赖真实 Android 运行时和 JNI 的字符串语义应通过设备端测试覆盖。 | Android instrumented tests 说明设备端测试运行于 Android 环境并可访问运行时 API。 | 单一设备或模拟环境通过不能代表完整 API、ABI、厂商和第三方库矩阵。 |
| JNI 字符串契约的发布过程应保留来源、构建、验证和变更证据。 | NIST SP 800-218 SSDF 给出组织级安全软件开发与供应链风险管理实践。 | SSDF 不定义 Modified UTF-8 细节,也不证明御盾或任何 SO 具备特定兼容能力。 |
| 外部标准 UTF-8 应先严格解码,再转换为 Java 字符串,而不是直接传给 NewStringUTF。 | 工程判断:标准 UTF-8 与 Modified UTF-8 在空字符、补充字符和非法序列上具有不同字节契约。 | 具体解码库、替换策略和业务容错由目标协议决定,需用项目 fixture 和设备回执确认。 |
| 借用的 JNI 字符串指针必须在全部成功、失败和异常路径配对释放。 | 工程判断:统一清理控制流可避免早退或异常分支遗留借用资源,并阻止指针跨生命周期使用。 | 静态代码结构不能证明运行时没有旁路,仍需异常注入、资源观察和同一候选构建验证。 |
工程常见问题
GetStringUTFChars 返回的是标准 UTF-8 吗?
不是。JNI UTF 系列使用 Modified UTF-8;它对 U+0000 和补充字符的字节表示与标准 UTF-8 不同。
能否用 GetStringLength 直接分配 UTF-8 字节缓冲区?
不能。它返回 UTF-16 code unit 数;Modified UTF-8 或标准 UTF-8 的字节容量要使用对应长度计算并检查溢出。
为什么 Java 字符串中的 U+0000 容易造成截断?
Modified UTF-8 会特殊编码它,但转成标准 UTF-8 后会出现零字节;下游若使用 strlen 或纯 C 字符串接口就可能只处理前半段。
外部网络 UTF-8 可以直接传给 NewStringUTF 吗?
不应这样做。先按标准 UTF-8 严格解码,处理非法序列与代理策略,再通过受控 Unicode 或 UTF-16 路径创建 Java 字符串。
GetStringUTFChars 返回 isCopy 为 false 时还要 Release 吗?
要。调用者不能依赖实现是否复制;只要成功取得结果,就按配对 API 和统一清理路径释放。
脱敏 Python 用例通过是否代表 JNI 字符串转换已经兼容?
不代表。它只验证字节 fixture,还需在设备端覆盖真实 JNI 长度、null、异常、Release、ABI 和候选构建。