先看结论与判断条件

  • 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。

JNI 字符串边界的表示与长度
对象表示长度单位典型用途
Java StringUTF-16 code unit 序列code unitJava 业务文本
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 的关键差异
场景Modified UTF-8标准 UTF-8工程动作
U+0000特殊双字节表示单个零字节禁止混用 strlen 语义
补充字符代理单元分别编码一个四字节序列明确转换器
终止符缓冲末尾零字节协议通常按长度分开内容与终止符
未配对代理项可按 code unit 表示不属于 Unicode 标量制定拒绝或保留策略
网络传输不作为默认协议编码按协议常见约定先做标准解码
JNI APIUTF 系列使用不能直接假定兼容按 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 是否允许由接口契约决定。任一长度不一致都返回明确错误,不进入部分转换或静默截断。

常见长度值的正确用途
长度来源单位可用于不能用于
GetStringLengthUTF-16 code unit范围与 jchar 数量Modified UTF-8 容量
GetStringUTFLengthModified 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 字节和业务往返结果。测试用字符串使用专门生成的公开安全内容,不复制客户输入。任何一层采用替换或拒绝策略,都在回执中写明,而不是把返回非空当作成功。

嵌入 U+0000 的处理决策
目标接口推荐传递空字符策略失败信号
JNI UTF APIModified UTF-8 加配对长度按 JNI 表示JNI 异常或空返回
UTF-16 Native APIjchar 加 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 路径。本文没有性能数据,不给出百分比或固定阈值。

JNI 字符串借用资源的清理路径
阶段可能失败必须保留的状态清理责任
取得 jstringnull 不符合契约参数状态不产生借用
取得 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 内容并绑定候选构建
JNI Modified UTF-8 脱敏用例校验器
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 和候选构建。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: SO 加固与 Native 兼容性检查清单