Background

Under German law, employers are required to regularly verify that their employees hold a valid driving license if the employees need to drive a vehicle for their job. Usually, there is a 3-month interval for this verification. Although some sources claim that a monthly interval is needed. During the check, the employer needs to verify that the employees physically hold their licenses, and additionally, the employees need to assure that they are not banned from driving a vehicle in Germany.

As you can imagine, a process like this produces some administrative overhead. This is especially true in settings where employees work on multiple schedules or part-time. In many cases, the person responsible for the license check only works during normal office hours. Therefore, it can be difficult for them to meet a night-shift employee in person to take a look at their license.

Luckily, we live in a modern world where we have an app for everything. But modern solutions are not always good solutions. In this article, I will explain how I assessed the security of a driving license check app and how I found a way to circumvent the system within a few seconds.

Driving License Check via App

Some time ago, I was asked to start using an app that digitalized the mandatory driving license check. To work with this application, you get a ‘temper-safe’ RFID sticker on your driving license.

RFID-Sticker on license

The sticker can be seen on the left side. The pixelated area is the logo of the vendor. Behind the black bar is a personal registration code that is mapped to your account by an administrator.

Next, you have to install the app from the vendor. Once every month, the app asks you to scan the RFID tag on your license. In a second step, you need to input your password and mark a checkbox that (simplified) says, ‘I am not banned from driving a vehicle’. And then you are done for another month.

The employer can see the verification status of all employees in a web application. Employees can use the same web application to see their own verification status.

Besides the initial registration of every employee, there is no more need for a physical meeting and validation. Sounds good, right?

2 seconds to vulnerability

If you give me an RFID tag, there is an almost certain chance that I will scan it. And of course, I needed to scan the RFID tag that was put on my license (that’s what computer guys do, or not?). I used NXP’s app TagInfo to do so.

The RFID tag in question is a NXP NTAG213. A regular RFID tag that can store data but without any special features.

Scan result of the RFID tag

The memory content starts with the serial number of the tag. Besides that, the memory holds a url to the webapp that I mentioned above. Otherwise, there is no data on the tag.

So, how can they tell that I was the one that scanned his license? Remember, the app first scans the tag and then asks for a password. There is no authentication beforehand, and no permanent login. The only unique information on the tag is the serial number. So, my assumption at this point was instantly that they map the serial number to the employee account and then assume that the employee still holds their license, when they provide the serial number of their RFID tag.

But, as you might guess, there is a huge problem with this approach. The serial number is not a secret. You can simply read it out and provide it to the backend servers. There is no need to scan the RFID tag, as the tag does not actually do anything. This way, an employee could successfully validate their license status without actually holding their license. It took me about two seconds to come to this conclusion after I scanned my RFID tag.

Let’s have a closer look at the implementation to verify if this assumption is correct.

Technical Details

I used the Android version of the app and extracted its content with apktool. You can also simply use any archive tool to look into the file. The app is built with capacitor.js, a framework for cross-platform development. The application code is written in Javascript and can be found inside the assets/public folder in the .apk file. I assume that the same code is used for the iOS version.

After a brief search for keywords like password or ID, I was able to identify the two files that handle the application logic. The app first uses an external library to scan the RFID tag. Then, the serial number is extracted from the result data.

Following that, the app calls an encryption function with the serial number as a string argument. The serial number is encrypted with AES in CBC mode.

encrypt(text){
        let plaintext = 'pcSNtjZaeSsHeYMNcMCGbXs2v9LFKc8U#' + text;
        const ENCRYPTION_KEY = '***************4duBABtn24xWyGK54';
        const key = ENCRYPTION_KEY;
        const iv = crypto_js__WEBPACK_IMPORTED_MODULE_0___default().lib.WordArray.random(16);
        const res = crypto_js__WEBPACK_IMPORTED_MODULE_0___default().AES.encrypt(plaintext, key, { iv, mode: (crypto_js__WEBPACK_IMPORTED_MODULE_0___default().mode.CBC), padding: (crypto_js__WEBPACK_IMPORTED_MODULE_0___default().pad.Pkcs7) });
        return (res.toString());
    }

First, the serial number is padded with a fixed string. Then, the library crypto-js is used to generate an initialization vector and to do the acutal encryption. As you can see, the key for the encryption is hard-coded into the application. As HTTPS is used to interact with the backend, this encryption has little to no benefit. Additionally, hard-coded keys are another issue on their own.

After sending the serial number to the server, the app receives a response. This response consists of a seemingly random token and an HTML-formatted ‘I am not banned from driving’ message. Additionally, a cookie is transmitted.

{
  "text": "Sie haben Ihren Führerschein in unser System eingelesen.<br>Um den Vorgang abzuschließen bitten wir Sie mit Eingabe Ihres Passwortes um folgende Bestätigung:<br><br>
   <i>Ich bestätige mit der Eingabe meines Passworts, dass ich zum jetzigen Zeitpunkt im Besitz der im System registrierten Fahrerlaubnis bin.<br>
   /* ... */",
  "token": "654fc06a15709521030217"
}

