Operation triangulation - audio module analysis.

Operation triangulation - audio module analysis.

March 5, 2024

This blog post is the continuation of our series on Operation Triangulation. The first blog post can be found here : https://shindan.io/posts/keychain_module_analysis/

In this blogpost we will focus on the audio module : ff2f223542bbc243c1e7c6807e4c80ddad45005bcd78a77f8ec91de29deb2f6e

This module is in charge of recording the device microphone. It implements some tricks to hide itself which will be explained in this blog post. This module does not contain any symbols and uses some sort of obfuscation.

Decrypting strings #

The first thing we see is that a function located at 0x10000C168 is called several times. The second parameter seems to contain a sequence of bytes. The first parameter seems to be a destination pointer.

Analyzing the function confirms that it’s a string decryption routine. A script has been written to decrypt them. This script can be found in the Annexes.

char *__fastcall decrypt_string(char *result, char *src)
{
  int i; // w8
  int v3; // w9
  unsigned int v4; // w9
  int v5; // w9
  unsigned __int8 v6; // w11

  i = 0;
  v3 = (unsigned __int8)*src | ((unsigned __int8)*src << 8);
  do
  {
    v4 = 9 * ((1025 * v3) ^ ((unsigned int)(1025 * v3) >> 6));
    v5 = 32769 * (v4 ^ (v4 >> 11));
    v6 = src[i + 1] ^ v5;
    result[i++] = v6;
    v3 = v5 ^ v6;
  }
  while ( v6 );
  return result;
}

Retrieving the configuration #

The module uses a predefined configuration stored in its .data segment. When analysing the main function, we saw a call to function 0x10000bc24. This function is used to retrieve the value from a parameter name. Analyzing this function allowed us to understand how the configuration is stored.

Module configuration object #

Configuration parameters are stored in the data segment. They are stored in the form of this struct :

struct param_obj
{
    int64_t encoded_name_len;
    int64_t offset_to_name;
    int64_t encoded_value_len;
    int64_t offset_to_value;
    int64_t offset_to_next_obj;
    char    encoded_name[encoded_buffer_len];
    char    encoded_value[param_len];
};

We can observe that in memory :

We noticed that the offsets are stored “obfuscated”. To retrieve their correct value, the module is using the function 0x10000c2d0. This function is reversing the bytes order, e.g : 0x5030000000000000 will became 0x3050.

uint64_t rev_bytes(int64_t arg1)
{
    uint64_t x0_1 = (((uint64_t)(((((((int32_t)(arg1 >> 8)) & 0xff000000) & 0xffffff00) | ((int32_t)((arg1 & 0xff00000000000000) >> 0x38))) | (((int32_t)(arg1 >> 0x18)) & 0xff0000)) | (((int32_t)(arg1 >> 0x28)) & 0xff00))) | (((uint64_t)_byteswap(arg1)) << 0x20));
    return x0_1;
}

The parameter name and value are also stored “obfuscated”. A classical xor obfuscation is done on them. The function in charge of doing this will be introduced later.

Get configuration value flow #

The 0x10000bc24 function takes in the last argument the name of the parameter, e.g. : syslogRelayOverride

int64_t retrieve_config_param(int64_t flag, char* param_out, uint64_t* param_out_size, char* param_name)

To retrieve a parameter value, the function is doing the following action :

  • (1) Retrieves encoded_name_len.
  • Compares it with the length of the input parameter name string.
  • if it does not match :
    • Retrieves offset_to_next_obj and go to the next object. A new iteration is done (go to (1)).
  • if it matches :
    • Retrieves offset_to_name and calls the function 0x10000c1ac to decode the encoded_name.
    • Verifies if the decoded parameter name correspond to the input name.
    • if it does not correspond : Output pointer is set to null, and a new iteration is done with the next object (go to (1)).
    • If it correspond :
      • Retrieves the encoded value length.
      • Allocates memory using malloc.
      • Uses the function 0x10000c1ac to decode the parameter value into the previously allocated memory.
      • Set param_out and param_out_size value and returns.
int64_t retrieve_config_param(int64_t flag, char* param_out, uint64_t* param_out_size, char* param_name)

