Foreword
iOS implants are rare! This is why I didnβt wait to read all the publications about it and really enjoyed the hands-on video by Christopher Lopez. But after watching it, I was even more curious about other components of the iOS spyware. I looked at the Symbols in Binary Ninja and found a class called CameraEnabler. Since then, Iβve been investigating it. Three weeks later, hereβs the first part of it. I hope youβll enjoy it.
Introduction
What is Predator?
Predator is a sophisticated iOS spyware attributed to Intellexa/Cytrox, deployed against journalists, activists, and political figures between 2021-2023. While excellent analyses from [Amnesty Tech](https://securitylab.amnesty.org/latest/2025/12/intellexa-leakβ¦
Foreword
iOS implants are rare! This is why I didnβt wait to read all the publications about it and really enjoyed the hands-on video by Christopher Lopez. But after watching it, I was even more curious about other components of the iOS spyware. I looked at the Symbols in Binary Ninja and found a class called CameraEnabler. Since then, Iβve been investigating it. Three weeks later, hereβs the first part of it. I hope youβll enjoy it.
Introduction
What is Predator?
Predator is a sophisticated iOS spyware attributed to Intellexa/Cytrox, deployed against journalists, activists, and political figures between 2021-2023. While excellent analyses from Amnesty Tech, iVerify, and Google TIG have documented the what and why, this series focuses on the how: the internal mechanics that enable this surveillance capability.
What This Article Covers
- How the malware initializes its control server after initial compromise
- The Unix socket-based IPC mechanism for receiving commands
- The factory pattern used to create surveillance modules on-demand
- How operations are managed, cached, and destroyed
Notes for the reader
- Assembly code is provided as evidence; AI-generated pseudo-code is used for clarity.
- Non-essential assembly snippets were omitted for brevity.
- Some claims are hypotheses (especially regarding C2 orchestration) and should not be taken as definitive.
- Conclusions are presented before proofs to improve readability.
Architecture Overview
Malware Architecture: the dual-mode binary
Predator uses a single executable that operates in two distinct modes based on a command-line argument:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Same Binary - Two Execution Modes β
β β
β Launched with argv[14]: β
β β’ "watcher" β Orchestrator & persistence β
β β’ "helper" β Surveillance operations server β
ββββββββββββββββ¬βββββββββββββββββββββββ¬ββββββββββββββββββββββββ
β β
β β
mode="watcher" mode="helper"
β β
βΌ βΌ
ββββββββββββββββββββββββ βββββββββββββββββββββββββββββββ
β Watcher Process β β Helper Process β
β β β β
β β’ Initial checks β β β’ Unix socket server β
β β’ Downloads payloadsβ β (/tmp/helper.sock) β
β β’ File monitoring β β β’ Surveillance modules: β
β β’ Spawns Helper βββββΌββββ€ - CameraEnabler (13) β
β β β - Voip (11) β
β β β - KeyLogger (12) β
β β β - HiddenDot (10) β
ββββββββββββββββββββββββ βββββββββββββββββββββββββββββββ
For the rest of this paper I will focus on the Helper process, which contains the surveillance modules Iβll analyze.
High-Level Flow
Before diving into implementation details, hereβs how the complete surveillance pipeline works:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Startup Sequence β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β 1. main() detects β
β "helper" mode β
ββββββββββββ¬ββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β 2. HelperHandler.start()β
β - Anti-debugging β
β - Self-deletion β
β - Spawn socket serverβ
ββββββββββββ¬ββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β 3. Listen on Unix socketβ
β /tmp/helper.sock β
ββββββββββββ¬ββββββββββββββββ
β
ββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββ
β Runtime Operations β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββΌβββββββββββββββββ
βΌ βΌ βΌ
βββββββββββ βββββββββββ βββββββββββ
β Command β β Command β β Command β
β 'A' β β 'E' β β 'D' β
β Init β β Execute β β Delete β
ββββββ¬βββββ ββββββ¬βββββ ββββββ¬βββββ
β β β
βΌ βΌ βΌ
Create & Run method Remove from
cache op on cached op cache
The system follows a clear request-response pattern: external commands arrive via Unix socket, get parsed and validated, then dispatched to surveillance operation modules managed by a factory pattern.
The Command Protocol
Before examining how the server processes commands, let me explain the protocol itself. This is the interface between the command sender (likely a remote controller) and the Helperβs surveillance modules.
Protocol Format
Commands sent to the Unix socket follow a simple text-based protocol:
Format: "operation_id,command_type,data_size\n<data>"
Where:
operation_id = Integer (10-13)
command_type = Single character ('A', 'E', or 'D')
data_size = Integer (bytes of optional data)
\n = Newline separator
<data> = Optional payload (data_size bytes)
The Four Operation IDs
Each of these identifiers points to a class that is part of this executable.
| ID | Operation | Purpose (hypothesis) |
|---|---|---|
| 10 | HiddenDot | Suppress iOS privacy indicator (camera/mic active dot) |
| 11 | Voip | VoIP call interception |
| 12 | KeyLogger | Keystroke capture |
| 13 | CameraEnabler | Camera surveillance (Part 2 focus) |
The Three Command Types
The client can choose between 3 possible commands:
βAβ - Initialize (0x41):
- Creates a new operation instance
- Calls its
init()method - The optional data field is ignored for this command
- Example:
"13,A,0\n"creates and initializes CameraEnabler
βEβ - Execute (0x45):
- Calls
execute(<data>)on an existing operation - Can pass command string
- Example:
"13,E,6\nenable"enables camera surveillance
βDβ - Delete (0x44):
- Destroys an operation instance
- Cleans up resources
- Example:
"13,D,0\n"destroys CameraEnabler
Data Size and Data
The data_size field specifies how many bytes of payload follow the newline. The server validates that size β€ 8191 bytes, allocates a buffer, and reads the data in a loop.
How the data is used:
- βAβ (Initialize): Data is read but not passed to
init()- the method takes no parameters - βEβ (Execute): Data is converted to
NSStringvia[NSString stringWithUTF8String:data]and passed toexecute() - βDβ (Delete): Data is read but completely ignored by the delete operation
Implementation: Server Infrastructure
Now that Iβve explained the protocol, let me examine how the Helper process sets up its command server.
From main() to the Unix Socket Server
HelperHandler Initialization
When launched in "helper" mode, the binary follows this sequence:
Pseudo C++:
int main(int argc, char** argv) {
// ...
char* mode = argv[14];
if (strcmp(mode, "helper") == 0) {
Helper::HelperHandler handler;
bool success = handler.initWithRW();
if (!success) return 1;
handler.start(); // Never returns
}
}
Now Iβll dive into assembly instructions related to this logic.
main @ 0x100004e1c:
The binary first checks if itβs running in helper mode by comparing argv[14]:
0x100004f8c adr x1, data_10004115e ; x1 = "helper" string
0x100004f90 nop
0x100004f94 mov x0, x25 ; x0 = argv[14]
0x100004f98 bl _strcmp ; Compare with "helper"
0x100004f9c cbz w0, 0x10000508c ; If match, jump to helper mode
If the mode matches "helper", the HelperHandler object is constructed on the stack:
0x10000508c add x0, sp, #0x88 ; x0 = &handler
0x100005090 bl Helper::HelperHandler::HelperHandler
The handler is then initialized with kernel read/write primitives:
0x100005094 add x0, sp, #0x88 ; x0 = &handler
0x100005098 bl Helper::HelperHandler::initWithRW
0x10000509c tbz w0, #0, 0x1000050b0 ; Exit if init failed
If initialization succeeds, the handler is started:
0x1000050a0 add x0, sp, #0x88 ; x0 = &handler
0x1000050a4 bl Helper::HelperHandler::start
What Happens in start()?
Helper::HelperHandler::start @ 0x10000d44c:
| Step | Operation | Purpose |
|---|---|---|
| 1 | Utils::enableMemoryProtection() | Anti-debugging protection |
| 2 | unlink(executable_path) | Remove binary from disk (anti-forensics) |
| 3 | Register shutdown observer | Clean up on device shutdown |
| 4 | Create health check timer | Monitor Agent process status |
| 5 | Spawn socket server thread | Start Unix socket IPC server |
Step 1: Enable memory protection as an anti-debugging measure:
0x10000d468 bl Utils::enableMemoryProtection
I did not perform a full analysis. This code calls memorystatus_control with commands 5 and 6 (_SET_JETSAM_HIGH_WATER_MARK and _SET_JETSAM_TASK_LIMIT) to query or adjust Jetsam memory limits for the current process, then checks the returned flags for bit 0x80000000. If not, the code proceeds to call setrlimit() with RLIM_INFINITY for several POSIX resource classes, including CPU time and other per-process limits.
A reasonable hypothesis is that this combination is intended to reduce the likelihood of termination under memory or resource pressure by opting out of both Jetsam memory limits and POSIX resource limits, assuming the process has sufficient privileges or entitlements.
Step 2: Delete the executable from disk for anti-forensics:
0x10000d47c bl __NSGetExecutablePath ; Get path to self
0x10000d480 cbnz w0, skip_unlink ; If failed, skip
0x10000d484 add x0, sp, #0x10 ; x0 = path buffer
0x10000d488 bl _unlink ; Delete file
The code executes a self-deletion routine by first resolving its own absolute file path using NSGetExecutablePath. After a safety check to ensure the path was resolved correctly it immediately invokes the _unlink system call on this path. In Unix-based systems like iOS, this removes the fileβs directory entry, effectively making it invisible to the filesystem, while the kernel maintains the actual data on disk as long as the running process holds the file handle open.
The primary goal of this technique is anti-forensics. By unlinking the directory entry, the malware prevents filesystem based discovery.
Step 3: Register for system shutdown notifications to clean up before device powers off:
0x10000d48c bl _CFNotificationCenterGetDarwinNotifyCenter
; Returns CFNotificationCenterRef in x0
0x00000d490 adr x3, cfstr_com.apple.springboard.deviceWillShutDown
; x3 = notification name
0x00000d498 adr x16, 0x10000d7a0 ; Load callback function address
0x00000d4a4 mov x2, x16 ; x2 = &Helper::HelperHandler::onShutdown
0x00000d4a8 mov x1, x19 ; x1 = observer (this)
; `mov x19, x0` during prologue
0x00000d4ac mov x4, #0 ; x4 = object (NULL - all sources)
0x00000d4b0 mov w5, #0x4 ; x5 = DeliverImmediately
0x00000d4b4 bl _CFNotificationCenterAddObserver
; x0 = center (from above)
; Register: call onShutdown() on device shutdown
The code first retrieves the system-wide Darwin Notification Center using CFNotificationCenterGetDarwinNotifyCenter. It then prepares a callback function (Helper::HelperHandler::onShutdown at 0x10000d7a0). Finally, it calls CFNotificationCenterAddObserver to register this callback specifically for the system event com.apple.springboard.deviceWillShutDown.
Technically, the onShutdown callback retrieves the applicationβs main event processing loop using CFRunLoopGetMain and immediately halts it with CFRunLoopStop.
Step 4: Create a timer to periodically check if the Agent process is still alive:
0x10000d4e4 mov x8, #0xc00000000000
0x00000d4e8 movk x8, #0x4072, lsl #0x30 {0x4072c00000000000}
0x10000d4d0 ldr x0, [x8] ; x0 = allocator
0x10000d4e0 mov x3, x16 ; Callback: onCheckAgentTimer
0x10000d4ec fmov d1, x8 ; Interval
0x10000d500 bl _CFRunLoopTimerCreate
0x10000d504 str x0, [x19, #0xa8] ; Store timer handle
0x10000d508 bl _CFRunLoopGetMain
0x10000d51c bl _CFRunLoopAddTimer
This segment establishes a persistent background task by scheduling a recurring CFRunLoopTimer on the applicationβs main event loop. Configured with a floating-point interval of 300.0 seconds (derived from the double-precision constant 0x4072c00000000000), the timer triggers the Helper::HelperHandler::onCheckAgentTimer callback exactly every five minutes.
This callback serves as a file-based watchdog designed to manage the malwareβs lifecycle. Every time the 5-minute timer triggers, the code invokes _access to verify the existence of a specific flag file located at /private/var/tmp/kusama.txt. If the file is present, the function returns immediately, allowing the process to continue. However, if the file has been deleted, the function proceeds to call CFRunLoopStop, terminating the main event loop and shutting down the daemon.
I would expect another part of the malware that would create or remove that file but I did not find other references of that path in the code...
Step 5: Spawn a new thread that will run the Unix socket server:
0x10000d524 bl operator new ; Allocate thread_struct
0x10000d528 mov x21, x0 ; x21 = thread_struct*
0x10000d52c bl std::__thread_struct::__thread_struct
0x10000d534 bl operator new ; Allocate argument
0x10000d538 mov x20, x0
0x10000d53c stp x21, x19, [x0] ; Store (thread_struct, this)
0x10000d540 adr x16, start_unix_socket_server_cb
0x10000d54c mov x2, x16 ; x2 = thread function
0x10000d55c bl _pthread_create
Pseudo C++:
void Helper::HelperHandler::start() {
Utils::enableMemoryProtection();
char path[1024];
uint32_t size = sizeof(path);
if (_NSGetExecutablePath(path, &size) == 0) {
unlink(path); // Remove executable file
}
CFNotificationCenterRef center =
CFNotificationCenterGetDarwinNotifyCenter();
CFNotificationCenterAddObserver(
center,
this,
&HelperHandler::onShutdown,
CFSTR("com.apple.springboard.deviceWillShutDown"),
nullptr,
CFNotificationSuspensionBehaviorDeliverImmediately
);
CFRunLoopTimerRef timer = CFRunLoopTimerCreate(
kCFAllocatorDefault,
0.0, // Fire time (immediate)
300.0, // Interval (~5 minutes)
0,
0,
&HelperHandler::onCheckAgentTimer,
&timerContext
);
CFRunLoopAddTimer(CFRunLoopGetMain(), timer, kCFRunLoopDefaultMode);
std::thread socket_thread(
start_unix_socket_server_cb,
this
);
socket_thread.detach();
CFRunLoopRun();
}
The Unix Socket Server Thread
The spawned thread simply contains a call to the function startUnixSocketServer which takes two arguments:
- The
thisatx0 - The path of the socket at
x1
; Store context and prepare arguments
0x0d948 mov x19, x0 ; Store this pointer
...
0x0d964 ldr x0, [x19, #0x8] ; Get this pointer
0x0d968 adr x1, data_100041c77 ; x1 = "/tmp/helper.sock"
0x0d96c nop
; Start Unix socket server
0x0d970 bl Helper::HelperHandler::startUnixSocketServer
Now let me analyse startUnixSocketServer properly:
startUnixSocketServer @ 0x10000cc68:
This function implements the main server loop that accepts incoming Unix socket connections and dispatches them to a handler.
The function begins by calling createListeningUnixSocket to establish the listening socket:
; Create listening socket
0x10000cc84 mov x20, x0 ; Save socket_path
0x10000cc88 bl Helper::HelperHandler::createListeningUnixSocket
0x10000cc8c mov x19, x0 ; Save socket fd
0x10000cc90 tbnz w0, #0x1f, 0x10000ccfc ; Exit if failed
The createListeningUnixSocket function performs standard Unix socket operations: creates a socket with AF_UNIX and SOCK_STREAM, binds it to /tmp/helper.sock, calls listen() with a backlog of 5, and sets 0777 permissions to allow any local process to connect (AF_UNIX sockets are local-only, not network-accessible).
Note: The client connecting to
/tmp/helper.sockis not analyzed here. My hypothesis is that the WatcherβsdownloadExecutable/executeDownloadedfunctions fetch an external payload that bridges C2 commands to local socket messages, orchestrating the surveillance operations remotely.
βββββββββββββββ
β C2 Server β (Remote)
ββββββββ¬βββββββ
β HTTPS/Network
βΌ
ββββββββββββββββββββ
β Downloaded β (External payload)
β C2 Bridge β β’ Receives C2 commands
ββββββββββ¬ββββββββββ β’ Translates to socket protocol
β Unix Socket
βΌ
ββββββββββββββββββββ
β /tmp/helper.sock β (Local IPC)
β Helper Process β β’ Surveillance operations
ββββββββββββββββββββ β’ CameraEnabler, KeyLogger, etc.
The server then enters a loop calling accept() to wait for incoming connections, processing each client as it connects:
; Accept incoming connections
0x10000cc94 mov w23, #0x10 ; socklen = 16
0x10000cc98 str w23, [sp, #0xc] ; Store socklen on stack
0x10000cc9c add x1, sp, #0x10 ; x1 = &address
0x10000cca0 add x2, sp, #0xc ; x2 = &length
0x10000cca4 mov x0, x19 ; x0 = listening socket
0x10000cca8 bl accept
0x10000ccac tbnz w0, #0x1f, 0x10000ccf4 ; Skip if failed
0x10000ccb0 mov x21, x0 ; Save client socket
Later in the loop, the code processes the client request by calling the processClient method:
; Process client request
0x10000ccbc mov x0, x20 ; x0 = socket_path
0x10000ccc0 mov x1, x21 ; x1 = client socket
0x10000ccc4 bl Helper::HelperHandler::processClient
Implementation: Request Processing
With the server infrastructure in place and the protocol defined, let me examine how commands are parsed and executed.
Protocol Parsing
This function handles the command and control protocol for each connected client. It reads a command line, parses the request size, allocates memory for the payload, reads the full data, processes the command, and sends back a response.
processClient @ 0x10000ce00:
The code starts by reading a line using the Utils class:
; Read command line
0x10000ce1c mov x19, x1 ; Save socket_fd
0x10000ce20 mov x21, x0 ; Save arg1
0x10000ce24 add x1, sp, #0x10 ; x1 = &buffer
0x10000ce28 mov x0, x19 ; x0 = socket_fd
0x10000ce2c mov w2, #0x80 ; buffer size = 128
0x10000ce30 bl Utils::readLine
0x10000ce34 cbz w0, 0x10000ce60 ; Exit if failed
The Utils::readLine function is trivial and wonβt be detailed here.
The command is then split by commas to extract the command type and size, which is converted to an integer:
; Parse command protocol
0x10000ce38 add x0, sp, #0x10 ; x0 = buffer
0x10000ce3c mov w1, #0x2c ; delimiter = ','
0x10000ce40 bl Utils::split ; Split operation_id
0x10000ce44 mov x22, x0 ; Save remainder
0x10000ce48 mov w1, #0x2c ; delimiter = ','
0x10000ce4c bl Utils::split ; Split command_type
0x10000ce50 bl atoi ; Convert size to int
0x10000ce54 lsr w8, w0, #0xd ; size >> 13
0x10000ce58 cbz w8, 0x10000ce78 ; Continue if size <= 8191
0x10000ce5c mov w0, #0 ; Return 0 if too large
Let me show what is happening with a schema:
Initial command line read from socket:
βββββββββββββββββββββββββββββββββββββββββββ
β "operation_id,command_type,data_size\n" β (e.g., "13,E,6\nenable")
βββββββββββββββββββββββββββββββββββββββββββ
After first Utils::split(&buf, ','):
βββββββββββββββββ¬βββββββββββββββββββββββββββ
β "operation_id"β "command_type,data_size" β
β (buf) β (x22) β
βββββββββββββββββ΄βββββββββββββββββββββββββββ
β β
Saved for Second split
later use applied here
After second Utils::split(x22, ','):
βββββββββββββββββ¬ββββββββββββββββ¬βββββββββββββ
β "operation_id"β "command_type"β "data_size"β
β (buf) β (x22) β (->atoi) β
βββββββββββββββββ΄ββββββββββββββββ΄βββββββββββββ
β β β
Operation Command type Size of
ID (10-13) ('A','E','D') payload
Then the code allocates the necessary memory for the optional payload:
; Allocate buffer for payload
0x10000ce7c mov w23, w0 ; Save size
0x10000ce80 mov w0, #0x1 ; count = 1
0x10000ce84 mov x1, x23 ; x1 = size
0x10000ce88 bl calloc
0x10000ce8c mov x20, x0 ; Save buffer
0x10000ce90 cbz w24, 0x10000cebc ; Skip if size == 0
0x10000ce94 mov x24, x20 ; x24 = buffer pointer
The payload is then read from the socket in a loop:
; Read payload data
0x10000ce98 mov x0, x19 ; x0 = socket_fd
0x10000ce9c mov x1, x24 ; x1 = buffer position
0x10000cea0 mov x2, x23 ; x2 = remaining bytes
0x10000cea4 bl read
0x10000cea8 cmp x0, #0 ; Check bytes read
0x10000ceac b.le 0x10000cf28 ; Exit if error/EOF
0x10000ceb0 add x24, x24, x0 ; Advance pointer
0x10000ceb4 subs x23, x23, x0 ; Decrease remaining
0x10000ceb8 b.ne 0x10000ce98 ; Loop if more expected
The command type is parsed and the request is dispatched to the handler:
; Dispatch to request handler
0x10000cebc str xzr, [sp, #0x8] ; Initialize result
0x10000cec0 add x0, sp, #0x10 ; x0 = buffer
0x10000cec4 bl atoi ; Parse operation_id
0x10000cec8 mov x1, x0 ; x1 = operation_id
0x10000cecc ldrsb w2, [x22] ; x2 = command type
0x10000ced0 add x4, sp, #0x8 ; x4 = &result
0x10000ced4 mov x0, x21 ; x0 = this
0x10000ced8 mov x3, x20 ; x3 = payload
0x10000cedc bl Helper::HelperHandler::processRequest
Pseudo C++:
bool HelperHandler::processClient(int socket_fd) {
char buffer[128];
char* response = nullptr;
if (!Utils::readLine(socket_fd, buffer, 128)) {
return false; // Failed to read
}
char* remainder = Utils::split(buffer, ','); // Split operation_id
char* size_str = Utils::split(remainder, ','); // Split command_type
int data_size = atoi(size_str); // Parse size
if ((data_size >> 13) != 0) {
return false; // Size too large
}
char* payload = static_cast<char*>(calloc(1, data_size));
if (data_size > 0) {
char* write_ptr = payload;
int remaining = data_size;
while (remaining > 0) {
ssize_t bytes_read = read(socket_fd, write_ptr, remaining);
if (bytes_read <= 0) {
free(payload);
return false; // Read error or EOF
}
write_ptr += bytes_read;
remaining -= bytes_read;
}
}
int operation_id = atoi(buffer); // First field
char command_type = remainder[0]; // Second field ('A', 'E', 'D')
int result = this->processRequest(
operation_id, // 10-13
command_type, // 'A', 'E', or 'D'
payload, // Optional data
&response // Output parameter for response string
);
// ...
}
Request Dispatcher
This function implements the command dispatcher for the C&C protocol. It routes commands to the appropriate operation handler based on the command type (βAβ, βEβ, or βDβ).
Helper::HelperHandler::processRequest @ 0x10000cf80:
The function first saves parameters and checks the command type to determine which operation to perform:
; Dispatch command type
0x10000cfb4 cmp w2, #'E' ; Execute?
0x10000cfb8 b.eq 0x10000d004 ; Jump to Execute
0x10000cfbc cmp w2, #'D' ; Delete?
0x10000cfc0 b.eq 0x10000d06c ; Jump to Delete
0x10000cfc4 cmp w2, #'A' ; Initialize?
0x10000cfc8 b.ne 0x10000d08c ; Invalid, return 0
Let me detail each case.
Command βAβ - Initialize Operation
Check if operation already exists, and create it if needed:
; Check if operation exists
0x10000cfcc ldr x0, [x22] ; x0 = this->factory
0x10000cfd0 add x8, sp, #0x8 ; x8 = &shared_ptr
0x10000cfd4 mov x1, x21 ; x1 = operation_id
0x10000cfd8 bl Helper::HelperFactory::getOperation
0x10000cfdc ldp x24, x23, [sp, #0x8] ; Load result
0x10000cfe0 cbz x23, 0x10000cff4 ; Continue if not exists
If operation already exists, return error:
; Return error if already initialized
0x10000cff4 cbz x24, 0x10000d0d4 ; Skip if NULL
0x10000cff8 adr x0, data_100041bef ; Error message
0x10000d000 b 0x10000d1ec ; Return error
Create new operation and initialize it:
0x10000d0d4 ldr x0, [x22] ; x0 = this->factory
0x10000d0d8 add x8, sp, #0x8 ; x8 = &shared_ptr
0x10000d0dc mov x1, x21 ; x1 = operation_id
0x10000d0e0 bl Helper::HelperFactory::newOperation
0x10000d0e4 ldp x8, x9, [sp, #0x8] ; Load new operation
0x10000d0f0 stp x8, x9, [sp, #0x20] ; Save to stack
0x10000d0f4 cbz x21, 0x10000d138 ; Skip if failed
Call the operationβs init() method:
; Call init() - NO NSString creation, NO parameters
0x10000d108 ldr x16, [x21] ; Load vtable from object
0x10000d10c mov x17, x21 ; x17 = object (for PAC)
0x10000d110 movk x17, #0x634f, lsl #0x30
0x10000d114 autda x16, x17 ; Authenticate vtable pointer
0x10000d118 ldr x8, [x16, #0x10]! ; Load init() at vtable+0x10
0x10000d11c mov x9, x16
0x10000d120 mov x0, x21 ; x0 = this (ONLY parameter)
0x10000d124 mov x17, x9 ; PAC discriminator
0x10000d128 movk x17, #0x4444, lsl #0x30
0x10000d12c blraa x8, x17 ; Call init() with just 'this'
The vtable is a function pointer array. The code:
- Loads
vtablepointer from object ([x21]) - Authenticates it (ARM Pointer Authentication)
- Indexes into
vtableat offset+0x10to getinit()function pointer - Calls it with authenticated indirect call (
blraa)
For instance, this the vtable of CameraEnabler:
Helper::Operation::Helper::CameraEnabler::VTable _vtable_for_Helper::CameraEnabler{for `Helper::Operation'} =
{
int64_t (* const vFunc_0)() __pure = sub_10000ebb0
int64_t (* const j_operator delete)(void* arg1) = j_operator delete(void*)
int64_t (* const init)(NSString* arg1, char** arg2) = Helper::CameraEnabler::init(NSString*, char**)
int64_t (* const execute)(NSString* arg1, char** arg2) = Helper::CameraEnabler::execute(NSString*, char**)
}
The vtable is located at 0x45070, then if you do 0x45070 + 0x10 you get 0x45080, which points to:
+0x45080 int64_t (* const init)(NSString* arg1, char** arg2) = Helper::CameraEnabler::init(NSString*, char**)
The vtable structure shows init(NSString*, char**) as the interface signature, but the calling convention for command βAβ ignores these parameters.
Pseudo C++:
case 'A': { // Initialize Operation
std::shared_ptr<Operation> existing =
this->factory->getOperation(operation_id);
if (existing != nullptr) {
*message_out = strdup("Operation has been already initialized");
return 0;
}
std::shared_ptr<Operation> operation =
this->factory->newOperation(operation_id);
if (operation == nullptr) {
*message_out = strdup("Invalid Operation");
return 0;
}
operation->init();
*message_out = strdup("Operation initialized");
return 1;
}
Command βEβ - Execute Operation
Retrieve existing operation and call its execute() method:
; Get existing operation
0x10000d004 ldr x0, [x22] ; x0 = this->factory
0x10000d008 add x8, sp, #0x8 ; x8 = &shared_ptr
0x10000d00c mov x1, x21 ; x1 = operation_id
0x10000d010 bl Helper::HelperFactory::getOperation
0x10000d014 ldp x21, x8, [sp, #0x8] ; Load operation
0x10000d01c cbz x21, 0x10000d094 ; Error if not found
If not found, return error:
; Return error if not initialized
0x10000d094 adr x0, data_100041c3e ; "Operation has not been initialized yet"
0x10000d09c b 0x10000d1ec ; Return error
Create NSString from payload and call execute():
; Call operation->execute()
0x10000d024 ldr x0, clsRef_NSString ; NSString class
0x10000d028 mov x2, x20 ; x2 = response string
0x10000d02c bl 0x10003e580 ; Create NSString
0x10000d030 mov x1, x0 ; x1 = NSString arg
0x10000d034 ldr x16, [x21] ; Load vtable
0x10000d040 autda x16, x17 ; Authenticate (PAC)
0x10000d044 ldr x8, [x16, #0x18]! ; Load execute() method
0x10000d050 mov x0, x21 ; x0 = operation
0x10000d05c blraa x8, x17 ; Call execute()
0x10000d060 mov x20, x0 ; Save return
0x10000d064 ldr x0, [sp, #0x18] ; Load response
0x10000d068 b 0x10000d1f4 ; Return
Similar to init(), the execute() method is called via vtable dispatch at offset +0x18 (the 4th entry). The operation processes the command and may set a response string via the output parameter.
C++ Pseudo Code:
case 'E': { // Execute Operation
std::shared_ptr<Operation> operation =
this->factory->getOperation(operation_id);
if (operation == nullptr) {
*message_out = strdup("Operation has not been initialized yet");
return 0;
}
NSString* nsPayload = [NSString stringWithUTF8String:payload];
// execute() at offset +0x18
int result = operation->execute(nsPayload, message_out);
return result;
}
Command βDβ - Delete Operation
Simply delete the operation from the factory:
; Delete operation
0x10000d06c ldr x0, [x22] ; x0 = this->factory
0x10000d070 mov x1, x21 ; x1 = operation_id
0x10000d074 bl Helper::HelperFactory::deleteOperation
0x10000d078 adr x0, data_100041c65 ; "Operation deleted"
0x10000d080 bl strdup
0x10000d084 mov w20, #0x1 ; Return success
0x10000d088 b 0x10000d1f4 ; Return
Pseudo C++:
case 'D': { // Delete Operation
// Remove operation from factory cache
// Decrements reference count and destroys if last reference
this->factory->deleteOperation(operation_id);
*message_out = strdup("Operation deleted");
return 1; // Success
}
The function implements a clean factory pattern where operations are created (βAβ), executed (βEβ), and destroyed (βDβ). Each operation type has its own virtual init() and execute() methods that handle the specific functionality. The shared_ptr reference counting ensures operations are safely cleaned up only when all references are released.
The Factory Pattern
The request dispatcher relies on a factory to create and manage operation instances. Let me examine how this factory works.
Creating Operations
The newOperation() function is responsible for instantiating the correct operation based on the operation identifier.
Helper::HelperFactory::newOperation @ 0x10000daa8
Range Validation:
First, the function validates that the operation_id is in the valid range (10-13):
; Validate operation_id range
0x10000dac4 sub w9, w1, #0xa ; w9 = operation_id - 10
0x10000dac8 cmp w9, #0x3 ; Compare with 3
0x10000dacc b.hi return_null ; If > 3, invalid
Operation Creation Switch:
This function uses a PC-relative jump table to dispatch to different operation constructors. The table stores offsets from the current Program Counter (PC = current instruction address) rather than absolute addresses. Each offset points to code that allocates and initializes the corresponding operation type.
; Dispatch mechanism
0x10000dae0 adr x17, 0x10000dd70 ; x17 = &jump_table
0x10000dae8 ldrsw x16, [x17, x16, lsl #0x2] ; Load offset from table[index]
0x10000daec adr x17, 0x10000daec ; x17 = PC (base address)
0x10000daf0 add x16, x17, x16 ; target = PC + offset
0x10000daf4 br x16 ; Jump to constructor code
Jump table mapping:
[0] = 0x00cβ PC + 0x00c = 0x10000daf8 (HiddenDotconstructor logic)[1] = 0x088β PC + 0x088 = 0x10000db74 (Voipconstructor logic)[2] = 0x130β PC + 0x130 = 0x10000dc1c (KeyLoggerconstructor logic)[3] = 0x1a0β PC + 0x1a0 = 0x10000dc8c (CameraEnablerconstructor logic)
Example: CameraEnabler construction (index 3):
; Allocate and construct CameraEnabler object
0x10000dc8c mov w0, #0x38 ; 56 bytes
0x10000dc90 bl operator new
...
0x10000dcd8 str x16, [x0, #0x18] ; Setup vtable (for init/execute)
0x10000dcdc strb wzr, [x0, #0x30] ; enabled = false (field of the class)
This sets up the vtable so init() and execute() can be called later.
Store in factory cache:
0x10000dcb4 add x8, x0, #0x18 ; x8 = object pointer (x0 + offset)
...
0x10000dce4 stp x8, x0, [x20, #0x30] ; Store at factory + 0x30
; Cache slot 3 (index 3 Γ 16 = 0x30)
Note: The allocated memory block starts at
x0, but the actualCameraEnablerobject begins atx0 + 0x18. The factory stores both pointers:x8(the object, used as this in method calls) andx0(the memory block, used for reference counting)
We setup our object at x0 and then the newly created CameraEnabler is stored at factory + 0x30, which corresponds to:
operation_id13 β index 3 β offset0x30
Factory cache after creation:
Factory object layout:
ββββββββββββββββββββββββββββββββ
+0x00 HiddenDot (slot 0)
+0x10 Voip (slot 1)
+0x20 KeyLogger (slot 2)
+0x30 CameraEnabler (slot 3) β Just stored here!
Now when getOperation(13) is called, it calculates the same address (factory + 0x30) and retrieves the CameraEnabler I just stored.
Cache Lookup
This function retrieves operations that were previously created and cached. It uses the same addressing calculation as newOperation to find the right cache slot.
Step 1: Validate operation_id
0x10000da68 sub w9, w1, #0xe ; w9 = operation_id - 14
0x10000da6c cmn w9, #0x5 ; Check if valid
0x10000da70 b.hi 0x10000da7c ; Continue if valid
; Invalid - return NULL:
0x10000da74 stp xzr, xzr, [x8] ; Return NULL
0x10000da78 ret
Step 2: Calculate cache slot and load operation
0x10000da7c sub w9, w1, #10 ; index = operation_id - 10
0x10000da80 add x9, x0, w9, uxtw #0x4 ; address = factory + (index Γ 16)
0x10000da84 ldp x10, x9, [x9] ; Load operation from that address
0x10000da88 stp x10, x9, [x8] ; Return to caller
The connection to newOperation:
During creation, newOperation stored the operation at factory + (index Γ 16):
; From newOperation (CameraEnabler case):
0x10000dce4 stp x8, x0, [x20, #0x30] ; Store at factory + 0x30
; (index 3 Γ 16 = 0x30)
Now getOperation retrieves it from the same address:
; For operation_id = 13:
; index = 13 - 10 = 3
; address = factory + (3 Γ 16) = factory + 0x30
0x10000da84 ldp x10, x9, [factory + 0x30] ; Load what was stored earlier
Cache layout:
Factory object:
ββββββββββββββββββββββββββββββββββ
+0x00 HiddenDot (index 0)
+0x10 Voip (index 1)
+0x20 KeyLogger (index 2)
+0x30 CameraEnabler (index 3) β Stored by newOperation
ββββββββββββββββββββββββ Retrieved by getOperation
This symmetry ensures operations stored during βAβ (Initialize) commands can be retrieved during βEβ (Execute) and βDβ (Delete) commands.
Putting It All Together
Now that Iβve examined each component, let me walk through a complete example showing how all the pieces work together.
Complete Flow: From Socket to Surveillance
Letβs say Iβm analyzing the initialization of CameraEnabler with command 13,A,0\n.
Visual Overview:
βββββββββββββββ
β Socket β "13,A,0\n"
β Connection β βββββββββββββ
βββββββββββββββ β
βΌ
βββββββββββββββββ
β processClient β
βββββββββ¬ββββββββ
β Parse protocol
βΌ
βββββββββββββββββββββββββββ
β operation_id = 13 β
β command_type = 'A' β
β data_size = 0 β
βββββββββββββ¬ββββββββββββββ
β
βΌ
ββββββββββββββββββββ
β processRequest() β
ββββββββββ¬ββββββββββ
β Command 'A'
βΌ
ββββββββββββββββββββ
β newOperation(13) β
ββββββββββ¬ββββββββββ
β index = 3
βΌ
ββββββββββββββββββββββββββββββ
β Jump to CameraEnabler β
β constructor (PC + 0x1a0) β
βββββββββββ¬βββββββββββββββββββ
β
βββββββββββββββββΌββββββββββββββββ
βΌ βΌ βΌ
βββββββββββ ββββββββββββ ββββββββββββββββ
β Allocateβ β Setup β β Store β
β 56 bytesβ β vtable β β in factory β
βββββββββββ ββββββββββββ β cache[3] β
ββββββββ¬ββββββββ
β
βΌ
ββββββββββββββββββββ
β operation->init()β
ββββββββ¬ββββββββββββ
β
βΌ
ββββββββββββββββ
β Success! β
β "Operation β
β initialized" β
ββββββββββββββββ
Memory State After Initialization:
Factory Cache: CameraEnabler Object (56 bytes):
ββββββββββββββββββββ βββββββββββββββββββββββββββββββ
β +0x00: NULL β β +0x00: shared_ptr control β
β +0x10: NULL β β +0x08: refcount = 1 β
β +0x20: NULL β β +0x10: weak_count = 0 β
β +0x30: βββββββββββΌβββββββββββββββΆβ +0x18: CameraEnabler vtable βββ
ββββββββββββββββββββ β +0x20: hooker_context β β
β² β +0x28: hooker_context β β
β β +0x30: enabled = false β β
β βββββββββββββββββββββββββββββββ β
β β
βββββ Cached for later getOperation(13) calls ββββββββββββββ
Now I can imagine a future "Execute" flow:
Socket: "13,E,6\nenable"
β
βββΆ Parse ββΆ operation_id=13, command='E', data="enable"
β
βββΆ getOperation(13) ββΆ Load from factory[+0x30]
β β
β βΌ
β βββββββββββββββββββββββ
β β CameraEnabler found β
β ββββββββββββ¬βββββββββββ
β β
βββΆ Load vtable βββββββββββ€
β from object+0x18 β
β βΌ
β ββββββββββββββββββββββββ
β β vtable[+0x18] β
β β = execute() β
β ββββββββββββ¬ββββββββββββ
β β
βββΆ Call ββββββββββββββββββ
CameraEnabler::execute("enable")
β
βΌ
βββββββββββββββββββββββ
β Install camera hooksβ
β Start surveillance β
βββββββββββββββββββββββ
Conclusion
What Iβve Learned
In this article, Iβve reverse-engineered Predatorβs command and control infrastructure, revealing:
- Professional surveillance platform: The factory pattern and modular design show this isnβt opportunistic malware. Itβs a commercial spyware product built for reliable, long-term deployment.
- Dynamic capability management: The cache lookup mechanism enables real-time control over active surveillance operations, allowing operators to selectively enable/disable modules based on target activity.
- Multi-layered anti-analysis: The malware employs Jetsam memory limit manipulation, immediate self-deletion and shutdown notification handlers. It demonstrates awareness of both forensic analysis and proficient iOS knowledge.
Whatβs Next: Part 2
Iβve seen how operations are created and managed, but not what they do. Part 2 will deep-dive into the CameraEnabler module, examining:
- How it hooks into iOSβs private
CMCapture.framework - The Mach exception-based hooking mechanism (
DMHooker) - Kernel memory primitives (
FDGuardNeonRW) (Iβll pray that I donβt loose my mind there) - Iβll find maybe more...
References
- Amnesty Tech: Intellexa Leaks
- iVerify: Trust Broken at the Core
- Google TIG: Intellexa Zero-Days
- L0psec - Predator iOS Implant RE
Sample Information
- Hash:
85d8f504cadb55851a393a13a026f1833ed6db32cb07882415e029e709ae0750 - Type: Mach-O 64-bit executable (arm64)
- Analysis Tool: Binary Ninja
Disclaimer: This analysis is for educational and defensive security purposes. The techniques described should only be used for legitimate security research, authorized penetration testing, or defensive security operations.