Reverse engineering of a QR code for proof of vaccination

image

When Quebec announced that it would be sending out vaccination confirmation emails to everyone who was vaccinated using the attached QR code, my knees buckled a bit. I was eager to take it apart and shake my head at the amount of private health information that will no doubt be revealed in the process.

My vaccination confirmation has finally arrived, and the result is … not bad at all. However, there is always some fun in zero knowledge hacks, so I decided to blog about my experience anyway.

My first impression was, “Oh my God, this is an unnecessarily large QR code.” There is not much information listed under the QR code, so they probably encrypt all kinds of personal information without my knowledge. You know how that barcode on the back of your driving license

Naturally, the first thing I did was scan the code using the QRcode app.

Spoiler header
shc: /

Interesting. I was expecting a lot of JSON, but something else is going on here. The giant wall of numbers seems inefficient compared to base64 encoding, but they were able to cram everything into one QR code.

Unfortunately, this is where the zero knowledge part of the process ends, because I have a pretty clear indicator of where to go next: the URI scheme. Clearly this is meant to communicate with some application on the device of the person verifying the code that will register to handle this schema shc :… But what is this scheme?

A little search led me to the diagrams IANA’s Big Book O ‘URI Schemeswhere shc listed as pre-registered under the name SMART Health Cards Framework. So it’s not just something that the Quebec government came up with on the go, it’s actually part of a real project! This is encouraging and unexpected.

It turns out that this format has extensive documentation and very reasonable design goalswhich I find both a relief for the holder of such code and a little frustrating when someone is about to parse it out in full. But it doesn’t matter! I have some code and a document to follow, so let’s remove the lid and take a look inside.

According to the doc, using numeric mode to encode QR code data provides slightly higher data density than using binary mode, which explains the giant URI of numbers rather than a more sensible base64 encoded string. The first riddle is solved.

The long string of numbers appears to be encoded from an ASCII string, where each pair of digits represents a single character code in base 10. To further confuse, the output is computed using Ord © -45… It’s time to write a script to reverse this process.

php -r '$o = ""; foreach (str_split(preg_replace("/[^0-9]/", "", file_get_contents("php://stdin")), 2) as $c) $o .= chr($c + 45); echo $o;' <input.txt | xxd

00000000: 6579 4a72 6157 5169 4f69 4a73 4d33 6c79  eyJraWQiOiJsM3ly
00000010: 5254 4632 526a 646d 6157 5270 6257 5649  RTF2RjdmaWRpbWVI
...
000003b0: 3561 6876 5265 336d 6368 7335 7836 4e49  5ahvRe3mchs5x6NI
000003c0: 4669 3556 5277                           Fi5VRw

Several things can be learned from this. First, it’s obvious that PHP is still my fast programming language. Sadly, we will put this personal revelation aside for further introspection.

From a technical point of view, everything now looks like base64 encoded strings. And of course the doc tells me that I should be looking at JWS, that is, a JSON signed web token.

I will pause and say that this is actually a great use case for JWT. Basically, instead of some meaningless token or giant block of sensitive data, the JWT concept implies that I should expect a list of permissions to which I am entitled, wrapped in a blob that is cryptographically signed by the issuer (in this case, Quebec Santé et Services sociaux).

The good thing about this model is that it can be verified by anyone with the corresponding public key, even without an Internet connection. In addition, the answer to the question “does this person have the right to board an aircraft / attend a concert / visit a residence for the elderly?” should respond directly to built-in, not implicitly implied through a proprietary API or a bunch of secret fields related to vaccine lot numbers, etc.

Now I don’t have a copy of the corresponding public key, but the body must be signed, not encrypted, so I can still read it.

Perhaps, in the spirit of reverse engineering, I should manually disassemble the JWS, but this is a fairly well-documented (and importantly, well-implemented) specification. I’m going to go to lazy exit and use the Composer package for that web-token / jwt-framework

$ composer require web-token/jwt-framework
<?php
require_once(__DIR__.'/vendor/autoload.php');

use JoseComponentSignatureSerializerJWSSerializerManager;
use JoseComponentSignatureSerializerCompactSerializer;

$serializerManager = new JWSSerializerManager([
    new CompactSerializer(),
]);

$input_raw = file_get_contents('php://stdin');
$input_token = implode(
    array_map(
        function ($ord) { return chr($ord + 45); },
        str_split(preg_replace('/[^0-9]+/', '', $input_raw), 2)
    )
);

$jws = $serializerManager->unserialize($input_token);
var_dump($jws);