{
...
        void* head_maybe = sub_10000b590(&data_1000280b4);
        // 0x1000280bc
        uint64_t size_maybe = rev_bytes(*(uint64_t*)((char*)head_maybe + 0x10));
...
            x26_1 = 0;
            bool z_1;
            do
            {
                struct struct_1* head_plus_offset = (size_maybe + head_maybe);
                strLen_from_struct = rev_bytes(head_plus_offset->encoded_buffer_len);
                len_strInput = _strlen(param_name);
                output = nullptr;
                if ((len_strInput != 0 && len_strInput == (strLen_from_struct - 1)))
                {
                    uint64_t offset_to_buffer = rev_bytes(head_plus_offset->offset_to_buffer);
...
                    void decoded_string;
                    decode_string_(&decoded_string, (offset_to_buffer + head_maybe), (strLen_from_struct - 1));
                    if (memcmp(&decoded_string, param_name, (strLen_from_struct - 1)) != 0)
...
                    else
                    {
                        uint64_t x24_3 = (rev_bytes(head_plus_offset->param_len) - 1);
                        // alloc a struct and put the size in first four bytes
                        char* output_1 = alloc_size_plus_4(x24_3);
                        output = output_1;
                        if (output_1 == 0)
...
                        decode_string_(output, (rev_bytes(head_plus_offset->offset_to_param) + head_maybe), x24_3);
                        *(uint64_t*)param_out_size = x24_3;
                        x26_1 = 1;
                    }
                }
                size_maybe = rev_bytes(head_plus_offset->next_obj);
...
            } while (z_1);
...

Parameter decoding function #

The function used to decode parameters is located at 0x10000c1ac. Nothing really interesting in this function.

void decode_string_(char* output, char* bufffer_encoded, int32_t len)

{
    if (len != 0)
    {
        char byte = *(uint8_t*)bufffer_encoded;
        int32_t shifted_byte = ((0xffff00ff & byte) | (((uint32_t)byte) << 8));
        uint64_t len2 = ((uint64_t)len);
        void* next_byte = &bufffer_encoded[1];
        char* output_1 = output;
        uint64_t i;
        do
        {
            int32_t x11_1 = (shifted_byte * 0x401);
            int32_t x11_3 = ((x11_1 ^ (x11_1 >> 6)) * 9);
            int32_t x11_5 = ((x11_3 ^ (x11_3 >> 0xb)) * 0x8001);
            char x12_1 = *(uint8_t*)next_byte;  // 0x3d
            next_byte = ((char*)next_byte + 1);
            char x12_2 = (x12_1 ^ x11_5);
            *(uint8_t*)output_1 = x12_2;
            output_1 = &output_1[1];
            shifted_byte = (x11_5 ^ ((uint32_t)x12_2));
            i = len2;
            len2 = (len2 - 1);
        } while (i != 1);
    }
}

Module configuration extraction #

To extract the full configuration of the module, we have used Unicorn Engine to emulate the decoding function. Here you can find the extracted module configuration :

recordingDuration -> 10800
recordingQuality -> 6
fileSizeLimit -> 640000
minFreeDisk -> 500000000
suspendOnDeviceInUse -> Y
outputFilePath -> /private/var/tmp/
filePrefix -> sr6d
fileSuffix -> .srm
encryptionKey -> 05b709adb479d528ceccc0569340a845f8efc5e5245b3ada5e43d78238757977
selfDelete -> N
syslogRelayOverride -> N

This script used can be found in Annexes.

Module security checks #

Before diving into the main module’s logic, let’s explain the various checks that the module does in attempt to detect if the device is plugged / used. These checks are done by the module with the aim of hiding itself from detection.

Checking if syslog_relay is running #

The function 0x100009350 is trying to detect if the process syslog_relay is running. In old iOS version (< 10.3), this process is running when the device log are being read (thus the device being plugged). To detect if syslog_relay is running, the function is using sysctl with CTL_KERN and KERN_PROC names to retrieve the running processes. Then it uses strcmp to verify if a process named syslog_relay is running (using kinfo_proc.kp_proc->p_comm) and returns 1 if it runs, and 0 if not.

_int64 check_proc_syslog_relay_exists()
{
...
  *(_OWORD *)name = xmmword_100021830;
  v7 = 0LL;
  sysctl(name, 4u, 0LL, &v7, 0LL, 0LL);
  v0 = (kinfo_proc *)calloc((unsigned int)v7);
...
  sysctl(name, 4u, v0, &v7, 0LL, 0LL);
...
  else
  {
...
    p_comm = v1->kp_proc.p_comm;
    while ( 1 )
    {
      v4 = decrypt_string(result, "syslog_relay\x00m");
      if ( !strncmp(p_comm, v4, 0xCuLL) )
        break;
      p_comm += 648;
...
    }
    v5 = 1LL;
  }
  free_maybe(v1);
  return v5;
}

Calling host_info #

The module is calling host_info and returns 1 if the call has been successful. If the call fails, the module will not start the recording. Not sure why this check is done.

bool host_infos_basic_info()
{
  host_t v0; // w19
  _BOOL8 v1; // x20
  mach_msg_type_number_t host_info_outCnt; // [xsp+Ch] [xbp-44h] BYREF
  integer_t host_info_out[12]; // [xsp+10h] [xbp-40h] BYREF

  host_info_outCnt = 12;
  v0 = mach_host_self();
  v1 = host_info(v0, 1, host_info_out, &host_info_outCnt) == 0;
  mach_port_deallocate(mach_task_self_, v0);
  return v1;
}

Check system version #

The function 0x100007268 retrieves the iOS version. The function calls [UIDevice currentDevice].systemVersion ; to retrieve the system version. From this, the function splits the version string in an array of strings. Then it calls strtol to convert the major version number into a integer, and returns it.

__int64 sub_100007268()
{
  NSAutoreleasePool *v0; // x19
  char *__str; // [xsp+0h] [xbp-40h] BYREF
  char __dst[12]; // [xsp+1Ch] [xbp-24h] BYREF

  v0 = objc_alloc_init(&OBJC_CLASS___NSAutoreleasePool);
  strncpy(
    __dst,
    -[NSString UTF8String](
      -[UIDevice systemVersion](+[UIDevice currentDevice](&OBJC_CLASS___UIDevice, "currentDevice"), "systemVersion"),
      "UTF8String"),
    0xCuLL);
  __dst[11] = 0;
  -[NSAutoreleasePool drain](v0, "drain");
  if ( (unsigned int)delete_char(__dst, 46LL, &__str, 3LL) && (unsigned int)(unsigned __int8)*__str - 49 <= 8 )
    return strtol(__str, 0LL, 10);
  else
    return 0xFFFFFFFFLL;
}

Checks UIDevice BatteryState #

The function 0x100007144 is checking the device batteryState, using currentDevice object. Depending on the state, the function returns :

  • 2 -> if battery is unplugged.
  • 1 -> If battery is charging or if battery is full.
  • 0 -> if battery state is unknown.
__int64 check_if_device_is_plugged()
{
  UIDeviceBatteryState v0; // x21

  -[UIDevice setBatteryMonitoringEnabled:](
    +[UIDevice currentDevice](&OBJC_CLASS___UIDevice, "currentDevice"),
    "setBatteryMonitoringEnabled:",
    1LL);
  v0 = -[UIDevice batteryState](+[UIDevice currentDevice](&OBJC_CLASS___UIDevice, "currentDevice"), "batteryState");
  -[UIDevice setBatteryMonitoringEnabled:](
    +[UIDevice currentDevice](&OBJC_CLASS___UIDevice, "currentDevice"),
    "setBatteryMonitoringEnabled:",
    0LL);
  if ( v0 == UIDeviceBatteryStateUnplugged )
    return 2LL;
  else
    return (v0 & 0xFFFFFFFFFFFFFFFELL) == 2;
}

And lastly let’s describe an important function that is called a lot during the recording.

Main checking function #

The function 0x100007BC0 is central, and used before and during the recording process.

This function is doing several checks :

First it checks if the opening of the file (where audio is wrote) has failed. Then, it’s calling the two previously described functions to verify if the device is plugged. After that, a new check is done to verify if the device is in use. The function will use the com.apple.springboard.hasBlankedScreen notification to verify if the device is unlocked. The value is retrieved using notify_register_check and notify_get_state functions. If the value is equal to 1 it means that the phone is locked (unused). If value is 0, the device is in use.

After that, It’s verifying if the available space in the directory /privar/var using fstats is not superior to minFreeDisk configuration parameter.

Afterwards, a last check is done. The value of the property kAudioSessionProperty_OtherAudioIsPlaying is checked using the function AudioSessionGetProperty. It’s possible that this check is done to verify if a music application is playing on the device.

The last thing to explain is the return value of this function. The return value consists of a code of four bytes :

  • eukn : The file opening failed (error).
  • slrn : The device is plugged.
  • devn : The device is in use.
  • idsn : Available space is inferior to minFreeDisk.
  • bsyn : No other audio is playing.
  • begn : Other audio is playing on the device.
__int64 __fastcall check_device_used_n_other_audio_playing(int suspendOnDeviceInUse)
{
...
  if ( (did_opening_failed & 1) != 0 )
    return 'eukn';
  if ( should_syslogRelayOverride_maybe == 1
    && (unsigned int)check_proc_syslog_relay_exists() == 1
    && (unsigned int)check_if_device_is_plugged() != 2 )
  {
    return 'slrn';                              // device is plugged or in use
  }
  if ( !suspendOnDeviceInUse
    || (v1 = 'devn',
        state64[0] = 0LL,
        v3 = decrypt_string(
               (char *)&v12,
               "com.apple.springboard.hasBlankedScreen\x00"),
        !notify_register_check(v3, &out_token))
    && (notify_get_state(out_token, state64), notify_cancel(out_token), state64[0]) )
  {
    v4 = decrypt_string((char *)state64, "/private/var\x00");
    v5 = statfs(v4, &v12);
    available = v12.f_bavail * v12.f_bsize;
    if ( v5 )
      available = 0LL;
    if ( available >= minFreeDisk )
    {
      v12.f_bsize = 0;
      out_token = 4;
      Property = fptr_AudioSessionGetProperty('othr', &out_token, &v12);
      if ( v12.f_bsize + 1 > 1 && Property == 0 )
        return 'bsyn';
      else
        return 'begn';
    }
    else
    {
      return 'idsn';
    }
  }
  return v1;
}

Main module #

Now that the main checks have been explained, we can delve into the main module’s logic. When started, the module will verify the system version. If the system version is inferior to 10, the module will decide to never check for syslog_relay, else it will depend on the configuration parameter syslogRelayOverride.

A first check is done to verify if the device is not plugged, and if it is plugged, the module will exit.

  if ( (int)check_version() < 10 )
  {
    v1 = 0;
    should_syslogRelayOverride_maybe = -1;
  }
  else
  {
    should_syslogRelayOverride_maybe = 1;
    ret_str = decrypt_string((char *)str_array, "syslogRelayOverride\x00\xF0cordingDuration\x00\x87");
    retrieve_config_param(0LL, (char *)&__s, v26, ret_str);
    if ( __s )
    {
      if ( strlen(__s) <= 3 && (*__s & 0xDF) == 'Y' )
        should_syslogRelayOverride_maybe = -1;
      free_maybe(__s);
      __s = 0LL;
    }
    if ( should_syslogRelayOverride_maybe == 1
      && (unsigned int)check_proc_syslog_relay_exists() == 1
      && (unsigned int)check_if_device_is_plugged() != 2 )
    {
      runTimerLoop = 0xFFFFFFFFLL;
      goto free_and_exit;
    }
    v1 = 1;
  }

Then the module will setup it’s configuration parameter. It’s calling the function 0x100006480 which will iterate over every parameter and sets their values.

  str_decrypted_array[0] = decrypt_string((char *)&str_array[5 * v1], "recordingDuration");
  str_decrypted_array[1] = decrypt_string((char *)&str_array[5 * v1 + 5], "recordingQuality");
  str_decrypted_array[2] = decrypt_string((char *)&str_array[5 * (v1 | 2)], "fileSizeLimit");
  str_decrypted_array[3] = decrypt_string((char *)&str_array[5 * v1 + 15], "minFreeDisk");
  str_decrypted_array[4] = decrypt_string((char *)&str_array[5 * (v1 | 4)], "suspendOnDeviceInUse");
  str_decrypted_array[5] = decrypt_string((char *)&str_array[5 * v1 + 25], "outputFilePath");
  str_decrypted_array[6] = decrypt_string((char *)&str_array[5 * (v1 | 6)], "filePrefix");
  str_decrypted_array[7] = decrypt_string((char *)&str_array[5 * ((v1 + 7) % 0xA)], "fileSuffix");
  str_decrypted_array[8] = decrypt_string((char *)&str_array[5 * ((v1 | 8) % 0xA)], "encryptionKey");
  str_decrypted_array[9] = decrypt_string((char *)&str_array[5 * ((v1 + 9) % 0xA)], "selfDelete");
  ret_by_config_init = retrieve_and_init_config(
                         10,
                         str_decrypted_array,
                         (char **)&recordingDuration,
                         &recordingQuality,
                         &fileSizeLimit,
                         &minFreeDisk,
                         &selfDelete,
                         &suspendOnDeviceInUse,
                         &outputFilePath,
                         (char **)&filePrefix,
                         &fileSuffix,
                         &encryptionKey);

After that, the module will verify the configuration parameter selfDelete. If the parameter is set to Y, the module will retrieve its executable path using _NSGetExecutablePath and will call unlink on it.

Another check is done after that, if the system version is equal to 8.0 and if the call to host_info fails, the module will exit.

Now the module will start its initialisation by calling the function 0x100007438. The main goal of this function is to create a file path (the file where the data will be written). The file path is stored in a global variable. With this module configuration, the file path is : /private/var/tmp/sr6dXXXXXX.srm. This function is also dynamically resolving some functions by calling the function 0x10000948C.

Dynamic resolving of AudioToolbox framework #

This function uses dlopen and dlsym to dynamically load several functions from the AudioToolbox framework. Here is the list of the functions:

AudioQueueAddPropertyListener
AudioQueueAllocateBuffer
AudioQueueDispose
AudioQueueEnqueueBuffer
AudioQueueGetProperty
AudioQueueNewInput
AudioQueuePause
AudioQueueStart
AudioQueueStop
AudioSessionGetProperty
AudioSessionInitialize
AudioSessionSetActive
AudioSessionSetProperty

These function pointers are stored into global variables. They are the function that will be used to record the audio.

Main loop #

The module’s main loop consists of :

  • Verifying if it can start the recording safely (safety checks to see if device is being used).
  • Initializing required components.
  • Starting audio recording.
  • Wait until recording duration time is elapsed.
  • Start again.

This is mostly done in the function 0x1000075DC. This function is in charge of creating the file where the data will be written, initializing the CCCryptor interface, managing the recording (using AudioQueue), and initializing the encoder. Let’s detail these different steps.

Initializing CCCryptor interface #

The function 0x100009E64 is in charge of initializing the CCCryptor interface, used for the data encryption. First, this function dynamically resolves CCCryptor required functions :

v6 = decrypt_string(result, "/System/Library/Frameworks/Security.framework/Security");
  v7 = dlopen(v6, 1);
  securifyFramework_dlopened = (__int64)v7;
  if ( !v7 )
    return 0xFFFFFFFFLL;
  v8 = v7;
  v9 = decrypt_string(result, "CCCryptorCreate");
  CCCryptorCreate = (__int64 (__fastcall *)(_QWORD, _QWORD, _QWORD, _QWORD, _QWORD, _QWORD, _QWORD))dlsym(v8, v9);
  if ( !CCCryptorCreate )
    return 0xFFFFFFFFLL;
  v10 = (void *)securifyFramework_dlopened;
  v11 = decrypt_string(result, "CCCryptorUpdate");
  CCCryptorUpdate = (__int64 (__fastcall *)(_QWORD, _QWORD, _QWORD, _QWORD, _QWORD, _QWORD))dlsym(v10, v11);
  if ( !CCCryptorUpdate )
    return 0xFFFFFFFFLL;
  v12 = (void *)securifyFramework_dlopened;
  v13 = decrypt_string(result, "CCCryptorFinal");
  CCCCryptorFinal = (__int64)dlsym(v12, v13);
  if ( !CCCCryptorFinal )
    return 0xFFFFFFFFLL;
  v14 = (void *)securifyFramework_dlopened;
  v15 = decrypt_string(result, "CCCryptorRelease");
  CCCryptorRelease = (__int64)dlsym(v14, v15);
  if ( !CCCryptorRelease )
    return 0xFFFFFFFFLL;
  v16 = (void *)securifyFramework_dlopened;
  v17 = decrypt_string(result, asc_100021863);
  CCCryptorReset = (__int64 (__fastcall *)(_QWORD, _QWORD))dlsym(v16, v17);
  if ( !CCCryptorReset )
    return 0xFFFFFFFFLL;

After that, the key used for encryption is created. It is created from the module configuration encryptionKey. The string is transformed into bytes array using sscanf.

  for ( i = 0LL; i != 0x20; ++i )
  {
    v19 = decrypt_string(result, "%2hhx");
    sscanf(encryptionKey, v19, (char *)&key + i);
    encryptionKey += 2;
  }

The same steps are done for the IV. The IV seems to be used without initialization (directly from the BSS variable.) Maybe this variable is initialized by another module, not located in this binary.

Finally CCCryptorCreate is called, with kCCEncrypt operation, and with kCCAlgorithmAES128 (AES128) algorithm.

Initializing speex encoder #

The module seems to use Speex as an audio encoder to compress the recorded audio. The function 0x100006ABC is in charge of this part. The configuration parameter recordindQuality is used.

__int64 __fastcall speex_related(__int64 result)
{

  recordingQuality = result;
  if ( (_DWORD)result != 11 )
  {
    word_100028680 = 0;
    speex_handler = speex_encoder_init(&speex_nb_mode);
    speex_encoder_ctl(speex_handler, 4LL, &recordingQuality);
    return speex_bits_init(&s_speexbits);
  }
  return result;
}

File creation and first writes #

The function 0x100008F08 is in charge of opening the file that will contain the recorded data. In this module, the file path is /private/var/tmp/sr6dXXXXXX.srm. This file path is used as a template for mkstemps. After that, the file is opened using fdopen()

...
v12 = mkstemps(filepath, v11);
  if ( (_DWORD)v12 != -1 )
  {
    v13 = fdopen_and_cryptorreset(v12, "w");
    g_openedfd = v13;
    if ( v13 )
    {
      *(_QWORD *)&xmmword_1000287A8 = 0LL;
      g_total_written += g_written;
      g_written = 0;
      v0 = 0LL;
      g_written = write_to_file(v13, &g_header_block, 'seql');
    }
...
  }

Finally, the first data block (header) will be written in the file.

Data blocks #

The module structures the written data using what we call “data block”. These data blocks consist of two types of structure :

The “header” block. Only written one time, when a new file is created.

struct s_header_block {
	uint32_t code;
	uint32_t counter;
	uint8_t recordingQuality;
};

The “data” block. Written several time depending on the code used.

struct s_data_block {
	uint32_t code;
	time_t time;
	uint32_t tm_gmtoff;
	uint8_t tm_isdst;
}

The function used to write these blocks is the function 0x100006B2C.

__int64 __fastcall write_to_file(FILE *fd, void *block, int code)
{
  result = 0LL;
  size = 0xF;
  if ( fd && block )
  {
    if ( (unsigned __int8)code == 'n' )
    {
      time = ::time(0LL);
      time_1 = time;
      block->code = code;
      if ( time )
      {
        block->time = time;
        v8 = localtime(&time_1);
        tm_gmtoff = v8->tm_gmtoff;
        block->tm_isdst = v8->tm_isdst;
      }
      else
      {
        tm_gmtoff = 0;
        block->time = 0;
      }
      block->tm_gmtoff = tm_gmtoff;
      v10 = fwrite_encrypted(&size, 1LL, 4LL, fd);
      v11 = (s_header_block *)block;
      v12 = 15LL;
    }
    else
    {
      ++block->time;
      size = 11;
      v10 = fwrite_encrypted(&size, 1LL, 4LL, fd);
      v11 = block;
      v12 = 0xBLL;
    }
    return fwrite_encrypted(v11, 1LL, v12, fd) + v10;
  }
  return result;
}

The function uses some sort of “code” consisting of four bytes. These codes are also used by the function that performs these safety checks. For example, the first block written after the opening of the file, is a seql code. We deduced that this corresponds to the header. These blocks are written in the form “Length then Value”, and are written in the file after encryption.

Starting the recording #

If everything went well, the function 0x1000089B8 will start the recording. Before starting everything, safety checks using the previous methods are done again to determine if the module should start the recording.

Depending on the result of the check, different actions will be made :

  • eukn : An error happened. The function returns and a new iteration is done.
  • slrn : The device is plugged. Two messages begn and endn are written in the file. After that, the function returns and a new module iteration is started.
  • devn : The device is in use. A message devn is written in the file, the module sleeps for 10 seconds, then returns and starts a new iteration.
  • idsn : Available space is inferior to minFreeDisk. A data block with code idsn is written in the file. After that, a new iteration is started.
  • begn : Everything is ok to start the recording. We will detail this step below.

These message sand actions are also used in the AudioInputCallback when the recording is running.

Recording audio using AudioQueue #

The function AudioQueueNewInput is called to create a new Audio Queue object. This Audio Queue will execute the callback located at 0x100008444 when the queue has finished filling its buffer. This is the function that is in charge of writing the “audio” data into the file.

After that, the module calls AudioQueueStart to start the recording.

if ( (unsigned int)fptr_AudioQueueNewInput(
                         &g_audioQueueIn.aqft_prop,
                         audioInputCallback,
                         &g_audioQueueIn,
                         0LL,
                         0LL,
                         0LL,
                         &g_audioQueueIn) )
    {
      return 'eukn';
    }
    else
    {
      v7 = 40;
      fptr_AudioQueueGetProperty(*(_QWORD *)g_audioQueueIn.inAq, 'aqft', &g_audioQueueIn.aqft_prop, &v7);// aqft == kAudioQueueProperty_StreamDescription
      fptr_AudioQueueAddPropertyListener(*(_QWORD *)g_audioQueueIn.inAq, 'aqrn', nullsub_1, &g_audioQueueIn);// kAudioQueueProperty_IsRunning = 'aqrn'
      v0 = allocate_and_queuebuffer();
      if ( (_DWORD)v0 == 'begn' )
      {
        -[AVAudioSession setActive:error:](
          +[AVAudioSession sharedInstance](&OBJC_CLASS___AVAudioSession, "sharedInstance"),
          "setActive:error:",
          1LL,
          v8);
        g_audioqueue_activated = 1;
        fptr_AudioQueueStart(*(_QWORD *)g_audioQueueIn.inAq, 0LL);
        return 'begn';
      }
    }

For a comprehensive understanding of how Apple’s Audio Queues operate, additional details can be accessed here: https://developer.apple.com/documentation/audiotoolbox/audio_queue_services?language=objc.

AudioInputCallback - Audio data write #

The AudiotInputCallback registered during the call to AudioQueueNewInput is the function : 0x100008444. According to Apple, it is “Called by the system when a recording audio queue has finished filling an audio queue buffer”. This is an important function of the module, as it is the function in charge of writing the recorded data into the file.

This function calls the function 0x100006C2C which is the one that writes recorded audio data into the file. This function is detailed below.

The callback also decides if a new file needs to be created (if the global bytes written counter exceeds the fileSizeLimit). When this happens, the callback stops the recording using AudioQueuePause and -[AVAudioSession setActive:error:], write a pwrn message in the file, close the file, re-open a new file, writes a begn message, and starts the recording again using AudioQueueStart and -[AVAudioSession setActive:error:].

Finally, the callback uses the function 0x100007BC0 to perform safety checks, and depending on if the device is being used/plugged decides to stop the recording or not, and writes corresponding message in the file.

bool __fastcall audioInputCallback(
        void *inUserData,
        AudioQueueRef inAq,
        AudioQueueBufferRef inBuffer,
        const AudioTimeStamp *inStartTime,
        const AudioStreamPacketDescription *inPacketDescs)
{
...                   
    g_written += write_audio_to_file((FILE *)g_openedfd, (__int16 *)inBuffer->mAudioData, (unsigned int)inPacketDescs);
    if ( g_written <= (unsigned int)g_fileSizeLimit )
    {
      flag_error = 0;                           // did not passe the file size limit
    }
    else
    {
      ...
      fptr_AudioQueuePause(*(_QWORD *)g_audioQueueIn.inAq);
      -[AVAudioSession setActive:error:](
        +[AVAudioSession sharedInstance](&OBJC_CLASS___AVAudioSession, "sharedInstance"),
        "setActive:error:",
        0LL,
        v20);
      g_audioqueue_activated = 0;
      if ( (ret_by_config_init & 1) != 0 )
        g_written += write_to_file((FILE *)g_openedfd, (s_data_blocks *)&g_data_block, 'pwrn');
      close_fd();
      ++g_file_counter_maybe;
      if ( (unsigned int)write_to_temp() == 'eukn' )
      {
        flag_error = 1;
      }
      else
      {
        if ( (ret_by_config_init & 1) != 0 )
          g_written += write_to_file((FILE *)g_openedfd, (s_data_blocks *)&g_data_block, 'begn');
        -[AVAudioSession setActive:error:](
          +[AVAudioSession sharedInstance](&OBJC_CLASS___AVAudioSession, "sharedInstance"),
          "setActive:error:",
          1LL,
          v20);
        g_audioqueue_activated = 1;
        fptr_AudioQueueStart(*(_QWORD *)g_audioQueueIn.inAq, 0LL);
...
    v10 = check_device_used_n_other_audio_playing(g_suspendOnDeviceinuse);
    ...
}

Audio data writes #

The function 0x100006C2C is the function in charge of writing the audio data. This function is called from the AudioInputCallback. Depending of the recordingQuality configuration parameter, this function will either encrypt and write the data, or use speex encoder to compress the audio before encrypting and writing. In all cases, the data is stored in LV (length, value) format.

__int64 __fastcall write_audio_to_file(FILE *fd, void *audioData, const AudioStreamPacketDescription *inPacketDescs)
{
...
      if ( recordingQuality == 11 )
      {
        to_write = 2 * (_DWORD)inPacketDescs;   // audio packet value
        v7 = malloc((unsigned int)(2 * (_DWORD)inPacketDescs));
        if ( v7 )
        {
          v8 = v7;
          if ( v5 )
            memcpy(v7, v4, 2LL * v5);
          v9 = fwrite_encrypted(&to_write, 1LL, 4LL, fd);// write size
          v3 = (unsigned int)fwrite_encrypted(v8, 1LL, to_write, fd) + v9;// write audioData
...
      }
      else
      {                                         // audioquality, must encode
          v10 = (unsigned int)inPacketDescs;
          do
          {
...
            if ( v13 == 0xA0 )
            {
              speex_encode_int(speex_handler, word_100028540, &s_speexbits);
              v14 = speex_bits_nbytes(&s_speexbits);
              if ( (_DWORD)v14 )
              {
                v15 = v14;
                v16 = malloc((unsigned int)v14);
                if ( v16 )
                {
                  v17 = v16;
                  LODWORD(size) = speex_bits_write(&s_speexbits, v16, v15);
                  v18 = fwrite_encrypted(&size, 1LL, 4LL, fd) + v3;
                  v3 = v18 + fwrite_encrypted(v17, 1LL, (unsigned int)size, fd);
                  free(v17);
                }
              }
              speex_bits_reset(&s_speexbits);
            }
            v4 = (char *)v4 + 2;
            --v10;
          }
          while ( v10 );
...
  return v3;
}

Conclusion #

This module inner working is not complex, but uses several tricks that are important to understand. This module seems to contain old code (referencing iOS version 8.0, and syslog_relay process), and seems to be able to run on several and older iOS versions.

The way that detection results are also stored in the file indicates that the attackers can follow and know when the device is being used, or plugged. These informations could help them to understand the habits of their targets.

There are other modules to analyse, and in the next blogpost, we will see how the location module is working.

Annexes #

String decryption script. #

#!/usr/bin/python

def decrypt_string(src):
    dest = []
    w8 = 0
    w9 = src[0] | src[0] << 8
    while True:
        w9 = (w9   + (w9 << 10) & 0xFFFFFFFF)
        w9 =  (w9  ^ (w9 >> 6) & 0xFFFFFFFF)
        w9 = (w9 + (w9 << 3) & 0xFFFFFFFF)
        w9 = (w9 ^ (w9 >> 11) & 0xFFFFFFFF)
        w9 = (w9 + (w9 << 15)  & 0xFFFFFFFF)

        w10 = w8 + 1
        w11 = src[w10] & 0xFF
        w11 = w11 ^ w9
        dest.append(chr(w11 & 0xFF))

        w8 = w10
        w10 = w11 & 0xFF
        w9 ^= w10
        if w10 == 0:
            break
    return dest


# get function instance of target function
target_function = bv.get_function_at(0x000000010000C168)
# set of already decrypted bytes
already_decrypted = []

# 1: walk over all callers
for caller_function in set(target_function.callers):
    # 2: walk over high-level IL instructions
    for instruction in caller_function.hlil.instructions:
        # 3: if IL instruction is a call
        #    and call goes to target function
        if (instruction.operation == HighLevelILOperation.HLIL_CALL and
            instruction.dest.constant == target_function.start):

            p2 = instruction.params[1]
            if p2.value.value not in done:
                ret = decrypt_string(bv.read(p2.value.value, 0x400))
                done.append(p2.value.value)
                print("".join(ret))

Configuration extraction script #

from unicorn import *
from unicorn.arm64_const import *
from capstone import *
from hexdump import hexdump
from unicorn.arm64_const import *
from capstone import *
import struct

def emulate_rev_highest_byte(uc, x0):
    uc.reg_write(UC_ARM64_REG_X0, x0)
    try:
        uc.emu_start(0x10000c2d0, 0x10000c2fc)
    except UcError as e:
        print(f"Error: {e}")
    return uc.reg_read(UC_ARM64_REG_X0)

def emulate_decode_string(uc, output, inputstr, len):
    uc.reg_write(UC_ARM64_REG_X0, output)
    uc.reg_write(UC_ARM64_REG_X1, inputstr)
    uc.reg_write(UC_ARM64_REG_X2, len)
    try:
        uc.emu_start(0x10000c1ac, 0x10000c1f4)
    except UcError as e:
        print(f"Error: {e}")
    return uc.reg_read(UC_ARM64_REG_X0)

# Set up Unicorn engine
uc = Uc(UC_ARCH_ARM64, UC_MODE_ARM)

# Load binary into memory
binary_path = "../ff2f223542bbc243c1e7c6807e4c80ddad45005bcd78a77f8ec91de29deb2f6e"
with open(binary_path, "rb") as f:
    binary_data = f.read()

# Determine the address where you want to start emulation
start_address = 0x100000000

# Map the binary into memory
uc.mem_map(start_address, 8 * 1024 * 1024)
uc.mem_write(start_address, binary_data)

#stack
STACK_SIZE = 0x1000
STACK_ADDRESS = 0x20000000
uc.mem_map(STACK_ADDRESS, STACK_SIZE)
uc.reg_write(UC_ARM64_REG_SP, STACK_ADDRESS + STACK_SIZE)

# Hook the function
#uc.hook_add(UC_HOOK_CODE, h_debug)

#addr_head_obj = 0x1000280d4
addr_head = 0x1000280bc
addr_obj = 0x1000280d4


while (1):
    size_buffer = emulate_rev_highest_byte(uc, struct.unpack("<Q", uc.mem_read(addr_obj, 0x8))[0])
    #print(f"{size_buffer:#08x}")

    offset_to_buffer = emulate_rev_highest_byte(uc, struct.unpack("<Q", uc.mem_read(addr_obj+0x8, 0x8))[0])
    #print(f"{offset_to_buffer:#08x}")
    
    addr_decoded_str = 0x1000
    uc.mem_map(addr_decoded_str, 1024)

    emulate_decode_string(uc, addr_decoded_str, addr_head + offset_to_buffer, size_buffer -1)
    decoded_name_param = uc.mem_read(addr_decoded_str, size_buffer-1)

    size_param = emulate_rev_highest_byte(uc, struct.unpack("<Q", uc.mem_read(addr_obj+0x10, 0x8))[0])
    #print(f"{size_param:#08x}")

    offset_to_param = emulate_rev_highest_byte(uc, struct.unpack("<Q", uc.mem_read(addr_obj+0x18, 0x8))[0])
    #print(f"{offset_to_param:#08x}")

    addr_decoded_param = 0x2000
    uc.mem_map(addr_decoded_param, 1024)

    emulate_decode_string(uc, addr_decoded_param, addr_head + offset_to_param, size_param -2)
    decoded_value_param = uc.mem_read(addr_decoded_param, size_param-2)

    offset_next_object = emulate_rev_highest_byte(uc, struct.unpack("<Q", uc.mem_read(addr_obj+0x20, 0x8))[0])
    #print(f"{offset_next_object:#08x}")
    addr_obj = addr_head + offset_next_object

    uc.mem_unmap(addr_decoded_str, 1024)
    uc.mem_unmap(addr_decoded_param, 1024)

    print(f"{decoded_name_param.decode('utf-8')} -> {decoded_value_param.decode('utf-8')}")

    if offset_next_object == 0:
        break