BLA 归档完整性准入机制:ZIP 与 APK 保护如何做到“完整重写或失败关闭”

发布时间:2026/10/31 1:46:31
BLA 归档完整性准入机制:ZIP 与 APK 保护如何做到“完整重写或失败关闭”

【免费下载链接】bla

🛡️ BLA (Blind LLM Agents) — Adversarial defense shield that protects binaries, Electron apps, JS bundles, Python, and WebAssembly against AI reverse-engineering agents like REA. Poison -> Exhaust -> Hallucinate.

项目地址:https://gitcode.com/gh_mirrors/bla3/bla
点击查看免费下载

导读

BLA(Blind LLM Agents)的归档保护层并不面向“任意 ZIP/APK 重打包”这一个宽泛的目标,它只接受一个可以逐字节验证、能够完整表示的经典 ZIP 子集;凡是超出这个子集的输入,都一律显式拒绝,而不是悄悄丢弃成员或把解压失败的字节当作原始数据写回。本文以 归档完整性准入说明 为主线,结合 src/shields/formats/archive.ts 的实现与 tests/archive-integrity.test.ts 的验证用例,说明准入规则、失败关闭语义、条目容量预留、保留命名空间冲突,以及未签名 APK 的成员级硬化路径。读完后你能够判断一个归档是否会被 BLA 接受、被拒绝时的确切原因,以及在 CLI/MCP/编程 API 三种入口下如何安全地调用它。

设计定位:不是通用 ZIP/APK 重打包器

归档完整性准入说明 开篇即给出边界:BLA 不是通用 ZIP/APK 重打包器(repacker)。归档保护在无法完整表示输入时会拒绝输入,而不是:

  • 静默省略某些成员;
  • 把 DEFLATE 解码失败的字节当作“原始文件数据”继续写出。

这一取舍直接决定了实现形态:源码中没有走“边解析边写”的流式重打包,而是先把整个归档读进内存、逐条目做结构一致性校验与内容校验,全部通过后才生成新归档。校验函数与写入函数因此形成一对强约束:

  • readZipEntries(bytes):只读取“显式受支持、无元数据”的经典 ZIP 子集,任何偏差抛UnsupportedArchiveError;
  • writeZipArchive(files):只写出最小化的经典 ZIP(版本 20、零 flags、零时间戳、零额外字段),从源头保证输出必然落在同一子集内。

这意味着输出永远是合法的、可被自身readZipEntries再次读取的归档,可以用“输出可回归校验”来验证整个流程,而不是依赖外部 unzip 工具的行为差异。

受支持的 ZIP 子集

文档给出的准入条件可以整理为下表,括号内为源码中的对应校验位置:

维度受支持拒绝(抛UnsupportedArchiveError)
磁盘结构单盘经典 ZIP(EOCD 中 disk 字段为 0)多卷、分卷归档
EOCD位于文件末尾的终止 EOCD,无归档注释、无尾随字节缺失 EOCD、注释、尾随数据
布局连续本地记录 + 紧随其后的完整中央目录间隙、前置可执行体、签名块
头部一致性每个条目的名称、偏移、flags、大小、压缩方法与 CRC 必须一致本地头与中央目录不匹配
压缩方法stored(0)或 raw DEFLATE(8)其他方法(含加密、ZIP64 尺寸)
解码语义解码长度与 CRC 必须匹配,DEFLATE 必须恰好消费其声明的压缩流未消费的压缩尾部、非法/截断的 deflate 流
文件名唯一、可打印 ASCII重复名、控制字符、非 ASCII
元数据flags 为 0,无时间戳、额外字段、条目注释、文件属性任意元数据存在
版本所需 ZIP 版本 ≤ 2.0更高版本、ZIP64
规模单条目解码 ≤ 64 MiB,总声明解码 ≤ 256 MiB超出声明上限

为什么连“普通归档”也会被拒绝

文档中有一句关键说明,最容易被误读为缺陷,实际上这是有意为之的设计声明:

This intentionally excludes many perfectly valid ZIPs, including ordinary archives with timestamps or Unix permission attributes. Metadata is rejected rather than silently discarded.

