ONNX & Computational Graph Primer for Offensive Security - Part 1

ONNX & Computational Graph Primer for Offensive Security - Part 1

In early 2026 I learned about the ShadowLogic attack that demonstrated something I hadn't thought much about, namely that you can inject backdoors directly into neural network computational graphs without retraining and no weight modification needed. Just...edit the graph.

The backdoor injection was interesting, but something else caught my attention. The computational graphs themselves. This post documents how I started treating neural network computational graphs the way I used to treat disassembly, and more importantly whether structural patterns alone can reveal security weaknesses.

I spent years in offensive security looking at code, malware and disassembled programs. One of the things that always fascinated me was seeing actual code patterns like loops, arrays, structs, values being pushed onto the stack etc rendered in assembly. You could recognize patterns. A for loop has a recognizable pattern of mnemonics. A switch statement looks different from a series of if-else blocks. Experienced reverse engineers can glance at a chunk of disassembly and get a feel for what the code is doing.

I found myself wondering: "Are there similar patterns in ONNX computational graphs for vision models that could inform exploit research?"

Not backdoor patterns specifically, ShadowLogic already covered that. I was wondering something different: could you look at a graph and predict what kinds of attacks a vision or audio model might be vulnerable to? Could certain structural patterns indicate susceptibility to specific adversarial inputs? Would reversing these computational graphs be of any use to anyone? And if you could identify those patterns... could you create them deliberately to help an attack along?

My thought was that empirical attacks are expensive, incomplete, and often unguided. Graph analysis might inform which attacks are worth running, not necessarily whether attacks should be run at all. But more on this in other posts as we progress.

Before I could answer any of those questions, I needed to understand the ONNX spec and how its behavior depends on the runtime itself. This is important for later when we discuss how graph semantics alone are not the same as runtime semantics. What follows is what I learned, written for offensive security practitioners.


What Is ONNX?

ONNX stands for Open Neural Network Exchange. It's an open format spec for representing machine learning models. It's a portable, inspectable, modifiable container that can run across different platforms and runtimes.

More specifically, ONNX defines:

  1. A computational graph format - the operations and how they connect
  2. A set of standard operators - Conv, MatMul, Relu, Softmax, etc.
  3. A way to store model weights (more on this below)

When you export a PyTorch or TensorFlow model to ONNX, you're converting from the framework's internal representation into this standardized intermediate representation (IR). Different runtimes like ONNX Runtime, TensorRT, OpenVINO, CoreML can then execute it.

ONNX files are just Protocol Buffers. You can parse them, inspect them, modify them, and save them back out. ONNX has no mandatory signing or integrity enforcement. This is what makes ShadowLogic possible. And it's what made my research possible too. Some pipelines may wrap ONNX in signed containers or checksums, but the format itself does not enforce trust. Anyone with write access to an ONNX file can modify it. 

What ONNX Files Contain by Default

Core Structure: An ONNX file (.onnx) is essentially a serialized Protocol Buffers (protobuf) message based on the ONNX schema. It includes:

  • The model's computational graph - nodes representing operations, inputs, outputs, edges representing data flow
  • Metadata - model name, producer (PyTorch, TensorFlow, etc.), ONNX version
  • Input/output specifications - what the model expects and produces
  • And crucially, the model's parameters - weights, biases, and other constants, stored as tensors in the initializer field of the GraphProto

Weights Are Included: In standard usage, when you export a model to ONNX (e.g., from PyTorch, TensorFlow, or other frameworks), the weights are embedded directly in the .onnx file. This makes the file self-contained and portable. For small to medium-sized models, this is the norm. I've seen and worked with countless ONNX files where inspecting them (e.g., via onnx.load() in Python) shows the weights right there as raw tensor data.

Here's the basic intuition for loading and inspecting one:

import onnxmodel = onnx.load("resnet50.onnx")# Basic info
print(model.producer_name)      # "pytorch"
print(len(model.graph.node))    # 176 nodes (operations)
print(len(model.graph.initializer))  # 161 initializers (weights)# What operators does it use?
op_types = set(node.op_type for node in model.graph.node)
# {'Conv', 'BatchNormalization', 'Relu', 'MaxPool', 'Add', 'Gemm', ...}

 

Those nodes are operations—convolutions, activations, pooling. The initializers are the learned weights. The graph structure defines how data flows through them.

For a ResNet-50, you'd see operators like  Conv,  BatchNormalization, Relu, MaxPool,  Add, GlobalAveragePool, and Gemm (general matrix multiply, used for the final fully-connected layer).

The Confusion About Weights

This is something that confused me when I started researching, and I've seen it maybe confuse others. Some experienced people told me ONNX files don't contain weights. They said the format is just for representing the computational graph—the architecture—and weights are stored separately. This is partially true, but somewhat misleading for most of the vision models I looked at. Let me explain.

The Protobuf Size Limit and External Data

The 2GB Constraint: Protobuf messages have a practical upper limit of around 2GB (technically, the default max is 64MB in some implementations, but libraries like ONNX Runtime allow up to 2GB). This becomes a bottleneck for large models (e.g., modern transformers like GPT variants or large vision models) where weights alone can exceed several GB.

How ONNX Handles It: To work around this, ONNX supports external data storage for tensors. In this mode:

  • The .onnx file still contains the full graph structure and metadata
  • But large tensors (weights) are offloaded to separate external files (often .bin or .data files in the same directory)
  • The .onnx file includes references to these external files via fields like external_data in the TensorProto, specifying the file path, offset, and length
  • When loading the model (e.g., in ONNX Runtime), the runtime pulls in the external data automatically

Resulting Variations: This is why "some ONNX files have weights and some don't":

  • For models under ~2GB total size, weights are inline, fully stored in the .onnx file
  • For larger models, weights are external, so if you just look at the .onnx file in isolation (e.g., its file size or a hex dump), it might seem like it lacks weights. But it's not that the format can't store them; it's a deliberate split to avoid protobuf limitations
  • Additionally, it's possible to export an ONNX file without weights intentionally, e.g., for sharing just the graph topology for inference with custom-initialized parameters. But this isn't the default behavior in most export tools

Evidence from Practice and Tools

  • If you load an ONNX model in Python using the onnx library, you can check model.graph.initializer and it'll list the tensors if they're inline. For external ones, you'll see placeholders with external references
  • Tools like Netron will show the graph and indicate if data is external
  • Official ONNX docs confirm this: The format is designed to include weights, but external storage is an optional extension for scalability

If you've primarily worked with LLMs in ONNX format, you may have only ever seen models with external weights. The .onnx file looks "empty" because the weights are elsewhere. But for vision models, audio models, and smaller architectures, the weights are typically embedded directly in the file from what I could see during my experiments. The following figure shows how to check:

# Pseudocode for checking weight location
model = onnx.load("your_model.onnx")embedded_count = len(model.graph.initializer)
external_count = count_external_references(model)# Vision models: embedded_count high, external_count zero
# LLMs: embedded_count low or zero, external_count high

For my research, this matters because if weights are embedded, I can analyze them directly alongside the graph structure. If they're external, graph-only analysis is still possible but weight analysis requires both access to and loading the external files.

Back to blog