$ cat input.txt | php parse.php
object(JoseComponentSignatureJWS)#5 (4) {
  ["isPayloadDetached":"JoseComponentSignatureJWS":private]=>
  bool(false)
  ["encodedPayload":"JoseComponentSignatureJWS":private]=>
  string(772) "hVNhb9..."
  ["signatures":"JoseComponentSignatureJWS":private]=>
  array(1) {
    [0]=>
    object(JoseComponentSignatureSignature)#6 (4) {
      ["encodedProtectedHeader":"JoseComponentSignatureSignature":private]=>
      string(106) "eyJraW..."
      ["protectedHeader":"JoseComponentSignatureSignature":private]=>
      array(3) {
        ["kid"]=>
        string(43) "l3yrE1..."
        ["zip"]=>
        string(3) "DEF"
        ["alg"]=>
        string(5) "ES256"
      }
      ["header":"JoseComponentSignatureSignature":private]=>
      array(0) {
      }
      ["signature":"JoseComponentSignatureSignature":private]=>
      string(64) "�Q�..."
    }
  }
  ["payload":"JoseComponentSignatureJWS":private]=>
  string(579) "�Sao..."
}

So, we successfully decode the header, but no body arrives. The hint here is “zip”: “DEF” in the header, as also stated in the spec.

the payload is compressed using the DEFLATE algorithm (see RFC1951) before signing (note, this must be raw DEFLATE compression, without any zlib or gz headers

Let’s try:

echo json_encode(json_decode(gzinflate($jws->getPayload())), JSON_PRETTY_PRINT);

NB: we decode and then recode the JSON object to add white space for readability by specifying the JSON_PRETTY_PRINT constant

{
    "iss": "https://covid19.quebec.ca/PreuveVaccinaleApi/issuer",
    "iat": 1621476457,
    "vc": {
        "@context": [
            "https://www.w3.org/2018/credentials/v1"
        ],
        "type": [
            "VerifiableCredential",
            "https://smarthealth.cards#health-card",
            "https://smarthealth.cards#immunization",
            "https://smarthealth.cards#covid19"
        ],
        "credentialSubject": {
            "fhirVersion": "1.0.2",
            "fhirBundle": {
                "resourceType": "Bundle",
                "type": "Collection",
                "entry": [
                    {
                        "resource": {
                            "resourceType": "Patient",
                            "name": [
                                {
                                    "family": [
                                        "Paulson"
                                    ],
                                    "given": [
                                        "Mikkel"
                                    ]
                                }
                            ],
                            "birthDate": "1987-xx-xx",
                            "gender": "Male"
                        }
                    },
                    {
                        "resource": {
                            "resourceType": "Immunization",
                            "vaccineCode": {
                                "coding": [
                                    {
                                        "system": "http://hl7.org/fhir/sid/cvx",
                                        "code": "208"
                                    }
                                ]
                            },
                            "patient": {
                                "reference": "resource:0"
                            },
                            "lotNumber": "xxxxxx",
                            "status": "Completed",
                            "occurrenceDateTime": "2021-xx-xxT04:00:00+00:00",
                            "location": {
                                "reference": "resource:0",
                                "display": "xxxxxxxxxxxxxxxxxx"
                            },
                            "protocolApplied": {
                                "doseNumber": 1,
                                "targetDisease": {
                                    "coding": [
                                        {
                                            "system": "http://browser.ihtsdotools.org/?perspective=full&conceptId1=840536004",
                                            "code": "840536004"
                                        }
                                    ]
                                }
                            },
                            "note": [
                                {
                                    "text": "PB COVID-19"
                                }
                            ]
                        }
                    }
                ]
            }
        }
    }
}

There’s a bit more personal information in there than is strictly necessary, although I believe that combining name and date of birth with photo ID is a sensible process. They also provide specific information about vaccines rather than specific approvals as I was hoping. Again, this makes it all the more usable across jurisdictions and eliminates the need to re-release the JWS every time the policy changes, which in the case of Quebec happens about twice a week.

Throughout this analysis, I have wondered what might prevent someone from simply presenting perfectly valid proof of another person’s vaccination. Since the entire body is cryptographically signed, you cannot modify someone else’s vaccination proof to add your name, which means combining the proof of vaccination with a photo ID is a perfectly reasonable plan. This will certainly be the case at airports, but I highly doubt that at sports venues, etc. E. Will ask for a second ID. They will simply scan the QR code, see a checkmark on their device, and move on to the next one.

One parting thought: While my process was geared towards figuring out which of my personal data is encoded in the QR code, the JWT model is notorious for being easy to mess up by either forgetting to check before parsing the data or allowing unsigned tokens… If implementations don’t respect a central whitelist of authorized signers, it would be trivially easy to create a perfectly valid token that you sign with your own key. As always, the security of the model really depends on how rigorously the relying party enforces the standard.

However, it turns out that the only personal information is exactly the information that is contained in the full PDF document about vaccinations: name, date of birth, gender (for some reason), as well as information about the date and specific doses that the owner received on present day. Once you are comfortable with the privacy implications of presenting your driver’s license at a bar, you no longer have to worry about being asked to show proof of vaccination.

The code is a whole bunch of junk, but if you want to see what’s in your own QR code, you can check github repository for this post.

Similar Posts

Leave a Reply Cancel reply