Gulaschprogrammiernacht
20. Juni 2025
schtick
noun (plural schticks)
A characteristic trait or theme,
especially in the way people or media present themselves.
[1]: XKCD 927
c.f. Semsterticket ohne Google und Apple - Spline FU-Berlin
They have a Webapp, can I get my ticket there?
The ticket inspector likely won't like this!
Made with Zügli
My talk from Congress on the data inside these barcodes.
Available on media.ccc.de
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.
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)
d
with ic5.d
- we need to figure out what this doesa
- somehow with u83.a
|
X-Eos-Date
X-TICKeos-Anonymous
X-EOS-SSO
ic5.d
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()));
}
\[ \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} \]
e
?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());
...
}
parsed_licenses
a95.a(context, vd6.f)
)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)
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} \]
{
"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..."
}
}
]
}
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:
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.
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.
dbnav://
, destroyer of securityNothing 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
Head to zügli.app
TheEnbyperor / zuegli
Email: q@magicalcodewit.ch
Fedi: q@glauca.space
Slides at magicalcodewit.ch/gpn23-slides/