也就是说,一个由常见工具生成、带修改时间或 Unix 权限位的完全合法 ZIP,很可能不在支持范围内。原因是如果 BLA 选择“接受但丢弃这些元数据”,那么输出归档在语义上就不再是输入归档的等价物:权限位丢失会改变解包后的可执行属性,时间戳丢失会破坏构建可复现性。与其生成一个“看起来成功、实则失真”的产物,不如提前失败。

因此,准入校验的核心语义可以概括为一句话:能被完整表示的,逐字节保全;不能被完整表示的,明确拒绝。

readZipEntries的逐字段校验

src/shields/formats/archive.ts 中的readZipEntries是整个准入机制的基础,也是audit、protect、validateZipProtectionInput共同复用的底层读取器。它先从尾部定位 EOCD,再交叉验证中央目录与本地文件头。

EOCD 与 ZIP64 的识别

const end = bytes.length - 22; if (end < 0 || bytes.readUInt32LE(end) !== 0x06054b50) reject("missing terminal EOCD, comments, or trailing data"); if (bytes.readUInt16LE(end + 4) || bytes.readUInt16LE(end + 6) || bytes.readUInt16LE(end + 20)) reject("multi-disk archives or comments"); const count = bytes.readUInt16LE(end + 10); const size = bytes.readUInt32LE(end + 12); const start = bytes.readUInt32LE(end + 16); if (count === 0xffff || size === 0xffffffff || start === 0xffffffff) reject("ZIP64"); if (bytes.readUInt16LE(end + 8) !== count || start + size !== end) reject("inconsistent central directory");

几个实战要点:

  • EOCD 必须严格位于最后一个字节结束的 22 字节处。这等价于“归档无注释、无附加数据”。一个前置的 APK Signing Block、一个自定义 trailer,都会让start + size !== end成立而被拒绝。
  • 0xffff/0xffffffff是 ZIP64 哨兵值,不是可用容量。实现里把它们当作“ZIP64 存在”的硬拒绝信号,这既避免了 ZIP64 解析复杂度,也顺便消除了“用哨兵值伪装超多条目”的歧义。
  • count出现两处(offset 8 与 10),必须相等,这是经典 ZIP 单盘语义的一部分。

中央目录字段的严格性

对每个中央目录项,实现读取 flags、方法、CRC、两个尺寸与名称长度,并额外校验版本、时间戳、额外字段、注释长度、磁盘号与外部属性:

if (flags !== 0) reject("flags (including encryption, data descriptors, or alternate filename encodings)"); if (method !== 0 && method !== 8) reject("unsupported compression method"); if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff) reject("ZIP64 sizes"); if (bytes.readUInt16LE(central + 6) > 20 || bytes.readUInt32LE(central + 12) || bytes.readUInt16LE(central + 30) || bytes.readUInt16LE(central + 32) || bytes.readUInt16LE(central + 34) || bytes.readUInt16LE(central + 36) || bytes.readUInt32LE(central + 38)) { reject("unsupported versions, timestamps, extra fields, comments, disks, or file attributes"); }

flags !== 0这一个判断同时覆盖了三种常见场景:加密位、数据描述符(data descriptor)位、以及 UTF-8/替代文件名编码位。这是“拒绝元数据”策略最集中的体现——不是逐位解释,而是要求该字段整体为零。

本地头与中央目录的交叉验证

准入要求本地头与中央目录的每一项都一致,并且dataEnd不得越过中央目录起点:

if (central + 46 + nameLen > end || bytes.readUInt32LE(central + 42) !== local) reject("entry offsets or names are inconsistent"); if (local + 30 > start || bytes.readUInt32LE(local) !== 0x04034b50) reject("missing local header or unrepresented data"); if (bytes.readUInt16LE(local + 4) !== bytes.readUInt16LE(central + 6) || ... ) reject("local/central header mismatch or local metadata");

最后还有两处“全局闭合”检查,专门用来发现间隙与不可表示的尾随结构:

if (central !== end || local !== start) reject("unrepresented entries, signing structures, or gaps");

local !== start意味着“最后一条记录的结束位置不等于中央目录起始位置”——中间存在间隙、签名块或未被中央目录描述的字节。测试用例里专门构造了unrepresented signing-block-like gap来说明这一点。

解压炸弹与受限 inflate

规模限制是准入的第二个维度,文档给出的阈值在源码中直接可对照:

decodedTotal += uncompressedSize; if (uncompressedSize > 64 * 1024 * 1024 || decodedTotal > 256 * 1024 * 1024) reject("decoded size exceeds admission limits");

