September 24, 2024


Episode 14


New Release! FT-Crack: An Open Source Wireless Hacking Tool 


Author: David Underwood

DEFCON has come and gone again. It was another wonderful time spent seeing old friends, making some new ones, and of course participating in one of the CTF events held every year to try my luck at taking home a Black Badge. This year, we put together a crew to take on the Radio Frequency Village CTF and ended up coming back with a solid third place on the scoreboard. While our team was preparing for the event this year, we managed to identify a potential problem: we didn't have a means to crack an FT-PSK handshake, which had the potential to be a challenge during the CTF.

As it turned out, there aren't (at least, as far as we are aware at the time of this writing) any public tools that can calculate the values needed to confirm that a given password (the PSK in FT-PSK) is being used for an access point if it's using fast BSS transition (the FT in FT-PSK). While it wasn't a guarantee that it would be a challenge we would have to face at the competition, it did mean that if we showed up and didn't have an answer for it, we could fall behind the rest of the teams. And if it did end up being a problem that we had to face, we could be better prepared than the rest and net some of those sweet, sweet First Blood points.

With all that being said, I'm happy to say that we did manage to come up with something, and I would like to share with the rest of the world my solution, FT-Crack: a simple Python script that can crack an FT-PSK handshake given a hash and a wordlist. While I am by no means an expert, I thought it might be fun to share some insight into how I went about figuring out what needed to be done to create a usable tool and the process as I went about my research.

As you read further, you can find the FT-Crack repository hosted here: https://github.com/DarkWolf-Labs/ft-crack/tree/main


WPA-PSK, THE CRACKED

"What does all of that even mean?" I hear you ask yourself. Good question! Before diving too deep into the rest of it, here is a snippet shamelessly stolen from Wikipedia (which was shared with me by a colleague) describing fast transition:

"IEEE 802.11r-2008 or fast BSS transition (FT), is an amendment to the IEEE 802.11 standard to permit continuous connectivity aboard wireless devices in motion, with fast and secure client transitions from one Basic Service Set (abbreviated BSS, and also known as a base station or more colloquially, an access point) to another performed in a nearly seamless manner. It was published on July 15, 2008. IEEE 802.11r-2008 was rolled up into 802.11-2012.[1] The terms handoff and roaming are often used, although 802.11 transition is not a true handoff/roaming process in the cellular sense, where the process is coordinated by the base station and is generally uninterrupted."

I won't go into too much detail here, but the gist of it (from my understanding) is that it's a way for a client to be able to roam within an environment without having to do the entire authentication process for each access point. The idea was that if a client could prove that it knew the correct password by knowing keys derived from it, time could be saved. What did that mean for us, though?

