A Saudi government app shipped a bank's private key. Reporting it required being Saudi.

@iam_zachi
الإنجليزية08 سبتمبر 2026
235K
1.5K
95
28
1.1K

ليرة تركية؛ د

A security researcher discovered that the official Saudi Nusuk app leaked a private RSA key and OAuth credentials for the Saudi National Bank, secured by a single-digit password. Reporting the bug required a viral tweet due to geofenced vulnerability portals.

A 2,835-byte file inside a government app with 10M+ installs held a live client certificate for the Saudi National Bank. Getting anyone to look at it took a viral tweet.

TL;DR

The official Nusuk app (com.moh.nusukapp, Ministry of Hajj and Umrah, 10M+ installs, carrying Google Play's "Government" badge) shipped a PKCS#12 file containing a private RSA key and a client certificate issued by The Saudi National Bank. The password for that file was hardcoded a few lines away in the app's own code. It was a single character: 2.

Next to it, in plaintext, sat the OAuth2 client ID and client secret for the bank's Banking-as-a-Service API, requesting the scopes identity accounts cards verification kyc cardpay transfers.

Anyone who downloaded the app from Google Play had all of it.

I tried to report it. I was told the vulnerability portal is only available to users inside Saudi Arabia. So I tweeted it. The tweet did 1.5M views, and suddenly the same portal wanted details. A day later the credentials were gone from the app.

https://x.com/iam_zachi/status/2094445016194207745

This write-up covers only the banking finding. Everything below has been fixed in the shipping app, and the vendor side states the credentials have been rotated.

What Nusuk is

Nusuk is the Saudi government's official platform for Hajj and Umrah. It handles pilgrimage permits, e-visas, bookings, and the Nusuk Card. It's operated by the Ministry of Hajj and Umrah, it's flagged as a verified government app on Google Play, and it has over ten million installs. It also contains a wallet feature, Nusuk Wallet, built together with the Saudi National Bank and approved by SAMA, the Saudi central bank. The wallet is the part this article is about.

The finding

I pulled the APK set straight from Google Play (version 17.4.9, versionCode 131215) and unpacked it. Nothing exotic about it: standard Kotlin/Compose, no packer, no meaningful obfuscation.

Inside the app's resources, at res/raw/nusuk.pfx, sat a 2,835-byte PKCS#12 container. A .pfx file is the standard bundle format for a certificate plus its private key, encrypted with a password.

The password was in the app's code, a few lines from where the file gets loaded:

text
1const-string v3, "2"

One character, sitting in the decompiled bytecode as a literal. One openssl call later:

text
1RSA private key, 2048 bit, 2 prime factors
2Subject: C=SA, ST=Jeddah, L=Jeddah, O=Nusuk.sa, CN=eshbeata@staq.io
3Issuer: C=SA, ST=Riyadh, L=Riyadh, O=The Saudi National Bank, OU=Finto,
4 CN=Application Issuer
5Serial: 0x24 (36)
6Valid: 2026-04-27 -> 2027-04-27
7Extended Key Usage (critical): TLS Web Client Authentication

A live client certificate issued by a bank, valid for another year, meant for authenticating a client to a server over TLS.

It wasn't alone. The same code path is the wallet component, packaged as com.walletstaq and wired into the Trustless SDK from Staq Technologies, the company that runs SNB's Finto BaaS platform. Three more values sat there in cleartext:

with the requested OAuth scope:

text
1identity accounts cards verification kyc cardpay transfers

Both credentials were also concatenated into a single string and handed to a debug logger, along with the base URL.

(I'm not publishing the key material or the full secret values. What matters here is the shape of the problem.)

Why this is bad, in plain terms

Think of the bank's API as a door with two locks.

The first lock is mutual TLS. Normally a server proves its identity to you with a certificate. With mTLS, you also have to prove your identity to the server with a certificate. That's the .pfx file: the certificate and the private key that proves you own it. It's supposed to be the thing only the legitimate client system has.

The second lock is the OAuth client secret, the password the application uses to ask the bank's API for an access token.

Both locks shipped inside a free app on Google Play, and the key to the box holding the first one was the digit 2.

I confirmed the target host actually enforces mTLS: the TLS handshake with api.baas.alahli.com requests client certificates (it sends Acceptable client certificate CA names), and the server certificate reads CN=*.baas.alahli.com, O=The Saudi National Bank. So this wasn't a decorative certificate for a test environment. It was the credential for the front door of a production banking API, with scopes covering identity, accounts, cards, KYC, card payments, and transfers.

Separate from the leak, there's a design problem underneath it. A client certificate that ships identically to ten million devices cannot distinguish one installation from another. Every copy presents the same credential, so the certificate tells the bank which app is calling and nothing about who is calling. Credentials for an API like this belong behind your own backend: the app talks to your server, your server talks to the bank.

What I did not do

I ran exactly one check against the token endpoint (POST /api/tppa/token) to see whether the credentials were live. It returned HTTP 403 from nginx. So did a request with no certificate at all, and so did the bare root URL. That's a network-level block sitting in front of the API, almost certainly geographic, and it says nothing about whether the credentials work.

From outside Saudi Arabia, I could not determine whether these credentials were active. I stopped there. Anything further would have been an attempt to work around a bank's access control, and the finding doesn't depend on it: a private key and a banking OAuth secret with transfer scope sitting in a publicly downloadable artifact is the finding, whether or not I personally can reach the endpoint.

Trying to report it

This is the part that made the tweet go viral, and it's the more interesting half.

I looked for a way to report this responsibly. What exists:

Channel

Result

security.txt on nusuk.sa, haj.gov.sa, hajj.nusuk.sa

Does not exist

Saudi CERT (cert.gov.sa) vulnerability reporting page

Redirects to NCA; own reporting page shut down

NCA vulnerability form (haseen.gov.sa)

Not reachable from Germany: timeout, geo-blocked

bugbounty.sa

Closed program, HTTP 403 from outside

HackerOne / Bugcrowd

No program for Nusuk, the Ministry, or Elm

App store listing contacts

Support addresses, no security mandate

None of that left me a channel with a security mandate I could actually reach. I emailed anyway. The reply from Haseen Support:

"Access to the Haseen portal is restricted to users within the Kingdom of Saudi Arabia. For any further inquiries you may contact us via the 'We Care' service available on the official Haseen Portal."

The Haseen Portal, which is the thing I can't reach. I wrote back explaining that I'm not Saudi, that this is a private bank certificate exposed in a government app, and that I just wanted to hand it over. The answer, again, was that the form only works for citizens of KSA.

So I filed a report with CERT/CC through their VINCE platform as a coordinating intermediary (VRF#26-08-DXMKL), scoped to this finding only. That's the route you take when the affected party has no reachable channel of its own.

And then I tweeted about it, mostly out of frustration.

The tweet did 1.5M views. Within hours, Haseen Support emailed me, unprompted, on the same thread that had twice told me the portal wasn't for me:

"According to our relevant team, kindly provide us with more details about the security vulnerability."

I sent the full details. I'd rather have the thing fixed than be right about the process.

The fix

The next app update landed on both Android and iOS. I pulled the new Android build (17.5.0, versionCode 156635) directly from Play and diffed it against what I'd analysed. Three checks:

  1. No certificate container anywhere in the new APK set: no file with a .pfx, .p12, .pkcs12, .jks, .bks, .pem, or .key extension. I also hashed the old nusuk.pfx and compared it byte for byte against every same-size file in the new build, in case it had simply been renamed. No match.
  2. None of the known credentials. I searched the exact old values for base URL, scope, client ID, and client secret across all DEX files, native libraries, assets, XML, JSON, and raw resources. Zero hits, all four.
  3. No code path. Version 17.4.9 contained 4,571 files under the com.walletstaq and com.trustless packages; 17.5.0 contains zero. The markers baas.alahli, tppa/token, nusuk.pfx, the certificate subject and the issuer name all return zero hits in the decoded build.

The entire wallet and BaaS integration was pulled out. The rename check in step 1 is what rules out the values having simply moved somewhere else in the package.

The part nobody outside can verify

Removing a secret from an app doesn't invalidate the secret. Old APK copies stay available forever, and the certificate was valid until April 2027. So the open question is whether it was revoked and whether the OAuth secret was rotated.

I went looking for a way to check that independently. There isn't one, and the reason is itself a finding.

The certificate carries no crlDistributionPoints extension, so no revocation list is referenced at all. Its only revocation endpoint is:

text
1OCSP - URI: http://finto-ocsp-responder.prod.svc.cluster.local:8080/api/v1/ocsp

.cluster.local is the internal DNS suffix of a Kubernetes cluster. It is by definition not routable on the public internet, and it resolves to NXDOMAIN from anywhere outside that cluster, over plain HTTP on port 8080.

So the revocation status of this certificate can't be checked from outside the bank's infrastructure, because there is nothing out here to query. For any relying party outside that one cluster, certificates from this issuer are effectively unrevocable, which deserves a paragraph in someone's architecture review on its own.

That same field also published the cluster name, namespace, service name, and port of the production PKI of a bank's BaaS platform, in an app distributed to ten million people.

Only SNB, Finto, or Staq can confirm rotation. The vendor side states the credentials have been rotated. I have no way to verify that independently.

Timeline

Date (2026)

Event

Aug 29

APK analysed, finding confirmed locally

Aug 29

Report emailed; Haseen replies that the portal is restricted to users inside KSA

Aug 31

Repeated attempts, same answer. I tweet about it; ~1.5M views

Sep 1

Haseen re-opens the thread unprompted, asks for details. Details sent

Sep 1

Report also filed with CERT/CC VINCE (VRF#26-08-DXMKL) as intermediary

Sep 1

Version 17.5.0 published on Google Play

Sep 3

Retest on the unmodified 17.5.0 APK set confirms complete removal

Sep 8

This write-up

I can't prove the update was caused by my report. 17.5.0 may well have been in the pipeline already. What I can show is that the material was in 17.4.9 and is not in 17.5.0.

What I'd take from this

  1. Hiding something in the app is not a security boundary. Not in resources, not in a native .so, not behind obfuscation or a password stored in the same binary. If the app can read it, so can everyone who installs the app. Several teams learn this in public every year.
  2. A shared client certificate is not authentication. If ten million devices present the same certificate, it says which app is calling and nothing about who is calling, and the app is a file anyone can download. Credentials for a third-party API, a bank's above all, belong on a server you control.
  3. Geofencing your vulnerability disclosure channel is itself a vulnerability. Attackers do not fill out forms. If the only way to report a flaw in an app published globally to ten million people is to be physically inside one country, then the people who can't reach the form are exactly the people you most want to hear from. It took a viral tweet to open a channel that a security.txt file would have opened for free.

I analysed the publicly available Play Store APK set in guest mode, with no account and no real personal data. I made no attempt to bypass the network-level block in front of the banking API. Every value in this article is either structural (paths, class names, certificate metadata) or redacted; no private key material and no full secrets are published.

بنقرة واحدة حفظ

استخدم YouMind للقراءة العميقة للمقالات سريعة الانتشار بتقنية الذكاء الاصطناعي

احفظ المصدر، واطرح أسئلة مركزة، ولخص الحجة، وحوّل المقالة واسعة الانتشار إلى ملاحظات قابلة لإعادة الاستخدام في مساحة عمل واحدة تعمل بالذكاء الاصطناعي.

اكتشف YouMind
للمبدعين

حول Markdown إلى مقالة 𝕏 نظيفة

عندما تنشر كتاباتك الطويلة، فإن الصور والجداول وكتل التعليمات البرمجية تجعل تنسيق 𝕏 مؤلمًا. YouMind يحول مسودة Markdown كاملة إلى مقالة نظيفة وجاهزة للنشر 𝕏.

حاول Markdown إلى 𝕏

المزيد من الأنماط لفك التشفير

المقالات الفيروسية الأخيرة

استكشاف المزيد من المقالات الفيروسية