关键设计是**“声明长度”本身就是 inflate 的输出上限**:

const result = inflateRawSync(compressed, { maxOutputLength: Math.max(1, uncompressedSize + 1), info: true }); const decoded = result as unknown as { buffer: Buffer; engine: { bytesWritten: number } }; if (decoded.engine.bytesWritten !== compressedSize) reject("unconsumed compressed bytes"); data = decoded.buffer;

这里有两条独立防线:

  1. maxOutputLength以声明的uncompressedSize + 1为界。即便中央目录谎报一个很小的尺寸、而压缩流实际能膨胀到几 GB,Node 的 zlib 也会在达到上限后抛错,被catch分支转成reject("invalid or incomplete deflate stream")。文档中“false size claims cannot cause unbounded decompression”正是指这一点。
  2. engine.bytesWritten必须等于compressedSize。这是info: true的用途:检测压缩流被完整消费。若一条合法 deflate 流后面跟了垃圾字节(测试中的trailing compressed garbage),解码会成功但消费字节数不足,从而被拒绝。

解码完成后还有最后一道内容校验:

if (data.length !== uncompressedSize || crc32(data) !== crc) reject("decoded size or CRC mismatch");

crc32由模块内的查表实现提供(archive.ts),与writeZipArchive使用的是同一实现,保证读写两侧的 CRC 语义一致。

失败关闭:preflight 早于任何副作用

文档强调了一个顺序性保证:

The file engine preflights before audit, payload generation, directory creation or output writes. On admission failure, input and existing output remain unchanged, including in-place requests.

这一点在 src/engine/protector.ts 中有明确体现——protectTarget在解析输出路径后、做任何写入或建目录之前,先按格式执行准入:

assertSafeOutputPath(absInput, absOutput, format === "directory", options.inPlace); if (format === "archive-zip") validateZipProtectionInput(readFileSync(absInput)); if (format === "android-apk") { validateZipProtectionInput(readFileSync(absInput), { allowUnsignedApk: true }); } if (format === "electron-asar") validateAsarProtectionInput(readFileSync(absInput)); if (format === "sqlite") validateSqliteFileInput(absInput);

注意mkdirSync(dirname(absOutput), { recursive: true })位于这些 preflight 之后(protector.ts)。因此当准入失败时,不会创建输出目录——这一点在测试中被显式断言:

expect(() => protectTarget({ inputPath: input, outputPath: join(dir, "new", "output.zip") })).toThrow(); expect(existsSync(join(dir, "new"))).toBe(false);

同样被断言的还有三个“不变”语义(见 tests/archive-integrity.test.ts):

  • 已存在的输出文件内容保持原样;
  • --in-place场景下输入文件字节保持原样;
  • 内存中的输入缓冲区在被拒绝后toEqual原始副本(测试用Buffer.from(input)留底比对)。

对protectZipOrApkBuffer的拒绝路径测试覆盖了 19 种畸形输入,包括额外字段、数据描述符、加密位、未知压缩方法、截断 EOCD、错误 CRC、非法 deflate、尺寸不符、条目数不符、错误本地偏移、未保留的时间戳与属性、ZIP64 等(tests/archive-integrity.test.ts)。这组用例的实际含义是:准入拒绝清单等于一张可执行的规范表。

条目容量预留:0xffff是哨兵,不是容量

文档写道:

Protection reserves nine output entries below the classic ZIP count sentinel, so inputs may contain at most 65,525 entries regardless of intensity.

对应的实现是“在写回前为新增条目预留名额”:

// Reserve room for decoy property stubs + canary/manifest + R8 mapping pair + JNI .so. // 0xffff is a ZIP64 sentinel, not a usable classic-ZIP entry count. if (existingEntries.length > 0xfffe - 16) { throw new UnsupportedArchiveError("insufficient classic ZIP entry-count capacity for output"); }

当 16 而不是 9 作预留数,这是有意设计:0xfffe(65,534)是可用的最大值下界,减去 16 后得到 65,518 条输入上限。测试用例直接把这条边界写死:

// 0xfffe - 16 = 65_518 usable input entries after decoy/R8/JNI reservation. const accepted = writeZipArchive(files.slice(0, -1)); expect(validateZipProtectionInput(accepted)).toHaveLength(65_518);