The token changes for every response. However, I noticed that the token seems to follow a pattern. After submitting multiple requests over some time, I received the following tokens.

    654fc4a9 90b00074983261
    654fc54f ae878804084158
    654fc5c5 36d1c005362960
    654fdb8c df259677718840
    6550b7ba d8c13771057821

The first part of the tokens seems to increase with time. After converting the hex decimal string into an integer, it looked like this: 1699788730. A classic Unix timestamp. It corresponds with the time when I sent the requests to grab data for this article.

So, the first part of the string is the time of the request. But what about the second part? Is it truly random? Or does it follow a predictable pattern? As I did not want to interfere with the backend of the vendor, I did not investigate this any further. However, this could be an interesting aspect of future investigations.

The message text is presented to the user, next to a password field and a checkbox. After entering the password, checking the box, and pressing a confirm button, the app sends the token and the password back to the server.

confirm()
{
    this.xxxx.confirm(this.token, this.password)
        .then((response) => {
            this.successAlert();
        })
        //... Handlers for worng password and unexpected errors
}
// in this.xxxx
confirm(token, password) {
    let data = { 'sToken': token, 'sPassword': password };
    this.http.setDataSerializer('utf8');
    return this.http.post(this.endpoint + '/validatescan/', JSON.stringify(data), { 'Content-Type': 'application/json' });
}

Unlike the serial number, the password is not encrypted beforehand. As HTTPS is used, this is not an issue, but again raises the question of why the serial number is encrypted.

If the password is correct, the server responds with the full name of the user, the time of the confirmation, and a few other details.

{
      "sTimestamp": "xx.xx.2023 hh:mm",
      "oUser": {"sLastname": "xxxx", "sFirstname":  "xxxx"},
      "sToken": "654fc06a15709521030217",
      "lStatus": 2,
      "lRewisUser": null,
      "id": 12345
}

Exploit POC

After understanding how the verification process works, I wrote a proof-of-concept Node.js script that does the verification without needing the RFID tag. The script uses the same encryption library as the app and encrypts the serial number string with AES. The encrypted string is then sent to the backend of the app.

Next, the script waits for the response with the token and the message. As the script is intended to work without user interaction, it sends the user password and the token back to the server without waiting for any manual action. Here is the example output.

[Startup] tagID: 043b**********
[encrypt] key: ***************4duBABtn24xWyGK54
[encrypt] iv: a647a42a8bc8db4a05aea9d34c8ec786
[encrypt] encrypted ID: U2FsdGVkX1/cFyORwwy/f+NzCBrK9g3N0n7...
[SCAN] Sending scan request
[SCAN] Request accepted (200)
[SCAN] Received confirmation token: 654fc06a15709521030217
[SCAN] Waiting 3500 ms... done
[VALIDATE] Sending validation data: [Token]: 654fc06a15709521030217  [Password]: ******************
[VALIDATE] Request accepted (200)
[VALIDATE] Receiving response data
[VALIDATE] Validation was confirmed for: [firstname], [lastname]
[VALIDATE] Validation timestamp: xx.xx.2023 hh:mm

After checking the admin menu in the web interface, I could verify that the backend accepted my validation input. The confirmation date of my account was set to the current date without using the regular app on that day.

Vulnerability classification

According to OWASP TOP 10, there are three vulnerability classifications that can be applied to the app in question.

Insecure Design

The primary vulnerability in this case is an insecure system design.

The vendor relies on the serial number to verify that the users have physical access to their driving licenses. However, the serial number is not a secret value, as it can be read out by everyone with access to the RFID tag. Even encryption does not change this fact.

The verification protocol does not include any actual confirmation that the user is scanning the RFID tag. The backend does provide a (seemingly) random token. However, it is simply sent back to the server without any modification. At this time, there is no way to ensure that the users actually have their licenses at hand.

Identification and Authentication Failures

The implemented verification protocol also suffers from an Identification and Authentication Failure.

The backend fails to verify that incoming data is sent from the app. Additionally, there is no rate limit or timing check.

It is reasonable to assume that a user will need a few seconds to input their password into the app. My script did not use any timeouts in the first version. I only added the timeout later to prevent overly aggressive interactions with the backend. As soon as the server responded with the token and the message, my script sent back the password and the token. The whole process was finished before a user could enter any password with more than one letter.

A second issue in this category is a session validation failure. After receiving the serial number, the backend sends a session cookie to the app. But it is not required to use the session cookie when performing the second validation step. My POC script also worked without setting the session cookie.

Cryptographic Failures

As mentioned earlier, the app uses static keys to perform an encryption operation. This represents a cryptographic failure, as such keys can be easily targeted by an attacker.

In this case, the serial number is already visible to the user. Thus, there is no additional risk to the protocol. However, this kind of implementation does not provide any benefit. Additionally, hard-coded keys are a well-known anti-pattern.

