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.
导读
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;这里有两条独立防线:
maxOutputLength以声明的uncompressedSize + 1为界。即便中央目录谎报一个很小的尺寸、而压缩流实际能膨胀到几 GB,Node 的 zlib 也会在达到上限后抛错,被catch分支转成reject("invalid or incomplete deflate stream")。文档中“false size claims cannot cause unbounded decompression”正是指这一点。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"); }要点拆解:
- 普通 ZIP 路径(
allowUnsignedApk为假)使用assets/enclave/与enclave/两个输出命名空间。一旦输入里已有同名条目,protectZipOrApkBuffer写入的shadow_module_*.json就可能覆盖或与之混淆,因此提前拒绝。 - 必须拒绝“恰好叫作
assets的普通文件”。ZIP 里没有真正的目录节点,assets/enclave/...只是一段路径字符串;如果输入中存在一个普通文件条目恰好叫assets或assets/enclave,那么在解包到文件系统时它会占据本应是目录的位置,导致新写入的后代条目录无法落地。这正是文档中 “regular files ... that would collide with new descendants” 的含义,测试也逐个覆盖了assets、assets/enclave、enclave三种名称(tests/archive-integrity.test.ts)。 - 未签名 APK 路径的保留集合收窄为
assets/bla-shield/与固定路径lib/arm64-v8a/libbla_enclave_jni.so。原因很直接:APK 中assets/是开发者常用的合法目录,不能一刀切禁止;同时 JNI decoy 必须落在固定路径上,所以要精确拦截同名条目,避免覆盖开发者自己的.so。源码注释把这条写成了红线:never rewrite the developer's own .so。 - 注意
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, throw
UnsupportedApkProtectionError.
更准确地说,当前实现的行为是分层递进的:
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”的路径:
- 对
.dex/classes.dex/classesN.dex成员调用protectAndroidDexBuffer; - 对
global-metadata.dat调用protectIl2cppMetadataBuffer; - 在
assets/bla-shield/下追加审计清单、canary token 文件与 decoy 属性存根; - 追加 R8/ProGuard mapping 诱饵与 JNI decoy
.so; - 追加 decoy
AndroidManifest.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 路径非零) |
androidJniExportsPlanted | JNI decoy.so中的Java_*动态符号数量 |
androidManifestDecoyBytes | decoyAndroidManifest.xml字节数 |
androidManifestNamedComponents | decoy 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),可以把归档输出规模整理为:
| intensity | chaff 载荷 | decoy 属性存根 | 固定条目(manifest + canary) | 未签名 APK 额外条目 | 普通 ZIP 条目录入增量 |
|---|---|---|---|---|---|
stealth | 3 | 2 | 2 | 5 | 4 |
balanced(protect 默认) | 6 | 4 | 2 | 5 | 6 |
maximum | 10 | 8 | 2 | 5 | 10 |
bankrupt | 16 | 8 | 2 | 5 | 10 |
说明:
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"));这带来两个实际后果:
- 对不受支持的归档执行
audit,会抛UnsupportedArchiveError,而不是返回“条目数未知”的模糊报告。这是一种诚实失败:宁可没有报告,也不给出会导致误判的部分清单。 - 判定“受保护”的标记是 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:
- 是否带注释或尾随数据?用严格参数重新打包(如
zip -X去除额外字段与属性),确认归档末尾没有自定义 trailer。 - 是否存在时间戳或 Unix 权限位?它们会让中央目录的
mod time/date与外部属性非零,直接被拒绝。构建流水线里应产出“无元数据 ZIP”。 - 是否使用了数据描述符或加密?流式生成的 ZIP(边写边定尺寸)常带 data descriptor 位;需要改为先计算 CRC 与尺寸、再写中央目录的方式。
- 是否 ZIP64、多卷或方法非 0/8?这三类一律拒绝,需换用经典单盘归档。
- 文件名是否唯一、纯可打印 ASCII?中文名或重复名都会被拒绝;先规范化名称。
- 是否包含
META-INF/*.SF|RSA|DSA|EC或顶层AndroidManifest.xml?若是普通 ZIP,会被视为已签名/APK-shaped 而拒绝;若是未签名 APK,请直接以.apk走 APK 路径。 - 是否预占了
assets、assets/enclave、enclave或assets/bla-shield/?包括“恰好同名的普通文件”,都必须重命名。 - 条目数是否超过 65,518?超过则在写入前失败,输入输出均不变。
- 单条目解码是否超过 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.
相关推荐
IDM下载加速完全指南:从安装配置到高级优化的12个实用技巧
IDM下载加速完全指南:从安装配置到高级优化的12个实用技巧 先说明本文要讲什么:这是一篇围绕 IDM 下载加速的完整实操指南,从安装激活、核心功能、场景化用法
CLI开发工具Foundry 符号执行 gasleft() 失败关闭语义:从反例模型到调用输入的完整防护
Foundry 符号执行 gasleft 失败关闭语义:从反例模型到调用输入的完整防护 导读 本文围绕 Foundry 原生符号执行( forge test s
区块链开发工具DynamicTp优雅关闭机制:确保任务不丢失的完整解决方案
DynamicTp优雅关闭机制:确保任务不丢失的完整解决方案 在微服务架构和分布式系统中, 线程池优雅关闭 是保障系统稳定性的关键环节。DynamicTp作为一
后端微服务可观测性
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考