Ibiyemi Abiodun

« main page

ShareBRB

Reverse-engineered meal plans

April 2020 to April 2021

pythonnodejswebpackreactkoareverse-engineering

At my school, we have a meal plan currency called Big Red Bucks (BRBs) that we can only spend at the cafés and convenience stores situated across the campus. They can’t be converted back into ordinary dollars or spent anywhere else, but more importantly, they can’t be transferred and they (usually) don’t roll over between school years.

This means that if you don’t frequent the type of cafés which consume BRBs, you might have hundreds left over that will simply be wasted, and if you go there too much, you’ll run out before the year is over. Tragically, I fall into the latter category; at the time of writing I have enough BRBs remaining for 2 or 3 meals, and a month remaining in the school year.

Due to the pandemic, the school implemented a contactless payment solution via the CBORD GET Mobile app, which allows us to pay for our food by scanning a barcode at the register, and having the BRBs automagically subtracted from our accounts.

The GET app barcode page. This isn't my real barcode, so don't even think
about
it.

The GET app is not great, in my opinion. It’s slow and clunky. But more importantly, in order for this barcode scheme to work, the barcode needs to contain information about who generated it. This means that if I could generate barcodes that appear to belong to my friends with hundreds of BRBs remaining (with permission), I could save those BRBs from going to waste, and I could save myself from being BRBroke.

Thus, I figured that I would take a crack at making my own barcode generator. How hard could it be?

Reverse-engineering the barcode

The first obstacle was to figure out how to read what the barcode says. A quick search revealed that the GET app generates PDF417 barcodes. I fired up Jupyter Notebook and downloaded a pip package called pdf417decoder. Then I generated some barcodes and ran them through it, and got outputs like these:

/image/projects/get-app-hack/Untitled%201.png

/image/projects/get-app-hack/Untitled%202.png

The first ten digits formed a number that seemed to increase every time I generated a new barcode. Indeed, they form a Unix timestamp: the number of seconds between the time the barcode was generated, and midnight on January 1,

/image/projects/get-app-hack/Untitled%203.png

The 11th digit was always 1, and the remaining digits had no obvious pattern to them. I was stumped. I asked two friends who were sitting next to me at the time to give me barcodes as well, and that didn’t make things any clearer:

/image/projects/get-app-hack/Untitled%204.png

I didn’t make any more progress that night. I wasn’t ready to give up, either, but no amount of XORing the numbers was giving me useful information.

In order for the register to subtract money from the correct person’s account, these strings must contain enough information to uniquely identify the person they belong to. I figured that I would make more progress by inspecting the GET app directly.

Spying on the GET app

APKLab is a gift from the heavens that takes a lot of the grunt work out of reverse-engineering Android apps. One of its most important inclusions is that it makes it effortless to patch apps so that they can be spied on using mitmproxy.

I downloaded the GET app into an Android emulator from the Google Play store, patched it using APKLab, reuploaded it to the emulator, and fired up mitmproxy:

These are all requests being made by the GET app to CBORD's
servers.

Again, I was hoping this would be straightforward. Hopefully, the GET app would make a simple request to its servers when a barcode is requested, and then I could generate my own barcodes by spoofing this request. Let’s try generating a barcode!

…and, nothing. The GET app makes four separate requests when I generate a barcode, but none of them contain information about the barcode. There’s a request for my ID photo, and some checks to make sure I have permission to generate barcodes, and that’s about it.

Reverse-engineering the GET app

In order to make more progress, I needed to understand how the app works. So I went back to VSCode and started digging around in the source code of the app. Here, I hit the jackpot. The GET app is written in JavaScript!

/image/projects/get-app-hack/Untitled%206.png

Indeed, the assets folder contains hundreds of JavaScript files, none of which are minified.

I zeroed in on a file named containers-scan-card-scan-card-module.js, where I found a pair of functions called beginBarcodeGeneration and createBarcodeString that contained basically everything I needed:

