CPAN dependency confusion
CPAN dependency confusion
Rationale
Take advantage of an incorrectly configured setup that resolves a public module (published on CPAN) in place of the expected private/local version due to one of the following reasons:
- Local module is not installed at the time of running the CPAN client
- Public CPAN version is preferred to local
- Using a CPAN repository (proxy) that can be confused
This type of vulnerability was proved to affect other ecosystems (PyPI, RubyGems and NPM). This is presented in Dependency Confusion by Alex Birsan.
Background
“Dependency confusion” takes advantage of mixing (semi-)private modules with public CPAN modules.
In the Perl ecosystem, it could be one or more of the following options:
- Using a “DarkPAN” along with CPAN
- Using a “GrayPAN” along with CPAN
- Using a “BackPAN” along with CPAN
- Mixing manual installs (
make install
, “blib install”…) along with CPAN
See About the various PANs for details about DarkPAN and GrayPAN.
An attacker can “claim” a public namespace, install a high version (to take precedence in the index over a smaller local version number) and wait for vulnerable setups to install (and execute) their newly uploaded module.
Not all uploads end into claiming a namespace or a new version of a module (both can be referred as “indexing”). Permissions on name (FIRSTCOME, COMAINT…) and status of release (development version) impacts the visibility of releases. Index and visual appearance in MetaCPAN would make it clear, highlight dev or “unauthorized” releases and finally, not installable.
See PAUSE operating model for details.
General about CPAN dependency management
The mirror versus the indexer
The notions of “mirror” and “indexer”:
- “mirror” - where module tarballs are stored
- “indexer” - translate module to path and version
These notions are not always well differenciated and their meaning heavily depends on the CPAN client. For instance, from a pool (urllist), CPAN.pm
will use only one mirror for both indexing and downloading modules. On the other hand, cpanm
will use MetaDB as the index in priority and the mirrors to download, and these notions are well decoupled.
Not all indexers are equals: 02packages only contains the latest version of each module, making it difficult to specify more complex constraints (pinned or ranges).
General characteristics about CPAN/PAUSE and installers
- Module installs are (often) “dynamic” (= some dependencies are only known at install)
- Claiming a namespace is case insensitive but index/install is case sensitive. It’s important for TypoSquatting or attacks based on modules/filenames collisions (e.g.
GetOpt::Long
would overwriteGetopt::Long
module on a Windows machine).
Installer are not zealous
“leafs” vs “requirements”
CPAN installers treats differently a “leaf” versus a “requirement”.
Specifying a module as a “leaf”, e.g. cpanm Requirement
is different from having it specified as a dependency (Makefile.PL/Build.PL, META.*, cpanfile…)
- When explicitly targeting to install
Requirement
,cpanm
(and others) will install the latest, upgrading the module if present - When
Requirement
module is specified as a dependency,cpanm
will not upgrade the module if the installed version satisfies the constraints
Installing with cpanm --installdeps .
will not install anything:
- Requirement@0.01
- cpanm –installdeps .
- Requirement@0.01
But cpanm Requirement
will update Requirement
:
- Requirement@0.01
- cpanm Requirement
- Requirement@0.02
Note: This is not specific to the CPAN ecosystem, for instance pip
and gem
behave the same way.
--skip-satisfied
But cpanm --skip-satisfied Requirement
will not update Requirement
:
- Requirement@0.01
- cpanm –skip-satisfied Requirement
- Requirement@0.01
Simulations
Setup
For these tests, I’m recreating a setup with a mix of private and public modules.
As a commodity, it is presented as a cpanfile
:
requires 'PrivateAndPublicGreaterCPAN', '0';
requires 'Private', '0';
requires 'Public', '0';
requires 'PrivateAndPublicGreaterLocal', '0';
It simulates a complex setup where the installer has to install some modules, sometimes local requirements and sometimes remote ones.
Specifying the requirements in Makefile.PL, Build.PL or META.json/META.yml achieves the same and was used in some cases (e.g. CPAN/CPANPLUS)
On DarkPAN:
PrivateAndPublicGreaterCPAN@0.03
Private@0.01
PrivateAndPublicGreaterLocal@0.02
On CPAN:
PrivateAndPublicGreaterCPAN@0.04
Public@0.12
PrivateAndPublicGreaterLocal@0.01
Private
is only present on DarkPAN, Public
is only present on CPAN.
PrivateAndPublicGreaterCPAN
and PrivateAndPublicGreaterLocal
are requirements that can be “confused” (if install ends up with PrivateAndPublicGreaterCPAN@0.04
and/or PrivateAndPublicGreaterLocal@0.02
)
GrayPAN/DarkPAN was simulated with the following tools:
- pinto either targetted via HTTP
http://0.0.0.0:8000/
orfile:///data/darkpan
forcpanm
,cpm
,carton
,carmel
andcpan
- CPAN::Mini for
cpanplus
(cpanplus index fetch incompatible with pinto) - StratoPAN/JFrog Artifactory for CPAN artifact manager proxy
Dependency confusion when local is non-existent
Unsafe
- Specifying the private requirements in Makefile.PL, Build.PL, META.* or cpanfile/cpmfile is unsafe. Doing manual installs (via EUMM/MB or
cpanm tarball
) and then relying on requirements being already satisfied (and CPAN client not being “zealous”) to not install anything public is risky, to say the least.
Safe
- Do not specify private requirements in Makefile.PL, Build.PL, META.* or cpanfile/cpmfile. Specifying requirements is technically not required for runtime. This is suboptimal because it makes dependencies implicit and shifts right the verification of requirements.
- Use reserved namespaces (
_Underscore::Trick
) that can not resolve to public modules.
Documented but unsafe
Local::*
is documented as a name space for private modules but as of November 2024 only reserved “by convention”, see Do not index Local::* (nor Local)
Dependency version confusion between PANs
Unsafe
cpanm –mirror
- It keeps resolving first via public indexer (has no information about DarkPAN versions)
- It only “appends” so beware mirrors installed first via
PERL_CPANM_OPT
- Order is important
e.g.:
- Problem with “appends”:
PERL_CPANM_OPT="--mirror https://cpan.org" cpanm --mirror-only --mirror http://darkpan.local --installdeps .
- Problem with “indexer”:
cpanm --mirror http://darkpan.local --mirror https://cpan.org --installdeps .
- Problem with “ordering”:
cpanm --mirror-only --mirror https://cpan.org --mirror http://darkpan.local --installdeps .
cpanfile/cpanm
mirror ""
andmirror =>
directives from cpanfile are (silently) ignored by cpanm (!)
# WRONG with cpanm
mirror "https://darkpan.local/";
requires 'PublicAndPrivateGreaterCPAN', '0',
mirror => 'https://darkpan.local/',
dist => 'USER/PublicAndPrivateGreaterCPAN-0.03.tar.gz';
cpanfile/carton
- per req
mirror =>
alone (withoutdist =>
) is silently ignored by carton - per file
mirror ""
is not used for index by carton PERL_CARTON_MIRROR="https://pause.local/" carton install
- error prone (mirror is not explicited in requirements file)
cpanfile/carmel
- carmel is “experimental”
- per req
mirror =>
/dist =>
is not supported - per file
mirror ""
feature is not stable and behaviour not decided yet
Safe
cpanm --from
- but requires full sync DarkPAN or to differenciate private and public cpanfile(s)unset PERL_CPANM_OPT cpanm --mirror-only --mirror darkpan --mirror cpan
- beware order of mirrorsunset PERL_CPANM_OPT cpanm --mirror-index file:///data/02packages --mirror darkpan --mirror cpan
- beware order of mirrorsunset PERL_CPANM_OPT cpanm --cascade-search --mirror-index file:///data/02packages --mirror darkpan --mirror cpan
- beware order of mirrorscpm --no-default-resolvers --resolver darkpan --resolver cpan
- carton/cpanfile
mirror =>
ANDdist =>
- pinned version and storage - carton/cpanfile
url =>
- pinned url - CPAN urllist (but with
pushy_https
disabled) - use index of first mirror, fallback for download - CPANPLUS
hosts
- use index of first mirror, fallback for download
Carton pinning with url =>
:
requires 'Private', '0',
url => 'https://darkpan.local/authors/id/U/US/USER/Private-0.01.tar.gz';
Deployment and lockfiles
Lockfiles mostly refers to the deployment phase that is out of scope of this document.
carton --deployment
and carton --cached
are not vulnerable, because they use dumped/recreated local index with local artefacts.
CPAN Artifacts Repositories
JFrog Artifactory
JFrog is known to be vulnerable (internally) to these dependency confusion attacks with other ecosystems.
Since JFrog Artifactory does not support CPAN as a first class citizen but only a “proxy” CPAN (as in “HTTP proxy”), CPAN is not vulnerable via this artifact repository manager.
StratoPAN
Not affected, not doing fallback on the public CPAN.
Mitigations
Beyond technical CPAN client/options recommendations exposed earlier, sharing some more “shift left” guards and explicit security advice.
PAUSE operational model
The way CPAN and PAUSE work, and the very “social” nature of this ecosystem makes it very difficult to attack CPAN via dependency confusions.
- New authors don’t immediately get an account but are approved by an admin. With a priori approval like this, automatic creation of fake accounts is less likely.
- New uploads do not follow a priori control but are publicly reviewed. Malware code can be spotted and claiming namespaces with bad intention would likely be reported rapidly.
In this context, it seems difficult for an attacker/malware module to go unnoticed and exploit dependency confusion.
But still, PAUSE and CPAN have experienced some “raids” of spam, see for instance the surge in spammy accounts (and uploads) in August 2018.
Isolate private requirements
- Use different files for private and public requirements (
cpanfile.private
/cpanfile
) - Do not specify private requirements in Makefile.PL, Build.PL, META.* or cpanfile/cpmfile
Both previous solutions are sub-optimal
Naming
Scoping like NPM
Remediation for NPM ecosystem mentions scoping (naming packages like @my-company/my-module
).
A scope guarantees protection of all names under it but also can be tied to a registry.
The CPAN operating model works differently and has no such scopes. Technically, being the first person to publish a module offers FIRSTCOME permissions on the module name. Though it does not recusively protect all names under it, it effectively protects the fully qualified name forever.
Doing this (“placeholding”) for private modules is an option, but it would be very unwise and very egoist to do that, effectively polluting the CPAN namespace for personal needs.
Beyond these considerations, you need only publish once (a very small version that never will interfer with your local versioning) to claim the namespace. Remember, it’s not a scope. Claiming Module
does not protect Module::Foo
nor Module::Bar
.
The module install somewhat fills this use case, like gem bundle in rubygems.
Reserved namespaces
Use reserved namespaces that can’t resolve to public things:
_Underscore::Trick
Local::*
but as of today only reserved “by convention”, see Do not index Local::* (nor Local)
From PAUSE Naming Modules - Local:: and The Perl Module Library (perlmodlib)
Resources
- Hacking with gems (2013)
- Dependency Confusion (2021)
- Distribution Confusion in PyPi (2023)
- PAUSE Operating Model
- cpanm
- cpm
- CPANPLUS
- CPAN
- Carton
- Carmel
- Module::CPANfile
- CPAN::Meta::Spec
- Pinto
- CPAN::Mirror::Tiny
- PerlRocks
- Carl
- anton
- PAUSE will continue to index Local::* (2023)
- Do not index Local:: namespace (nor Local) (2024)
- x_static_install
- CPAN always tries file:// first
- CPANPLUS Can I use local mirror
- CPAN::Mirror::Tiny - How to install modules from DarkPAN
- Addressing CPAN vulnerabilities related to checksums
- Signature Verification Vulnerabilities in CPAN.pm, cpanminus and CPAN::Checksums
- Article about minicpan
- BackPAN - A Complete History Of CPAN