# Your Keras model config can contain a marshalled Python code object

> Source: <https://dev.to/aisbom/your-keras-model-config-can-contain-a-marshalled-python-code-object-5885>
> Published: 2026-08-16 13:54:58+00:00

Most conversations about malicious ML artifacts stop at pickle. That's understandable — `torch.load`

calling `__reduce__`

is the canonical example, and it's the one everybody has read about. But pickle isn't the only file format in your `models/`

directory that hands a loader something executable, and the pickle story itself has more edge cases than the summary version suggests.

Here are a few of the mechanisms, described in enough detail that they're useful whether or not you ever run a scanner.

Keras lets you define a layer as an arbitrary Python callable via `Lambda`

. That callable has to survive serialisation, so it's stored in the model config as a marshalled code object. When `load_model`

reconstructs the model, it runs it.

This is not an exploit. It's the documented behaviour of a feature people use. But it means a `.keras`

file is a code-carrying format in exactly the way a pickle is, and a pipeline that treats `.h5`

as "the safe alternative to pickle" is mistaken.

There's a second trap for anyone building tooling here: the obvious way to inspect a marshalled blob is to unmarshal it, and `marshal.loads`

on untrusted input is itself unsafe. The blob's type can be determined from its header bytes instead. In AIsbom v1.3.0, Lambda layers and embedded code objects are flagged CRITICAL, and the payload is never unmarshalled. Both containers Keras writes — the `.keras`

zip and legacy HDF5 — are handled without adding an HDF5 library to the install.

ONNX is a protobuf graph, and by reputation it's the "safe" format because there's no embedded bytecode. Mostly true, with two caveats worth knowing.

First, tensors can live outside the file. The external-data mechanism stores a *path*, and the loader reads it. A path that points outside the model directory turns `load_model`

into an arbitrary-file read.

Second, operators can come from a non-standard domain — meaning the graph depends on something other than the standard operator set to execute.

Neither of these requires running the graph to detect. You walk the protobuf. AIsbom does exactly that: no ONNX runtime is imported, the graph is never executed, and subgraphs carried by `If`

, `Loop`

and `Scan`

are walked too, because that's an easy place to hide a reference if a tool only inspects the top level.

GGUF files can embed a `chat_template`

— a Jinja template that downstream code renders to format prompts. Jinja's sandbox has known escape constructs. The important consequence for tooling: you cannot analyse this by rendering it, because rendering it *is* the vulnerability. It has to be checked statically. AIsbom extracts the template into the SBOM component and checks it there, unrendered.

Even within pickle, several plausible-looking scanner implementations are wrong:

`STOP`

opcode.`torch.save`

file hides its object behind several header pickles. Parse one stream and you never reach the payload. All concatenated streams have to be walked.`CRITICAL (Non-Standard Container: …)`

— naming the container without unpacking it, so no native dependency enters the install.Also in this release: indirect-execution gadgets (`bdb.Bdb.run`

, the asyncio gadget chain, and import-mechanism primitives like `sys.modules`

, `importlib`

, `runpy`

, `pkgutil`

, `builtins.__import__`

) are caught in both modes, and strict mode judges a global by its *resolved* module and attribute, so a submodule no longer inherits an allowlisted parent's trust.

Two new risk levels exist purely so an unfinished scan is never reported as a clean one. `MEDIUM (Pickle Scan Incomplete)`

appears when a file is unusual enough that the scan reaches its work limit. `MEDIUM (Unreadable Pickle Member)`

appears when an archive member can't be read at all — a loader that doesn't verify integrity would still run it.

A scanner that quietly gives up and returns clean is worse than no scanner, because you'd act on the clean bill.

The uncomfortable part of writing a scanner is that "we detect evasion technique X" is unfalsifiable from the outside. So v1.3.0 ships a command that falsifies it:

```
# build inert replicas of documented evasion techniques,
# scan them in both modes, print catches and misses
aisbom bypass-scorecard

# release gate: fails CI if the caught count regresses
aisbom bypass-scorecard --check
```

Each case traces to a published CVE or research paper. Nothing in the corpus is ever executed. Current result: **8 of 11 caught, up from 5 of 11.** The three that aren't are listed openly with their mechanism and source. Two are partial: AIsbom refuses to call the file safe but never reaches the payload, so it reports the wrong reason. One — a pickle carried under a non-standard extension, so extension-driven discovery never opens it — is a straight miss. None of the three is treated as acceptable; every case in the corpus is marked as one a correct scanner should catch.

`--check`

runs in CI on every push and cannot be satisfied by regenerating the scorecard. Improving detection is the only way to raise the floor.

Exit codes, CycloneDX and SPDX output, and every existing verdict are unchanged; that was verified against the pre-release baseline in both scan modes.

Release notes: [https://github.com/Lab700xOrg/aisbom/releases/latest](https://github.com/Lab700xOrg/aisbom/releases/latest)
