Caught in the Reeloop
Tracing an Android Infection From Lure to Loop
Search for a command to run...
Tracing an Android Infection From Lure to Loop
nice
How a simple lipstick icon hides a sophisticated banking trojan
There is a specific kind of cognitive dissonance that comes from reverse engineering a malware sample in one terminal while your own app's backend runs in another. I spent the better part of the last
Part 3 of the eChallan malware walkthrough series. Part 1 covered the dropper/loader. Part 2 covered the Flask C2 and the media player payload that wasn't one.
DEX Payload Analysis & C2 Infrastructure Mapping
Its been a while since the last post here. College reopened after the summer break, I was involved in hosting the IEEE NETSIP 2026, and the analysis itself took longer than expected. Two weeks is a bigger gap than what I would've liked, but the wait brought some depth this time.
The sample which I started analyzing calls itself NightPlay. It was the final payload, missing the whole delivery chain around it. I traced the lure site, the dropper and the propagation mechanism later. So this post is not written in the order in which I figured things out. Instead, it follows the malware's actual execution path, starting from where a victim would actually land: an adult video site which never plays any.
The trail starts on nightroom.cc, an adult video aggregator site. It clearly looks like a vibe-coded site, consisting of a grid of thumbnails of "Beauty Videos", and a CTA button to download an app to access 999 Free Plays.
Clicking on any video, and attempting to play it results in a APK download being triggered. The authors seem to be frequently rotating the APKs and the webpage being served over the C2 domain, as atleast a couple of variations were observed over the 2 weeks of analysis.
The downloaded APK is called Vexo. It sits tiny at around 2.71MB, and it doesn't serve much purpose beyond installing the next stage of the malware.
When trying to install the app, the package manager reports the name of the app as Onda Pulse.
Upon installing the app and launching it, a VPN connection is set up, and the app requests permission to install unknown apps. With some static code analysis, it doesn't look like the VPN service is doing much by itself. It seems to have been implemented by the author as an anti-analysis measure, since only one VPN app at a time can be active on Android Devices, hence rendering VPN based inspection tools like HTTP Proxy unusable to network analysis.
Followed by this, the user is presented with a screen which tries to impersonate the Google Play Store and tricks the user into installing the second stage payload.
The app then pulls another app with the same Launcher Name (Onda Pulse), but a larger size (64MB) from it's C2, and installs it on the device. After a brief loading screen, the user is presented with a short-form video like interface with adult content. Newer versions of the dropper also included a menu offering to connect users to adult content creators, and an "AI Studio", which showcased a blurred thumbnail library of multiple videos.
Trying to click upon any of these 3 lures results in the app requesting "Playback service access", which actually takes the user to the Accessibility settings for the app.
Enabling the permission brings up an initialization screen, which blocks everything else from the screen, and in the background grants the app access to the common permissions like calls, SMS etc, while also enrolling the app as a "Device admin" app, which makes it much more difficult to uninstall/force stop an app for naive users.
The app uses the device admin permission to lock the device, and when the user unlocks the device, it presents an unlock screen similar to the lock method set by the user (pattern/PIN etc.) The first entry on the lock always results in a failed attempt, and the second attempt always succeeds.
Everything up to this point was Vexo and Onda Pulse, but as noted earlier, the attackers rotate the APKs and webpages served by their infrastructure over time. The following analysis was performed on samples that shipped under different names entirely: the dropper was called Reeloop, and the payload called itself NightPlay. Different names, different hashes, but nearly identical behavior. That earlier analysis went much deeper than this one, so from here on, the technical breakdown continues on that sample.
While this was the flow of a victim of the malware, I also recorded the app's activity, analyzed its source code, and dumped files from its private storage, thanks to my newly rooted secondary device.
The first move of the app is to request a config file, hosted, of all places on GitHub pages at https://yanglin202107.github.io/project-docs/d/config.json
{
"version": 9,
"updatedAt": "2026-07-03T05:28:43Z",
"servers": [
{ "name": "主节点", "apiBase": "https://voicezone.vip", "region": "hk", "priority": 1 },
{ "name": "主节点", "apiBase": "https://nightroom.cc", "region": "hk", "priority": 2 }
]
}
The two candidate C2 hosts are also on the domain on which the lure sites run. The app checks /api/health on both before picking one.
During my analysis runs of the app, voicezone.vip answered with a HTTP 200 on the health check, but the step after that (device registration) returned HTTP 500. Instead of trying to figure out why their priority 1 server was half broken, I blocked outbound traffic to voicezone.vip in my test environment, which forced the app to fall back to nightroom.cc, and from there, the whole registration and config flow went cleanly.
Once it settles on a working host, the app checks in via /api/device/init. The request carries the Android ID, app version, build fingerprint, screen metadata, and a SHA256 hash of its own signing certificate, alongside a locally computed proof value:
{
"androidId": "84ed4d529051ab92",
"appVersion": "3.0.83",
"brand": "google",
"buildFingerprint": "google/sdk_gphone16k_x86_64/emu64xa16k:17/CP31.260618.005/15731206:userdebug/dev-keys",
"packageName": "com.codex.nightplay",
"packageSignature": "28fb1fbf9c71354729eb5d2d38dfb5de99a092ef83e086532a4e178d7c474ba3",
"proof": "5a5580d54802e968e9f9bac47dc7ccba30ddcced3e2ba37712c963b7ca3b3d68",
"proofAlgorithm": "sha256-local-v1",
"proofTimestampMs": 1785134538068
}
That packageSignature value isn't cosmetic. It matches the SHA256 fingerprint of the app's own debug certificate exactly. The backend is fingerprinting every build by its signing key, meaning whoever runs this operation can tie a registered device back to the exact build, and by extension the exact campaign, that infected it.
Both voicezone.vip and nightroom.cc passed their /api/health checks fine. It was the actual /api/device/init call to nightroom.cc that failed, a 502 from nginx. voicezone.vip took the request cleanly instead and returned:
{
"code": 0,
"data": {
"deviceId": "DEV-DD6B2E497B18",
"deviceCode": "DEV-DD6B2E497B18",
"token": "b7b16159-fe52-49c1-a31a-e26a0423b46c"
},
"msg": "初始化成功"
}
With that token, it calls /api/app/auth/device-login:
{
"channel": "official",
"deviceCode": "DEV-DD6B2E497B18",
"inviteCode": "123456",
"loginSource": "app-native"
}
That 123456 invite code isn't something a user typed in anywhere, it's hardcoded into the build (BuildConfig.DEFAULT_INVITE_CODE). The response is a JWT plus a full user object:
{
"code": 0,
"data": {
"isNewUser": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"deviceCode": "DEV-DD6B2E497B18",
"inviteCode": "GT3PQU",
"isVip": false,
"nickname": "user1231",
"permissionLevel": 0,
"permissionStage": "registered",
"userId": 1453678,
"userNo": "U2026072791231"
}
},
"msg": "登录成功"
}
Notice the two invite codes at play here. The app logs in using the hardcoded default 123456, and the server logs it in as isNewUser: true and hands back a brand new invite code of its own, GT3PQU. Every infected device becomes a fresh node in a referral tree, with its own code to pass along. This is the same mechanic behind the "My Invite Code" field you'd have seen on the lure app's profile screen, copy code, invite friends, rack up plays. It's not decoration bolted onto a RAT. The RAT is bolted onto a referral program.
Registered and logged in, the app pulls its full operating configuration from /api/device/config. This single response is enormous, deep enough that reproducing all of it here would bury the interesting parts, so I've uploaded the full JSON to a GitLab snippet for anyone who wants to dig through it themselves. A few pieces are worth pulling out directly.
appSoftwareMonitorConfigs lists the apps it watches for, PhonePe, Paytm, GPay, BHIM, Nagad, and dozens more across UPI, international banking, and crypto. Each entry is tagged with a listenType and a target package name, so the client knows exactly what to look for on the device without any of that logic being hardcoded into the APK itself. Push a new entry to this list server-side, and every infected device starts watching for it, no update required.
stagesConfigs goes a level deeper for a smaller set of these apps (PhonePe, GPay, Paytm, BHIM, and around fifteen others), defining a four-stage credential harvesting playbook per app: a login stage to detect a PIN or password screen, a balance stage to locate and read account balance, a payment stage to catch a transaction PIN entry and extract bank name and amount, and a success stage to confirm the payment went through. Each stage carries its own view IDs, keyword lists, and PIN length bounds. This is app-specific phishing logic, shipped and updated entirely from the server side.
lockscreenUnlockAuto is the piece that explains the fake-unlock trick directly. It's enabled by default, and it defines multilingual hint word lists (English, Hindi, and Chinese observed) to recognize password, PIN, and pattern lock screens, plus biometric fallback prompts like "Use password" or "Try another way." It carries its own timing parameters too, swipe count, PIN entry delay, pattern duration, retry limits, all tunable from the server without touching the client. This is the same screen I ran into during the Onda Pulse trace:
The overlay convincingly mimics the device's actual lock type because the malware already knows the lock type, it recorded it before triggering the lock in the first place. The first attempt always fails regardless of what's entered, and the second always succeeds. Looking at lockscreenUnlockAuto, that's not a bug or a race condition, it's a scripted behavior with its own retry and timing config.
One more piece worth a mention: keepAlive defines a set of evasion strategies, one of which watches for the user landing on a battery-drain or force-stop screen (matched against a long multilingual keyword list) and immediately backs out. Combined with the device admin enrollment, this is another layer standing between a suspicious user and actually removing the app.
With the HTTP side of registration done, the app opens a WebSocket connection to wss://voicezone.vip/device/ws. The first few attempts failed outright, 502s, with a fresh Sec-WebSocket-Key sent on each retry (EosWnDcSTt5w0F3peonQUQ== among the failures). Eventually one landed, AV4bQJ6jm/BTZv5i0cjIIg==, and the handshake completed.
The protocol that follows is a small, tightly typed message set. Every message carries a deviceId, a msgId, a timestamp, and a type. The session opens with an AUTH message, essentially a fuller version of the device fingerprint sent earlier, plus the token issued during login:
{
"type": "AUTH",
"data": {
"appVersion": "3.0.83",
"brand": "google",
"model": "sdk_gphone16k_x86_64",
"screenHeight": 2400,
"screenWidth": 1080,
"token": "b7b16159-fe52-49c1-a31a-e26a0423b46c"
},
"deviceId": "DEV-DD6B2E497B18",
"msgId": "a3acb07c-afcd-4e14-904b-2e45926ad8ca",
"timestamp": 1785136071
}
The server replies with a bare AUTH_OK, and from there the device starts talking on its own. A PING follows almost immediately, carrying thermal and battery telemetry (temperature, charge state, power-save status), then a much heavier STATE message that dumps the device's full connection and battery state in one shot: network type, signal quality, WebSocket health metrics down to the millisecond, thermal readings again, all of it.
From here the server can push COMMAND messages at will, and the client answers each one with a REPORT referencing the original msgId. Two commands showed up during this session. The first was mundane:
{
"type": "COMMAND",
"data": { "action": "REFRESH_DEVICE_CONFIG" }
}
which the client acknowledged with a plain success report ("设备配置已刷新", config refreshed).
The second was more interesting. GET_INSTALLED_APPS came in with a commandId and a value of SCAN_LOCAL, and the device responded with a full JSON array of every installed app, package name, and version string on the phone. Scrolling through that list is where it gets a little uncomfortable: sitting right there next to Chrome, Gmail, and YouTube is tech.httptoolkit.android.v1, HTTP Toolkit. The malware's own reconnaissance command handed back the exact tool I was using to intercept its traffic. It doesn't appear to have acted on that information in this session, no evasive behavior kicked in, but the operator on the other end of that WebSocket had every opportunity to notice they were being watched.
What's left on disk
Static analysis had already predicted where the app would keep its local state, and pulling the actual files off a rooted device confirmed it. nightplay_prefs.xml holds nothing but the hardcoded invite code:
<map>
<string name="invite_code">123456</string>
</map>
domain_resolver.xml caches the result of the domain resolution step from earlier, so the app doesn't have to re-fetch and re-check both C2 hosts on every launch:
<map>
<string name="resolved_region">hk</string>
<string name="resolved_config_urls">["https://yanglin202107.github.io/project-docs/d/config.json"]</string>
<string name="resolved_name">主节点</string>
<string name="resolved_api_base">https://voicezone.vip/api</string>
<long name="resolved_timestamp" value="1785142888875" />
</map>
Two smaller files round it out: permission_status_diagnostics.xml, tracking whether the keep-alive service is running, and offline_diagnostics.xml, timestamping the last time accessibility permission state changed. Nothing dramatic on its own, but together with the config pulled over the wire, it's a complete picture of exactly what the app knows about itself and the device it's sitting on at any given moment.
Everything up to this point, registration, config, WebSocket, is plumbing. This is where the app actually does things to the device. ControlAccessibilityService is the component that turns a single granted permission into full control over the UI, and the code around it splits into a few distinct jobs.
Uninstall guard: The service watches every accessibility event on the device, clicks, window changes, content changes, and walks the active window's node tree looking for a small set of screens: app info pages, uninstall or force-stop dialogs, the accessibility settings page itself, developer options, the notification shade, the device admin deactivation screen. It recognizes these across languages too, keyword lists exist for English, Simplified and Traditional Chinese, Hindi, Bengali, Urdu, Tamil, Telugu, and Marathi. The moment one of these screens is detected, the guard fires an exit burst: a configurable number of Back presses at a configurable interval, sometimes followed by Home. It even accounts for different OEM launchers (MIUI, Oppo, Samsung, stock Google) so it can catch someone trying to uninstall from the app drawer instead of settings. The one place this guard stands down is during the app's own onboarding, it disables itself while running its own permission requests, so it doesn't accidentally Back-button its way out of asking for the permissions it needs.
Credential and PIN theft: This is where stagesConfigs from the earlier config dump gets used. For any of the twenty-odd tracked financial apps, the service watches for a login, balance, payment, or success screen using the view IDs and keywords defined server-side, and extracts whatever it finds, PINs, balances, transaction amounts, bank names, straight off the screen without ever touching the app's actual authentication flow. As far as the banking app is concerned, a real user is just using it normally.
The lockscreen trick: lockscreenUnlockAuto, as covered earlier, drives this directly. The overlay you see isn't guessing at your lock type, it already knows it, because the accessibility service recorded by forcing a device lock abusing the admin permission. What the config also confirms is the "fail once, succeed always" behavior isn't a UI bug on my end, it's built around inputRetries and scripted timing (enterDelayMs, pressEnter) meant to look like a real device rejecting a mistyped PIN once before accepting it. It's a small detail, but it's the kind of thing that makes a fake screen feel real for exactly as long as it needs to.
The command set: Everything above gets triggered by commands arriving over the WebSocket, the same COMMAND message type we saw asking for REFRESH_DEVICE_CONFIG and GET_INSTALLED_APPS. The full list runs to more than thirty distinct actions, but they group cleanly:
Credential theft: CREDENTIAL_SIM_SHOW, PAYMENT_PIN_SHOW, and their dismiss counterparts, pop the fake overlays on demand.
Privilege escalation: REQUEST_PERMISSION_AUTH, RUN_PERMISSION_INIT, AUTO_SETTINGS_SEARCH_BIOMETRIC, walk the user (or walk past the user) through granting more access.
Remote control: SET_CONTROL_MODE, SET_SYNC_FREQ, switch between screen and layout streaming and how often frames get pushed out.
Evasion: KEEP_SCREEN_OFFKEEP_SCREEN_ON, NOTIFICATION_DISMISS_AUTOMATION, SET_ONLINE_LITE_MODE, keep the app quiet and unnoticed when it needs to be.
Message abuse: MSG_BROADCAST, the propagation engine, more on that next.
Unrecognized commands fall through to a logged error (远程指令未识别, unrecognized remote command), which is a small tell that this command set has grown organically over time, there's error handling built for the day the operators add a new one the client doesn't know about yet.
Propagation: The Loop Closes
MSG_BROADCAST is the command that turns an infected phone into a distribution node. When it fires, the server hands over a communication channel (whatsapp, sms, or, as observed in a live trace, telegram), a message body, a list of recipient numbers, and a delay range to space sends out. The recipients are the victims own contacts.
The engine doesn't touch any of these apps through an API. Instead, it accesses them using the accessibility tree. Numbers get normalized first, non-numeric characters stripped, country code prefixed if missing, defaulting to 91.
Then for each recipient, the same sequence repeats:
Open the conversation
Wait for the UI to settle
Find the message input field by its accessibility view ID
Insert the text with ACTION_SET_TEXT
Find the send button either by known view ID or by searching for common labels
Click it with ACTION_CLICK, then confirm the send actually went through by checking that the compose field went empty again. A randomized delay, then the next contact.
Under the hood this runs through an adapter pattern, one adapter per messaging app, each one knowing that specific app's exact view IDs and quirks. Only one broadcast job is allowed to run at a time, tracked with atomic state so a second command can't stomp on one already in progress, and the whole thing is built to survive a screen that doesn't load in time rather than just crashing out.
This is the mechanism that explains why this post opened on a site called nightroom.cc at all. Somewhere, on someone else's phone, that same engine ran once already, sending a link out to contacts.
Same Loop, Different Skin
Everything above, the accessibility engine, the credential harvesting, the lockscreen trick, the WebSocket protocol, was reverse engineered from a sample calling itself Reeloop and NightPlay. The lure site and the live trace that opened this post ran on an entirely different set of names, Vexo and Onda Pulse, different file names, different hashes, different sizes even, but the same backend and the same behavior underneath, right down to the fail-once-succeed-always lockscreen and the exact WebSocket message types.
That's not a coincidence, and it's not a copycat borrowing the idea. It's the same operation shipping a different build under a different skin. Follow enough of these lure sites and the pattern keeps showing up, one traced session pulled down something called Vexo.apk from cdn.zorex.live, with a build path in the download URL literally tagged india-prod-admin-44698. That's campaign infrastructure with the naming baked right into the file path, which means there's a pipeline somewhere generating these builds on demand, each with a new package name and a freshly debug-signed cert, but the same C2 domains wired in underneath every time.
Which is really the only lesson worth taking from any of this. Don't trust the name. A file called Reeloop today might be called something else by the time you're reading this, and whatever it drops probably won't be called NightPlay either. What stays constant is the stuff underneath, the domains, the hashes, the shape of the WebSocket messages, the exact way it locks your screen and lets you back in on the second try. That's what's actually worth watching for.
Indicators of Compromise
File Hashes (SHA256)
| File | Hash |
|---|---|
| Primary Payload APK (NightPlay) | e63c5d5296dcf338483fa8e3b1d5c3260c5ccc9d266599506f7e11779e82e211 |
| Related Dropper APK (from C2 lure pages) | f24cb0ba56146cfd700c06320061c36ea8f17c263cebefce0db9313dc0d399bc |
| First-Stage Dropper APK (Reeloop) | dcf66e3f1a79f1f26353fb38d4d95686faa8c12626804e98c115244b34289cee |
| Alternate Payload APK (Vexo, report-referenced) | 98c2b855c75dad571b31e6ef822a435c1df23417453b52b2f052aab2b87c927e |
| Embedded dex within Vexo.apk | 48ad8bf0e7bacfe0e37dcbf1e0d91fe82dbd080414b0a6696 63a77bff11b35d2 |
| Vexo1.apk (second build variant, this trace) | 49786c125bb91de846a17ba53880c15e35d1f1785b0ad6ef35f98a9e912feab5 |
| base.apk (Onda Pulse, second-stage payload extracted from device) | a3374e864c888e82cd5228d072b2df10882330317444d0d96cc0593b930e4e04 |
| Helper App (NexaNest) | Not yet obtained |
Application Identity
| Field | Value |
|---|---|
| Package Name (NightPlay) | com.codex.nightplay |
| App Label (First-stage dropper) | Reeloop / Vexo |
| App Label (Second-stage payload) | NightPlay / Onda Pulse |
| App Label (Helper app) | NexaNest |
| Default Invite Code | 123456 |
Command & Control
| Field | Value |
|---|---|
| Config Resolver | https://yanglin202107.github.io/project-docs/d/config.json |
| Priority 1 C2 Domain | voicezone.vip |
| Priority 2 C2 Domain | nightroom.cc |
| WebSocket Endpoint | wss://voicezone.vip/device/ws |
Backend & APIs
| Field | Value |
|---|---|
| Primary API Server | api.grabluck.cc |
| Secondary API Server | apiv2.grabluck.cc |
| Control Panel (Admin) | h5.grabluck.cc |
| Gin-Vue-Admin Panel | v2boss.taskira.top |
Infrastructure / CDN
| Field | Value |
|---|---|
| Payload Delivery CDN | cdn.privatecorner.live |
| Thumbnail CDN 1 | cdn.nightplay.live |
| Thumbnail CDN 2 | cdn.playlust.vip (Amazon CloudFront) |
| Payload Delivery CDN (Vexo build) | cdn.zorex.live |
| AWS S3 Bucket | coinlist000-in.s3.ap-south-1.amazonaws.com |
Deceptive Lure Domains
| Domain | Role |
|---|---|
midnightheat.live |
Embedded adult lure (WebView) |
grabluck.cc |
Adult video dropper lure |
luck.grabluck.cc |
Betting / lucky draw lure |
callzone.vip |
Adult video dropper lure |
chatnest.vip |
Adult video dropper lure |
privateplay.live |
Adult video dropper lure |
nightstream.vip |
Adult video dropper lure |
loveview.live |
Adult video dropper lure |
nightvault.vip |
Adult video dropper lure |
callwave.vip |
Adult video dropper lure |
talkwave.vip |
Adult video dropper lure |
vibemate.vip |
Adult video dropper lure |
secretplay.live |
Adult video dropper lure |
nightroom.cc |
Adult video lure / priority 2 C2 |