#  Caught in the Reeloop

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 Lure: Nightroom

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.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/8bff6cbe-ba06-498e-b424-9a08aa80250b.png align="center")

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.

* * *

## The Dropper: Vexo/Onda Pulse

When trying to install the app, the package manager reports the name of the app as `Onda Pulse`.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/3436da54-3e2a-448f-b5c0-a72d25b712b0.png align="center")

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.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/a45c465f-a708-4ab7-95b3-7c42893154d6.png align="center")

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.

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/96ee53f6-b7cc-4f02-b250-60fed40b4363.png align="center")

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.

* * *

## Under the hood

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.

### Remote Config

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`

```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.

### Device Registration

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:

```json
{
  "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:

```json
{
  "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`:

```json
{
  "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:

```json
{
  "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.

### Runtime Configuration

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:

![](https://cdn.hashnode.com/uploads/covers/68d0b0f032185084509faad5/19f37c18-41fb-46b2-8617-26aa93589e21.png align="center")

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.

### WebSocket Protocol

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:

```json
{
  "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:

```json
{
  "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:

```json
<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:

```json
<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.

### The Accessibility Automation Engine

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:

1.  Open the conversation
    
2.  Wait for the UI to settle
    
3.  Find the message input field by its accessibility view ID
    
4.  Insert the text with `ACTION_SET_TEXT`
    
5.  Find the send button either by known view ID or by searching for common labels
    
6.  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 |
