1. 知识点:mmap 的两个"变脸",为什么能"原地解密"
VQF 明文加载从 Day 初就是同一招:mmap后指针直挂权重结构,页由内核按需从磁盘载入(vllm_platform.h 144 行mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0))。mmap 有两个常被忽略的性质,正是 VQF-Enc 的地基:
- 映射权限可以事后升级:
mprotect能把一段PROT_READ映射改成PROT_READ | PROT_WRITE——同一段地址,先只读、后放写。 - MAP_PRIVATE = 写时复制(COW):进程第一次写某个映射页时,内核先把该页从文件读进来并复制一份私有副本,之后你写的都是副本,磁盘原页纹丝不动。映射上发生再多的写,文件本身永远不会被改掉。
两条合起来,"原位解密"就安全了:解密结果写进各页的私有副本,进程内存从密文变成明文;磁盘上始终是一整块密文——进程崩溃、掉电、下次冷启动,拿到的还是密文。这也回答了 24-2 预告里的"密文存哪、何时解密":密文永远在磁盘,明文只存在于进程私有内存,COW 保证二者不互相污染。
再解释单遍:HMAC 覆盖"头部 + 目录 + 全部密文数据区"(24-2),解密也必须覆盖整个数据区——两者可以在同一个流式循环里完成:每读一块,先把密文原值喂进 HMAC,再对同一块原位解密。磁盘只读一遍。若是两遍方案(先全量算完 HMAC 验完、再从头解密),磁盘要读两遍,加载时间直接翻倍。
2. 对应代码:vqf.c 的 vqf_load 解密段(591–639)
代码主干(行号按仓库src/model/vqf.c):
st_mmap_open(&m, 0, path); /* vllm_platform.h 139–147: O_RDONLY + PROT_READ + MAP_PRIVATE */ ... if (enc) { /* 591: VQF-Enc 分支 */ vqf_enc_derive(pass, sm4key, hmackey); /* 597: 口令 → SM4 16B ‖ HMAC 16B */ vc_hmac_init(&hc, hmackey, 16); VQFHeader hdr_auth = *h; hdr_auth.file_len = 0; memset(&hdr_auth.sig, 0, sizeof(hdr_auth.sig)); vc_hmac_update(&hc, &hdr_auth, sizeof(hdr_auth)); /* 609: 头部(回填字段归 0) */ vc_hmac_update(&hc, dir, n_tensors * sizeof(VQFTensor)); /* 610–611: 目录(明文) */ if (mprotect(m.data, m.len, PROT_READ | PROT_WRITE) != 0) /* 613: 只读 → 放写 */ { fprintf(stderr, "[VQF] mprotect failed\n"); ... } while (done < dlen) { /* 624: 单遍循环 */ vc_hmac_update(&hc, dp + done, k); /* 627: 先喂【密文】 */ vc_sm4_ctr_crypt(&sm4ctx, ctr, dp+done, dp+done, k); /* 628: 再【原位】解密 */ done += k; } vc_hmac_final(&hc, tag); /* 633 */ if (memcmp(tag, stored, 32) != 0) /* 635–638: 失败即拒绝 */ { fprintf(stderr, "[VQF] HMAC-SM3 mismatch (tampered or wrong key)\n"); ... return -1; } fprintf(stderr, "[VQF] decrypted %s (SM4-CTR, HMAC-SM3 ok)\n", path); /* 639 */ }三层各管一件事:
- 挂载层:加密/明文共用同一个
st_mmap_open入口,O_RDONLY打开(139 行)、PROT_READ | MAP_PRIVATE映射(144 行)。加密文件并不需要特殊挂载——密文和明文的磁盘布局逐字节同长(CTR 保长),解密只是把"读到的字节"在内存里换一遍。 - 放写层:613 行
mprotect把整段映射升级为可写。注释(612 行)写明"先 mprotect 使整映射可写"。没有这一步,循环里第一个写就会 SIGSEGV。 - 单遍层:624–631 行,1 MB 一块流式推进。627 必须在 628 之前:
vc_sm4_ctr_crypt是原位的(in == out),若先解密,喂进 HMAC 的就是明文,tag 永远对不上。 - 拒绝层:635–638 行 tag 比对失败 →
st_mmap_close并返回 -1,上层打印model load failed rc=1。HMAC 覆盖到文件尾,所以拒绝发生"读完 + 解完"之后——攻击者既拿不到"哪一块错了"的提示,也拿不到任何明文字节。
3. 改动后果:明文 vs 密文加载实测 + 篡改拒绝闭环
实测口径:RK3588 (OrangePi 5 Plus) / aarch64 / 2026-09-07。对照组为同一份 Qwen3-VL-2B 权重:明文生产版Modl/Qwen3-VL-2B-Instruct/model.vqf与加密版day24_enc/model.vqf(VLLM_VQF_KEY=testkey123经 SM3 派生,写侧 flags=0xe3),两者字节尺寸完全一致:2,331,316,232 B(CTR 保长的直接证据)。计时取 admin API 的last_load_ms,与 serve 日志[M-T] model load took … ms一致。
| 文件 | 加载路径 | last_load_ms | 磁盘数据区读取 |
|---|---|---|---|
| 明文(无 ENC flag) | mmap 直挂 + 目录解析 | 322 | 不读(页按需惰性载入) |
| 密文(VQF-Enc) | HMAC 单遍 + 原位解密 | 67,967 / 67,935(两轮) | 全量 1 遍 ≈ 2.17 GiB |
serve 日志对照(同一引擎同一格式):
# 明文 serve(:8804) [M-A] VQF loaded: /mnt/emmc/Modl/Qwen3-VL-2B-Instruct/model.vqf [M-T] model load took 322 ms (startup time) 加密 serve(:8803,VLLM_VQF_KEY=testkey123) [VQF] decrypted /mnt/emmc/day24_enc/model.vqf (SM4-CTR, HMAC-SM3 ok) [M-A] VQF loaded: /mnt/emmc/day24_enc/model.vqf [M-T] model load took 67935 ms (startup time)吞吐核算与诚实边界:约 2.17 GiB(去尾部 32 B tag 后 ≈ 2,331,316,200 B)÷ 67.95 s ≈32.7 MiB/s。这远低于 RK3588 eMMC 顺序读带宽(150–280 MiB/s 量级)——瓶颈不在盘,而在纯 C 国密原语(SM3 压缩轮 + SM4 分组均无 NEON 加速)的单核流式计算。所以发布口径应写明:68 s 是"当前未加速实现的真实成本",对应公理名at_rest_encryption_load_tradeoff(vllm_crypto.h 4–7 行注释)——以"加载期一次性全量读 + 解"换取"磁盘上无明文、运行期零额外开销"。将来若对压缩轮/轮函数做 NEON 化,这一数字会显著下降,那是优化篇的事,本篇只记账、不改实现。(另注:明文对照文件此前多次加载、页缓存偏热,但 mmap 挂载本身不读数据区,322 ms 的量级结论不受影响。)
篡改拒绝闭环(翻转 1 字节 → 拒绝 → 恢复 → 成功):
[start tamper] pid=76195 [flip] @1MB 48 -> b7 # 文件偏移 1MB(数据区内;目录区仅几千字节,必然落在密文数据区)翻转一字节 [load1] REJECTED elapsed=68s err=model load failed rc=1 [restore] flip back [load2] OK elapsed=68s decrypted ... HMAC-SM3 ok这条证据链说明:任何一字节的改动都会被 HMAC-SM3 在解密完成后拒绝,且拒绝发生在"读完 + 解完"之后,攻击者拿不到任何中间明文或错误位置提示;恢复原字节后,同一份文件原样加载成功。至此,VQF-Enc 的"磁盘存密文、内存跑明文、篡改必被拒"三条承诺全部落地。