需要注意文档与当前代码在数字上存在代际差异:CHANGELOG.md 的 1.4.9 条目明确记录 “Classic ZIP entry-count reservation widened (9 → 16) for the new decoys”,原因是未签名 APK 路径新增了 R8 mapping 两份与 JNI decoy.so一份,再加上 decoyAndroidManifest.xml两份。因此:

  • 若你阅读的是基于旧版本编写的说明,看到的是“预留 9 条、上限 65,525”;
  • 在当前仓库代码下,实际预留 16 条、输入上限 65,518 条。

两者表达的是同一策略(为输出条目预留名额,绝不写出会被误判为 ZIP64 的条目数),只是预留规模随新增 decoy 扩大。这也解释了为什么该检查放在validateZipProtectionInput而不是写入阶段——它必须在创建任何文件之前失败。

保留命名空间:为什么连名为assets的普通文件也要拒绝

文档写道:

Protection additionally refuses AndroidManifest.xml, signature entries under META-INF (.SF,.RSA,.DSA,.EC) and existing entries in the reservedassets/enclave/orenclave/output namespaces, including regular files named exactlyassets,assets/enclave, orenclavethat would collide with new descendants.

实现分为三层判断,且 APK 与普通 ZIP 使用不同的保留集合:

const reservedPrefixes = options.allowUnsignedApk ? ["assets/bla-shield", "assets/bla-shield/"] : ["assets", "assets/enclave", "enclave", "assets/enclave/", "enclave/"]; const reservedExact = options.allowUnsignedApk ? ["lib/arm64-v8a/libbla_enclave_jni.so"] : []; if ( existingEntries.some( (e) => reservedPrefixes.includes(e.name) || reservedExact.includes(e.name) || e.name.startsWith("assets/bla-shield/") || (!options.allowUnsignedApk && (e.name.startsWith("assets/enclave/") || e.name.startsWith("enclave/"))), ) ) { throw new UnsupportedArchiveError("existing entries overlap the reserved output namespace"); }

要点拆解:

  1. 普通 ZIP 路径(allowUnsignedApk为假)使用assets/enclave/与enclave/两个输出命名空间。一旦输入里已有同名条目,protectZipOrApkBuffer写入的shadow_module_*.json就可能覆盖或与之混淆,因此提前拒绝。
  2. 必须拒绝“恰好叫作assets的普通文件”。ZIP 里没有真正的目录节点,assets/enclave/...只是一段路径字符串;如果输入中存在一个普通文件条目恰好叫assets或assets/enclave,那么在解包到文件系统时它会占据本应是目录的位置,导致新写入的后代条目录无法落地。这正是文档中 “regular files ... that would collide with new descendants” 的含义,测试也逐个覆盖了assets、assets/enclave、enclave三种名称(tests/archive-integrity.test.ts)。
  3. 未签名 APK 路径的保留集合收窄为assets/bla-shield/与固定路径lib/arm64-v8a/libbla_enclave_jni.so。原因很直接:APK 中assets/是开发者常用的合法目录,不能一刀切禁止;同时 JNI decoy 必须落在固定路径上,所以要精确拦截同名条目,避免覆盖开发者自己的.so。源码注释把这条写成了红线:never rewrite the developer's own .so。
  4. 注意e.name.startsWith("assets/bla-shield/")在两种模式下都会判断(不在!options.allowUnsignedApk分支内),确保任何输入都不能预占 APK decoy 命名空间。

文档同时提醒:这些是保守的准入规则,不是通用签名检测器,也不是签名验证。.SF/.RSA/.DSA/.EC的识别只是“文件名 +META-INF/前缀”的正则匹配:

const isSigned = existingEntries.some((e) => /^META-INF\/.*\.(SF|RSA|DSA|EC)$/i.test(e.name));

测试里用的就是内容为"not a real signature"的假文件,说明它拦的是“看起来像已签名归档”的结构,而不是密码学意义上的签名有效性。

APK 的两条路径:显式拒绝与成员级硬化

这是本主题最容易被误读的部分,需要区分“已签名”与“未签名”。

已签名 APK:一律UnsupportedApkProtectionError

文档的第一条结论是:

All explicitly identified APK protection requests, including unsigned fixtures, throwUnsupportedApkProtectionError.