Authentication Failures in the web application

During the writing of this article, I also took a quick look at the web application. For a regular user, there is not much to do. You can see your verification status, and you can change your e-mail address. Of course, you can also change your password. But here we can find another security issue.

If you go to the password menu, you can change your password without inputting your current password. Thus, anyone can change the password of a user if they can access a browser with an active session.

Password change menu without current password field

As there is not much to do in the user interface, this vulnerability on its own is not critical. But, this implementation implies that security question were not in focus during the development of the webinterface. Security-sensitive actions, like a password change, should always require authentication by the user. A password change without the current password is another well-known anti-pattern.

Improper session handling

The web interface suffers from another vulnerability. When logging in, a session token is set in the local storage of the browser.

The web interface provides the user with a logout button. Usually, such a button would explicitly end the current session. However, the web interface fails to do so.

When the logout button is pressed, a javascript function deletes the session token from the local storage, and the user is redirected to the login form. But if the token is copied beforehand, it can be manually replaced in the local storage. After reloading the page, the user can access the application again. No new login is required. Hence, I assume that the session has not ended on the server. A quick look on the network traffic confirms this, as no packet is sent when the logout button is pressed.

Once again, this is not a huge risk in the context of this specific application. But it shows that various security issues were missed during the development of the application.

Broken by design

From a technical point of view, the found vulnerabilities do not have a huge impact. It is not possible to access the information of other users (at least with the Android app), and there is no indication for a more significant vulnerability in the backend.

But, from a functional point of view, the whole system is broken by design. The reason why the system exists in the first place is that the law requires regular license checks. It is implied that you cannot trust your employees to speak up if they lose their permission to drive. And this is reasonable, because sometimes people’s jobs and their income depend on driving.

The app vendor claims that the RFID tags are ‘temper proof’ and that the system is a secure way to manage the license checks (in a legal sense). But if you can ‘proof’ that you still have your license without actually having it, the system fails its sole purpose. If we have to assume that employees will lie about a driving ban, we should also assume that they will exploit the vulnerabilities that I presented above. Of course, this needs some technical background. But overall, it is quite easy to perform the above attack. It took me about 30 minutes to do the analysis and have a working exploit.

The important question in this situation is not a technical one but a legal one. Is it enough to use this system, especially if the employer knows about its flaws? I am no legal expert; therefore, I cannot answer that question. But, from my naive perspective, I would say no. If we cannot trust people to not drive when they are banned from driving, we should also expect people to resort to other means.

Disclosure

Before sending a vulnerability report to the app vendor, the app vendor was informed that there was an issue. The company seemed to be genuinely interested in the matter.

After sending my report, we received a response after a short time. The company admitted that the described attack is possible. Unfortunately, they have a different opinion on the conclusions.

First, they say that the exploit only works for the account of the attacker. This claim is true, but a bit off, as I never claimed something else. On the contrary, I stated in my report that the attack only works for the account of the attacker.

Second, the company claims that such an attack requires fraudulent action by an employee. Therefore, an employer would be fine.

As I am not a legal expert, I cannot argue this point with certainty. But, when I think about the situation, the only logical conclusion is that this argument is too easy. I could move this claim backwards and say that it is also fraudulent to not inform an employer about a driving ban. Especially, if th employee signed a paper obliging them to do so. Additionally, driving without permission is a crime in Germany. If we accept this argument, there is no need for the system at all. The whole argument would fall back to ‘people are not allowed to do this, so we don’t need to prevent it’. The fact that we have locks on most of our doors proves that this argument is not accepted in other settings. So, why should it be here?

As the issue is not resolved and the vulnerabilities can still be used, I will not disclose the name of the app or company. I also want to prevent any legal hassles that might appear otherwise. Furthermore, the problems with the web interface were only discovered during the writing of this article.

Mitigation

Mitigating the described attack is not possible without a re-design of the current system. The core issue is that the serial number of the RFID tags is used to assume physical ownership of the corresponding driving license. As this information is not a secret, the user can always spoof a scan of the license.

To mitigate this issue, the RFID tags on all licenses need to be replaced. There are tags that can perform cryptographic operations. They are not very expensive and could be used for this use case. The system could be designed to use a challenge-response protocol on such tags.

Such a system would send a truly random token to the app. The app forwards the token to the RFID tag. The tag then performs a cryptographic operation on the token. For example, the tag could create a HMAC from the token and a secret key that is stored on the tag. The key would need to be read-protected. This is a feature that is available on many RFID tags. The HMAC is then sent back to the servers, where it is compared to the expected value.

Of course, such a system is more complex than the current one. It would require the vendor to store the corresponding key for every tag. But such a task is manageable if it is implemented from the start. Similar systems are used by us every day.

Physical attacks on the RFID tags are still possible. However, the effort to read out a non-readable RFID tag is on a whole other level than the attack I presented here.