michael orlitzky

CharmBypass pt. 2: analysis

posted 2023-11-08; updated 2023-11-13

CharmPass is how you pay for mass transit in Maryland. I recently introduced a project called CharmBypass that lets you ride for free. Now I’m going to talk about the security features in CharmPass, and how CharmBypass gets around them.

The big question is, how does mobile ticketing work? On the surface, it’s straightforward: you buy a ticket online; then later when you intend to use it, there’s some other system that validates your ticket. It only gets complicated when you actually think about it.

A mobile ticketing system has a lot of IRL requirements. For example, the easiest way to validate a rider’s ticket would be to query the server where it was purchased. But if the system performing that validation is a kiosk on a bus, it may not have a reliable internet connection. And the more complicated your solution is, the less qualified the bus driver or fare inspector will be to deal with any problems that arise.

The CharmPass app was developed by a company called moovel. The app itself is surprisingly nice, and is basically a Maryland-themed variant of the company’s core mobile-ticketing product. Several mobile ticketing patents have been granted to moovel that describe how their system works, and in particular how it meets IRL requirements. The most general is US Patent #9,881,260 B2, approved in 2018, and appropriately titled Mobile ticketing. But much shorter and more relevant for us is US Patent #11,030,612 B2, Method and system for dynamically interactive visually validated mobile ticketing, which describes how CharmPass tickets are validated. Its summary is worth quoting:

Over time, physical tickets have evolved to contain additional security features… But this type of validation can be time consuming, cumbersome, and challenging to implement in many scenarios where timeliness is critical. For instance, in the context of public transit systems, ticketing for movies, shows, sporting events, or event parking systems, it is preferred that the validation procedure be less complicated and faster for consumers.


let’s fucking goooooooooooooooooooooooooooooo

the app

We’ll be looking at the android app. If you don’t have/want a Google account, you can still obtain a copy of the APK from a site like APKPure or apkmonk. CharmPass, like most android apps, is written in Kotlin and can be decompiled to Java using jadx:

user $ jadx charmpass.apk

INFO - loading ...

INFO - processing ...

ERROR - finished with errors, count: 14

You should wind up with two directories, charmpass/resources and charmpass/sources containing the resources and source code for CharmPass, respectively.

security features