beginBarcodeGeneration(patronKey, institutionKey) {
    return tslib__WEBPACK_IMPORTED_MODULE_0__["__awaiter"](this, void 0, void 0, function* () {
        /// init variables
        const cryptoAlgorithm = 'HmacSHA256';
        const returnDigits = 9;
        const checksum = this.generateDigit(patronKey);
        const institutionKeyBytes = this.hexStringToByteArray(institutionKey);
        const patronKeyBytesPadded = this.patronKeyToByteArray(patronKey);
        const cbordKeyBytes = this.hexStringToByteArray(atob(this.garble));
        const sharedKey = this.XorEncrypt(cbordKeyBytes, institutionKeyBytes);

        const time = big_integer__WEBPACK_IMPORTED_MODULE_6__(Date.now()).divide(big_integer__WEBPACK_IMPORTED_MODULE_6__(1000));
        /// run totp algo
        const totp = yield this.generateTOTP(sharedKey, time, returnDigits, cryptoAlgorithm);
        const encryptedKey = this.XorEncrypt(patronKeyBytesPadded, this.longToByteArrayLittleEndian(big_integer__WEBPACK_IMPORTED_MODULE_6__(totp)));
        const barcodeValue = this.createBarcodeString(time, this.byteArrayToLongLittleEndian(encryptedKey), checksum);
        return barcodeValue;
    });
}

createBarcodeString(timestamp, encryptedKey, patronKeyChecksum) {
    const version = '1';
    const timeString = timestamp.toString().padStart(10, '0');
    const keyString = encryptedKey.toString().padStart(10, '0');
    return timeString + version + keyString + patronKeyChecksum.toString();
}

This was the missing information behind the last 12 digits of the numbers from the barcodes!

How the “encrypted” key is generated

First, the app takes the cbordKey, which is a series of bytes hardcoded into the app, and the institutionKey, which is a series of bytes specific to each school that is retrieved when you log into the app. Then it XORs them together to make a sharedKey.

The app then runs the sharedKey through the TOTP (time-based one time password) algorithm to generate a time-sensitive 9-digit encrypted key. Only someone with access to the same cbordKey and institutionKey (such as CBORD themselves) is capable of generating the same encrypted key or verifying that the encrypted key is valid.

More about TOTP

TOTP is the algorithm that powers many two-factor authentication schemes using an app like Authy or Google Authenticator, which constantly generates six-digit codes based on the current time and a secret known only to your phone and the service you’re signing into.

Authy, a popular 2FA TOTP
app.

Normally, TOTP provides a window of validity when the code can be used by dividing the current time in seconds by the size of the window and rounding down. As long as dividing the current time by 30 seconds or so gives the same number, you will get the same code. This is how it’s possible for you to get a code that matches the server’s code. However, the GET app sidesteps this by including an exact timestamp in the barcode instead.

Then the encrypted key is XORed with the patronKey to get the final number that is included in the barcode. An important property of the XOR operation is that if you take two numbers AA and BB and take A xor B=CA\textrm{ xor }B=C, then C xor A=BC\textrm{ xor }A=B. If I have the same encrypted key, then I can derive the patronKey from the barcode, and then I can plug that patronKey into a routine to generate as many new barcodes as I want.

How I got the keys

I inserted a bunch of console.log calls into the code, recompiled the app, and generated a new barcode. Voila:

containers-scan-card-scan-card-module.js - Line 1581 - Msg: institutionKey 54ca61b73a <redacted>
containers-scan-card-scan-card-module.js - Line 1582 - Msg: institutionKeyBytes  54 ca 61 b7 3a <redacted>
containers-scan-card-scan-card-module.js - Line 1583 - Msg: patronKey <redacted>
containers-scan-card-scan-card-module.js - Line 1584 - Msg: patronKeyBytesPadded  <redacted>
containers-scan-card-scan-card-module.js - Line 1585 - Msg: garble NTBGQ0RG <redacted>
containers-scan-card-scan-card-module.js - Line 1586 - Msg: cbordKeyBytes  50 fc df 3f e4 <redacted>
containers-scan-card-scan-card-module.js - Line 1587 - Msg: sharedKey  04 36 be 88 de <redacted>,

Testing it out

