Reversing SMART Health Cards

marcan2020
5 min readMay 20, 2021

--

TLDR: Data stored in the SMART Health Cards is digitally signed but it is not encrypted. Anyone who scan the QR code will be able to retrieve your full name, date of birth and information on your vaccination (including the date and location).

Some people in Quebec started receiving proof of vaccination against COVID-19 in the format of a QR code. There was not a huge amount of details on the code at the time of the writing.

https://twitter.com/mareMtl/status/1393264869726621696

So, is it a good idea to print your proof of vaccination on a T-shirt? I guess it’s time to analyse one.

High Level Analysis

Note: We will be analysing this fictive QR code in this post.

The first step is to read the data stored within the QR code (I used this online decoder, but you should use a local QR reader if you want to read your own code).

Decode QR Code

The decoded data starts with the prefix shc:/ followed by a blob of digits.

shc:/567629095243206034602924374044603122295953265460346029254077280433602870286471674522280928613331456437653141590640220306450459085643550341424541364037063665417137241236...

What does shc mean? Doing a simple google search for shc:/ QR code returns a known framework.

SMART Health Cards Framework

Analysing the SHC Framework

We can now analyse the SHC Framework on GitHub to try reading the blob of digits. We will focus on the index.ts file that can be used to generate a SMART Health Card.

Here’s a simplified version of the general flow to create QR code:

  1. Create a JWS payload with the patient information.
  2. Sign the JWS.
  3. Transform JWS into a numeric QR.
  4. Generate the QR code.

We will only focus on the step 2 and 3 since the others are implicit.

The signJws function will stringify, deflate and then sign the client data.

Looking at the RFC 7515 for the JWS Compact Serialization, it shows that there are three sections: header, payload and signature. Each section is base64url encoded but no other transformation (such as encryption) is done on the data.

https://datatracker.ietf.org/doc/html/rfc7515#section-3

The last step before generating the QR code is to transform the JWS to a numeric QR. ThetoNumeric function is a bit complicated but in the end, it only stores each character of the JWS in two-digits format and the prefix shc:/ with the chunk index. In our example, there’s only one chunk so it will return an array that looks like this:[{data:'shc'},{data:'567629095...'}].

There are two questions that remain in the aforementioned code:

  • Why are we subtracting 45 to the decimal value of each char?
  • What does .flatMap((c) => [Math.floor(c/10), c % 10]) do?

If we look at an example, the decimal value of the character z is 122. When subtracting 45, it will become 77. It’s now possible to store the value z on two digits instead of three. But why 45 and not 42? It could have been 42 and all the characters used after base64url encoding the JWS would have been stored on two digits. They used 45 because - is the smallest character used when encoding data with base64url.

The flatMap section is used to left pad the values under 10 with a 0, thus storing them also on two digits. Fun fact, if they chose to subtract the decimal value by 35 instead of 45, they could have removed this code.

Personally, I would have stored the JWS in the hexadecimal format but they must have their reasons. ¯\_(ツ)_/¯

Now that we know each step used to generate the QR code, we can write a decoder.

Writing a decoder

First, we need to revert the changes from the toNumericQr call. To do so, we will:

  1. Split all the digits in groups of two characters.
  2. Convert each group to an integer.
  3. Add 45 to retrieve to the original char code
  4. Cast it as a char.

Next, we can base64 decode all the parts of the JWS.

Finally, we can decompress the data section. Since deflateRaw was used to created raw data without a wrapper (header and adler32 crc) and the zlib module in Python does not support it, we need a workaround. Fortunately, it’s possible to pass the parameterwbits=-15 to emulate a raw decompression.

Data Analysis

Some interesting information can be found such as the full name and date of birth of the patient.

"entry": [{
"fullUrl": "resource:0",
"resource": {
"resourceType": "Patient",
"name": [{
"family": "Anyperson",
"given": [
"Johnathan",
"Biggleston III"
]
}],
"birthDate": "1951-01-20"
}
},

There’s also details on the vaccine received.

'vaccineCode': {
'coding': [{
'system': 'http://hl7.org/fhir/sid/cvx',
'code': '207'
}]
},
"occurrenceDateTime": "2021-01-29",
"performer": [{
"actor": {
"display": "ABC General Hospital"
}
}],
"lotNumber": "Lot #0000001"

Finally, the CVX code can then be used to find out which vaccine has been injected.

https://www.cdc.gov/vaccines/programs/iis/COVID-19-related-codes.html

Conclusion

Since there’s some personal information in the QR code, you should share it only with trusted entities. Also, I would avoid to print it on a T-shirt. 😉

I expect an official tool to be released soon but the full code shown in this post is available here: https://github.com/marcan2020/shc-decoder-poc.

Another project exists to decode an SHC: https://github.com/fproulx/shc-covid19-decoder.

Update: I just found out a notebook that explains the SHC Framework in depth: health-cards-walkthrough.

--

--

Responses (6)