There are several security features present on a CharmPass ticket, but most of them can be summarized (as in Patent #11,030,612 B2) as visually-validated items of interactive digital content. The QR code is the one exception.

Custom fonts

Since we just decompiled the APK, the first security feature worth mentioning is the font. CharmPass uses a custom font called MoovelAkkurat that you can find among the recently-decompiled APK resources:

user $ ls -1 charmpass/resources/assets/fonts/








If we want CharmBypass to look like CharmPass, we need to imitate those fonts. But we can’t just copy them without violating somebody’s copyright. Fortunately there’s a loophole: in the United States, a font is eligible for copyright, but the underlying typeface is not. That means that we can legally copy the shapes of the letters, so long as we don’t copy any other information from the font.

This is easy enough to do. OpenType Font (OTF) is basically a vector drawing format, and you can use your favorite drawing program to export their letterforms. Once you have those, it’s easy to make a new font. I used FontForge to create two new CharmBypass-Regular and CharmBypass-Bold fonts for the typefaces in MoovelAkkurat-Regular and MoovelAkkurat-Bold. The source files are included with the project.

Service name

There are three named services from which you can choose when you buy a ticket, and your choice is displayed on your ticket in MoovelAkkurat-Regular:

CharmBypass screenshot with the service name highlighted Service name

The three services are,

and it should come as no surprise that CharmBypass gives you the same three choices. Whatever you pick, we write it on the ticket in CharmBypass-Regular.

Service identifier

To each service name there is associated a one-letter identifier, shown on your ticket in large MoovelAkkurat-Bold type:

CharmBypass screenshot with the service identifier highlighted Service identifier

The correspondence between service names and identifiers is uncomplicated:

BaltimoreLink (local bus, light rail, and metro)
Commuter Bus and MARC Train

Since the identifiers never change, in CharmBypass we simply display the correct one for your ticket in CharmBypass-Bold.

Service zone

Commuter Bus and MARC Train tickets have an additional piece of data not present on BaltimoreLink tickets called a zone. A zone is basically a group of destinations used to simplify the fee structure. On the Commuter Bus, a zone refers to a portion of the route. Longer trips (larger portions of the route) cost more than shorter ones.

CharmBypass screenshot with the Commuter Bus zone highlighted Commuter Bus zone

You are supposed to know which zone you belong to when you buy a Commuter Bus ticket. The information is buried in the PDF copy of your Commuter Bus schedule; for example the 210 Commuter Bus from Baltimore to Kent Island:

Zone 2
From Kent Island to Annapolis
Zone 3
From Annapolis to Baltimore
Zone 4
From Kent Island to Baltimore

(If you’re wondering why they start counting at two, it’s because the five zone names are used consistently across all of the commuter bus routes, always with the same fare.)

Curiously, the zones on the MARC Train tickets are named Ecks Zone instead of Zone X:

CharmBypass screenshot with the MARC Train zone highlighted MARC Train zone

As far as I can tell, the meaning of the zones on MARC Train tickets is not public information. Regardless, each origin-destination pair (discussed in the next section) has an associated price in CharmPass, and I think those prices map directly to zones:

One Zone
Two Zone
Three Zone
Four Zone

To find your MARC Train zone, you would look up the purchase price of your ticket in CharmPass and then use the mapping above to find the corresponding entry. But thankfully, CharmBypass does that for you. CharmBypass maintains a precomputed list of all valid origin-destination pairs, each associated with the correct zone. When you choose your origin and destination, the zone is computed automatically.

Service origin/destination

MARC Train tickets contain yet more data than Commuter Bus tickets. The MARC Train costs more the longer you ride it, so your ticket has to contain your origin and destination; otherwise, you could pay for a short trip and take a long one. Technically, if I’m right about the zone, then the origin and destination are redundant—zones map bijectively onto ticket prices. But I strongly suspect that no one in real life has a clue what the zones mean. And in that case the origin and destination are valuable to the ticket-takers.

CharmBypass screenshot with the origin and destination highlighted Origin and destination

The → is a unicode Rightwards Arrow (U+2192) typeset in MoovelAkkurat-Regular, so we have to include it in our CharmBypass-Regular font. Aside from that, the origin and destination are trivial to print on the ticket. The only caveat is that we have to know the abbreviations used for the various stops. Here’ the list so far; email me if you know more:

Baltimore/Penn (Penn Line)
Baltimore/Camden (Camden Line)
Bowie State (Penn Line)
BWI Airport (Penn Line)
College Park (Camden Line)
Halethorpe (Penn Line)
Seabrook (Penn Line)
Washington D.C.
West Baltimore (Penn Line)

Ticket size

Looking at the screenshots, you’ll see that the ticket sizes vary for the three services. BaltimoreLink tickets are small, Commuter Bus tickets are medium, and MARC Train tickets are large.

Having three separate tickets in the DOM and keeping track of which is active would be distasteful, so instead, CharmBypass remembers the transformations needed to turn a BaltimoreLink ticket into the other two. BaltimoreLink is then the only ticket that lives in the document. There’s no good way to discern the transformations programmatically; I had to draw all three tickets in Inkscape and compare.

Expiration date/time

At the top of your ticket, typeset in MoovelAkkurat-Regular, are an expiration date and time. This is a security feature in the sense that it prevents you from using a one-way ticket to ride all day. The date and time are also animated to help the driver or fare inspector identify fakes: for the first ten minutes or so, they blink. Afterwards, they remain solid black until the ticket expires.

The length of time that your ticket is valid for depends on the service name:

In CharmBypass, only one-way tickets are implemented because who cares because they’re free. As a result, when the ticket loads, all we have to do is set the expiration date/time to either 10 or 90 minutes in the future using our CharmBypass-Regular font based on the service name. The blinking behavior is a CSS animation whose duration is set to ten minutes.

Scene animation

The most obvious security feature in CharmPass is the background animation that plays behind your ticket. According to our favorite patent, this animation is meant “to be shown to a ticket-taker, or inspector” and is intended to be so complicated that “upon viewing the display, the ticket-taker can discern that the mobile device user has already purchased a ticket.”

This animated scene is downloaded from the server rather than bundled with the CharmPass app, so you won’t find it in your decompiled charmpass/resources directory. But if you look in charmpass/sources, the bit that handles the download can be found in com/moovel/ticketing/network/NoAuthAgencyMetadataService.java:

interface NoAuthAgencyMetadataService {
    "Content-Type: application/json",
    "Accept: application/json"
      @Body TicketAnimationRequest ticketAnimationRequest

That would be a significant hurdle, except that the downloaded animation never changes. The security provided by the animation therefore resides solely in how unlikely it is that some asshole would spend a week painstakingly recreating it from scratch.

So anyway, that’s what I did. CharmBypass includes my own drawing of the background scene, constructed in Inkscape and exported as an SVG. It’s included into an HTML5 document where it’s animated with CSS. To avoid copyright issues, my copy isn’t actually a copy. But given that the goal of the system is to make “the validation procedure be less complicated and faster,” it’ll do.

Day/night toggle

When you tap the screen in CharmPass, the animated scene switches from day to night and back. Patent #11,030,612 says that the validation procedure should “incorporate interactive digital content to further ensure that the ticket being validated is authentic.” In the case of CharmPass, the idea is that only an authentic ticket will respond to the screen being tapped.

CharmBypass screenshots in both day and night mode Day/night toggle

In CharmBypass, the animated scene is an SVG image embedded within an HTML5 document that retains all of its layer and opacity information. Switching from day to night involves nothing more than changing the sky layer’s sky.style.fill value. The switch is triggered by a document.body.onclick (screen tap) event handler.

QR Code

The QR code is the one real security feature in CharmPass. Regardless of how it’s actually implemented, it’s not hard to imagine the ticket server encrypting some data at purchase time and QR-encoding it to be stored along with your ticket. Your ticket would then be validated if the QR scanner on the bus can decrypt it, the keys having been shared beforehand. There are plenty of details to worry about in that scenario (can I get the decryption keys by stealing a bus?) but it’s at least in the ballpark.

CharmBypass screenshot with the QR code highlighted QR code

But how is it actually implemented? Returning to our decompiled Java sources in charmpass/sources, we can trace the execution backwards from the point where it generates the QR code to see what the code contains. The bitmap is ultimately encoded in the file com/moovel/rider/ticketing/flash/QRCodeEncoder.java:

From now on, I’m heavily editing the decompiled sources for readability.

class QRCodeEncoder {
  Bitmap encodeAsBitmap() {
    MultiFormatWriter multiFormatWriter = new MultiFormatWriter();
    String str = this.contents;
    BarcodeFormat barcodeFormat = this.format;
    int i = this.dimension;
    BitMatrix result = multiFormatWriter.encode(
      i, i,
    // Edit: pixel-setting logic removed

The important part here is the multiFormatWriter.encode(str, …) call, because that’s where the data to be QR-encoded is coming from. The encode() method is being passed some string data, namely str = this.contents, where this is the current QRCodeEncoder object. So what we want to find is where this.contents gets set. Spoiler alert, it’s in com/moovel/rider/ticketing/flash/FlashPassPresenter.java:

void generateQrCodeBitmapForTicketPayload(String str, ...) {
  void subscribe(MaybeEmitter<Bitmap> emitter) {
    Bitmap encodeAsBitmap = new QRCodeEncoder(
      Math.min(point.x, point.y)

This gets us one step closer. The str variable—which, apparently, contains the data—was passed in as an argument to generateQrCodeBitmapForTicketPayload(), so now we have to find out where that was called from. There’s lots of voodoo at this point, but the trail continues in the same file:

void generateTicketPayloadAndQrBitmap(List<TicketDataModel> list,
                                      ...) {
  TicketPayloadFactory ticketPayloadFactory;
  ticketPayloadFactory = FlashPassPresenter.this.ticketPayloadFactory;
  List<TicketDataModel> list2 = list;
  List<TicketLayoutConfig> ticketLayoutConfigs =
  String payload = ticketPayloadFactory.generatePayload(
  subscriptions.add(payload)...subscribe(new Consumer<String>() {
    void accept(String ticketPayload) {
      FlashPassPresenter flashPassPresenter = FlashPassPresenter.this;

So after a fashion, we see that the QR code is encoding a string called the “ticket payload.” Now if that wasn’t complicated enough for you, you’re in luck, because it turns out that there are two different versions of the ticket payload format called V3 and V4. How do we know which we’ve got? Let’s start decoding some QR codes.

In the example below, I’ve taken screenshots of the QR codes on each of the three CharmPass ticket types and have named them accordingly: QR-BaltimoreLink.png, QR-CommuterBus.png, and QR-MARCTrain.png. We’ll be using zbar, pyzbar, and pillow to decode them.

>>> from pyzbar.pyzbar import decode
>>> from PIL import Image
>>> bl = decode(Image.open('QR-BaltimoreLink.png'))
>>> cb = decode(Image.open('QR-CommuterBus.png'))
>>> mt = decode(Image.open('QR-MARCTrain.png'))
>>> bl[0].data
>>> cb[0].data
>>> mt[0].data

We first notice that there are 65 ASCII characters at the front of each string before it gets wacky, and that the first character is always “t”. Then, because we’re good at computers, we observe that 65 is 1 + 64. Is that a “t” followed by a 64-character hexadecimal string? You betcha. If you look in com/moovel/ticketing/model/V3Barcode.java you can see where the “t” comes from:

class V3Barcode {
  String toString() {
    StringBuilder sb = new StringBuilder();
    byte[] bArr = this.payload;
    Charset forName = Charset.forName("ISO-8859-1");
    sb.append(new String(bArr, forName));
    return sb.toString();

So we’ve got a V3 payload. And now that we know, we can investigate the signature:

class V3TicketPayloadGenerator {
  String generateV3PayloadSignature(ByteString byteString,
                                    List<TicketDataModel> list) {
    StringBuilder sb = new StringBuilder();
    Instant validFromDateTime = list.get(0).getValidFromDateTime();
        validFromDateTime != null ? validFromDateTime.getEpochSecond()
                                  : 0L
    ByteString key = ByteString.Companion.encodeUtf8(sb.toString());
    return byteString.hmacSha256(key).hex();

That 64-character hex string? It’s the HMACSHA256 of whatever string is passed to the method above, and it’s using the validFromDateTime of the first ticket (appended to the string “B6”) as a key. It turns out that what’s being passed to the method is just the payload data. This is another method on the same class:

String generatePayload(List<TicketDataModel> tickets, long j) {
  ByteString data = packQrCodeData(addHeaderToQrCodeData(tickets,j));
  String signature = generateV3PayloadSignature(data, tickets);
  return new V3Barcode(signature, data.toByteArray()).toString();

Looks reasonable; the payload is constructed from some “QR code data,” and (modulo details) a hash of that data. So what’s in the data? Same file, same class:

ByteString packQrCodeData(QrCodeDataV3 qrCodeDataV3) {
  MoshiPack moshiPack = new MoshiPack(null, null, 3, null);
  MoshiPack.Companion companion = MoshiPack.Companion;
  Moshi moshi = moshiPack.getMoshi();
  Buffer buffer = new Buffer();
    (JsonWriter) new MsgpackWriter(buffer),
    (MsgpackWriter) qrCodeDataV3

  return buffer.readByteString();

MoshiPack is a library that serializes Java objects using MessagePack. So this is essentially a binary dump of the QrCodeDataV3 object. What do those contain? Over to com/moovel/ticketing/model/QrCodeDataV3.java:

public class QrCodeDataV3 {
  long dt;
  String id;
  Long phdt;
  List<TicketDescription> t;
  String ver;

Do those look familiar to you? They look familiar to me. Look closely at the wacky parts of that QR data we decoded earlier. For example, after cutting off the first 65 characters in the BaltimoreLink example,

>>> bl[0].data[65:]

You can see the ver, id, dt, and phdt fields right there. There are some other fields, too, but they all come from the (singleton) list of TicketDescription objects, each of which brings its own fields. According to com/moovel/ticketing/model/TicketDescription.java,

class TicketDescription {
  Long T;
  Float[] l;
  String line;
  String off;
  String on;
  Long p;
  String sig;
  Long sigT;
  long t;

You can see those pretty clearly in the binary data, too. And that’s it. That’s everything in the QR code. If you really want to, you can unpack the MessagePack format in your head to see exactly what values are stored, but it would make more sense to write a small MoshiPack program to do it for you.

What you should take home from this is that the QR codes themselves are not encrypted. How about the individual fields though? If you follow the calls to QrCodeDataV3() and TicketDescription.create(), you’ll eventually find where their contents arise in com/moovel/ticketing/payload/V3TicketPayloadGenerator.java:

addHeaderToQrCodeData(List<TicketDataModel> list, long j) {
  return new QrCodeDataV3(
    null,                                                        // ver
    this.appIdManager.getAppId(),                                // id
    TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()), // dt
    Long.valueOf(j),                                             // phdt

getTicketDescriptions$ticketing_release(List<TicketDataModel> tickets) {
  TicketDescription.Companion companion = TicketDescription.Companion;
  String productId = ticketDataModel.getProductId();
  Long valueOf = 0L;
  if (productId != null) {
    valueOf = Long.valueOf(safeToLong(productId));
  String id = ticketDataModel.getId();
  long safeToLong = id != null ? safeToLong(id) : 0L;
  Instant validFromDateTime = ticketDataModel.getValidFromDateTime();
  if (validFromDateTime == null) {
    validFromDateTime = Instant.ofEpochSecond(0L);
  Long valueOf2 = Long.valueOf(validFromDateTime.getEpochSecond());
  float latitude = lastKnownLocation.getLatitude();
  float longitude = lastKnownLocation.getLongitude();
  String signature = ticketDataModel.getSignature();
  Instant signatureDateTime = ticketDataModel.getSignatureDateTime();
  if (signatureDateTime == null) {
    signatureDateTime = Instant.ofEpochSecond(0L);
  create = companion.create(
    valueOf,                                           // p
    safeToLong,                                        // t
    valueOf2,                                          // T
    latitude,                                          // l[0]
    longitude,                                         // l[1]
    (r26 & 32) != 0 ? "R" : null,                      // line
    (r26 & 64) != 0 ? "01" : null,                     // on
    (r26 & 128) != 0 ? "05" : null,                    // off
    signature,                                         // sig
    Long.valueOf(signatureDateTime.getEpochSecond())   // sigT

Oh, honey. The line, origin, and destination are hard-coded to R, 01, and 05, respectively. Ain’t nobody validating those. Tracing the long j argument back to the FlashPassPresenter class, we see that it’s just a long-int representation of the “phone home” date/time. Of the remaining fields, the only two that look like they might be hard to fake are the TicketDataModel id and signature. However, those fields are stored in the “t” and “sig” fields of the TicketDataModel. And if you go back and look at our QR dumps

  1. the t value doesn’t change
  2. there is no sig value
  3. the signature timestamp sigT is always null

The whole thing is bullshit.

Anyway, now that we know how the QR code is actually implemented, let me tell you how it’s actually actually implemented: it isn’t. No gates, operators, or fare inspectors in Maryland have the equipment to read—much less validate—a QR code. Back in 2021, the Board of Public works approved a plan to overhaul the MTA’s fare-collection system. But it’s not supposed to be finished until 2025, and this is the government, so. In the meantime, nobody can read the QR code. It might as well say Dicks Out for Harambe.

In CharmBypass, there’s a QR code, and maybe that’s what it says.

Daily security code

Finally, the one security measure you do have to think about. The daily security code:

CharmBypass screenshot with the daily security code highlighted Daily security code

The daily security code is retrieved from the network just like the animation is, in com/moovel/ticketing/network/NoAuthAgencyMetadataService.java:

      "Content-Type: application/json",
      "Accept: application/json"
        @Body GetSecurityCodesRequest getSecurityCodesRequest

The code changes every day at 3am, but is the same on every ticket and every phone, all day long. (Source: bought a bunch of tickets on a bunch of phones on a bunch of days.) The risk with the daily security code is that the driver or fare inspector might know it. The MTA could disseminate the code at the beginning of each day, but I think it’s more likely that after seeing the same code 2387612 times, the few that are different stand out to the person checking them.

It does happen. You will sometimes get caught trying to use the wrong code. There are however no real consequences beyond embarrassment; you get a do-over. In Part 1 of this article, I listed a few ways to obtain the code. If you know the code, CharmBypass lets you provide it. If instead you’re feeling lucky, it generates a random code.