Semestertickets ohne Überwachung

Reverse engineering public transport apps

Q Misell

Gulaschprogrammiernacht

20. Juni 2025

Who am I?

  • Researcher at the Max-Planck Institut für Informatik
  • Runs Glauca Digital

schtick

noun (plural schticks)

A characteristic trait or theme,
especially in the way people or media present themselves.

Verkehrsverbünde everywhere

So many Verkehrsverbünde

Before Digitalisierung

Oops! The politicians had Ideas™

Comedic, but it works

Oops! The Verkehrsverbünde had Ideas™

We obviously need an app1

[1]: XKCD 927

A selection of problems with apps

Not everyone has:

  • A modern smartphone
  • A phone at all
  • A device in the Apple/Google ecosystem
  • A reliable Internet connection

S-Bahn Berlin

c.f. Semsterticket ohne Google und Apple - Spline FU-Berlin

SaarVV

They have a Webapp, can I get my ticket there?

But what do I need to show
the ticket inspector, really?

Only the barcode matters!

Some less 'professional' ways
to free a ticket

  • Make a screenshot
  • Take a picture of the screen

The ticket inspector likely won't like this!

Made with Zügli

'What's inside my train ticket?'

My talk from Congress on the data inside these barcodes.

Available on media.ccc.de

It's just a .zip file

FossWallet / fWallet / PassAndroid

PassWallet / WalletPasses

Something prettier, but closed source

This still requires installing the VU's app
to get the barcode on every renewal.
We can do better than that.

Cool! Lets login.


POST https://saarvv.tickeos.de/index.php/mobileService/login HTTP/2
Content-Type: application/json

{
  "credentials": {
    "password": "<redacted>",
    "username": "q@magicalcodewit.ch"
  }
}
            

Ah, not quite...


HTTP/2 400 Bad Request
Content-Type: application/json

{
  "message": "Unbekannter Client"
}
            

Maybe we need a UA?


POST https://saarvv.tickeos.de/index.php/mobileService/login HTTP/2
Content-Type: application/json
User-Agent: SaarVV Android/3.24.2/2022.03/saarvv-live

{
  "credentials": {
    "password": "<redacted>",
    "username": "q@magicalcodewit.ch"
  }
}
            

Exciting! A different error!


HTTP/2 400 Bad Request
Content-Type: application/json

{
  "message": "Ungültige Signatur"
}
            

This X-Api-Signature seems suspicious.

JADX to the rescue!


