1463 words
7 minutes
Patch 6 — Removing the `FRIDA:UNEXPECTED` Protocol Error Marker

( بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ )

CAUTION

#FreePalestine

WARNING

This is just for educational purposes.


Patch 6 — Suppressing the "Unexpected command" Protocol Error in the ADB Client#

Introduction#

Frida ships its own built-in ADB protocol implementation, called Droidy, inside src/droidy/droidy-client.vala. It is used by frida-server when acting as an ADB transport for the host-side tooling, handling the low-level ADB wire protocol directly in Vala without depending on the adb host binary.

In the original code, when Frida’s ADB state machine receives a connection-layer command (OPEN, CLSE, WRTE) in a context where the protocol does not expect them, it throws a hard Error.PROTOCOL ("Unexpected command"). This behaviour is a dual detection surface: the error string is a static binary fingerprint, and the error-throwing response to a crafted probe packet is a behavioral fingerprint. Detection tools and behavioral probes can trigger this path deliberately and observe the result.

This patch converts the throw to a break — the unexpected command is silently discarded instead of raising a protocol error — eliminating both the static string and the observable fault response simultaneously.


The Original Function / String#

File: src/droidy/droidy-client.vala
Namespace: Frida.Droidy
Context: The ADB packet dispatch loop inside Droidy’s connection handler.

The ADB protocol exchanges fixed-format 24-byte command packets over a TCP connection. Each packet carries a 4-byte command field: SYNC, CNXN, AUTH, OPEN, CLSE, WRTE, OKAY etc. Droidy’s packet loop reads these commands and dispatches them through a state machine.

The original code at the relevant switch branch:

case "OPEN":
case "CLSE":
case "WRTE":
    throw new Error.PROTOCOL ("Unexpected command");

default:
    var length = parse_length (command_or_length);

When in a state where OPEN, CLSE, or WRTE are not valid — for example when the handshake (CNXN/AUTH) has not yet completed — receiving one of these commands causes Droidy to throw a typed Error.PROTOCOL exception with the message string "Unexpected command". This exception propagates up and typically closes the connection.

Why this is a detection surface:

  1. Static string fingerprint. "Unexpected command" ends up as a null-terminated string literal in frida-server’s .rodata section. A scanner that extracts strings from the frida-server binary and looks for Frida-specific error messages will find it.

  2. Behavioral probe fingerprint. An app or server-side component can open a TCP connection to the frida-server port and deliberately send an OPEN packet before a valid CNXN handshake. A genuine ADB daemon either ignores the packet or responds differently. Frida’s Droidy drops the connection with a protocol error — a fingerprint distinguishable from real ADB daemon behavior by any tool that tests for it.

  3. Error surfacing through frida-core APIs. If the error propagates all the way to a managed exception handler, the string "Unexpected command" may appear in logcat or exception traces, which are also monitored by some detection systems.


Before Patching — Behavior on Android#

Confirming the string in the frida-server binary:

$ adb push frida-server /data/local/tmp/
frida-server: 1 file pushed, 0 skipped

$ adb shell strings /data/local/tmp/frida-server | grep -i "unexpected"
Unexpected command

Behavioral probe — sending an OPEN packet before CNXN handshake:

Frida-server by default listens on TCP port 27042. The probe sends a raw ADB OPEN packet (command bytes 4e45504f = OPEN in little-endian ADB format) without first completing the CNXN handshake that a real ADB connection always begins with:

$ adb forward tcp:27042 tcp:27042
$ python3 -c "
import socket, struct

## Minimal ADB OPEN packet: cmd=OPEN, arg0=1, arg1=0, data_length=0, crc=0, magic
OPEN_CMD = 0x4e45504f
pkt = struct.pack('<IIIIII', OPEN_CMD, 1, 0, 0, 0, OPEN_CMD ^ 0xffffffff)
s = socket.create_connection(('127.0.0.1', 27042))
s.sendall(pkt)
resp = s.recv(1024)
print('Response:', resp.hex() if resp else 'connection closed')
s.close()
"
Response: connection closed

Frida-server closes the connection immediately after the malformed OPEN — the throw is caught and handled by dropping the transport. A real ADB daemon on port 5037 would respond with an AUTH challenge or CNXN packet instead of a silent close. The distinction is detectable by anything that compares both behaviors.


How Apps Detect It#

Method 1 — Static string scan of the frida-server binary#

## Scan all executables in known Frida server locations for the error string
import subprocess, os

SCAN_PATHS = [
    "/data/local/tmp/frida-server",
    "/data/local/tmp/fs",
    "/data/data/re.frida.server/files/frida-server",
]

FRIDA_STRINGS = [
    "Unexpected command",
    "frida:rpc",
    "frida_agent_main",
    "gum-js-loop",
]

for path in SCAN_PATHS:
    if not os.path.exists(path):
        continue
    result = subprocess.run(["strings", path], capture_output=True, text=True)
    for sig in FRIDA_STRINGS:
        if sig in result.stdout:
            print(f"[!] Frida string '{sig}' found in {path}")

Method 2 — Behavioral probe against the Frida server port (Python)#

import socket
import struct
import time

