Detecting Backdoors in Kernel Patch Submissions: Lessons from the xz-utils Attack
The Problem
The xz-utils supply chain attack — CVE-2024-3094, disclosed in March 2024 — demonstrated the attack pattern in detail. A contributor known as “Jia Tan” spent two years building trust: submitting legitimate patches, improving build infrastructure, and taking on maintainer responsibilities. The backdoor was then inserted not in C source code but in .m4 build scripts and binary test fixture blobs — content reviewers did not scrutinise. It passed human review because the review was semantic, not adversarial.
The Linux kernel receives tens of thousands of patches per release cycle from thousands of contributors. The review process is primarily semantic — reviewers check that a patch does what it claims. A patient, trusted contributor targeting a specific subsystem can accumulate the reputation to get patches merged with less scrutiny than a newcomer receives. Full adversarial review of every patch is not humanly achievable at this scale; it is a structural constraint, not a process failure.
What can be addressed: detecting suspicious patterns at the diff level using automated tools, applying reproducible builds to catch build-time payload injection invisible to source review, and running behaviour-level analysis in isolated test VMs. These controls raise the cost and complexity of a successful attack without providing certainty.
Specific gaps in environments without systematic patch verification:
- Build-time backdoors (Makefile,
.m4, CMake, shell script injections) are invisible to code review that focuses on C source diffs. - Trusted contributors receive less scrutiny as their history grows, creating an incentivisation structure the xz-utils attacker exploited deliberately.
- Binary test fixtures and embedded blobs in patch sets are rarely examined.
- The gap between “this patch looks correct” and “this patch does not contain a backdoor” is large and requires different review techniques.
- Static analysis tools configured for correctness and safety bugs are not configured to detect backdoor patterns.
Target systems: Linux kernel 5.x/6.x; distribution kernel packaging systems (Debian, Fedora, RHEL, Ubuntu); kernel module build infrastructure; patch submission workflows on LKML and subsystem trees; GPG-signed tag verification workflows.
Threat Model
Adversary 1 — Long-game contributor (xz-utils pattern). A contributor submits legitimate patches for 12–24 months, builds maintainer relationships, and receives trusted-contributor status. The backdoor patch, when it arrives, is framed as a refactoring or correctness fix. The malicious logic may not be in the C diff — it may be in build scripts, test infrastructure, or a helper library. The social engineering is indistinguishable from legitimate maintainer behaviour.
Adversary 2 — Maintainer account compromise. An attacker compromises the GPG key or account of a kernel maintainer, pushes a malicious commit to a subsystem tree, and signs it with the maintainer’s key. Automated signature checks pass because the signature is valid. Detection requires out-of-band verification that the maintainer intended the specific commit.
Adversary 3 — Conditional build-system payload. A patch modifies Kbuild, Makefiles, or GCC plugins to embed a payload that activates only under specific build conditions — a target distribution’s specific CONFIG_ combination or GCC version. The payload is absent from default builds and invisible to standard code review.
Adversary 4 — Test-bypass commit. A patch to kselftest infrastructure removes a check that would catch a backdoor introduced by a separate concurrent patch. The two patches may be submitted months apart, making the connection non-obvious to reviewers who see each in isolation.
- Access objective: Introduce a persistent kernel-level backdoor (ring 0 code execution), enable privilege escalation from userspace, or exfiltrate data from kernel memory.
- Detection surface: Diff-level static analysis, reproducible build comparison, binary blob detection, GPG signature verification, and runtime behaviour monitoring in test VMs.
- Blast radius: A successfully merged kernel backdoor affects every distribution that ships the affected kernel, potentially hundreds of millions of systems.
Hardening Configuration
Step 1: Reproducible Build Verification with diffoscope
Reproducible builds detect when two independent builds of the same source produce different outputs — the primary indicator of build-time payload injection. The key tool is diffoscope, which performs a deep, human-readable diff of two build outputs including ELF sections, debug symbols, and embedded strings.
# Install diffoscope and build dependencies.
apt-get install diffoscope python3-tlsh
# Build the kernel twice from the same source tree, in independent environments.
# First build.
make -j$(nproc) KBUILD_BUILD_USER=build KBUILD_BUILD_HOST=build \
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) \
bzImage modules
cp arch/x86/boot/bzImage /tmp/bzImage.build1
cp System.map /tmp/System.map.build1
# Second build (identical environment variables).
make clean
make -j$(nproc) KBUILD_BUILD_USER=build KBUILD_BUILD_HOST=build \
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) \
bzImage modules
cp arch/x86/boot/bzImage /tmp/bzImage.build2
# Compare the two builds.
diffoscope /tmp/bzImage.build1 /tmp/bzImage.build2
# If the builds are reproducible, diffoscope exits 0 and produces no output.
# Any diff output indicates non-reproducibility and requires investigation.
For distribution package builds, compare builds across two independent build machines:
# Build the kernel package on builder-1.
ssh builder-1 "cd /build/linux-6.9 && \
dpkg-buildpackage -b -uc -us && \
sha256sum ../linux-image-6.9.0-1-amd64.deb" > /tmp/build1.sha256
# Build the same version on builder-2 from the same source.
ssh builder-2 "cd /build/linux-6.9 && \
dpkg-buildpackage -b -uc -us && \
sha256sum ../linux-image-6.9.0-1-amd64.deb" > /tmp/build2.sha256
# Compare checksums. A mismatch requires diffoscope investigation.
diff /tmp/build1.sha256 /tmp/build2.sha256
# For deeper analysis of a mismatch.
scp builder-1:/build/linux-image-6.9.0-1-amd64.deb /tmp/pkg1.deb
scp builder-2:/build/linux-image-6.9.0-1-amd64.deb /tmp/pkg2.deb
diffoscope /tmp/pkg1.deb /tmp/pkg2.deb --html /tmp/diffoscope-report.html
Binary blob detection in patch sets — a key signal for xz-utils-style attacks:
# Scan a patch set for binary content before applying.
# Binary files in patches are base64-encoded or literal binary diffs.
git format-patch origin/stable..HEAD | \
grep -l "^GIT binary patch" | \
xargs -I{} echo "WARNING: Binary patch content in {}"
# Scan the source tree for unexpected binary files after applying a patch.
find . -name "*.m4" -o -name "*.sh" -o -name "Makefile" | \
xargs file | grep -v "ASCII text" | grep -v "shell script"
# Detect base64-encoded blobs in test data (xz-utils attack vector).
find . -path "*/tests/*" -name "*.xz" -newer /tmp/before-patch-timestamp | \
xargs -I{} echo "New binary test fixture: {}"
# More targeted: find any binary files added in the current patch set.
git diff --name-only --diff-filter=A HEAD~1..HEAD | \
xargs -I{} file {} | grep -v "text"
Step 2: Semgrep and grep Patterns for Suspicious Kernel Patch Patterns
Static analysis on kernel diffs targets specific patterns associated with backdoor insertion: unexpected capability checks removed, new netlink message handlers without corresponding audit entries, syscall table modifications, and unusual pointer manipulations in security-critical paths.
# Install semgrep.
pip install semgrep
# Custom Semgrep rules for suspicious kernel patch patterns.
cat > /etc/semgrep/kernel-backdoor-patterns.yaml << 'EOF'
rules:
- id: capability-check-removed
patterns:
- pattern: |
- if (!capable($CAP)) {
- return -EPERM;
- }
message: "Capability check removed — verify this is intentional and documented."
languages: [c]
severity: ERROR
- id: new-netlink-handler-no-audit
patterns:
- pattern: |
static int $FUNC(struct sk_buff *$SKB, struct nlmsghdr *$NLH, ...)
{
...
}
message: "New netlink message handler — verify it has audit log entry and capability check."
languages: [c]
severity: WARNING
- id: direct-syscall-table-write
patterns:
- pattern: sys_call_table[$NR] = $FUNC;
message: "Direct syscall table modification — extremely suspicious in non-hook code."
languages: [c]
severity: ERROR
- id: kernel-ptr-to-user-without-check
patterns:
- pattern: |
copy_to_user($ADDR, $KPTR, $SIZE);
pattern-not-inside: |
if (access_ok(...)) {
...
}
message: "copy_to_user without access_ok — potential information disclosure."
languages: [c]
severity: WARNING
- id: hardcoded-ip-in-kernel
pattern: "\"$IP\""
metavariable-regex:
metavar: $IP
regex: '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'
message: "Hardcoded IP address in kernel source — investigate."
languages: [c]
severity: ERROR
EOF
# Run against a patch diff converted to files.
git diff HEAD~1..HEAD -- "*.c" "*.h" | \
filterdiff --include="*.c" --include="*.h" | \
patch --dry-run -p1 -d /tmp/kernel-analysis-copy
semgrep --config /etc/semgrep/kernel-backdoor-patterns.yaml \
--error /tmp/kernel-analysis-copy/
Supplement Semgrep with targeted grep patterns for patterns that are structurally harder to express as AST rules:
#!/bin/bash
# kernel-patch-audit.sh — Run against a git diff or mbox patch file.
# Usage: git diff base..head | bash kernel-patch-audit.sh
PATCH_INPUT="${1:-/dev/stdin}"
SUSPICIOUS=0
echo "=== Kernel Patch Security Audit ==="
# Check for removal of security_* LSM hook calls.
if grep -E "^-.*security_(inode_permission|file_open|bprm_check)" "$PATCH_INPUT"; then
echo "ALERT: LSM hook call removed — potential security bypass"
SUSPICIOUS=$((SUSPICIOUS + 1))
fi
# Check for __user annotation removal (potential unsafe pointer cast).
if grep -E "^-.*__user" "$PATCH_INPUT" | grep -v "^---"; then
echo "WARNING: __user annotation removed — verify no unsafe pointer dereference introduced"
SUSPICIOUS=$((SUSPICIOUS + 1))
fi
# Check for new EXPORT_SYMBOL calls on internal functions.
if grep -E "^\+.*EXPORT_SYMBOL(_GPL)?\(.*internal\|.*private\|.*__" "$PATCH_INPUT"; then
echo "WARNING: New symbol export of potentially internal function"
SUSPICIOUS=$((SUSPICIOUS + 1))
fi
# Detect modifications to Makefile that add external downloads.
if grep -E "^\+.*(wget|curl|fetch|download)" "$PATCH_INPUT"; then
echo "CRITICAL: Build script downloads external resource — review immediately"
SUSPICIOUS=$((SUSPICIOUS + 1))
fi
# Detect base64-encoded content in build scripts.
if grep -E "^\+.*[A-Za-z0-9+/]{60,}={0,2}" "$PATCH_INPUT" | \
grep -E "\.(sh|m4|py|pl|mk|Makefile)"; then
echo "CRITICAL: Possible base64-encoded payload in build script"
SUSPICIOUS=$((SUSPICIOUS + 1))
fi
# Check for new socket/network operations in unexpected subsystems.
CHANGED_FILES=$(grep "^diff --git" "$PATCH_INPUT" | awk '{print $3}' | sed 's|a/||')
for f in $CHANGED_FILES; do
SUBSYSTEM=$(echo "$f" | cut -d/ -f1)
if echo "$f" | grep -qvE "^(net|drivers/net|security|crypto)"; then
if grep -E "^\+.*(sock_create|kernel_connect|kernel_send)" "$PATCH_INPUT"; then
echo "ALERT: Network socket operation added in non-networking subsystem: $f"
SUSPICIOUS=$((SUSPICIOUS + 1))
fi
fi
done
echo ""
echo "Suspicious patterns found: $SUSPICIOUS"
if [ "$SUSPICIOUS" -gt 0 ]; then
exit 1
fi
Step 3: Coccinelle and sparse for Automated Diff Analysis
Coccinelle operates on the kernel’s AST, detecting patterns that text-based tools miss. It is part of the kernel’s existing CI infrastructure.
// coccinelle/check-capability-bypass.cocci
// Detects functions that previously checked capabilities but no longer do.
// Apply with: spatch --sp-file check-capability-bypass.cocci --dir drivers/
@ has_priv_check @
identifier fn;
expression e;
@@
fn(...)
{
<+...
capable(e)
...+>
}
// Flag functions where capable() was in a removed hunk.
// In practice, run this against the pre-patch and post-patch trees
// and diff the results.
@ no_priv_check depends on !has_priv_check @
identifier fn;
@@
fn(...)
{
...
* /* MISSING CAPABILITY CHECK */
...
}
# Run Coccinelle against a subsystem to build a baseline.
spatch --sp-file /etc/coccinelle/capability-patterns.cocci \
--dir drivers/net/ethernet \
--output-touch-only 2>/dev/null \
| tee /tmp/capability-baseline.txt
# After applying a patch, run again and compare.
spatch --sp-file /etc/coccinelle/capability-patterns.cocci \
--dir drivers/net/ethernet \
--output-touch-only 2>/dev/null \
| tee /tmp/capability-after.txt
diff /tmp/capability-baseline.txt /tmp/capability-after.txt
# Any new capability-less privileged operations in the diff require review.
Run sparse on the patched files to detect type-safety violations introduced by the patch:
# sparse type-checking on changed files.
# Get files changed by the patch.
CHANGED=$(git diff --name-only HEAD~1..HEAD -- "*.c")
for f in $CHANGED; do
# sparse checks for __user/__kernel pointer confusion and other type errors.
make C=1 CF="-D__CHECK_ENDIAN__ -Wsparse-all" \
$(echo "$f" | sed 's/\.c$/\.o/') 2>&1 | \
grep -E "error|warning" | \
grep -v "^/" | \
tee -a /tmp/sparse-report.txt
done
# Specifically flag new __force casts (bypassing sparse type checking).
git diff HEAD~1..HEAD -- "*.c" "*.h" | \
grep "^\+" | grep "__force" | \
grep -v "^+++" | \
while read line; do
echo "WARNING: New __force type cast: $line"
done
Step 4: GPG-Signed Commit Verification in the Maintainer Workflow
The kernel’s tag-based pull request model relies on GPG-signed tags. Verifying signatures is necessary but not sufficient — a compromised key still produces valid signatures. The critical check is whether the key is registered on kernel.org.
# Verify the GPG signature on a maintainer tag before pulling.
git verify-tag v6.9-rc1
# Expected output:
# gpg: Signature made Mon Apr 22 10:23:15 2024 UTC
# gpg: using RSA key 647F28654894E3BD457199BE38DBBDC86092693E
# gpg: Good signature from "Linus Torvalds <torvalds@linux-foundation.org>"
# gpg: WARNING: This key is not certified with a trusted signature!
# Check the key against the known-good keyring.
gpg --keyserver hkps://keys.openpgp.org \
--recv-keys 647F28654894E3BD457199BE38DBBDC86092693E
# Cross-reference the key fingerprint against the kernel.org keyring.
# The kernel.org keyring is at: https://www.kernel.org/signature.html
curl -s https://www.kernel.org/signature.html | \
grep -oE "[0-9A-F]{40}" | sort -u > /tmp/kernel-known-keys.txt
gpg --fingerprint 647F28654894E3BD457199BE38DBBDC86092693E | \
grep -oE "[0-9A-F ]{49}" | tr -d ' ' >> /tmp/my-key.txt
grep -f /tmp/my-key.txt /tmp/kernel-known-keys.txt || \
echo "ALERT: Key not in kernel.org keyring"
Automate signature verification in distribution packaging workflows:
#!/bin/bash
# verify-kernel-source.sh
# Verifies the kernel tarball signature before building.
KERNEL_VERSION="${1:?Usage: $0 <version>}"
TARBALL="linux-${KERNEL_VERSION}.tar.xz"
SIG_FILE="linux-${KERNEL_VERSION}.tar.sign"
# Download source and signature.
wget -q "https://cdn.kernel.org/pub/linux/kernel/v6.x/${TARBALL}"
wget -q "https://cdn.kernel.org/pub/linux/kernel/v6.x/${SIG_FILE}"
# Decompress signature target for .xz tarball.
xz -cd "${TARBALL}" > "${TARBALL%.xz}"
# Verify against the kernel.org trust keyring.
gpg --keyring /etc/apt/trusted.gpg.d/kernel-org-keys.gpg \
--verify "${SIG_FILE}" "${TARBALL%.xz}"
if [ $? -ne 0 ]; then
echo "CRITICAL: Kernel tarball signature verification failed"
echo "Do not proceed with build"
exit 1
fi
echo "Signature verified: ${TARBALL}"
For automated submission pipelines, verify every patch email signature using git’s built-in gpg verification:
# In the maintainer workflow: verify commit signatures on a pull request branch.
git log --show-signature --pretty=format:"%H %ae %G?" \
origin/master..HEAD | \
while read hash email gpg_status; do
if [ "$gpg_status" != "G" ]; then
echo "UNSIGNED OR INVALID: $hash from $email (status: $gpg_status)"
# G = good, B = bad, U = unknown, N = no signature, E = error
fi
done
Step 5: Behaviour-Level Detection in Test VMs
Run kernel modules and newly patched subsystems in isolated QEMU VMs with network monitoring to detect unexpected behaviour — the detection layer that would have caught xz-utils-style network-based backdoors.
#!/bin/bash
# kernel-behaviour-test.sh
# Boots a kernel in QEMU and monitors for unexpected network activity.
KERNEL_BZIMAGE="${1:?Usage: $0 <bzImage> <rootfs>}"
ROOTFS="${2:?}"
TEST_DURATION=120 # seconds
# Start packet capture on the host-side tap interface.
CAPTURE_FILE="/tmp/kernel-test-$(date +%s).pcap"
ip tuntap add dev tap-ktest mode tap
ip link set tap-ktest up
tcpdump -i tap-ktest -w "$CAPTURE_FILE" &
TCPDUMP_PID=$!
# Boot the kernel in QEMU with network monitoring.
timeout "$TEST_DURATION" qemu-system-x86_64 \
-kernel "$KERNEL_BZIMAGE" \
-initrd "$ROOTFS" \
-append "console=ttyS0 root=/dev/ram0 rdinit=/sbin/init" \
-m 512M \
-net nic,model=virtio \
-net tap,ifname=tap-ktest,script=no,downscript=no \
-nographic \
-serial stdio 2>&1 | tee /tmp/kernel-boot.log
# Stop packet capture.
kill "$TCPDUMP_PID"
# Analyse unexpected network connections.
echo "=== Network activity during kernel test boot ==="
tcpdump -r "$CAPTURE_FILE" -nn 2>/dev/null | \
grep -v "^reading from file" | \
head -100
# Check for connections to unexpected destinations.
EXPECTED_INTERNAL="192.168.0.0/16,10.0.0.0/8,172.16.0.0/12"
UNEXPECTED=$(tcpdump -r "$CAPTURE_FILE" -nn 2>/dev/null | \
grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | \
sort -u | \
while read ip; do
for range in $(echo "$EXPECTED_INTERNAL" | tr ',' '\n'); do
if python3 -c "import ipaddress; \
print('ok') if ipaddress.ip_address('$ip') in \
ipaddress.ip_network('$range') else print('unexpected')" \
2>/dev/null | grep -q ok; then
continue 2
fi
done
echo "$ip"
done)
if [ -n "$UNEXPECTED" ]; then
echo "ALERT: Unexpected external connections during kernel boot:"
echo "$UNEXPECTED"
exit 1
fi
# Check /proc modifications using a baseline snapshot.
# This would run inside the VM via a monitoring script.
echo "Checking /proc filesystem for unexpected entries..."
# In the VM init script, run:
# find /proc -name "*.ko" -newer /tmp/baseline-timestamp 2>/dev/null
# diff /tmp/proc-modules-baseline.txt <(lsmod | awk '{print $1}' | sort)
Monitor for unexpected /proc entries that would indicate hidden kernel modules or rootkit activity:
# Inside the test VM, run as part of the test suite.
cat > /tmp/proc-integrity-check.sh << 'EOF'
#!/bin/bash
# Verify /proc consistency — checks for hidden module indicators.
# Compare lsmod output with /proc/modules.
LSMOD_MODULES=$(lsmod | awk 'NR>1 {print $1}' | sort)
PROC_MODULES=$(awk '{print $1}' /proc/modules | sort)
DIFF=$(diff <(echo "$LSMOD_MODULES") <(echo "$PROC_MODULES"))
if [ -n "$DIFF" ]; then
echo "ALERT: /proc/modules and lsmod disagree:"
echo "$DIFF"
fi
# Check for unexplained entries in /proc/net/tcp and /proc/net/tcp6.
# In a clean test VM with no services, these should be empty.
TCP_CONNECTIONS=$(cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | \
awk 'NR>1 && $4 != "00000000:0000" {print}')
if [ -n "$TCP_CONNECTIONS" ]; then
echo "WARNING: Unexpected TCP connections in test VM:"
echo "$TCP_CONNECTIONS"
fi
EOF
chmod +x /tmp/proc-integrity-check.sh
Expected Behaviour After Hardening
Reproducible build mismatch caught. A build script is modified to fetch an additional object file from an external URL under specific conditions. The air-gapped second builder cannot reach the URL; the builds diverge. diffoscope reports ELF section size differences and an embedded object present in only one build. The patch is escalated for manual review.
Semgrep pattern triggered. A patch removes capable(CAP_NET_ADMIN) from a netlink handler, framed as a refactoring. CI runs:
[capability-check-removed] at drivers/net/wireless/ath/ath10k/cfg80211.c:2847
Capability check removed in privileged operation.
Severity: ERROR — 1 finding(s). Exiting with error code 1.
The CI check fails. The patch requires security team sign-off with a documented justification before merge.
GPG signature verification failure. A pull request is submitted with a GPG key rotated after an account compromise. The verification script detects the key fingerprint is not registered on kernel.org and rejects the pull request pending out-of-band maintainer confirmation.
Trade-offs and Operational Considerations
| Control | Benefit | Cost / Friction |
|---|---|---|
| Reproducible builds with diffoscope | Catches build-time payload injection invisible to code review | Requires two independent build environments; adds 30–60 min to build pipeline; non-reproducibility debugging is time-consuming |
| Semgrep backdoor patterns | Automated, consistent, runs on every patch | False positives on legitimate refactoring; rules require tuning and maintenance; cannot detect all semantic backdoor patterns |
| Coccinelle semantic patches | AST-level analysis catches structural patterns missed by text matching | Complex to write rules; must be maintained as kernel code evolves; slow on large trees |
| GPG-signed tag verification | Verifies commit provenance; detects account compromise | Does not prevent a compromised key from signing; cross-referencing with kernel.org keyring requires out-of-band trust |
| Behaviour-level VM testing | Detects network backdoors and /proc manipulation at runtime | Requires maintaining test VMs; slow; cannot detect all activation conditions; sophisticated backdoors may detect VM environment |
| Binary blob detection | Catches xz-utils-style embedded payload vector | Legitimate test fixtures contain binary data; high false positive rate requires manual review |
Reproducible build verification is the highest-value control but requires infrastructure investment. Run it on the final assembled kernel for each release candidate rather than per-patch.
Failure Modes
| Failure Mode | Consequence | Prevention |
|---|---|---|
| Backdoor activates only in specific architecture/config combination | Standard CI on x86_64 misses ARM64-specific payload | Build and test on all supported architectures and CONFIG combinations |
| Attacker controls one of the two reproducible build environments | Reproducible build comparison shows identical (malicious) output | Ensure build environments are fully independent with different hardware, OS versions, and build toolchains |
| Semgrep rules not maintained as kernel evolves | New backdoor patterns not caught; rules generate excessive false positives causing rule suppression | Assign ownership of security rules; review rule efficacy quarterly against recent CVEs |
| GPG key compromise without revocation | Malicious commits appear to have valid signatures | Monitor kernel.org keyring for new keys or key changes; require out-of-band confirmation for key changes |
| VM behaviour test does not activate backdoor | Backdoor requires specific triggering condition not present in test | Test under diverse workload conditions; include network-accessible services in test VMs |
| Binary blob check creates review fatigue | Reviewers approve binary content without inspection due to volume | Baseline legitimate binary test fixtures; alert only on new binary content not matching the baseline manifest |
| Patch submitted across multiple subsystems | No single maintainer sees the full picture of capability check removal + new handler addition | Cross-subsystem analysis tooling that correlates patches from the same author across trees |