9.8 KiB
Encryption & Anti-Replay
Every M-Faisa endpoint at superapp.ooredoo.mv mixes three things on the wire:
- Field-level RSA encryption for the mobile number (
mdnId/mobileNumber/userName/initiatingMDN/identifier) and the mPIN. - An anti-replay envelope (
rndValue+csValue) on every session-scoped form-encoded POST. - A Gson
htmlSafeJSON quirk that turns every literal=inside the JSON payload into=.
This document is the single source of truth for all three. The endpoint docs (login, history, transfer) just reference back here.
RSA keys
Two distinct RSA public keys are used. Both live obfuscated inside libnative-lib.so (file offsets given for app version 10.3.1, versionCode = 101349).
| Purpose | Bit length | JNI source | Cipher | Output format |
|---|---|---|---|---|
| Mobile / mdnId / mobileNumber / userName / initiatingMDN / identifier | 1024 | getBCPublicKeyImpl() (obfuscated, see below) |
RSA/ECB/OAEPWithSHA-256AndMGF1Padding |
base64 (NO_WRAP) — 172 chars + = padding |
| mPin / rndValue / OTP code | 2048 | getRSAModulusImpl() (modulus hex) + getRSAExponentImpl() ("10001") |
RSA/ECB/OAEPWithSHA-1AndMGF1Padding |
lowercase hex — 512 chars |
The native-function names are deliberately misleading — getRSAModulusImpl returns the modulus as a hex string, getRSAExponentImpl returns the exponent (also hex, always "10001"), and getBCPublicKeyImpl returns the mobile key in a custom obfuscated form (not a hex modulus).
Mobile key (1024-bit)
N = 12504370852445171564296397369840670875526950229356546060611893054268
22759715800327041313624881501743511944071724521752756122840313665124
84449720820820404229217064541745811143629538982383390723079478499614
16062061691167925660329675284421662011306487434253185147285131906525
8962732556596958868200227678294957694889
E = 65537
The plaintext is always "960" + msisdn (i.e. the local number prefixed with the Maldives country code, e.g. 9601234567). The trailing = from base64 encoding survives the wire — the server's strict JSON parser actually relies on it.
mPin key (2048-bit)
N (hex, 512 chars) = f46921c7091b315f8b9b20ef548deac32ff5b519a2e9ace2f971cc82a341a90eca39…
…d419274db7b
E (hex) = 10001 (= 65537)
The full hex modulus is the string returned by SecurityConfig.m() in the official app; see MfaisaCrypto.kt for the value as a decimal BigInteger.
The plaintext is pin + <6-character random salt>. The salt is drawn fresh on every encrypt from [A-Za-z0-9] (62-character alphabet) — e.g. 1234aB3xQz. It exists only to keep the OAEP plaintext above a minimum length; the server discards it after decryption.
The same routine is reused for any short string that needs anti-replay (OTP codes, the rndValue nonce) — see below.
Reference implementation (Kotlin)
object MfaisaCrypto {
private val MOBILE_N = BigInteger("125043708524451715642963973…7694889")
private val MOBILE_E = BigInteger("65537")
private val PIN_N = BigInteger("30853988905151679601945771998…504123")
private val PIN_E = BigInteger("65537")
private val mobileKey by lazy { rsaPublicKey(MOBILE_N, MOBILE_E) }
private val pinKey by lazy { rsaPublicKey(PIN_N, PIN_E) }
private val random = SecureRandom()
private const val SALT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
/** RSA-OAEP-SHA256 of "960" + msisdn → base64 NO_WRAP. */
fun encryptMobile(msisdn: String): String {
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
val params = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT)
cipher.init(Cipher.ENCRYPT_MODE, mobileKey, params)
val ct = cipher.doFinal(("960" + msisdn).toByteArray(Charsets.UTF_8))
return Base64.encodeToString(ct, Base64.NO_WRAP)
}
/** RSA-OAEP-SHA1 of `<value> + <6-char random salt>` → lowercase hex. */
fun encryptPin(value: String): String {
val salt = (1..6).map { SALT_ALPHABET[random.nextInt(SALT_ALPHABET.length)] }.joinToString("")
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
val params = OAEPParameterSpec("SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
cipher.init(Cipher.ENCRYPT_MODE, pinKey, params)
val ct = cipher.doFinal((value + salt).toByteArray(Charsets.UTF_8))
return ct.joinToString("") { "%02x".format(it) }
}
private fun rsaPublicKey(n: BigInteger, e: BigInteger): PublicKey =
KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(n, e))
}
The OAEP parameter spec must be passed explicitly. Android's Cipher.init(mode, key) without an OAEPParameterSpec defaults the MGF1 digest to SHA-1, even for RSA/ECB/OAEPWithSHA-256AndMGF1Padding — the server rejects this mismatch with HTTP 400.
Reference implementation (Python)
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
import base64, secrets, string
MOBILE_N = 12504370852445171564…7694889
PIN_N = 30853988905151679601…504123
E = 65537
def encrypt_mobile(msisdn: str) -> str:
key = rsa.RSAPublicNumbers(E, MOBILE_N).public_key()
ct = key.encrypt(
("960" + msisdn).encode("utf-8"),
padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None),
)
return base64.b64encode(ct).decode("ascii")
def encrypt_pin(value: str) -> str:
salt = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(6))
key = rsa.RSAPublicNumbers(E, PIN_N).public_key()
ct = key.encrypt(
(value + salt).encode("utf-8"),
padding.OAEP(mgf=padding.MGF1(hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
)
return ct.hex()
Anti-replay envelope: rndValue + csValue
Every session-scoped form-encoded POST (history, transfer, recipient-lookup, …) carries two extra fields the server uses to detect replays:
val offset = (Random.nextInt(5) + 10) xor 0xE // small noise: 0, 2, 3, 4, or 5
val nonceStr = (System.currentTimeMillis() + offset).toString()
val rndValue = MfaisaCrypto.encryptPin(nonceStr) // RSA-OAEP-SHA1 of nonceStr+salt, hex
val csValue = Adler32().apply {
update((formDataJson + nonceStr).toByteArray(Charsets.UTF_8))
}.value.toString() // decimal string of the 32-bit Adler32 sum
rndValueis anencryptPin(...)of the timestamp string — the SAME 2048-bit RSA key + OAEP-SHA1 routine documented above. The 6-char salt added byencryptPinmakes every encrypt non-deterministic.csValueisAdler32(formDataJson || nonceStr)rendered in decimal. The server recomputes this; tampering withformDataafter generatingcsValuewill cause rejection.- The offset (
(rand0-4 + 10) xor 14→ {0, 2, 3, 4, 5}) is a tiny bit of fixed noise on the timestamp. It exists in the official app's bytecode; the server tolerates any timestamp within a few seconds ofnowanyway.
csValue is computed from the pre-html-escape formData JSON (see next section) — i.e. the same string the server reads off the wire.
HTML-safe Gson = escape
The official app serialises every JSON payload with Gson in htmlSafe = true mode. The relevant side effect for the wire format: any literal = character becomes the six-byte sequence =.
This matters because:
- The base64 ciphertexts in
mdnId/mobileNumber/userName/initiatingMDN/identifieralways end with=(1024-bit ⇒ 128-byte output ⇒ 172 chars + 1 padding=). - The M-Faisa server's JSON parser is strict — sending the literal
=instead of=returns HTTP 400 even though both are valid JSON.
Match the on-wire form with a simple string replace before sending:
private fun String.matchGsonHtmlSafe(): String =
replace("\\/", "/").replace("=", "\\u003d")
The \/ → / swap covers the corresponding org.json quirk that escapes forward slashes by default — also rejected by the server.
The same csValue / rndValue pair must be computed against the escaped string (i.e. exactly what's sent on the wire).
The getBCPublicKeyImpl riddle
SecurityConfig.d() in the JVM bytecode returns a 231-character obfuscated string that, after a replace("MeWtSVjV3Mj","").trim() + "=" cleanup and base64 decode, should yield an ASN.1 SEQUENCE { INTEGER N, INTEGER E }. It does not — the bytes start with 0x10 instead of the 0x30 ASN.1 SEQUENCE tag, and BouncyCastle's ASN1Sequence.getInstance(bytes) throws unknown tag 16 encountered.
The cleaned form the runtime actually feeds into j() starts MIGJAoGB…AAE= (i.e. a valid 1024-bit RSAPublicKey SEQUENCE). The transformation from the obfuscated EAABMgAp… to MIGJAoGB… is NOT what the visible Kotlin bytecode does — strongly suggesting Pairip-style VM protection (com.pairip.licensecheck is present in the APK) intercepts the call at runtime.
Practical consequence: the only reliable way to recover the BC public key is to hook the running app and dump j()'s input. A Frida script for that is checked in at tmp/ooredoo_hook.js. Once dumped, the key matches the MOBILE_N value above — so unless Ooredoo rotates keys, the values in MfaisaCrypto.kt are stable and the Frida step is one-off.
If the keys ever rotate, the mPin key (SecurityConfig.m() + SecurityConfig.l()) can be re-extracted purely statically — both m() and l() return plain hex strings of the modulus and exponent respectively. Only the mobile key needs Frida.