بسم الله، الحمد لله حمدًا طيبًا مباركًا يليق بجلاله وعظيم سلطانه، والصلاة والسلام على نبينا محمد ﷺ، الذي بلّغ الرسالة وأدّى الأمانة وجاهد في الله حق جهاده.
I'm Ahmed Allah Mohamed, aka 0x_V3n0m.
In this write-up, I'll walk through how I analyzed and solved the EagleSpy challenge on MalOps, diving into the Android malware sample and uncovering its behavior through static analysis and reverse engineering.
We'll start by analyzing the APK structure and its AndroidManifest.xml, then trace the loader's execution flow, identify the embedded payload, investigate its persistence mechanism, and finally extract important C2 and encryption-related indicators.
So, enough talking — let's dive into the malware and see what's hiding under the hood.
Q1 — What is the name of the file used to define the essential structures and metadata of an Android application?
Before an Android app ever runs, the operating system needs to know what's inside it — what components exist, what permissions it's asking for, what package name identifies it. All of that lives in one file: AndroidManifest.xml, sitting at the root of every APK. It's not optional; without it the system has no way to register the app's Activities, Services, Broadcast Receivers, or Content Providers, or to enforce the permissions it declares. This is also why static analysis of any Android sample almost always starts here — it's the fastest way to map out an app's attack surface before touching a single line of Java or Smali.
Q2 — What is the package ID name of this sample?
Every Android package needs a unique identifier so the OS can tell it apart from every other app on the device — that identifier is set once, in the package attribute of the root <manifest> tag. For this sample it's com.appd.instll.load, sitting alongside versionName="3.31.165" and versionCode="331165" in the same tag. The naming pattern here is worth noting on its own: instll (missing the second "a") mimics a generic installer/updater app, which fits its role as the outer loader — the first-stage APK the victim actually installs.
Q3 — How many permissions are declared by this sample?
Four <uses-permission> entries appear in the outer loader's manifest:
| Permission | Role |
|---|---|
READ_EXTERNAL_STORAGE |
Read access to device storage |
WRITE_EXTERNAL_STORAGE |
Write access to device storage |
REQUEST_INSTALL_PACKAGES |
Silently trigger installation of a new APK |
REQUEST_DELETE_PACKAGES |
Silently trigger removal of an installed package |
Individually these look mundane, but together they form the exact permission set a dropper needs: write the next-stage payload to storage, then install it without the standard "install from unknown sources" friction a user would normally notice.
Q4 — What is the name of the class used in the main activity used as the entry point?
Looking at the <activity> block, two things mark com.appd.instll.splash as the entry point rather than any other component: the android.intent.action.MAIN action and the LAUNCHER category sitting together in the same <intent-filter>. This pairing is what Android's package manager scans for when building the home-screen launcher icon — whichever activity carries it is what actually runs first when the user taps the app. Everything downstream in this analysis (the install check, the permission prompts, the payload drop) traces back to splash.onCreate().
In JADX, this same answer is a lot faster to reach than manually scanning the manifest — Ctrl+Shift+M jumps directly to the main Activity, or it's reachable via Navigation → Main Activity.
Q5 — What is the method used to validate if an application is already installed on the device?
The check happens in a single line: if (isAppAvailable(applicationContext, TargetBaseid)). What makes this interesting isn't just the boolean check itself, but what each branch does with the result. If the target package is installed, a Timer scheduled after a 1-second delay grabs a launch intent for it via getPackageManager().getLaunchIntentForPackage(TargetBaseid) and starts it directly — silently handing control to the already-installed app. If it's not installed, the else branch instead launches oxgouacygcwohqzcuxomeqcxomnxun3 — the install-prompt activity that kicks off the payload drop covered in the next questions. In both cases the sample calls splash.this.finish() right after, closing the loader's own splash screen so the transition looks seamless to the victim.
Q6 — What is the name of the application package that is checked by the previous method?
The package name is com.found.hentai.
Q7 — The sample checks the device's default language. How many languages does this sample target?
Inside oxgouacygcwohqzcuxomeqcxomnxun3 a switch on Locale.getDefault().getLanguage() swaps the button text into: Arabic (ar), English (en), Portuguese (pt), Russian (ru), Turkish (tr), and Simplified Chinese (zh) — with default silently falling back to English.
Q8 — What are the two permissions requested dynamically by the sample to install the next payload?
android.permission.WRITE_EXTERNAL_STORAGE, android.permission.READ_EXTERNAL_STORAGE
The sample checks for both via checkPermissions() — which uses ContextCompat.checkSelfPermission() against each — and if either is missing, requestPermissions() fires ActivityCompat.requestPermissions(...) to prompt the user for both at once.
Q9 — From which internal location is the payload APK loaded during installation?
assets/childapp.apk
The install() method opens it directly from the APK's bundled assets — context.getAssets().open("childapp.apk") — and streams that data into a PackageInstaller.Session created via packageInstaller.openSession(...). Once the stream is fully written, the session gets committed, which triggers a silent install requiring nothing more from the user than the REQUEST_INSTALL_PACKAGES permission granted earlier. No download, no external fetch — the payload has been sitting inside the loader's own APK the whole time.
Q10 — What is the SHA256 of the next payload?
799a3d9663bb9c26c3cfe68ed2c8e35c657ac3edf969825dc83f2c5812f96429
Since childapp.apk sits inside assets/, static analysis alone won't hand you a hash for it — it has to be extracted from the loader's APK and hashed directly. After exporting childapp from the assets tree in JADX:
Running sha256sum childapp.apk on it gives the value above.
Q11 — The second payload acts as a legitimate government application. What is the name of the Trojanized application?
VNeID
Digging into childapp.apk's resources.arsc → res/values/strings.xml, the Myname string resolves to VNeID — this is what the app calls itself once installed, complete with a Copyrights string reading "© 2023 VNeID. All rights reserved." to sell the disguise. VNeID is Vietnam's real national digital-ID app, so the payload is impersonating a legitimate government identity service — a convincing lure that would push most victims to grant it broad permissions without a second thought.
Q12 — To avoid losing access to the compromised device, the malware establishes persistence on it. What is the name of the permission used for persistence?
android.permission.RECEIVE_BOOT_COMPLETED
Inside the utilities class's PERMISSIONS() method, most permissions in the returned list are conditional — gated behind isNO(...) checks that presumably reflect some server-side config. RECEIVE_BOOT_COMPLETED stands out because it's added unconditionally, right alongside storage and phone-state permissions, with no if guarding it. That's a strong signal of how critical it is to the malware's design: paired with a dedicated BootReceiver component (visible in the open tabs), it lets the RAT relaunch itself automatically every time the device reboots — surviving reboots without any user interaction.
Q13 — After identifying the method that establishes the C2 connection, what is the decoded IP address and port used by this malware?
103.253.23.8:7773
Inside initializeService — a class extending Service, tucked away in the same heavily-obfuscated package tree as the rest of the RAT's core logic — two static fields hold the C2 coordinates, both Base64-encoded:
* ClientHost = "MTAzLjI1My4yMy44" → 103.253.23.8
* ClientPort = "Nzc3Mw==" → 7773
Decoding both gives the full endpoint the malware phones home to. Storing these as plain Base64 rather than actual encryption is a low-effort evasion move — it defeats a raw string search for the IP, but folds instantly under any decompiler or automated static-analysis pass.
Q14 — What is the key used to identify this malware family?
EagleSpy
ConnectionKey isn't a plain field — it's the return value of a call into utilities, one of those heavily obfuscated method names (eosjvohlvdszzfnoawempbvgtfrhiukwdrdirywuhpeetixbkj45), passed the literal "RWFnbGVTcHk=" as its argument. That obfuscated method is doing nothing more exotic than a Base64 decode — running the string through it (or manually decoding it) resolves to EagleSpy. This is the strongest family-identification IOC in the whole sample: it's effectively the malware announcing its own name to the C2 on every connection, which is what let researchers attribute this RAT as EagleSpy in the first place.
Q15 — What is the secret key used to encrypt and decrypt the JSON data?
93b89a273c1fbe59073af7bd9adfa6c0
The EncryptionCUtils class hardcodes this 32-character string directly into a private static final String SECRET_KEY, used verbatim as a SecretKeySpec in both encrypt() and decrypt(). The implementation is about as basic as AES gets: no key derivation function, no IV, Cipher.getInstance("AES") defaulting to ECB mode with PKCS5 padding, and the ciphertext just Base64-wrapped before going over the wire. Since the key is hardcoded and identical for every infected device, anyone who extracts this string from the sample — like we just did — can decrypt every JSON packet exchanged between the RAT and its C2, including any captured network traffic.
Thanks a lot for taking the time to read this write-up — I hope it was helpful and easy to follow!
If I made any mistakes along the way, they're on me (or on Shaytan); any good in this write-up is purely from Allah.
If this article helped you, please share it with others!
Some information may be outdated