public final void b() {
    HttpURLConnection httpURLConnection = this.d;
    String d = ic5.d(this.e, nt.b().b());
    StringBuilder a = u83.a(d, "|");
    a.append(httpURLConnection.getURL().getHost());
    a.append("|");
    a.append(httpURLConnection.getURL().getPort());
    a.append("|");
    a.append(httpURLConnection.getURL().getPath());
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("X-Eos-Date"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("Content-Type"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("Authorization"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("X-TICKeos-Anonymous"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("X-EOS-SSO"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("User-Agent"));
    httpURLConnection.setRequestProperty("X-Api-Signature", ic5.d(a.toString(), nt.b().b()));
}
            

(code somewhat simplified)

  1. Get the HTTP request
  2. Make d with ic5.d - we need to figure out what this does
  3. Create a StringBuilder a - somehow with u83.a
  4. Append, seperated by |
    1. Host
    2. Port
    3. Path
    4. X-Eos-Date
    5. Content type
    6. Authorization
    7. X-TICKeos-Anonymous
    8. X-EOS-SSO
    9. UA
  5. Do something again with ic5.d
  6. Result is the request signature

u83.a


public final class u83 {
    public static StringBuilder a(String str, String str2) {
        StringBuilder sb = new StringBuilder();
        sb.append(str);
        sb.append(str2);
        return sb;
    }
}
            

Oh, it's just an artefact of decompilation.


public final void b() {
    HttpURLConnection httpURLConnection = this.d;
    String d = ic5.d(this.e, nt.b().b());
    StringBuilder a = new StringBuilder();
    a.append(d);
    a.append("|");
    a.append(httpURLConnection.getURL().getHost());
    a.append("|");
    a.append(httpURLConnection.getURL().getPort());
    a.append("|");
    a.append(httpURLConnection.getURL().getPath());
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("X-Eos-Date"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("Content-Type"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("Authorization"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("X-TICKeos-Anonymous"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("X-EOS-SSO"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("User-Agent"));
    httpURLConnection.setRequestProperty("X-Api-Signature", ic5.d(a.toString(), nt.b().b()));
}
            

ic5.d


public static final String d(String str, String str2) {
    byte[] bytes = str.getBytes("UTF-8");
    byte[] bytes2 = str2.getBytes("UTF-8");
    SecretKeySpec secretKeySpec = new SecretKeySpec(bytes2, "HmacSHA512");
    Mac mac = Mac.getInstance("HmacSHA512");
    mac.init(secretKeySpec);
    String a = m91.a(mac.doFinal(bytes));
    return a;
}
            

(code somewhat simplified)

m91.a


public final class m91 {
    public static final byte[] a = {48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102};

    public static String a(byte[] bArr) {
        byte[] bArr2 = new byte[bArr.length * 2];
        int i = 0;
        for (byte b : bArr) {
            int i2 = b & 255;
            int i3 = i + 1;
            byte[] bArr3 = a;
            bArr2[i] = bArr3[i2 >>> 4];
            i = i3 + 1;
            bArr2[i3] = bArr3[i2 & 15];
        }
        return new String(bArr2, StandardCharsets.UTF_8);
    }
}
            

That's a little confusing. Those numbers look suspicious though.


public static final byte[] a =
  {  48,   49,   50,   51,   52,   53,   54,   55,   56,   57,   97,   98,   99,  100,  101,  102};
public static final byte[] a =
  {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66};
public static final byte[] a =
  { '0',  '1',  '2',  '3',  '4',  '5',  '6',  '7',  '8',  '9',  'a',  'b',  'c',  'd',  'e',  'f'};
            

Oh its hex!

Lets rewrite that a bit


public static final String hmacSha512Hex(String data, String key) {
    SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA512");
    Mac mac = Mac.getInstance("HmacSHA512");
    mac.init(secretKeySpec);
    return toHexLowercase(mac.doFinal(data.getBytes("UTF-8")));
}
            

And now the signature function as well


public final void b() {
    HttpURLConnection httpURLConnection = this.d;
    String d = hmacSha512Hex(this.e, nt.b().b());
    StringBuilder a = new StringBuilder();
    a.append(d);
    a.append("|");
    a.append(httpURLConnection.getURL().getHost());
    a.append("|");
    a.append(httpURLConnection.getURL().getPort());
    a.append("|");
    a.append(httpURLConnection.getURL().getPath());
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("X-Eos-Date"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("Content-Type"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("Authorization"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("X-TICKeos-Anonymous"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("X-EOS-SSO"));
    a.append("|");
    a.append(httpURLConnection.getRequestProperty("User-Agent"));
    httpURLConnection.setRequestProperty("X-Api-Signature", hmacSha512Hex(a.toString(), nt.b().b()));
}
            

The signature so far

\[ \begin{aligned} \text{X-Api-Signature} & := \\ &\text{HEX}(\text{HMAC}_{\text{SHA512}}( \\ &~~\text{HEX}(\text{HMAC}_{\text{SHA512}}(\text{e}, k_{\text{unknown}}))~| \\ &~~\text{URL}_{\text{host}}~|~\text{URL}_{\text{port}}~|~\text{URL}_{\text{path}}~| \\ &~~\text{X-Eos-Date}~|~\text{Content-Type}~|~\text{Authorization}~| \\ &~~\text{X-TICKeos-Anonymous}~|~\text{X-EOS-SSO}~|~\text{User-Agent}, \\ &~~k_{unknown} \\ &)) \end{aligned} \]

  1. What is e?
  2. What is the HMAC key?

this.e


if ((httpURLConnection.getRequestMethod().equals("POST") ||
      httpURLConnection.getRequestMethod().equals("PUT")) && this.e != null) {
    OutputStreamWriter outputStreamWriter = new OutputStreamWriter(
     httpURLConnection.getOutputStream(), StandardCharsets.UTF_8);
    md6.a(context).H().c();
    outputStreamWriter.write(this.e);
    outputStreamWriter.close();
}
            

Ah! e is the request body.

nt.b().b()


public final String b() {
    return this.applicationKey;
}
            

public final void R0(String str) {
    this.applicationKey = str;
}
            

So where is R0 called?

Nowhere useful!

We've been looking at nt.b().b(),
maybe nt.b() constructs the value we need?

Inside nt we find...


public static void a(Context context) {
    InputStream openRawResource = context.getResources().openRawResource(R.raw.parsed_licenses);
    byte[] bArr = new byte[openRawResource.available()];
    openRawResource.read(bArr);
    String value3 = "saarvv" + a95.a(context, vd6.f);
    byte[] value22 = value3.getBytes(mj0.a);
    String str = new String(nu7.b(bArr, new SecretKeySpec(ic5.c("SHA-512", value22)
                  .getBytes(StandardCharsets.UTF_8), 0, 16, "AES")), StandardCharsets.UTF_8);
    Map map2 = (Map) new lh3().h(str, new jt().b());
    ...
}
            

What does this do?

  1. Open an Android app resource called parsed_licenses
  2. Construct a value out of "saarvv" and
    some unknown value (a95.a(context, vd6.f))
  3. Do some SHA-512 operation on this
  4. Do some AES stuff
  5. Parse the resulting string into a Map - maybe it's JSON?

That sure looks encrypted to me

What does a95.a do?


public final class a95 {
    public static Context a;
    public static Boolean b;

    public static final String a(Context context, vd6 key) {
        String str = key.a;
        Intrinsics.checkNotNullParameter(context, "context");
        Intrinsics.checkNotNullParameter(key, "key");
        String str2 = "";
        try {
            ApplicationInfo applicationInfo = context.getPackageManager()
                .getApplicationInfo(context.getPackageName(), 128);
            Intrinsics.checkNotNullExpressionValue(applicationInfo, "getApplicationInfo(...)");
            Bundle bundle = applicationInfo.metaData;
            String string = bundle.getString(str);
            if (string != null) {
                str2 = string;
            }
            return ((str2.length() == 0) && key == vd6.d) ? String.valueOf(bundle.getFloat(str)) : str2;
        } catch (Exception unused) {
            o86.a(
              "ManifestMetaDataAccessor",
              "Missing <meta-data android:name=\"" + str + "\" android:value=\"some_value\"/> "
              "in your AndroidManifest.xml file."
            );
            return "";
        }
    }
}
            

And what about vd6.f?


public final class vd6 {
    public static final vd6 f;

    static {
        f = new vd6("COMMIT_HASH", 5, "eos_ms_commit_hash");
    }
}
            

(code somewhat simplified)

Looking in the manifest...


<meta-data
    android:name="eos_ms_commit_hash"
    android:value="h3f5ba133"/>
            

ic5.c


public static final String d(String str, byte[] value) {
    MessageDigest messageDigest = MessageDigest.getInstance(str);
    messageDigest.update(value);
    return toHexLowercase(messageDigest.digest());
}
            

(code somewhat simplified)

Finally, nu7.b


public static byte[] b(byte[] bArr, SecretKeySpec secretKeySpec) {
    Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
    cipher.init(2, secretKeySpec, new IvParameterSpec(new byte[cipher.getBlockSize()]));
    return cipher.doFinal(bArr);
}
            

(code somewhat simplified)

\[ \begin{aligned} \text{App-License} & := \\ &\text{AES}_{\text{CBC}}(\\ &~~data=\text{parsed\_license.lcs}\\ &~~key=\text{HEX}(\text{SHA}_{512}(\text{``saarvv"}~|~\text{eos\_ms\_commit\_hash}))[0..16]\\ &~~iv=(0~~0~~...~~0)\\ &) \end{aligned} \]

Bingo!


{
    "instances": [
        {
            "vuIdentifier": "SAARVV",
            "moticsID": 6484,
            "appTheme": "eos_ms_ThemeSAARVV",
            "backendHost": "saarvv.tickeos.de",
            "productVoucherUrl": "https://saarvv.tickeos.de/gutschein",
            "timezone": "Europe/Berlin",
            "applicationKey": "e0737c32-f573-4e23-892f-2f4e0e0bf121",
            "backendRoute": "/index.php/mobileService/",
            "vuFullName": "Saarl\u00e4ndischer Verkehrsverbund",
            "clientName": "SaarVV Android",
            "mobileServiceAPIVersion": "2022.03",
            "identifier": "saarvv-live",
            ...
        },
        ...
    ]
}
            

Putting it all together


POST https://saarvv.tickeos.de/index.php/mobileService/login HTTP/2
Content-Type: application/json
User-Agent: SaarVV Android/3.24.2/2022.03/saarvv-live
X-Api-Signature: 4a5760b116d...
X-Eos-Date: Sun, 25 May 2025 13:54:55 GMT

{
  "credentials": {
    "password": "<redacted>",
    "username": "q@magicalcodewit.ch"
  }
}
            

It works!


HTTP/2 200 OK
Content-Type: application/json

{
  "authorization_types": [
    {
      "name": "tickeos_access_token",
      "type": "header",
      "header": {
        "name": "Authorization",
        "type": "TICKeos",
        "value": "eyJ0eXAiOiJKV1QiLCJh..."
      }
    }
  ]
}
            

Now all we need to do is download tickets

What was the next request the app made?


POST https://saarvv.tickeos.de/index.php/mobileService/sync HTTP/2
Content-Type: application/json
User-Agent: SaarVV Android/3.24.2/2022.03/saarvv-live
X-Api-Signature: edbe5809b123c3...
X-Eos-Date: Sun, 25 May 2025 13:54:55 GMT
Authorization: TICKeos eyJ0eXAiOiJKV1QiLCJh...

{}
            

We have a list of tickets,
how do we get the ticket itself?


HTTP/2 200 OK
Content-Type: application/json

{
  "tickets": [
    "eet-0671615f49990e9e3eee4b949ce74606eaed4633",
    "shop-25048405",
    "shop-250311353"
  ],
  "credits": []
}
            

POST https://saarvv.tickeos.de/index.php/mobileService/ticket HTTP/2
Content-Type: application/json
User-Agent: SaarVV Android/3.24.2/2022.03/saarvv-live
X-Api-Signature: 7d0e1262365945...
X-Eos-Date: Sun, 25 May 2025 13:54:55 GMT
Authorization: TICKeos eyJ0eXAiOiJKV1QiLCJh...

{
  "tickets": [
    "eet-0671615f49990e9e3eee4b949ce74606eaed4633",
  ],
  "details": true
}
            

Where's the ticket barcode?


HTTP/2 200 OK
Content-Type: application/json

{
  "tickets": {
    "eet-0671615f49990e9e3eee4b949ce74606eaed4633": {
      "meta": "{\"purchase_id\":\"eet-0671615f49990e9e3eee4b949ce74606eaed4633\",\"title\":\"Deutschland-Ticket\",...",
      "template": "'{\"purchase_id\":\"eet-0671615f49990e9e3eee4b949ce74606eaed4633\",\"content\":{\"header\":...",
      "certificate": "-----BEGIN CERTIFICATE-----\nMII...",
      "meta_signature": "aee26f694b3f9e414226092736df911bee6de...",
      "template_signature": "49978adad203c507a12c4550bc1d7613ddff8c0d..."
    }
  }
}
            

Is it in meta? No


{
  "purchase_id": "eet-0671615f49990e9e3eee4b949ce74606eaed4633",
  "title": "Deutschland-Ticket",
  "description": "Deutschland-Ticket",
  "anonymous": false,
  "customer_code": "",
  "vu_name": null,
  "vu_role": null,
  "validity_begin": "2025-05-01 00:00:00",
  "validity_end": "2025-06-01 03:00:00",
  "display_valid_end": "2025-06-01 03:00:00",
  "display_ticket_template_end": "2025-06-01 03:00:00",
  "purchase_datetime": "2025-05-01 00:00:00",
  "distribution_method": "external_entitlement",
  "price": null,
  "vat": null,
  "currency": null,
  "master_type": null,
  "device_identifier": null
}
            

It's in template


{
  "purchase_id": "eet-0671615f49990e9e3eee4b949ce74606eaed4633",
  "content": {
    "header": {
      "title_color": "FFFFFF",
      "description_color": "FFFFFF",
      "security_method": "analog_clock",
      ...
    },
    "images": {
      "background": "iVBORw0KGgo...",
      "logo": "iVBORw0KGgo...",
      "aztec_barcode": "iVBORw0KGgo..."
    },
    "styles": {
      "global": "body, p, table, tr, td {..."
    },
    "pages": ["<!doctype html><html><head><meta..."]
  }
}
            

If we take that aztec_barcode value
and put it as data:image/png;base64,..., we get:

S-Bahn Berlin

That looks suspiciously similar to SaarVV

Or not...
Let's keep going though

It is almost the same

Where did that code come from, and where's my username?


POST https://sbahn-ber.tickeos.de/index.php/mobileService/connect/authorize HTTP/2
Content-Type: application/json
User-Agent: S-Bahn Berlin AND - Live/3.30.0/2022.03/sbahn-berlin-live
X-Api-Signature: 6e0845008e5eb...
X-Eos-Date: Sun, 25 May 2025 17:07:07 GMT

{
  "id": "1",
  "code": "b0862a41-5d34-..."
}
            

It turns out, this is a Webview to Keycloak performing OIDC.

After going through the OAuth dance with keycloak,
we send the response code to the API.

We can then call
https://sbahn-ber.tickeos.de/index.php/mobileService/sync
exactly as we did with SaarVV;
modulo extracting the new license file from the APK.

OAuth hates the living

Deutsche Bahn done goofed

https://accounts.bahn.de/auth/realms/db/protocol/openid-connect/auth?
response_type=code&client_id=kf_mobile&
state=dbnav-6e6NmFBosk&code_challenge=2w1Nfr...&
code_challenge_method=S256&prompt=login&scope=offline_access&
redirect_uri=dbnav%3A%2F%2Fdbnavigator.bahn.de%2Flogin%2Fsuccess&
cancel_uri=dbnav%3A%2F%2Fdbnavigator.bahn.de%2Flogin%2Fback&
corid=f97a5347-535e-479e-a8b5-5fbea22ff4ea_83a4ee42-3a00-4741-8d17-1d04ffca8528

A lot of apps use myapp:// as an OIDC redirect

This can backfire...

A limitation of using private-use URI schemes for redirect URIs is that multiple apps can typically register the same scheme, which makes it indeterminate as to which app will receive the authorization code. Section 1 of PKCE [RFC7636] details how this limitation can be used to execute a code interception attack.

BCP 212 - RFC 8252 "OAuth 2.0 for Native Apps"

If we have the OAuth authorization code,
we have the login

accounts.bahn.de uses a redirect URL of
dbnav://dbnavigator.bahn.de/auth
for the DB Navigator app, and
bahnbonus://authentication/redirect
for the BahnBonus app.

I becometh dbnav://, destroyer of security

Nothing stops us telling the Operating System I we handle dbnav:// urls


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist>
<dict>
    ...
    <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleURLName</key>
            <string>DB Navigator</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>dbnav</tring>
            </array>
        </dict>
    </array>
</dict>
</plist>
            

With this, I redirect to my web app with the original URL in a parameter.
Security: broken.

TheEnbyperor / db-login-hook

I want my trackingless tickets

Head to zügli.app

TheEnbyperor / zuegli

Questions?

Email: q@magicalcodewit.ch
Fedi: q@glauca.space

Slides at magicalcodewit.ch/gpn23-slides/