Well, the process of cracking a normal WPA handshake is pretty much a solved problem in 2024. For example, here is a page from Hashcat (other popular tools such as Aircrack-ng do this as well, but I'll just focus on Hashcat to keep things simple) on handling hashes for WPA or WPA2:

https://hashcat.net/wiki/doku.php?id=cracking_wpawpa2 

Looking at the "Working with hash files," you can tell that Hashcat wants a hash in the format of:

WPA*02*MIC*MAC_AP*MAC_CLIENT*ESSID*NONCE_AP*EAPOL_CLIENT*MESSAGEPAIR

This is because Hashcat can take the given values (MAC address of the access point, MAC address of the client, the ESSID, a nonce provided from the access point, and the second EAPOL message), add in a password, and calculate a Message Integrity Check (MIC) using a known algorithm. If the provided values calculate out to be the same MIC that is inside the original EAPOL message (which is pulled out and placed in the hash for comparison), Hashcat can determine that the password it used is correct.

As this has been explained many times all over the internet, I'll try to keep this part brief—but normally, the process looks something like this:

PMK = PBKDF2(HMAC−SHA1, PSK, SSID, 4096, 256)

A Pairwise Master Key ("PMK") is calculated using the Pre-Shared Key ("PSK," or "the password") and the SSID for the access point. Then, the PMK is taken and expanded into multiple keys using a specific pseudo-random function (a "PRF"—I know, it's acronym after acronym, just bear with me):

PRF-512(PMK, "Pairwise key expansion", MAC1||MAC2||Nonce1||Nonce2)

The PRF itself uses an HMAC-SHA-1 algorithm to generate keys and concatenate them together:

HMAC-SHA-1(K, A|0|B|i)

Where "A" is the "application-specific text" (or "Pairwise key expansion"), a null byte, "B" is the data (the MAC addresses and nonces, sorted by lowest to highest), and an increasing counter "i". The entire process is detailed fairly well here, for those more curious:

https://etutorials.org/Networking/802.11+security.+wi-fi+protected+access+and+802.11i/Part+II+The+Design+of+Wi-Fi+Security/Chapter+10.+WPA+and+RSN+Key+Hierarchy/Computing+the+Temporal+Keys/

The PRF will generate bytes up to the required amount (in our case 512 bits, while throwing out the additional generated bytes per iteration), and then they will be split into various keys. The first 128 bits are known as the "KCK," which is used during the generation of a MIC. Unfortunately, I don't have as nice of an example as the previous ones to explain the MIC generation, but we can use the code for wpa_supplicant to get an idea (as well as the many WPA2 handshake cracking scripts and tools out there):

https://github.com/digsrc/wpa_supplicant/blob/master/src/common/wpa_common.c

The MIC is generated using an HMAC-SHA-1 algorithm (for WPA2), with the KCK as the key and the entire second EAPOL message as the data with its MIC field from the message zeroed out.

THE READING


Great, now we are experts at cracking normal WPA handshakes, more or less. What's the problem then?

Well, when using FT-PSK, the algorithms change after the PMK is generated—and they aren't detailed very well anywhere. As far as I can tell, the main tools that people might use to attempt to crack these handshakes haven't been updated to accommodate fast transition. An example of the problem (and the main source of inspiration for why the tool was made) can be found in a post on the Hashcat GitHub by the user ZerBea, the (a?) maintainer of hcxtools (another popular tool set used for dealing with Wi-Fi):

In order to generate the KCK for fast transition, we need to derive a set of different keys: PMK-R0, and PMK-R1. From there, we should be able to expand them into a new PTK and get the KCK just like before, using the KCK to once again calculate the MIC for a message and determine if a password is correct. This at first seems fairly simple; the generation of the PTK and MIC should be known quantities if we ignore the fact that S0KH-ID is an unknown value to us for now, so it should just be a matter of tweaking a few things to account for the different keys first. If you attempt to do this, however, you’ll quickly discover that it’s not quite the case. So what changed?

NUMBER CITY


To figure this out, some digging needed to be done. ZerBea appeared to have figured it out, and pointed out some code that looked a lot like C. Following their breadcrumbs, it was easy to pick up that the code was pulled from wpa_supplicant in the aptly named “wpa_derive_pmk_r0” function:

If you have been paying close attention, you’ll notice a slight discrepancy between the comment and the code, though. The Github comment specifies a KDF (which for our purposes is just an alternative acronym for “PRF”, to make this even less confusing than it is already) of 256, presumably for 256 bits. The comment in the code, however, mentions a KDF of 384- which if it stands for bits as well would be 128 more bits than previously thought. Then, it appears to take only the first 256 bits of the output data to serve as the PMK-R0, using the last 128 bits to generate another key- the PMKR0Name. Comparing the comment in wpa_supplicant to the code appears to confirm it: r0_key_data is initialized to an array of 48 bytes, which at 8 bits a byte comes out to be 384, and then only up to the size of PMK_LEN (32) gets copied into pmk_r0 for delivery. 

“But wait!” I hear you cry. “If a PRF/KDF/acronym is supposed to concatenate (combine) multiple instances of bits created from the same input (with a counter to change the bits per iteration), wouldn’t the first 256 bits be the same whether you generated 256 or 384?” And to that I would tell you: yes, you are correct. They should always be the same, and I made the mistake of initially assuming that the amount of bytes being generated would have no impact on the end result because I also assumed that the PRF being used was the same as before, just with a different amount of bits and application text (spoiler: it’s not). Looking at how the PRF was determined in the past, it’s a fair assumption; no part of the algorithm appears to take that into consideration. I’ll touch on this in a little bit, but for now I’m going to complete this train of thought before we talk about that one.

Following the trail, it would appear that from the PMK-R0 the PTK should be able to be generated using the “known” PRF: PMK-R0 as the key, the “Pairwise key expansion” as the text, and the MAC address and nonces as the data. If it’s correct, it should all be simple enough to determine using a different key. Then, confusingly, we are supposed to generate the PMK-R1 key using PMK-R0 with the same PRF as before. Why “confusingly”, you ask? Well, from the PTK- which we should have already, if this is all correct- we should be able to get the KCK. If we have the KCK, we should be able to calculate the MIC. If we can calculate the MIC… why do we need the additional key? On top of that, the generation of the PMK-R1 seemed to need another unknown value- S1KH-ID- which doesn’t seem to be anywhere in the post at all. The rest of the values can be captured in the EAPOL message, but the SXKH-ID’s are not anywhere to be found. At this point, there is a general idea of what needs to be done, but some questions need to be answered first. 

As a side note: I did try to just spin things up “as they were” to test if this all was correct anyways and I was just overthinking things, but had no success. Instead, I will save us all the time by saying “it didn’t work”.


RANDOM HIERARCHY SHIFT

To start, I needed to figure out all of the values necessary for the calculations. Mainly, I needed to know the “new” values necessary for FT-PSK. The Github post luckily gave me most of the information: the Mobility Domain Identification (MD-ID), R0KH-ID and R1KH-ID (which, interestingly, is just the MAC address for the access point). These are values specific to the access point, and are values that can also be easily pulled from a Wireshark capture of a handshake (from testing data of my own, courtesy of our wonderful RFCTF):

This means that the main “unknown” values I realistically have to determine would be those previously mentioned: S0KH-ID, and S1KH-ID. Searching around the internet didn’t seem to come up with a very clear answer, but did end up pointing me in a number of directions.

The above slides gave me the first clue as to what a S0KH-ID might entail: compared with the rest of the code and comments gathered, the slide deck seemed to claim that following the R0KH-ID would be a null byte and the value of “SPA”. With a little bit more digging (and some help from a friend), it becomes clear that the “SPA” in question should be the clients MAC address:

This is also confirmed by the code from wpa_supplicant when passing values to the call to the function wpa_derive_pmk_r0 in the wpa_derive_ptk_ft function of wpa_ft.c:

In the same function, it passes its own address as the value again as S1KH-ID when it calls the function wpa_derive_pmk_r1. This is great news for us; the client MAC address is going to be used as both S0KH-ID and S1KH-ID. Additionally, looking at the code for wpa_supplicant didn’t appear to have anything in place to add in a null byte like the slide deck seemed to show, which meant it’s likely that it could be ignored and assumed that it was a mistake in the slide.

Now that I had a good idea as to what the missing values were, I turned my attention to another comment on the post, again by ZerBea, which caught my eye:

They referenced the same PDF that I was already using for information, which should have made it easier for me to find what they were referring to. Unfortunately, I wasn’t able to glean the same information that they were, and didn’t find any reference to the MIC calculation that would help me out. I could, however, do what I do best- go back to digging around in wpa_supplicant:

Inside the wpa_eapol_key_mic function of wpa_common.c, it looked as if the config was 802.11r (which is fast transition) then the MIC should be generated using omac1_aes_128. OMAC1 is now actually CMAC, which fits what was mentioned on the post.

However, taking these values and trying to calculate the correct MIC (with AES-128-CMAC) using the “standard” PTK generation still wasn’t quite cutting it. There was still a piece that was missing which would need to be figured out if this was going to work.

ON THE BRINK

For the entirety of my testing, I was under the impression that the generation of the PTK was done using the same old PRF. But, looking closer at the code for wpa_supplicant showed that it had a function I was overlooking called wpa_pmk_r1_to_ptk:

Not only would this make more sense as to why the second key (PMK-R1) was needed, but it would also explain some of the confusion for why all of my calculations were off. The data provided would be the same (consisting of the MAC addresses and nonces), but the “application specific text” would be different- not to mention that the generation was going to use the PMK-R1 as opposed to the PMK-R0.

The function then just passes the data to the same-but-yet-to-be-scrutinized sha256_prf function, giving it a size of “ptk_len” to use in the PRF. For those curious, the “standard” appeared to be 48 bytes- which would be 3 sets of 128 bit keys, or 384 bits- but there did appear to be functionality for different lengths, which is why it’s “variable”:

This solves one part of my final problem: I now knew that the generation of the PTK wasn’t quite what the post made it appear to be, and could alter my tests accordingly. Still, attempting to use the “known” PRF to generate the PTK was coming up with the wrong answers even with the new information accounted for, which meant that there were still more issues to figure out- and another function to take a look at: sha256_prf.

Up until now, I had been operating under the idea that the PRF generation was really just the same, and maybe used a different algorithm (even though I haven’t mentioned it yet, I had been testing for others). If we go back to the previously mentioned article on “Computing the Temporal Keys”, it seemed clear that the typical PRF used an HMAC-SHA-1 algorithm to compute the keys. The function being used in the code for the parts of wpa_supplicant that was handling fast transition was called “sha256”, so it didn’t seem that much of a stretch to me that instead of multiple iterations of HMAC-SHA-1, the new PRF would just be doing multiple iterations of HMAC-SHA-256. Still, both were coming up with the wrong answers, so the best way to figure it out would be to go pick apart the code for wpa_supplicant some more:

https://github.com/digsrc/wpa_supplicant/blob/master/src/crypto/sha256-prf.c

sha256_prf just passed data to the following function, so really the source of information was going to be inside sha256_prf_bits. Looking closer at the function made it clear that it was not quite what I thought it would be:

My guess on the algorithm really being SHA256 was right at the least (shocker), but the data provided to it was different than the “old” way. For starters, the counter for this PRF is initialized at 1, instead of the previous 0 (additionally, it’s converted to bytes). Then the “label” is concatenated in (which is our “application specific text”), but there is no indication of a null byte following it. Following it is the “data”, which should be our MAC addresses and nonces. Finally (and perhaps one of the most important pieces, in my opinion) the size of the data being generated is accounted for in the generation of the keys. This might not be groundbreaking, but it does mean that the “size” of our PRF directly impacts the keys generated from it. A PRF for 256 bits should generate different keys than one with 384 bits, even with the same text and data. Comparing them side-by-side makes it a little easier to determine the differences between them:

On the right hand side is the “old” PRF generation- in the code, you can see where it initializes the counter to be “0” and adds the extra null byte to “label” by setting the overall length to be one more than its actual length. The sha1_prf function then takes the label (accounting for the null byte) and appends the data, then the counter, before passing it to an HMAC-SHA-1 function. This means that our custom PRF should go from this:

HMAC-SHA-1(K, A | 0 | B | i)

To this:

HMAC-SHA-256(K, b(i) | A | B | b(s))

Where “i” is our counter (starting with 1) converted to bytes, “A” is the provided text, “B” is our data (though not covered here, it is a specific order as opposed to the previous version which required them to be sorted), and then the new variable I dub “s”, which is the size of the bits to be generated- converted to bytes as well. It seemed like I had figured out the final piece of my puzzle, and at almost the last possible moment:


While on the plane to DEFCON, I managed to get it right, and my calculated MIC matched ZerBea’s exactly using their test data.

THE END: COMPLETE

After DEFCON was over (and I sadly didn’t get a chance to even use my script at all) I took a little bit of time to make it into something that was less of a “dirty Python script”, adding in a couple of features and making it look fancier than it really is. Behold!

Like I said at the beginning of all of this: “FT-Crack” is designed to take a hash (in either a file or on the command line) and a wordlist, and crack it. I did, however, want to address a few things about some of my decision making:

What does this mean for the world? Should I be worried?

Nothing too different, really. This isn’t a revolutionary kind of attack, nor is it a groundbreaking new idea. All of the information to do this has already been out there and available, I’ve simply taken the time to piece it together into something that works. The script ultimately operates the same way as any other WPA hash cracking script out there- just a slightly different version of it. The same general security principles apply: if you use a weak or easily guessed password, and you use FT-PSK in your environment, someone might be able to crack it (easier) now. Having a sufficiently strong password should be enough to keep you safe.

Why take a hash? Why not have it parse a capture file, for instance?

It’s a fair question, and one that I thought about. Overall, I expect that sometime soon the “big” hash crackers (Hashcat, John, Aircrack) will end up supporting FT-PSK. The post that I used for a lot of the inspiration involving FT-Crack was on the Hashcat Github as an open issue, after all. Before they do, however, I expect that something like hcxtools might end up supporting FT-PSK if a tool other than Hashcat- like, this script- is around and able to do the job. To support this, I designed FT-Crack with the proposed hash format by ZerBea in mind:


The only difference between the hash that works currently with FT-Crack and the example provided on the post is that I chose to swap the bytes for the MD-ID. Instead of 0x0201 translating to 0201 in my hash, I opted for having the MD-ID match the actual byte order in the EAPOL message- 0102. All of this hopefully allows the script to be a bit more handy and relevant for a little while.

Why only one hash at a time? Why is it so slow? Surely this could be better written/designed/optimized.

You are correct! It probably could - and I have (loose) plans to improve it. For now, it’s meant to be functional and simple, but improvements could certainly be made. This is, in part, why we’re choosing to open source this code so that the industry can benefit from taking this and integrating implementations of this tool and proof-of-concept where applicable. 

Okay, you did a bunch of talking - what now?

I just want to say a big thank you to friends, colleagues, fellow Dark Wolves, etc. Special shout out to Dr. Nalo Ron Broberg for our RFCTF environment, helping point out some things to me while I was researching, letting me help get flags at DEFCON, and all of that. Hopefully if you made it this far, it was a fun read - and if I did my job correctly, it was also somewhat informative.