Personal data of all Dutch public transport cards (“OV-Chipkaart”) accessible
December 19, 2017, an article on Tweakers.net was published about a publicly accessible form showing the balance and the date of last activity of any public transport card you fill in. Short after this article being published a comment on the article stated that the date of birth of the card’s owner was also available via the webshop of the Dutch railway company (NS). This was triggering me because it looked like a lot more information than only the balance and last activity date is available via a public accessible endpoint on the web.
The OV-Chipkaart is an NFC card being used to travel by public transport in the Netherlands, developed by TransLink BV. The card holds information about the balance and additional products, like subscriptions and discounts. The website www.ov-chipkaart.nl is the official website where you can check your balance, travel history, etc.
Let’s do some digging
Following the information in the article on Tweakers.net, I started looking around on the page where a publicly accessible form is available to check the balance of all cards. Via the network tab in the developer tools, I noticed that each request involves Google reCaptcha to pass. Leaving this HTTP header out resulted in an error message.
This endpoint was not much of use… I decided to log in to my personal account and watch all the network traffic in my developer console while clicking some elements in the dashboard. One of the requests caught my attention: https://www.ov-chipkaart.nl/web/medium_information. This POST request, filled with the parameters hashedMediumId and languagecode, responded with something I was looking for:
The only odd thing about this request was the parameter hashedMediumId. Where did its content come from and how was it calculated? Unfortunately, I could not find anything about this method in the resources of the page. A dead end. A bit disappointed I looked at the request again and, in a desperate attempt, I replaced the parameter hashedMediumId with just mediumId. Laughing at the idea this would ever work, I filled in a plain medium ID “35280208********” and pressed the return key. I could not believe what happened; the exact same result came back!
Let the fun begin
So now I found this endpoint I removed the cookie headers used before in my cURL request and tried again. The same result came back, meaning this request could be used to retrieve data from all possible card numbers out there.
In order to confirm this, I made myself a list of possible card numbers, all starting with 35280200. To rule out a lot of numbers, I checked the validation algorithm behind the OV-Chipkaart. Finding this algorithm wasn’t that hard since the JavaScript validation method in the forms was called luhnCheck. So the algorithm used is the Luhn algorithm, a modulo 10 check, also used with e.g. IMEI numbers and credit card numbers. To generate this list I created the following PHP snippet:
It resulted in a file of 170 MB with 10 million card numbers. Next I picked 5 random numbers;
rl -c 5 numbers.txt
… and tried them all of them with the endpoint. All requests were successful and returned the same data structure as I received with my own card number.
Impact
The impact of this publicly accessible endpoint is huge. I will give three scenarios, but there are a lot more use cases for what you could do with this data.
1. Querying the data of one specific OV-Chipkaart for every minute over a period of a month would give me a good insight into the daily schedule of its user. Calculating the difference of the card’s balance each time I query the data can give me an indication of where the user lives and traveling to since the fares for each route are pretty specific.
2. Fetching the data of all possible card numbers gives me a nice insight into the average user. I could make charts of the average age of all users. The average balance of all users. The average card type. The total amount of money still available on cards.
3. Using this form would give me the opportunity to reclaim the balance (max. € 20,-) of anonymous cards expired less than a year ago.
Responsible disclosure
After contacting TransLink BV and explaining to them what I had found they gave me the opportunity to advise them in possible solutions for the issue. The following days we kept in touch via mail and phone.
Within 7 days the issue was resolved. TransLink BV asked me to test everything again and confirm the fix.
In the end, I received a bounty of € 250,- with a corresponding letter from their CEO, they wanted to thank me for my help and making OV-Chipkaart.nl more safe for customers in the future.
I want to thank TransLink BV for their cooperation and giving me the opportunity to share this full disclosure with you!