Dutch
English
Talk to an Expert
android
reverse-engineering
mobile-security
frida
game-security

Breaking Roulette Royale, Part 1: reversing the APK and breaking the economy

Joel Aviad Ossi
20 September, 2026

Breaking Roulette Royale, Part 1: reversing the APK and breaking the client-side economy

Introduction

Roulette Royale (com.mw.rouletteroyale, published by Mywavia Studios) is a free to play Android casino game with over 10 million downloads on Google Play. You get a stack of chips, grind them at the tables, and when you run out you are nudged toward the shop. On top of the chips sit secondary currencies (gems, diamonds), a five tier VIP program, and an "Unlock All" premium purchase that opens higher chip denominations and shop bulk buying.

No real money is ever paid out. The chips, gems, and diamonds are virtual, there is no cash out, and this is a game rather than a real casino. Money moves in one direction only, when a player buys chips or the premium unlock through the Play Store. That still matters for what follows, because those paid tiers and premium features are exactly what the client decides for itself.

What we wanted to know was who decides your balance. In a well designed game the server is the single source of truth: the client asks to place a bet, the server validates and deducts, and the client displays what comes back. In Roulette Royale the client keeps the books and tells the server how rich it has decided to be.

This is Part 1 of two. Part 1 is the client side: pull the APK apart, find where the economy is decided, change it, rebuild and reinstall, then strip TLS with Frida to watch the modified client report its self assigned wealth to the server. The server's own config response confirms the design flaw. Part 2 takes the request signing and the endpoints recovered here and shows the server applies no ownership check, which turns the client side trick into read and write over every account, plus a RabbitMQ broker that exposes the whole player base.

Disclosure status: we reported this to the vendor on 28 August 2026 and followed up on 6 September and 15 September. None of the three emails got a reply. We are publishing 23 days after the first report, with the findings intact and the working parts removed. There is no proof of concept code here, no broker passcode, no srscore key, and no real account identifiers; the vendor can have all of it on request. Part 2 carries the full timeline and the reasoning. Scope is the mywavia.com hosts and classspace.in. Test accounts only.

Target and lab

Item Value
Package com.mw.rouletteroyale, client version 17.2 (from MW-version)
Device Android emulator, x86_64, rooted with Magisk, frida-server 17.15.3 as root
Account server https://adgen-self.mywavia.com/ (/config, /gameserver_roul, /srscore)
Realtime broker wss://roulmulti.mywavia.com:443/roul-ws (STOMP, Part 2)
Tooling apktool, jadx, frida, zipalign, apksigner, Burp Suite, and the MCP wrappers around them

Root and Frida up front:

$ adb shell su -c id
uid=0(root) gid=0(root) groups=0(root) context=u:r:magisk:s0
$ adb shell su -c 'setsid /data/local/tmp/frida-server -D &'
$ frida-ps -U | grep -i roulette
9156  Roulette Royale - Casino

First look at the APK

We ran two tools side by side because they answer different questions. apktool MCP gives the smali, which is editable and rebuildable. JADX MCP gives decompiled Java, which is easier to read while hunting logic. Library class names are obfuscated down to single letters, but the app's own classes survive under com.mw.rouletteroyale, and a few stand out:

  • RRRefreshActivity, RRGameActivity, the main game screens
  • AbstractMasterActivity, a base class many screens extend
  • VipTierManager, the VIP program
  • RRShopActivity, the shop
  • GameRoom, ChatManager, the multiplayer transport (Part 2)

Following the money: the client keeps the books

The economy sits in memory in a parsed JSON config (MWDeviceGlobals.config) and is read out through small getters, each returning an int from an obfuscated key. Nothing is fetched from the server per screen. The getters that matter:

  • RRRefreshActivity.getChips(), the chip balance
  • RRGameActivity.getGemsValue(), gems
  • AbstractMasterActivity.getDiamondsValue(), diamonds
  • VipTierManager.get_sp(), VIP status points
  • VipTierManager.get_tier(), VIP tier index
  • RRGameActivity.bigChipsAvailable(), the high value chip gate
  • RRShopActivity.enableBulk(), the shop bulk purchase gate

Decompiled, a getter is blunt and has no server round trip:

public int getGemsValue() {
    try {
        return MWDeviceGlobals.config.getInt("<obfuscated_key>");
    } catch (Exception e) {
        return 0;
    }
}

VipTierManager works the same way. It reads get_sp() and get_tier() and paints the VIP Center from them, and the server never challenges the displayed tier before the client acts on it.

Root cause. The client is authoritative for the economy. Balances, secondary currencies, VIP tier, and premium gates are computed and enforced on the device. Change what these getters return and you change the player's wealth, tier, and unlocks without ever touching the server.

Patching the smali

Decompiled Java does not recompile cleanly, so we edit smali, where the change is exact and apktool rebuilds it. The technique is to throw away each getter body and replace it with a constant load and a return. Opcode sizes matter: const/4 holds up to 7, const/16 up to 32767, const a full 32 bits.

Gems, before:

.method public getGemsValue()I
    .locals 3
    sget-object v0, Lcom/mw/.../MWDeviceGlobals;->config:Lorg/json/JSONObject;
    const-string v1, "<obfuscated_key>"
    invoke-virtual {v0, v1}, Lorg/json/JSONObject;->getInt(Ljava/lang/String;)I
    move-result v0
    return v0
.end method

After, a flat 10000 (0x2710):

.method public getGemsValue()I
    .locals 1
    const/16 v0, 0x2710
    return v0
.end method

Diamonds get the same 10000. VIP status points go to 999999 (0xf423f), past the top threshold, and the tier is pinned to 5, Ruby. Booleans are ints, so the premium gates become const/4 v0, 0x1:

.method public get_sp()I
    .locals 1
    const v0, 0xf423f
    return v0
.end method

.method public get_tier()I
    .locals 1
    const/4 v0, 0x5
    return v0
.end method

.method public bigChipsAvailable()Z
    .locals 1
    const/4 v0, 0x1
    return v0
.end method

enableBulk() gets the same one line edit. We left getChips() alone on purpose: when every counter reads a round modified number it is obvious even in a screenshot, so we moved the values that prove the point (gems, diamonds, VIP, unlocks) and left the headline chip balance normal. getChips() takes the identical edit if you want it.

Rebuild, sign, install

Android will not install an unsigned APK, and it will not install a differently signed build over the store version, so this installs as a fresh app:

$ apktool b roulette-royale -o roulette-royale-modded.apk
$ zipalign -p -f 4 roulette-royale-modded.apk roulette-royale-aligned.apk
$ keytool -genkey -v -keystore mod.keystore -alias mod -keyalg RSA -keysize 2048 \
    -validity 10000 -dname "CN=websec" -storepass modpass -keypass modpass
$ apksigner sign --ks mod.keystore --ks-key-alias mod --ks-pass pass:modpass \
    --key-pass pass:modpass --out roulette-royale-signed.apk roulette-royale-aligned.apk
$ adb uninstall com.mw.rouletteroyale && adb install roulette-royale-signed.apk
Success

Running the modified app

Nothing looks different at launch, and the main menu still reads the normal 25,000 chips because we left that getter alone.

Main menu. Chips read the normal 25,000 because getChips() was left untouched

The VIP Center is driven entirely by get_sp() and get_tier(), and both now lie in our favour: 999,999 status points and the top Ruby tier, which a legitimate player reaches only by crossing the 10000 point threshold over a long time or a lot of money. The threshold row (0, 1000, 3000, 6000, 10000) is the same one the server ships, which comes up again below.

VIP Center: 999,999 status points, Ruby tier, and the Bronze-to-Ruby thresholds the server also defines

Forcing bigChipsAvailable() to true unlocks the premium chips. Open a table, tap the chip selector, and instead of the low denominations a free player is limited to, everything is there: 500, 1000, 10K, 100K, 1M, 10M, up to 200M.

Every chip denomination unlocked, up to 200M. This is bigChipsAvailable() forced true

Secondary currencies and status items are spent in the luxury shop, which shows the modified state too.

The luxury shop with the modified economy

Impact of the client authoritative economy. Any player can grant themselves arbitrary gems and diamonds, assign the top VIP tier and its permanent multipliers (up to 3.0x VIP gain) without paying, and unlock the "Unlock All" premium features (higher chips, bulk buying) that are otherwise a real money purchase. getChips() is one edit away from the same. Both the VIP and the premium monetisation are defeated offline, and as the next section shows, the modified client still interoperates with the live server.

Making traffic readable with Frida

With the device proxied through Burp and the CA trusted, the game API traffic was still missing and the app threw "Account Creation Failed, network error". The usual suspect is certificate pinning, so we neutralised it with Frida rather than reversing the app's trust logic. Frida MCP attaches to the running process (frida-server is already root) and injects a universal unpinning script under the V8 runtime.

Frida MCP: SSL unpinning hooks firing live

The script hooks the conscrypt TrustManagerImpl.verifyChain, the SSLContext.init accept all trick, OkHttp CertificatePinner, the hostname verifiers, and WebView SSL errors. One line of its output is diagnostic: the OkHttp CertificatePinner class is not even present.

[unpin] skip okhttp3 CertificatePinner: ClassNotFoundException
[unpin] hooks installed (5): conscrypt.TrustManagerImpl.verifyChain,
        conscrypt.TrustManagerImpl.checkTrustedRecursive, SSLContext.init,
        apache.AbstractVerifier.verify, WebViewClient.onReceivedSslError

No OkHttp pinning means the game API does not use OkHttp. It uses the platform conscrypt path and a legacy Apache HTTP client, both covered by our conscrypt and SSLContext hooks. With pinning gone and Burp Intercept turned off (leave it on and held requests time out, which looks exactly like a network failure), account creation completes and the real API traffic decrypts.

What the server told us: the paywall it trusts the client to enforce

Decrypted, the config call looks like this, straight out of Burp with the device identifier redacted:

