I found an API that exposed encrypted credit card numbers. Here’s how I cracked them to reveal the full card details.
While hacking on a private bug bounty program, I found a graphql endpoint that exposed way more information about logged-in users than it should have done. By playing with the ‘about me’ graphql API request I was able to guess and retrieve all the logged-in user’s stored values present in the database, not just those disclosed by the web app.
Using these guessed parameters, I was able to retrieve all credit cards added to a user’s account. This included: the cardholders name, their registered address, the expiry date of the card, the last 4 digits of the card, the type of card, and an encrypted version of the full credit card number.
The application allowed users to store up to 10 payment cards on their account for easier checkout when buying products and services. In order for the company to use them in future, I knew that they must be stored in a decryptable format. The challenge I set myself before submitting my report was to crack the encryption algorithm and view the raw card numbers.
Credit Card Numbers Explained
Credit and debit card numbers use an internationally recognised standard. A Payment Card Number (PCN) can be anywhere from 8 to 19 digits long and the first 6 to 8 digits are used to identify the bank which issued the card. This is known as the Issuer identification number (IIN). All American Express cards start with 34 or 37, all Visa cards start with a 4, etc.
There is also a range of card numbers that are reserved for testing purposes and which will never be assigned to real customers. (This is another bug you can test for while bug bounty hunting – can I make a purchase with a test-only card?). My personal favourite is for Visa: 4111 1111 1111 1111. Stripe has a list of test cards you can use anywhere and you can find more with a quick google search for “test payment card numbers”.
Finally, the majority of credit card numbers include a validity verification number using the Luhn algorithm. With this, the final digit of the card number is a check digit for all of the preceding numbers. For ‘4111 1111 1111 111X’ the check digit value is 1. For ‘4111 1111 1111 112X’ the check digit value is 9. You can play with this yourself using tools such as this one. As you can see, anyone can generate all possible card numbers for any given bank provided you know the prefix, the length, and whether or not it uses the Luhn algorithm. This is why you also need the card expiry date and CV2 numbers to make purchases.
Now that we’ve got a better understanding of credit card numbers, I’ll continue with my attack.
Attempt 1: Reversing the Algorithm
I created a new user account and added my favourite Visa credit card number, ‘4111 1111 1111 1111’. When I queried the API I received a 16 digit numerical, encrypted version of my card. I deleted the card, then added it again. When I queried the API again I received the same encrypted value. Now I knew the results were statically generated using a fixed algorithm.
I then realised the API would allow me to submit up to 10 card numbers in a single request. I uploaded 10 numbers in a row starting from ‘4111 1111 1111 1129’, ‘4111 1111 1111 1137’, etc. and tried to spot a pattern. I could see the signs of a pattern emerging, but I couldn’t get my head around it. The last 4 digits of the response were always the plaintext of the original: ‘1111’, 1129′, 1137′, etc. The first 2 digits were always ’54’ for Visa, ’53’ for American Express, etc. so it looked like the first digit of the card IIN became the second digit of the response and they always started with a 5.
As I knew the card type from the API response and also the last 4 digits, I already had 6 of the 16 digits required to rebuild any card number uploaded to the application server.
I kept pushing it for another couple of hours, trying more and more combinations, trying to get as close to ‘0000 0000 0000 0000’ as possible, but only Luhn validating card numbers worked and I still couldn’t get my head around the pattern. I was close, but not close enough.
Back to the drawing board and off to bed.
Attempt 2: Rainbow Tables
At 3 am I woke up with an idea. If the same encrypted value is generated for all users who submit the same card number, I don’t need to reverse the algorithm, I just need a rainbow table of all of the possible encrypted values mapped to raw card numbers.
A rainbow table is a lookup table that contains encrypted values and their plaintext counterparts. These are usually created for hashes such as MD5s, but in this case, I needed one for a 2-way encrypted string – one that could be encrypted and decrypted with a custom algorithm.
The next morning I created a second user account and entered my go-to card number. When I queried it via the API, I got a match. Both users received the exact same encrypted 16 digit string. Amazing.
This means that the card numbers are encrypted without the use of an encryption salt. In cryptography, salt is random data that is added to an input to prevent duplicate inputs from producing duplicate cryptographic outputs. Without adding salt, if someone knows the input for a given output, any matching outputs for other users can be mapped back to the known input. With a different salt for each user, the outputs will always be different, even for the same card numbers.
User 1 with salt 5000: 4111 1111 1111 1111 + 5000 -> 5477 2356 1143 1111
User 2 with salt 7992: 4111 1111 1111 1111 + 7992 -> 5436 4343 8711 1111
Putting It All Together
Now I knew:
- The application stores credit card numbers in a 2-way encrypted 16 digit string
- The application uses the same algorithm for all users
- The application does not use a different cryptographic salt for each user so two user accounts with the same card number will generate the same encrypted number
- I can upload 10 card numbers at a time and then query all of the encrypted card numbers
I then wrote a python script that calculated all the valid card numbers for each bank, then used the application server to generate the cryptographic outputs for each one. I turned their application server into my own payment card number encryption tool.
Submitting my generated credit card numbers 10 at a time, I then issue the ‘about me’ graphql query to pull back the encrypted values. The tool would iterate through all possible combinations per bank, building up a rainbow table as it went. As this was only a proof of concept, I killed the script after a few hundred cycles to not waste resources on the application server. (Operations teams don’t like it when you crash things and fill up log space).
With my rainbow table proof of concept ready, I was able to decrypt any credit card number stored inside the application database for any logged-in user. As the ‘about me’ query also stored the card expiry date, registered owners name, and registered address, the only thing missing to make real payments was the CV2 number.
The biggest hurdle to declaring this a P1 or Critical vulnerability was the fact that it only worked for logged in users. In order to exploit it, I needed to take over the victims account. The site had less-than-perfect authentication protections in place and no MFA was possible at the time of testing. A brute force attack, leaked shared password attack, or phishing attack would all have been viable to gain access, but it was one step too far for this bug bounty program. Instead, we agreed on a lesser, but still nice severity level.
I always give remediation recommendations in my bug bounty reports. This time, the easiest thing to do was to simply stop returning the encrypted card numbers as part of the API query response. Adding a per-user salt was a nice-to-have, but once the encrypted number is no longer exposed to end-users, the risk is effectively mitigated.