I wrote a routine in Python to put it all together:

import base64
import hmac
import struct
import sys
import time
from datetime import datetime
import pdf417

## adapted from https://github.com/susam/mintotp
def hotp(key, counter, digits=6, digest='sha1'):
    counter = struct.pack('>Q', counter)
    mac = hmac.new(key, counter, digest).digest()
    offset = mac[-1] & 0x0f
    binary = struct.unpack('>L', mac[offset:offset+4])[0] & 0x7fffffff
    return str(binary)[-digits:].zfill(digits)

def xor_encrypt(subject, key):
    out = bytearray(len(subject))
    for i in range(len(subject)):
        out[i] = subject[i] ^ key[i % len(key)]
    return out

## this is Luhn checksum, which is the checksum function used in the GET app
def luhn_generate(num, num_digits = None):
    digits = []
    
    while num > 0:
        digits.insert(0, num % 10)
        num = num // 10
        
    if num_digits is not None:
        while len(digits) < num_digits:
            digits.insert(0, 0)
            
    for i in range(1, len(digits), 2):
        digits[i] *= 2
        if digits[i] > 9:
            digits[i] -= 9
            
    return (sum(digits) * 9) % 10

def generate_get_barcode(img_path, new_timestamp = int(time.time())):
    img_data = Image.open(img_path)
    barcode_decoder = PDF417Decoder(img_data)
    
    print('decoded barcode: ', barcode_decoder.decode() > 0)
    
    barcode_data = barcode_decoder.binary_data_to_string(decoder6.barcode_binary_data)
    barcode_timestamp = int(barcode_data[:10])
    barcode_enc = int(barcode_data[11:21])
    barcode_enc_bytes = barcode_enc.to_bytes(4, byteorder = 'little')
    
    print('got timestamp: ', datetime.fromtimestamp(barcode_timestamp))
    print('got encrypted section: ', barcode_enc)
    
    cbord_key = '<redacted>'
    institution_key = '<redacted>'
    
    cbord_key_bytes = bytes.fromhex(base64.b64decode(cbord_key).decode('utf-8'))
    institution_key_bytes = bytes.fromhex(institution_key)
    shared_key_bytes = xor_encrypt(cbord_key_bytes, institution_key_bytes)
    
    old_barcode_otc = int(hotp(shared_key_bytes, barcode_timestamp, 9, 'sha256'))
    old_barcode_otc_bytes = old_barcode_otc.to_bytes(4, byteorder = 'little')
    
    patron_key_bytes = xor_encrypt(barcode_enc_bytes, old_barcode_otc_bytes)
    print('derived patron key: ', patron_key_bytes.hex())
    
    patron_key = int.from_bytes(patron_key_bytes, byteorder = 'little', signed = False)
    
    checksum_digit = luhn_generate(patron_key)
    if checksum_digit == int(barcode_data[21]):
        print('checksum verified')
    
    new_barcode_otc = int(hotp(shared_key_bytes, new_timestamp, 9, 'sha256'))
    new_barcode_otc_bytes = new_barcode_otc.to_bytes(4, byteorder = 'little')
    
    new_enc_bytes = xor_encrypt(patron_key_bytes, new_barcode_otc_bytes)
    new_enc = int.from_bytes(new_enc_bytes, byteorder = 'little', signed = False)
    
    print('generated new encrypted section: ', new_enc)
    
    new_data = str(new_timestamp).zfill(10) + '1' + str(new_enc).zfill(10) + str(checksum_digit)
    
    print('generated new barcode data: ', new_data)
    
    new_barcode_codes = pdf417.encode(new_data, columns = 2)
    new_barcode_image = pdf417.render_image(new_barcode_codes)
    new_barcode_image.show()

And I had a new barcode that looked legit. I sent that barcode to my phone, and then I used it to buy a muffin.

What’s next?

The whole point of this was to allow me to be generous and help my friends spend their BRBs that would otherwise be wasted 😌

So I have constructed a website, sharebrb.dev, that automates this whole process. Now I have access to $700 worth of BRBs, and you can too, if you have friends who’ll share.