posted 2023-11-08; updated 2024-02-02
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.
moovel
let’s fucking goooooooooooooooooooooooooooooo
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.
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.
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/
MoovelAkkurat-Bold.otf
MoovelAkkurat-BoldItalic.otf
MoovelAkkurat-Italic.otf
MoovelAkkurat-Light.otf
MoovelAkkurat-LightItalic.otf
MoovelAkkurat-Regular.otf<
moovel-GDL.ttf
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.
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:
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.
To each service name there is associated a one-letter identifier, shown on your ticket in large MoovelAkkurat-Bold type:
The correspondence between service names and identifiers is uncomplicated:
Since the identifiers never change, in CharmBypass we simply display the correct one for your ticket in CharmBypass-Bold.
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.
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:
(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:
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:
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.
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.
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:
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.
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.
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 {
...
@Headers({
"Content-Type: application/json",
"Accept: application/json"
})
@POST(TopLevelFunctions.VOICE_PAUSE)
Call<DataResponse<ContentResponse<TicketAnimationResponse>>>
getTicketAnimation(
@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.
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.
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.
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.
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(
str,
barcodeFormat,
i, i,
enumMap
);
// 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(
str,
"",
Math.min(point.x, point.y)
).encodeAsBitmap();
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 =
FlashPassPresenter...getRiderApp().getTicketLayoutConfigs();
String payload = ticketPayloadFactory.generatePayload(
list2,
ticketLayoutConfigs,
phoneHomeTime.longValue()
);
subscriptions.add(payload)...subscribe(new Consumer<String>() {
void accept(String ticketPayload) {
FlashPassPresenter flashPassPresenter = FlashPassPresenter.this;
flashPassPresenter.generateQrCodeBitmapForTicketPayload(
ticketPayload,
point,
function1
);
}
}
}
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
b't18e72b9fee29f370fe2d15b91a0ded583ad759f5835bc52ffd9f89412feb54d0\xc2\x85\xc2\xa3ver\xc2\xa3t-3\xc2\xa2id\xc3\x99$af002d15-2493-4e8f-bcd1-6a03505ba3fc\xc2\xa2dt\xc3\x8f\x00\x00\x00\x00eA4\xc3\x80\xc2\xa4phdt\xc3\x8f\x00\x00\x00\x00eA4\xc3\x80\xc2\xa1t\xc2\x91\xc2\x88\xc2\xa1p\x01\xc2\xa1t\xc3\x8e\x00\xc3\x945A\xc2\xa1T\xc3\x8f\x00\x00\x00\x00eA4\xc3\x80\xc2\xa1l\xc2\x92\x00\x00\xc2\xa4line\xc2\xa1R\xc2\xa2on\xc2\xa201\xc2\xa3off\xc2\xa205\xc2\xa4sigT\x00'
>>> cb[0].data
b"tfb5f00d62b7268c136a98b9e866a17549cb378a552e1e87c23931fe7fe341426\xc2\x85\xc2\xa3ver\xc2\xa3t-3\xc2\xa2id\xc3\x99$af002d15-2493-4e8f-bcd1-6a03505ba3fc\xc2\xa2dt\xc3\x8f\x00\x00\x00\x00eA5\x04\xc2\xa4phdt\xc3\x8f\x00\x00\x00\x00eA5\x04\xc2\xa1t\xc2\x91\xc2\x88\xc2\xa1p\xc3\x8d\x01'\xc2\xa1t\xc3\x8e\x00\xc3\x945@\xc2\xa1T\xc3\x8f\x00\x00\x00\x00eA5\x04\xc2\xa1l\xc2\x92\x00\x00\xc2\xa4line\xc2\xa1R\xc2\xa2on\xc2\xa201\xc2\xa3off\xc2\xa205\xc2\xa4sigT\x00"
>>> mt[0].data
b't4e686bd944ddb5bfd0cc5049e6dc690a57970c254ffaa9642e42e961ad9abee5\xc2\x85\xc2\xa3ver\xc2\xa3t-3\xc2\xa2id\xc3\x99$af002d15-2493-4e8f-bcd1-6a03505ba3fc\xc2\xa2dt\xc3\x8f\x00\x00\x00\x00eA5\x1a\xc2\xa4phdt\xc3\x8f\x00\x00\x00\x00eA5\x1a\xc2\xa1t\xc2\x91\xc2\x88\xc2\xa1p\x0e\xc2\xa1t\xc3\x8e\x00\xc3\x945>\xc2\xa1T\xc3\x8f\x00\x00\x00\x00eA5\x1a\xc2\xa1l\xc2\x92\x00\x00\xc2\xa4line\xc2\xa1R\xc2\xa2on\xc2\xa201\xc2\xa3off\xc2\xa205\xc2\xa4sigT\x00'
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();
sb.append("t");
sb.append(this.signature);
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();
sb.append("B6");
Instant validFromDateTime = list.get(0).getValidFromDateTime();
sb.append(
String.valueOf(
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();
moshi.adapter(QrCodeDataV3.class).toJson(
(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:]
b'\xc2\x85\xc2\xa3ver\xc2\xa3t-3\xc2\xa2id\xc3\x99$af002d15-2493-4e8f-bcd1-6a03505ba3fc\xc2\xa2dt\xc3\x8f\x00\x00\x00\x00eA4\xc3\x80\xc2\xa4phdt\xc3\x8f\x00\x00\x00\x00eA4\xc3\x80\xc2\xa1t\xc2\x91\xc2\x88\xc2\xa1p\x01\xc2\xa1t\xc3\x8e\x00\xc3\x945A\xc2\xa1T\xc3\x8f\x00\x00\x00\x00eA4\xc3\x80\xc2\xa1l\xc2\x92\x00\x00\xc2\xa4line\xc2\xa1R\xc2\xa2on\xc2\xa201\xc2\xa3off\xc2\xa205\xc2\xa4sigT\x00'
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:
QrCodeDataV3
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),
1,
null
);
}
List<TicketDescription>
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…
t
value doesn’t change
sig
value
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.
Finally, the one security measure you do have to think about. The daily security code:
The daily security code is retrieved from the network just like the animation is, in com/moovel/ticketing/network/NoAuthAgencyMetadataService.java:
@Headers({
"Content-Type: application/json",
"Accept: application/json"
})
@POST(TopLevelFunctions.VOICE_PAUSE)
Call<DataResponse<ContentResponse<SecurityCodesResponse>>>
getSecurityCodes(
@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.