Background
← Back to blog

Predator iOS Malware: Building a Surveillance Framework - Part 1

27 min read
Predator iOS Malware: Building a Surveillance Framework - Part 1

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.

IDOperationPurpose (hypothesis)
10HiddenDotSuppress iOS privacy indicator (camera/mic active dot)
11VoipVoIP call interception
12KeyLoggerKeystroke capture
13CameraEnablerCamera 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 NSString via [NSString stringWithUTF8String:data] and passed to execute()
  • '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:

StepOperationPurpose
1Utils::enableMemoryProtection()Anti-debugging protection
2unlink(executable_path)Remove binary from disk (anti-forensics)
3Register shutdown observerClean up on device shutdown
4Create health check timerMonitor Agent process status
5Spawn socket server threadStart 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 this at x0
  • 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.sock is not analyzed here. My hypothesis is that the Watcher's downloadExecutable/executeDownloaded functions 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:

  1. Loads vtable pointer from object ([x21])
  2. Authenticates it (ARM Pointer Authentication)
  3. Indexes into vtable at offset +0x10 to get init() function pointer
  4. 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 (HiddenDot constructor logic)
  • [1] = 0x088 → PC + 0x088 = 0x10000db74 (Voip constructor logic)
  • [2] = 0x130 → PC + 0x130 = 0x10000dc1c (KeyLogger constructor logic)
  • [3] = 0x1a0 → PC + 0x1a0 = 0x10000dc8c (CameraEnabler constructor 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 actual CameraEnabler object begins at x0 + 0x18. The factory stores both pointers: x8 (the object, used as this in method calls) and x0 (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_id 13 → index 3 → offset 0x30

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! │ │ "Operationinitialized" │ └──────────────┘

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

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.