Decrypting HighLevel SSO sessions using Python

softwareghlmarketplace appsssopyhton

by Sean Kerr

This guest post first appeared in seankerr.dev

The beauty of web APIs is that you can use your preferred language to interact with them

The beauty of web APIs is that you can use your preferred language to interact with them

HighLevel recently rolled out support for single-sign-on access, which allows Agencies and Accounts to connect seamlessly to an installed marketplace application. Because it’s such a new feature, there is no documentation available at this time. But thankfully for me I stumbled across this video by Sergio Leon which explains the entire SSO process from the application developer perspective. But most importantly, it shows how to retrieve the encrypted SSO session from GHL. It also explains how to decrypt it using Javascript (CryptoJS library).

This is where my luck ran out, because I’m not using Javascript on my backend, I’m using Python.

The first thing I did was gather the encryption details from the video and I realized the session was encrypted using AES. That’s all I had to go with. I installed Pycryptodome and away I went. I could safely assume that the session data was base64 encoded, so I decoded it and saw the data in its raw format. This gave away the clue I needed. The raw data was prefixed with Salted__. When working with OpenSSL, this is standard practice. In fact, when you google "Encryption Salted__" you will see endless results talking about decrypting OpenSSL data. It’s also standard practice to use PBKDF2 as the key derivation function. PBKDF2 uses an iteration of the password and random salt to sha256 hash the password, from which the key and initial value are derived. Sadly, that is why I wasted a day trying to finish the task.

I made the worst assumption from the get go. I saw the Salted__ prefix and assumed OpenSSL format. I had to take a step back and find documentation (which doesn’t exist) or find somebody who has first hand experience with this. That person is Sergio Leon, from the video above. He pointed me to a stack overflow post regarding how CryptoJS encrypts AES data, and so I took a look through the CryptoJS source code and noticed something I wasn’t expecting…

var key = EvpKDF.create({ ... }).compute(password, salt);

Ahhh! There it was. That doesn’t look like PBKDF2. It’s EVPKDF! That was the turning point where I realized I was using the wrong key derivation from the beginning. I switched to EVP and instantly my problem was solved.

The crux of the problem is two parts:

  • CryptoJS is using the same prefix as OpenSSL when encrypting AES data.
  • It’s using MD5 instead of SHA256 as a hashing mechanism, and it only does a single iteration instead of thousands (more secure).

These two factors will invariably lead others to make the same assumption I did, which is why I’m writing this post.

And without further ado, here is a working example of decrypting a HighLevel SSO session in Python.

# system imports
from base64 import b64decode
from typing import Tuple

# pycryptodome imports
from Crypto.Cipher import AES
from Crypto.Hash import MD5
from Crypto.Util.Padding import unpad

# encryption details
BLOCK_SIZE = AES.block_size
KEY_SIZE = 32
IV_SIZE = 16
SALT_SIZE = 8

# working data
PASSWORD = "TOPSY KRETT PASSWORD"
ENCRYPTED_DATA = "ENCRYPTED SSO SESSION"

def derive_key_and_iv() -> Tuple[bytes, bytes]:
    result = bytes()

    while len(result) < KEY_SIZE + IV_SIZE:
        hasher = MD5.new()
        hasher.update(result[-IV_SIZE:] + PASSWORD.encode("utf-8") + salt)
        result += hasher.digest()

    return result[:KEY_SIZE], result[KEY_SIZE : KEY_SIZE + IV_SIZE]

# get the raw encrypted data from the base64 encoded string
raw_encrypted_data = b64decode(ENCRYPTED_DATA)

# the first block is "Salted__THESALT", so we extract the salt
salt = raw_encrypted_data[SALT_SIZE:BLOCK_SIZE]

# beginning at the second block is the cipher text
cipher_text = raw_encrypted_data[BLOCK_SIZE:]

# let's do some work
key, iv = derive_key_and_iv()
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(cipher_text)
unpadded = unpad(decrypted, BLOCK_SIZE)

print(unpadded.decode("utf-8"))