DNS Hijacking and Man-in-the-Middle on Dependency Downloads
- insecure package indexes and --trusted-host
- DNS spoofing of internal mirrors
- DNS tunnelling exfiltration from build agents
Scenario
A data team's build agents run on-premises. They install Python packages from an internal mirror. Two years ago someone got tired of certificate errors and set this on every agent:
# /etc/pip.conf
[global]
index-url = http://pypi-mirror.corp.example/simple
trusted-host = pypi-mirror.corp.example
Pointing pypi.org at a fake server would fail, because pip validates TLS certificates against public CAs. This configuration removes that protection for the one host that matters.
- An attacker gains a foothold on a device on the build network. They answer DNS queries for
pypi-mirror.corp.examplewith their own IP, using spoofed responses or a rogue DHCP server that hands out their resolver. - Their server proxies the real mirror, except for one popular data-processing library, where it serves a trojanised wheel with the same version number.
- pip has no TLS to check and no hashes to compare, so it installs the wheel. The payload runs when tests import the library.
- The build network blocks outbound HTTPS, but the internal resolver forwards any query to the internet. The payload base64-encodes credentials and model files and leaks them as DNS lookups such as
a3f9c1....x7.t.attacker.example. Each label can hold at most 63 characters and a full name at most 253, so it sends thousands of queries. The attacker's authoritative name server logs and reassembles them.
The same MITM works without any DNS tricks if an agent trusts a corporate TLS-inspection CA and that CA's key leaks, or if any repository URL still uses http://.
Real-world precedent: The SolarWinds SUNBURST backdoor (disclosed December 2020) used DNS lookups of generated avsvmcloud[.]com subdomains, encoding victim details, as its first-stage command and control. In 2019 Jonathan Leitschuh showed that many popular Java projects fetched dependencies over plain HTTP, leaving them open to exactly this kind of MITM. Maven Central went HTTPS-only in January 2020.
Reconnaissance
Explanation
Attackers look for the places where transport security has been switched off: http:// index URLs, --trusted-host, strict-ssl=false, allowInsecureProtocol, GOINSECURE. They also map how build networks resolve names and whether DNS can reach the internet when HTTPS cannot.
Insight
Public registries are hard to impersonate because of TLS. Internal mirrors are often easier, because teams weaken TLS to work around internal certificate problems. And DNS is the egress path firewalls most often forget.
Practical
- Search config and CI files for insecure settings across ecosystems.
- Draw how each build agent resolves names: which resolver, and whether that resolver forwards to the internet.
- List any TLS-inspection or internal CA certificates installed on build agents.
Tools/Techniques
-
A quick search:
rg -n -e 'trusted-host' -e 'index-url\s*=\s*http://' -e 'strict-ssl\s*=\s*false' \ -e 'allowInsecureProtocol' -e 'GOINSECURE' -e 'http://[^ ]*/(simple|maven|npm)' -
pip config debugon an agent to see the effective pip settings and where they come from.
Metrics/Signal
- Number of insecure registry settings across repos and agent images.
- Whether build networks can resolve arbitrary internet names.
Evaluate
Explanation
Check each dependency fetch for two independent protections: an authenticated transport (HTTPS with proper certificate validation) and content integrity (pinned hashes). Then check whether DNS is a usable exfiltration path.
Insight
Hash pinning is the control that survives everything else failing. If the lockfile says what the wheel's SHA-256 must be, a spoofed mirror, a rogue CA or a poisoned DNS answer all end in a failed install.
Practical
- Check that Python installs use a lockfile with hashes (
uv.lock, orpip install --require-hashes), and that npm installs usenpm ciagainst apackage-lock.jsonwithintegrityfields. - Check Maven and Gradle versions. Maven 3.8.1+ blocks
http://repositories by default, and Gradle 7+ refuses them unlessallowInsecureProtocolis set. - Check whether agents can send DNS queries to any resolver other than yours, or use DNS-over-HTTPS to a public provider.
Tools/Techniques
uv lockanduv sync --locked, orpip-compile --generate-hashes.nslookup example.com 8.8.8.8from an agent: if it answers, direct external DNS is open.
Metrics/Signal
- Percentage of builds that install with hash verification.
- Build networks with unrestricted outbound DNS.
Fortify
Explanation
Restore TLS everywhere, pin hashes, and close DNS as a side channel.
Insight
Fixing the certificate problem properly, by distributing your internal CA to agents through configuration management, is less work than living with --trusted-host forever.
Practical
-
Serve internal mirrors over HTTPS with certificates from your internal CA. Install that CA on agents and remove every
trusted-hostentry:# /etc/pip.conf [global] index-url = https://pypi-mirror.corp.example/simple cert = /etc/ssl/certs/corp-root-ca.pem require-hashes = true -
Install from a locked, hashed requirements set. With uv:
uv sync --locked -
Sign your internal DNS zones with DNSSEC where your resolvers validate it, and point agents only at your own resolvers.
-
On Linux agents using systemd-resolved, encrypt queries to your resolver with DNS-over-TLS:
# /etc/systemd/resolved.conf [Resolve] DNS=10.0.0.53#dns.corp.example DNSOverTLS=yes -
Block outbound TCP/UDP 53 and 853 from build networks except to your resolvers, and block well-known public DoH endpoints at the proxy.
-
Stop internal resolvers serving build networks from recursing to arbitrary internet domains. Allow only the domains builds need.
Tools/Techniques
- uv, pip-tools,
npm ci, Maven and Gradle HTTPS defaults. - Route 53 Resolver DNS Firewall, or response-policy zones on BIND or Unbound, for domain allow lists.
- Kubernetes
NetworkPolicyallowing UDP/TCP 53 only to cluster DNS; CiliumtoFQDNspolicies for name-based egress.
Metrics/Signal
- Insecure registry settings remaining (target zero).
- Build networks with DNS restricted to internal resolvers and an allow list.
Limit
Explanation
If a trojanised package does install, limit what it can read and how much it can send.
Insight
DNS tunnelling is slow. A strict domain allow list on the resolver turns it from slow into impossible, and a build that holds no long-lived secrets has little worth stealing.
Practical
- Install and test in a step with no cloud or registry credentials. Add secrets only in later steps that need them.
- Keep model files and datasets out of general build jobs. Fetch them only in jobs that need them, with read-only, short-lived credentials.
- Run builds in ephemeral containers so a payload cannot persist on the agent.
- Rate-limit queries per client on resolvers serving build networks.
Tools/Techniques
- CI step-level secrets and OIDC federation for short-lived cloud credentials.
- Resolver rate limiting (for example Unbound
ip-ratelimit).
Metrics/Signal
- Secrets in scope during dependency install.
- Resolver queries per build job (a sudden spike is a signal).
Expose
Explanation
Look for both halves of this attack: a package that does not match what you expected, and DNS traffic that looks like data.
Insight
DNS tunnelling has a recognisable shape: labels close to the 63-character limit, high entropy, and hundreds of unique subdomains under a single parent domain in a short time.
Practical
- Treat any hash mismatch during install as a security event. Do not let anyone "fix" it by regenerating the lockfile without finding out why.
- Log all queries from build networks. Alert on long, high-entropy labels and on bursts of unique subdomains under one domain.
- Alert when the IP address your internal mirror name resolves to changes unexpectedly.
- Compare installed package hashes in build logs with your mirror's records.
Tools/Techniques
- Route 53 Resolver query logging, CoreDNS
logplugin, or Zeekdns.log. - A simple query over logs: group by registered domain, count distinct subdomains, flag labels over 50 characters.
Metrics/Signal
- Hash mismatch events per month (should be rare and always explained).
- Domains with more than a few hundred unique subdomains queried per hour from build networks.
Exercise
Explanation
Run the whole attack in an isolated lab: a spoofed mirror and a harmless DNS leak.
Insight
Watching pip reject a spoofed wheel because of a hash mismatch builds more confidence than any policy review.
Practical
- On an isolated Docker network, run a local devpi or
pypiserveras the "real" mirror and another as the "spoofed" one. Put a modified but harmless copy of a test wheel on the spoofed server. - Run
dnsmasqon the lab network and pointpypi-mirror.labat the spoofed server. - Install with your old config (
http://andtrusted-host) and see the modified wheel install. Then install with your hardened config and confirm the hash check fails. - From a lab container, resolve a Canarytokens DNS token with a long random label prepended. Confirm the query appears in your DNS logs and trips the long-label alert, and that the resolver allow list blocks it once enabled.
Tools/Techniques
- Docker networks,
dnsmasq, devpi or pypiserver. - Canarytokens DNS tokens for a harmless, observable lookup.
Metrics/Signal
- Whether the hardened config rejected the spoofed wheel.
- Whether the DNS alert fired, and how long it took.