def probe_frida_server(host: str, port: int) -> bool:
    """
    Send a premature OPEN packet to port 27042.
    Frida (unpatched) drops the connection immediately.
    A real ADB daemon responds with CNXN or AUTH.
    """
    OPEN_CMD = 0x4e45504f
    pkt = struct.pack('<IIIIII',
        OPEN_CMD,       # command
        1,              # arg0 = local_id
        0,              # arg1 = 0
        0,              # data_length
        0,              # data_crc32
        OPEN_CMD ^ 0xffffffff  # magic
    )

    try:
        s = socket.create_connection((host, port), timeout=2.0)
        s.sendall(pkt)
        s.settimeout(1.0)
        resp = s.recv(24)
        s.close()

        if not resp:
            # Immediate close with no response → Frida behavior
            return True

        # Real ADB sends a CNXN (0x4e584e43) or AUTH (0x48545541) packet back
        cmd = struct.unpack_from('<I', resp, 0)[0]
        if cmd not in (0x4e584e43, 0x48545541):
            return True  # Unknown response — likely Frida

    except (ConnectionRefusedError, socket.timeout):
        pass

    return False

Method 3 — Logcat monitoring for the error message (shell / Java)#

## A device-side watchdog monitoring logcat for Frida protocol errors
adb shell logcat | grep -i "Unexpected command"
// Java logcat reader (requires READ_LOGS permission or root)
Process process = Runtime.getRuntime().exec("logcat -d");
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
    if (line.contains("Unexpected command")) {
        triggerTamperResponse();
    }
}

The Patch#

What changed: The throw new Error.PROTOCOL ("Unexpected command") is replaced with a break, turning a fatal error into a silent discard.

--- a/src/droidy/droidy-client.vala
+++ b/src/droidy/droidy-client.vala
@@ -1015,7 +1015,7 @@ namespace Frida.Droidy {
                         case "OPEN":
                         case "CLSE":
                         case "WRTE":
-                            throw new Error.PROTOCOL ("Unexpected command");
+                            break; // throw new Error.PROTOCOL ("Unexpected command");
 
                         default:
                             var length = parse_length (command_or_length);

Why break instead of removing the cases entirely:

The case "OPEN":, case "CLSE":, and case "WRTE": labels must remain in the switch to prevent their command strings from falling through to the default: branch, which would attempt to parse them as a hex length value (parse_length(command_or_length)) and corrupt the packet processing state. The break cleanly exits the switch for these commands, which are simply dropped. The original code comment is preserved inline as a tombstone.

What this eliminates:

SurfaceBeforeAfter
Static string "Unexpected command" in .rodataPresentAbsent (dead code path is compiled away or optimised out)
Connection drop on premature OPEN/CLSE/WRTEImmediate hard closePacket silently ignored, connection kept alive
Logcat error traceVisibleNever emitted

Why this does not break Frida’s functionality:

In normal operation, OPEN, CLSE, and WRTE packets only arrive on this branch when they are genuinely out-of-sequence — i.e., during a malformed or adversarial connection. Legitimate ADB clients always begin with CNXN, so this code path is never exercised in a real session. Silently discarding out-of-sequence packets and continuing is consistent with how most network protocol implementations handle unexpected input gracefully. The connection stays alive and resumes normal processing on the next valid packet.


After Patching — Behavior on Android#

Static string scan — gone:

$ adb shell strings /data/local/tmp/frida-server-patched | grep -i "unexpected"
(no output)

Behavioral probe — connection stays alive, no immediate close:

$ python3 -c "
import socket, struct

OPEN_CMD = 0x4e45504f
pkt = struct.pack('<IIIIII', OPEN_CMD, 1, 0, 0, 0, OPEN_CMD ^ 0xffffffff)
s = socket.create_connection(('127.0.0.1', 27042))
s.sendall(pkt)
s.settimeout(1.0)
try:
    resp = s.recv(24)
    print('Response:', resp.hex() if resp else 'empty')
except socket.timeout:
    print('Timeout — connection still open, packet was silently discarded')
s.close()
"
Timeout — connection still open, packet was silently discarded

The patched server absorbs the probe packet without error, leaving the connection open. The behavioral response is now indistinguishable from a permissive network service that ignores unknown input rather than crashing on it.

Logcat — silent:

$ adb shell logcat | grep -i "unexpected"
(no output)

All three detection surfaces — static string, behavioral probe, and logcat — are eliminated by a single one-line change.


Noob Section (In Simple Words)#

The problem:

Frida has its own mini ADB server built in (called Droidy). When it receives a network packet it doesn’t expect, it crashes the connection and produces an error message that says "Unexpected command". This is a problem in two ways: first, that error text is stored inside the Frida binary, so scanners can find it. Second, a security tool can deliberately send a bad packet to Frida’s port (Droidy) and watch what happens. If the connection drops immediately, it knows it’s talking to Frida and not a real ADB server.

What this patch does:

One line changes: instead of throwing an error and killing the connection, Frida now just silently ignores the unexpected packet and moves on. The "Unexpected command" text is removed from the code entirely.

The result:

The error string disappears from the binary, so static scanners can’t find it. If a security tool sends a probe packet, the connection stays open and nothing happens, making Frida behave like any other network service that quietly drops unknown input. The error also never shows up in Android’s system logs anymore.