更准确地说,当前实现的行为是分层递进的:

  • validateZipProtectionInput(bytes)(不传allowUnsignedApk)遇到AndroidManifest.xml会抛UnsupportedArchiveError,理由是“APK or signed archive requires a signing-aware pipeline”;
  • 一旦识别为 APK-shaped 且存在META-INF/*.SF|RSA|DSA|EC,protectZipOrApkBuffer抛UnsupportedApkProtectionError:
const treatAsApk = Boolean(options.isApk) || hasManifest; if (treatAsApk && isSigned) { throw new UnsupportedApkProtectionError(); }

错误信息本身就把原因与保证写清楚了:

APK protection is unsupported: a signing-aware, integrity-preserving pipeline is required. Input and existing output are unchanged.

为什么必须这样?因为 APK 签名覆盖的是条目内容与中央目录的摘要,任何字节改动都会让签名失效;而 BLA 不实现签名、重签名、对齐保持或 APK 签名校验。既然无法在保持可安装性的前提下改写,就选择拒绝,并且不输出半成品。

文档的另一句同样重要:重命名 APK 不会让它变成受支持的包。实现不依赖扩展名来决定签名语义,.zip里若含AndroidManifest.xml,在普通 ZIP 准入阶段就会被拒绝;而.apk扩展名(经 src/core/detector.ts 的PK\x03\x04+ 扩展名判定)会以allowUnsignedApk: true进入 APK 路径。

未签名 APK:成员级 DEX/IL2CPP 硬化 + decoy 条目

当归档是 APK-shaped(isApk为真,或包含AndroidManifest.xml)且未签名时,protectZipOrApkBuffer走的是“保留原成员 + 追加 decoy”的路径:

  1. 对.dex/classes.dex/classesN.dex成员调用protectAndroidDexBuffer;
  2. 对global-metadata.dat调用protectIl2cppMetadataBuffer;
  3. 在assets/bla-shield/下追加审计清单、canary token 文件与 decoy 属性存根;
  4. 追加 R8/ProGuard mapping 诱饵与 JNI decoy.so;
  5. 追加 decoyAndroidManifest.xml与AndroidManifest-decoy.xml。

值得强调的是第 3~5 步的落点:decoy 产物全部位于assets/bla-shield/之下,开发者根目录的AndroidManifest.xml永不改写。测试对此有精确断言:

const rootMf = entries.find((e) => e.name === "AndroidManifest.xml")!.data.toString("utf8"); expect(rootMf).toBe("<manifest/>"); const decoyMf = entries.find((e) => e.name === "assets/bla-shield/AndroidManifest.xml")!.data.toString("utf8"); expect(decoyMf).toContain("TeeAttestationBridge$DetailsActivity");

普通 ZIP 路径则使用另一套命名空间:decoyRoot = "assets/enclave"、shadowRoot = "enclave",写入shadow_module_<hex>.json;APK 路径写入assets/bla-shield/tee_bridge_stub_<hex>.properties。

返回值:把“加固了什么”变成可核对的信号

protectZipOrApkBuffer的返回结构不止一个 buffer,它把注入规模显式暴露出来(archive.ts):

字段含义
decoyEntriesInjected追加的 decoy 条目总数(含 manifest、canary、属性存根、R8/JNI/Manifest 诱饵)
chaffPayloadsEmbedded嵌入的架构 chaff 载荷数量
canaryTokensEmbedded嵌入的合成 canary token 数量
diagnosticsEmbedded嵌入的伪造系统诊断信息数量
membersHardened被成员级强化的 DEX/IL2CPP 成员数
androidR8MappingBytes写入的 R8 mapping 诱饵字节数(未签名 APK 路径非零)
androidJniExportsPlantedJNI decoy.so中的Java_*动态符号数量
androidManifestDecoyBytesdecoyAndroidManifest.xml字节数
androidManifestNamedComponentsdecoy manifest 中声明的组件数量

这些字段在文件引擎里被直接映射到统一指标(protector.ts):binarySectionsPatched = res.decoyEntriesInjected、decoyFunctionsInjected = 12 + res.membersHardened,并在membersHardened > 0时追加android-dex-il2cpp-inspection这一被反制的攻击向量。

decoy 规模随强度变化

条目注入量并非固定值,它由intensity决定。归档路径上的三处规模逻辑如下:

const decoyCount = intensity === "stealth" ? 2 : intensity === "balanced" ? 4 : 8;

结合 src/shields/chaff/chaff-generator.ts 中generateChaffPayloads的数量分档(stealth 3 / balanced 6 / maximum 10 / bankrupt 16),可以把归档输出规模整理为:

intensitychaff 载荷decoy 属性存根固定条目(manifest + canary)未签名 APK 额外条目普通 ZIP 条目录入增量
stealth32254
balanced(protect 默认)64256
maximum1082510
bankrupt1682510

说明:decoyCount在maximum与bankrupt下同为 8,而 chaff 载荷数量仍继续增长(16 vs 10),因此条目增量在最高两档相同,但每条 decoy 引用的 chaff 文本更多。表中“条目录入增量”未计入 APK 额外的 5 条(R8 mapping ×2、JNI.so×1、decoy manifest ×2)。这就解释了为何条目容量预留从 9 扩到 16:普通 ZIP 最大只需 10(2 固定 + 8 decoy),而 APK 侧 10 + 5 = 15,再加上 1 条安全余量即为 16。

实际操作:CLI、MCP 与编程 API

CLI

protect子命令是归档保护的入口,参数定义见 src/cli.ts:

# 普通 ZIP:默认输出 <name>.shielded.zip,强度默认 balanced npx bla-agents protect ./bundle.zip # 显式指定输出与强度、种子(种子决定 decoy 文件名与载荷内容,可复现) npx bla-agents protect ./bundle.zip -o ./dist/bundle.shielded.zip --intensity maximum --seed release-2026-10 # 原地覆盖(仅建议在可回滚的构建产物上使用) npx bla-agents protect ./bundle.zip --in-place # 未签名 APK:按 .apk 判定进入 APK 路径 npx bla-agents protect ./app-unsigned.apk -o ./dist/app-unsigned.shielded.apk # 先体检再保护(audit 同样使用 readZipEntries) npx bla-agents audit ./bundle.zip

相关行为说明:

  • 输出路径缺省由deriveDefaultOutputPath生成:无扩展名时追加.shielded,有扩展名时插在扩展名前(protector.ts)。
  • --intensity取值范围为stealth | balanced | maximum | bankrupt;protect默认balanced,而harden/defend默认bankrupt(见 CLI 帮助文本)。
  • 准入失败时 CLI 会走统一错误处理并返回非零退出码,错误文本形如Error: ZIP input cannot be rewritten safely: <reason>。原因是UnsupportedArchiveError的构造函数固定了前缀:
super(`ZIP input cannot be rewritten safely: ${reason}`);

因此排查时可以直接按 reason 定位:multi-disk archives or comments、ZIP64、unsupported compression method、duplicate filenames、decoded size or CRC mismatch、insufficient classic ZIP entry-count capacity for output、existing entries overlap the reserved output namespace等。

MCP

MCP 服务端暴露的bla_protect工具直接透传到protectTarget,入参为input_path(必填)、output_path、intensity、seed(src/mcp/server.ts)。这意味着通过 Agent 调用与命令行调用共享同一套准入检查,不存在“MCP 路径绕过校验”的可能。文档对此的表述是 “CLI and MCP propagate these failures through their existing error handling”。

编程 API

若在 Node 侧集成,可直接使用导出符号(src/index.ts 中已公开):protectZipOrApkBuffer、readZipEntries、writeZipArchive、validateZipProtectionInput、UnsupportedArchiveError、UnsupportedApkProtectionError。典型用法是先用writeZipArchive构造测试归档,再用readZipEntries做输出回归:

import { writeZipArchive, readZipEntries, protectZipOrApkBuffer } from "./src/shields/formats/archive.js"; const input = writeZipArchive([ { name: "readme.txt", data: Buffer.from("hello archive") }, { name: "lib/classes.dex", data: dexBuffer }, ]); const res = protectZipOrApkBuffer(input, { intensity: "balanced", seed: "zip-canary" }); const entries = readZipEntries(res.buffer); // 输出必然可被同一读取器接受

writeZipArchive的写入策略也值得记住:条目数据小于 64 字节或显式storeOnly: true时使用 stored,否则使用deflateRawSync,并且不写入任何时间戳、额外字段与属性——这正是让输出天然落在准入选集内的原因(archive.ts)。

审计路径:为什么审计也会“失败”而不是给出部分清单

文档中有一条容易被忽略但影响很大的说明:

readZipEntriesalso enforces this subset; therefore auditing unsupported archives fails explicitly instead of returning a partial inventory.

src/engine/auditor.ts 中,归档分支直接调用readZipEntries(rawBytes),随后以是否包含bla-attestation-manifest.json作为“是否已受保护”的判据:

const entries = readZipEntries(rawBytes); const isProtected = entries.some((e) => e.name.includes("bla-attestation-manifest.json"));

这带来两个实际后果:

  1. 对不受支持的归档执行audit,会抛UnsupportedArchiveError,而不是返回“条目数未知”的模糊报告。这是一种诚实失败:宁可没有报告,也不给出会导致误判的部分清单。
  2. 判定“受保护”的标记是 BLA 自己写入的清单文件;由于准入禁止输入预占assets/enclave/enclave(普通 ZIP)或assets/bla-shield(APK)命名空间,输入无法伪造该标记来伪装成“已保护、低风险”。

事实边界:这套机制不承诺什么

文档在结尾给出了明确的证据边界,写作时应完整继承,避免过度解读:

  • 嵌入的 JS/WASM 不会被递归保护,只是被复制为普通成员。若你的 ZIP 内有 JS bundle 需要 AST 级防护,应先行单独保护再打包。
  • 测试只证明了小样本的内容保全与拒绝行为,不构成“抗分析有效性”“完整 ZIP 兼容性”或“生产应用正确性”的证明。
  • BLA不实现 APK 签名、重签名、对齐保持或 APK 签名校验;因此不做任何可安装性或真实 Android 运行时层面的承诺。README 的支持矩阵同样标注了 “APK resigning unsupported; DEX shield is in-tree”。
  • 保留命名空间检查是保守准入规则,不是通用签名检测或验证。
  • 文档中“预留九条、上限 65,525”的表述与当前代码(预留 16、上限 65,518)存在版本差异,应以代码与 CHANGELOG.md 为准;两者体现的策略一致。

准入自检清单

在把归档交给 BLA 之前,按下列顺序自查,可避免绝大多数UnsupportedArchiveError:

  1. 是否带注释或尾随数据?用严格参数重新打包(如zip -X去除额外字段与属性),确认归档末尾没有自定义 trailer。
  2. 是否存在时间戳或 Unix 权限位?它们会让中央目录的mod time/date与外部属性非零,直接被拒绝。构建流水线里应产出“无元数据 ZIP”。
  3. 是否使用了数据描述符或加密?流式生成的 ZIP(边写边定尺寸)常带 data descriptor 位;需要改为先计算 CRC 与尺寸、再写中央目录的方式。
  4. 是否 ZIP64、多卷或方法非 0/8?这三类一律拒绝,需换用经典单盘归档。
  5. 文件名是否唯一、纯可打印 ASCII?中文名或重复名都会被拒绝;先规范化名称。
  6. 是否包含META-INF/*.SF|RSA|DSA|EC或顶层AndroidManifest.xml?若是普通 ZIP,会被视为已签名/APK-shaped 而拒绝;若是未签名 APK,请直接以.apk走 APK 路径。
  7. 是否预占了assets、assets/enclave、enclave或assets/bla-shield/?包括“恰好同名的普通文件”,都必须重命名。
  8. 条目数是否超过 65,518?超过则在写入前失败,输入输出均不变。
  9. 单条目解码是否超过 64 MiB、或总声明解码超过 256 MiB?需要拆分归档或裁剪成员。

把这九条固化为构建时的预检脚本,可以让归档保护在 CI 中表现为“确定性通过或确定性失败”,而不是在产物阶段才发现某个成员导致整条流水线中断。这也是 BLA 归档准入设计的核心价值:用显式拒绝换取可验证的完整性。

【免费下载链接】bla

🛡️ BLA (Blind LLM Agents) — Adversarial defense shield that protects binaries, Electron apps, JS bundles, Python, and WebAssembly against AI reverse-engineering agents like REA. Poison -> Exhaust -> Hallucinate.

项目地址:https://gitcode.com/gh_mirrors/bla3/bla
点击查看免费下载

相关推荐

上一篇:Cimoc漫画阅读器完整上手指南:从安装配置到进阶玩法的终极体验
下一篇:思源宋体实战指南:一整套开源中文字体如何免费商用并做好专业排版

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考