GET /config?appid=MW_RR_ANDROID2&cv=17.2&rand=0.798656 HTTP/1.1
Host: adgen-self.mywavia.com
MWHDR-MWHDR2: 211575b094b4804
MWHDR-MWHDR1: 9607bc08729587b91903f952b40ef485
MW-uuid: f6a5606d........          (device udid, redacted)
MW-platform: android
MW-appid: MW_RR_ANDROID
MW-version: 17.2
User-Agent: Apache-HttpClient/UNAVAILABLE (java 1.5)

The Apache-HttpClient user agent confirms what Frida implied, and the response describes the same paywall we bypassed offline:

{
  "cfg": {
    "unlock_all_diam": 100000,
    "vip_hdr":  [0, 1000, 3000, 6000, 10000],
    "vip_mult": [[1.0,1.0,1.0,1.0,0], ..., [1.4,1.4,1.4,1.4,0.8]],
    "inapp": { "unlock": { "best": [{
        "id": "unlock_all",
        "title": "Unlock All Features",
        "short_desc": "Unlock All Features - Higher Chips & Shop Bulk Purchases."
    }]}}
  }
}

The vip_hdr array is the Bronze to Ruby threshold set from the VIP Center screenshot, including the 10000 our get_sp() override sailed past. The unlock_all product is described in the vendor's own words as "Higher Chips & Shop Bulk Purchases", which is what the bigChipsAvailable() and enableBulk() gates control.

Impact / proof. The server ships the client the definition of the paywall, the tier thresholds and the unlock product and what it grants, then trusts the client to enforce it. It never verifies that a device claiming Ruby tier or unlocked features actually paid.

The request signature: com.mw.secure.S

The MWHDR-MWHDR1 and MWHDR-MWHDR2 headers on that request are the app's home grown signing scheme. The client computes and controls the signature, so it is not an integrity control, but a request still has to carry it to be accepted, and we need it to forge our own calls in Part 2. apktool MCP over classes2.dex returns the whole backend and both live secrets as constants.

apktool MCP: endpoints, the hardcoded STOMP login and passcode, and the 129 character srscore key, from classes2.dex. Secret values redacted

The signer is com.mw.secure.S, straight from the decoded app.

com.mw.secure.S: the MD5 quirk and the MWHDR header builder

It runs entirely on the device. There is no server nonce, key, or token involved, just an iterated MD5 over the query string and a public timestamp, with one odd quirk: prepend a zero when the hex comes out 31 characters long.

The MWHDR signing reconstructed in Python

Signing flow: query string plus timestamp to the two headers

We also confirmed there is no native fallback. The only .so in the APK is the AndroidX DataStore counter.

The only native library is the AndroidX DataStore counter, no native signer

Impact of the signing design. MWHDR is a keyless checksum over data the caller already holds, so any client can sign any request for any account offline. It enables every /gameserver_roul and /srscore finding in Part 2 and should be treated as providing zero security.

The API side companion: forging srscore

The getters above set what the device shows. There is also a server side worth, submitted to /srscore, and it is just as forgeable. This endpoint does use a real secret, the 129 character string pulled from classes2.dex, but it is the same string in every install.

The srscore signing key and the untamperable tail

A worth write is one GET. CurrentWorth and the leaderboard fields go into the signed score blob, while a set of currency and unlock fields ride in the query string outside the hash:

GET /srscore?type=put&un=websec.nl&all=Y&appid=MW_RR_ANDROID&cv=17.2
  &uuid=<rdsid>&o_uuid=<udid>&isocc=
  &score=...***CurrentWorth<<>>100000000000***...&hash=<md5(score_payload + key)>
  &gems=0&diamonds=0&unlock=0&invite_coupon=0&promotion_coupon=0&defer=0   <-- NOT covered by hash

We lifted the key, reproduced the salt and hash, submitted a worth of 100000000000, and after a resync the account shows it.

After the forged srscore write and a resync, the account's worth reads 100,200,006,999

Impact of the srscore design. Two problems. The shared key means a valid signature only proves the caller unzipped the APK, so any player can set their own CurrentWorth and every leaderboard stat. And the tail (gems, diamonds, unlock, coupons, defer) is appended outside the signed payload, so premium currency is editable with no key at all.

Root cause and what carries into Part 2

Every symptom traces to one decision: the economy is client authoritative. Balances, currencies, VIP status, and premium unlocks are computed and enforced on the device, the server accepts what the device reports, and it even ships the client the paywall to enforce. Obfuscation, pinning, and the MWHDR signature raise the cost of looking and nothing more; we removed pinning in minutes with a generic script.

Everything above targeted our own account. In Part 2 we change one field, the account id in the request, and find the server never checks we own it. The MWHDR signer and the shared srscore key from here become read of any account's email, device id, Facebook id, worth, and private room code; write of any account's name, worth, stats, and currency; takeover of any profile picture; and, over the RabbitMQ broker reachable with one shared passcode, the ability to sniff every table and post chat as any player.

Authored By
Joel Aviad Ossi

Managing Director

Share with the world!

Need Security?

Are you really sure your organization is secure?

At WebSec we help you answer this question by performing advanced security assessments.

Want to know more? Schedule a call with one of our experts.

Schedule a call
Authored By
Joel Aviad Ossi

Managing Director