Logomarka Tadas Varanauskas' story

How I forged the Lithuanian Vaccine Certificate


Short description

I was able to alter the data of the national vaccine certificate, to make it appear that an invalid certificate permits some exceptions for contact activities, making it possible to mislead certificate checkers. The issue has since been fixed.

Opportunity passport

The government of Lithuania has recently introduced an Opportunity Passport (National Certificate), designed to identify people (by vaccine, recovery or a negative test) that pose a low Covid-19 risk and allow higher risk activities like indoor seating at restaurants, attendance at bigger public and private events.

People who have already had Covid-19, been vaccinated or recently tested negative can receive a QR code that when scanned using the website displays if the national certificate is valid, and shows the full name with the year of birth of a person, that could be checked against an ID document.

After I found out that the validation requires no internet connection (article in Lithuanian) I was eager to figure out how it works.

Discovery and Exploitation

My first assumption was that all the data presented (full name, year of birth) has to be in the QR code itself. To my surprise, the example pictures presented in the usage instructions were scannable and presented information (no longer present information after the fix).

National Certificate instructions containing the QR code

By decoding the QR code myself all I found was an indecipherable ASCII string


Having confirmed that all of the data must be present in the QR code by verifying that the browser makes no network requests when validating, I was left with no other options other than applying some elbow grease and getting to work reverse-engineering the JavaScript on the website. Luckily, even though the code was minified, it was small enough to be examined quickly. Not long after I was able to determine the format of the contents of the QR code, and come up with a parser, after figuring out it was using base45 encoding.

function parse(content) { const payloadLength = parseInt(content.substr(0, content.indexOf("$"))); const encodedPayload = content.substr(content.indexOf("$") + 1, payloadLength); const encodedSignature = content.substr(content.indexOf("$") + 1 + payloadLength); return { payload: JSON.parse(decode(encodedPayload)), signature: decode(encodedSignature) }; }

The contents were in the format of payloadLength + '$' + encodedPayload + encodedSignature. Running the parser on the contents of the QR code yielded a lot more promising payload:

{ "fn": "Jonas", // First name "John" "ln": "Galioja", // Last name "Valid" "by": 1985, // Year of birth "vt": 1622186733468, // Validity timestamp in miliseconds "iss": 1621581933468, // Issuance timestamp in miliseconds "t": "g" // Unknown, possibly type? }

Knowing that the payload was cryptographically signed, I was sceptical about being able to change the payload. However, after doing a quick test by changing only the payload and regenerating the QR code I unfortunatelly was proven wrong.

Screenshot of the website showing tampered payload with fake data

After scanning the tampered certificate, the website shows that the certificate is invalid, however, it still shows the tampered data. Thus, I could generate a QR code for any invalid payload I wanted, with fake issuance and expiry dates.

function generate(payload) { const encodedPayload = encode(JSON.stringify(payload)); const fakeSignature = 'A'; return `${encodedPayload.length}$${encodedPayload}${fakeSignature}`; }

While this does not pose a big immediate risk, as the website displays the certificate is invalid, after some name manipulation I came up with a way to make a semi convincible certificate that says "Contact activities not allowed" but still permits some contact activities such as "indoor restaurants, indoor events up to 500 people" by adding that text to a new line after the name.

{ "fn": "JOHN", "ln": "DOE                                     Allowed contact activities: indoor restaurants, indoor events up to 500 people", "by": 2077, "vt": 1735693261000, "iss": 1577840461000, "t": "g" }
Screenshot of the website showing tampered payload with fake data that makes it appear like exceptions apply"

Using this tampered certificate I could convince a waiter at a restaurant that my certificate is valid for sitting inside, as it would state that on their device.

Luckily, the JavaScript displays the text in such a way that prevents injecting HTML tags or running arbitrary JavaScript, so modifying the text is the most I can do using this exploit, and Cross-Site Scripting is not possible.

Additionally, because the certificate validation is done offline, no user data leak is possible using this method. The system itself is easily scalable as backend servers are not burdened with certificate verification.

The fix is easily implementable, as signature validation is done correctly, thus if the signature is invalid, the website would not show the fake payload.

Disclosure and Fixes

As my footballer friend likes to say Lithuania is a land of brothers-in-law (Lietuva - švogerių kraštas), it is easy to get in touch with others because of our small population. I had little trouble informing both the State Centre of Registers and contacting the creators of the system Wix.com who have responded and fixed the issue in less than a day, but stated that this is not a security vulnerability as certificate validity cannot be tampered with.

I have discovered this issue on the evening of May 24th, 2021 and contacted Wix.com same day. Morning of May 25th, I was informed that the issue was being fixed, and by noon of the same day, I could confirm my exploit no longer works and has been fixed.

I am disclosing this publicly on May 26th, 2021.