Reversing iOS Application

In this blog post, I will talk about how I generally approach reverse engineering iOS applications. I will use a chat application that has the following crypto functionalities

  • MD5
  • SHA1
  • HMAC
  • RSA

I think it will be a good exercise to learn more about crypto and Frida. I will not name and shame the application since my purpose is to share some tips and tricks.

Why?

I was kinda bored and trying to pass some time. Reversing applications, and digging through code eases my mind. I like solving complex puzzles and reverse engineering is way more interesting than my daily job.

Therefore I downloaded an anonymous chat application from the App Store. After some time, I was disgusted by the people I see there and amount of bots. If there were so many bot users, I guess it was so easy to create accounts and spam users. Turns out it wasn’t that easy lol. I became curious and decided to reverse engineer the application

Initial Research

I always try checking the traffic with some proxy application and try to understand what’s going on. This app doesn’t use any SSL pinning on iOS so it was so easy to see the traffic. I guess most of the developers are lazy and don’t implement SSL pinning. Some popular libraries like Alamofire don’t support SSL pinning out of the box which leaves most of the users at dark. I tried to contribute but they rejected my PR after 10 months and I am still salty about it lol. Anyway, even if it had SSL pinning, it is pretty easy to circumvent SSL Pinning. Please check this blog post to learn more about this issue.

Logging traffic

First I used Charles Proxy to record the traffic but it seems it wasn’t enough to record chat traffic. Therefore I used SSLSplit to capture the raw traffic. Besides basic analytics, each request was using special parameters like

auth_token	4e61756768747920426f79203a44
locale	en_us
nonce	1A3F91BC-2301-3714-7100-125333D0697
uid		4d792053656372657420756964

The nonce and auth_token were changing for every request but other parameters such as User Agent, uid, etc were constant. When I saw changing nonce, I immediately thought it was HMAC. We need to find out how this application is generating these values. It is best to get decrypted binary to analyze both statically and dynamically. Decompiling the app gives us to see function names and allows us to see the overall picture.

Decompiling app

To decompile an iOS application, we first need to decrypt the application. There are multiple ways to get the decrypted .ipa. I used frida-ios-dump to dump the application. I checked the class and function list and saw references to Keychain. It means once the account is created, it will not be easy to extract the secret keys from Keychain and use those values to create nonce and auth_token. We need to observe how this application creates an account and learn the encryption scheme. Let’s delete the application, download the app and start again.

Surprise! 🥳

This application stores user account in Keychain. Even if you delete it, you’ll always recover your account. Apple enforces account deletion but these folks don’t care. Just like they don’t care about anything going on in this app. Let’s remove the junk this app leaves in our Keychain.

If you’re not jailbroken, you can’t delete Keychain items. The only way is to reset your iPhone. If you’re jailbroken, it is pretty easy.

Applications write keychain items to /private/var/Keychains/keychain-2.db It’s an encrypted file. Open this file with Filza, and run below SQL queries to delete the junk. SECRET_APP_NAME is the name of the app.

 delete from genp where agrp like '%SECRET_APP_NAME%'
 delete from keys where agrp like '%SECRET_APP_NAME%'

Make sure, the app is removed from the task list when you run those queries. Deleting the Documents folder of the application and Keychain items is enough to reset the application. So we have a way to test this application as much as we want.

Dynamic Analysis

It’s easy to get lost during statistical analysis. This application uses lots of libraries, Ad Services and Analytics. Therefore we should focus on the functions of the main application. We will use Frida for this purpose.

It is easy to trace functions with Frida. For example, using the below command will allow you to see all the CCCryptorCreate* calls on your console

frida-trace -U -i "CCCryptorCreate*" SECRET_APP_NAME

When you trace the application, Frida creates special JavaScript files inside the __handlers__ folder. Each file looks like below

__handlers__/libcommonCrypto.dylib/CCCryptorCreate.js

{
  onEnter(log, args, state) {
    log(`CCCryptorCreate(op=${args[0]}, alg=${args[1]}, options=${args[2]}, key=${args[3]}, keyLength=${args[4]}, iv=${args[5]}, cryptorRef=${args[6]})`);
    console.log("Key: ")
    console.log(hexdump(ptr(args[3]), {
            length: args[4].toInt32(),
            header: true,
            ansi: true
        }))

  },
   */
  onLeave(log, retval, state) {
  }
}

By using the onEnter and onLeave hooks, you can easily print the parameters of the functions. This gives us lots of flexibility.

Cracking the Algorithm

The application first calls the /user/new endpoint. It returns the following JSON

{
	"token": "7b7ce83fd9e81fc97a010dccbaa545c9",
	"expected": 8
}

Next, it calls the following endpoint /user/new

Parameters

device_id	4d7920446576696365204944
nonce	D181206D-B829-41B9-95F6-C99220B94C5B
hmac	4d792053656372657420486d6163
token	7b7ce83fd9e81fc97a010dccbaa545c9
work	7b7ce83fd9e81fc97a010dccbaa545c9160

Body

public_key=MIIBCg...

I can emulate device_id since it seems random. The token is the same as the original request. But, how work, nonce, and hmac are generated? Do you see how the token and work are so similar to each other? Let’s ask Frida to do this job for us. When I checked the functions I saw a class named XSecurity. It seems obvious that this class is responsible for security functions. I asked Frida to trace this class.

frida-trace -U SECRET_APP_NAME -m "+[XSecurity *]"

I saw the following function call in the logs

findHashValueWithToken:zeroes:

I modified its handler function and added some log functions to see what’s doing with its input

__handlers__/XSecurity/findHashValueWithToken_zeroes_.js

  onEnter: function (log, args, state) {
    log('+[XSecurity findHashValueWithToken:' + args[2] + ' zeroes:' + args[3] + ']');
    log('NSString: ' + ObjC.Object(args[2]).toString());
    console.log('IN: ' + hexdump(ptr(args[2]), {
            length: 32,
            header: true,
            ansi: true
    }))

  },

  onLeave: function (log, retval, state) {
        var  ret = ObjC.Object(retval).toString(); // NSString
        log("RET: " + ret);
  }
}

In the logs, I saw 7b7ce83fd9e81fc97a010dccbaa545c9 as input and 7b7ce83fd9e81fc97a010dccbaa545c9160 as output. Let’s see what this function doing with Ghidra

void XSecurity::findHashValueWithToken:zeroes:(void)

{
  CC_LONG len;
  int iVar1;
  undefined8 uVar2;
  undefined8 uVar3;
  void *data;
  undefined8 uVar4;
  int in_w3;
  long lVar5;
  uchar local_7c [20];
  long local_68;
  
  local_68 = *(long *)__got::___stack_chk_guard;
  do {
    __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSString,"stringWithFormat:",&cf_%@%d);
    uVar2 = __stubs::_objc_retainAutoreleasedReturnValue();
    __stubs::_objc_msgSend(uVar2,"dataUsingEncoding:",4);
    __stubs::_objc_retainAutoreleasedReturnValue();
    uVar3 = __stubs::_objc_retainAutorelease();
    data = (void *)__stubs::_objc_msgSend(uVar3,"bytes");
    len = __stubs::_objc_msgSend(uVar2,"length");
    __stubs::_CC_SHA1(data,len,local_7c);
    __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSMutableString,"string");
    uVar4 = __stubs::_objc_retainAutoreleasedReturnValue();
    lVar5 = 0;
    do {
      __stubs::_objc_msgSend(uVar4,"appendFormat:",&cf_%02x);
      lVar5 = lVar5 + 1;
    } while (lVar5 != 0x14);
    iVar1 = __stubs::_objc_msgSend(&objc::class_t::XSecurity,"zerosInString:",uVar4);
    __stubs::_objc_release(uVar4);
    __stubs::_objc_release(uVar3);
    __stubs::_objc_release(uVar2);
  } while (iVar1 != in_w3);
  __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSString,"stringWithFormat:",&cf_%d);
  if (*(long *)__got::___stack_chk_guard == local_68) {
    return;
  }
                    /* WARNING: Subroutine does not return */
  __stubs::___stack_chk_fail();
}

Don’t be scared about a bunch of pseudo-code. It seems this function is using the SHA1 function to convert our input. If you don’t want to bother with what it’s doing, you can use Frida to trace the CC_SHA1 function. It is pretty simple. It adds an integer starting from 0 to the original token and calculates the SHA1 hash. Then, it counts the number of zeros of the hash. If the number of zeroes is equal to the expected zeroes(8), we found our work value. Let’s try our hypothesis.

SHA1(7b7ce83fd9e81fc97a010dccbaa545c9160) = d22f0eda07ab044620201772024920d9fac3ba02

Yes, our hash has 8 zeroes. The first mystery is solved.

Here is the little JavaScript function

  static findHashValue(token, expected) {
    let hashValue = 0;
    let zeroCount = 0;

    do {
      hashValue += 1;
      let source = token + String(hashValue);
      let shasum = crypto.createHash("sha1").update(source).digest("hex");
      zeroCount = shasum
        .split("")
        .reduce((acc, ch) => (ch === "0" ? acc + 1 : acc), 0);
    } while (zeroCount != expected);
    return hashValue;
  }

HMAC

We need to find the salt of the HMAC to calculate HMAC for a given nonce. When we trace the XSecurity::nonceAndHmacForNewUse function we again extract the salt.

LLPHI... It also uses MD5(iN..) to calculate the secret for HMAC but I don’t want to bore you with the details.

Creating a User

We still don’t know how to create the public_key parameter. It looks like it’s trying to create public and private key pairs. When we search the function list we see the following function

void XUserAccount::generateNewKeypair(ID param_1,SEL param_2)

{
  undefined8 uVar1;
  undefined8 uVar2;
  undefined8 uVar3;
  undefined8 uVar4;
  undefined8 uVar5;
  undefined8 uVar6;
  undefined8 uVar7;
  undefined8 uVar8;
  long local_70;
  long local_68;
  
  uVar1 = __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSMutableDictionary,"alloc");
  uVar1 = __stubs::_objc_msgSend(uVar1,"init");
  uVar2 = __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSMutableDictionary,"alloc");
  uVar2 = __stubs::_objc_msgSend(uVar2,"init");
  uVar3 = __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSMutableDictionary,"alloc");
  uVar3 = __stubs::_objc_msgSend(uVar3,"init");
  __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSData,"dataWithBytes:length:","com.example.publickey",0x14);
  uVar4 = __stubs::_objc_retainAutoreleasedReturnValue();
  __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSData,"dataWithBytes:length:","com.example.privatekey",0x15)
  ;
  uVar5 = __stubs::_objc_retainAutoreleasedReturnValue();
  local_70 = 0;
  local_68 = 0;
  __stubs::_objc_msgSend
            (uVar3,"setObject:forKey:",*(undefined8 *)__got::_kSecAttrKeyTypeRSA,
             *(undefined8 *)__got::_kSecAttrKeyType);
  __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSNumber,"numberWithInt:",0x800);
  uVar6 = __stubs::_objc_retainAutoreleasedReturnValue();
  __stubs::_objc_msgSend
            (uVar3,"setObject:forKey:",uVar6,*(undefined8 *)__got::_kSecAttrKeySizeInBits);
  __stubs::_objc_release(uVar6);
  __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSNumber,"numberWithBool:",1);
  uVar6 = __stubs::_objc_retainAutoreleasedReturnValue();
  uVar8 = *(undefined8 *)__got::_kSecAttrIsPermanent;
  __stubs::_objc_msgSend(uVar1,"setObject:forKey:",uVar6,uVar8);
  __stubs::_objc_release(uVar6);
  uVar7 = *(undefined8 *)__got::_kSecAttrApplicationTag;
  __stubs::_objc_msgSend(uVar1,"setObject:forKey:",uVar5,uVar7);
  __stubs::_objc_msgSend(&_OBJC_CLASS_$_NSNumber,"numberWithBool:",1);
  uVar6 = __stubs::_objc_retainAutoreleasedReturnValue();
  __stubs::_objc_msgSend(uVar2,"setObject:forKey:",uVar6,uVar8);
  __stubs::_objc_release(uVar6);
  __stubs::_objc_msgSend(uVar2,"setObject:forKey:",uVar4,uVar7);
  __stubs::_objc_msgSend(uVar3,"setObject:forKey:",uVar1,*(undefined8 *)__got::_kSecPrivateKeyAttrs)
  ;
  __stubs::_objc_msgSend(uVar3,"setObject:forKey:",uVar2,*(undefined8 *)__got::_kSecPublicKeyAttrs);
  __stubs::_SecKeyGeneratePair(uVar3,&local_68,&local_70);
  if (local_68 != 0) {
    __stubs::_CFRelease();
  }
  if (local_70 != 0) {
    __stubs::_CFRelease();
  }
  loadKeyPair(param_1,(SEL)"loadKeyPair");
  __stubs::_objc_release(uVar5);
  __stubs::_objc_release(uVar4);
  __stubs::_objc_release(uVar3);
  __stubs::_objc_release(uVar2);
  __stubs::_objc_release(uVar1);
  return;

Again we don’t need to waste time with the pseudocode. If we trace the SecKeyGeneratePair function we can easily get the parameters of this function. It seems it’s creating RSA 2048 key pairs. Below code will create the keys.

  static generateNewKeyPair() {
    const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
      modulusLength: 2048,
      publicKeyEncoding: {
        type: "pkcs1",
        format: "der",
      },
      privateKeyEncoding: {
        type: "pkcs1",
        format: "der",
      },
    });

    return {
      publicKey: publicKey,
      privateKey: privateKey,
    };
  }

The server creates a user and returns the following JSON

{
	"uid": "4d792053656372657420756964",
	"pin": "undefined"
}

Then it calls /user/authenticate?uid=${uid} and gets new nonce

{
	"nonce": "76657279206c6f6e67206e6f6e636520666f72206578747261207365637572697479"
}

Authenticate

It then calls /user/authenticate?uid=${uid} with the following body

sig=VKG0o...

As you can guess, the sig is nonce signed with our private key.

  static produceSignedData(auth_nonce, privateKeyStr) {
   const privateKey =  crypto.createPrivateKey({
      key:Buffer.from(privateKeyStr,'base64'),
      format : 'der',
      type: 'pkcs1'
  });
    const source = `${auth_nonce}${SECRET_SALT}`;
    const sign = crypto.createSign("sha1");
    sign.update(source);
    sign.end();
    const signature = sign.sign(privateKey);
    return signature.toString("base64");
  }

This endpoint will return our session_token and we’re good to go.

{
	"session_token": "aGVsbG9fdGhlcmVfY3VyaW91c19vbmU="
}

We’re logged in, we can get some parts of the app but we can’t use the chat functionality. Chat uses different endpoints. Let’s try to understand that part as well.

Chat

/messaging/conversations/..

This endpoint returns the username and password for the chat endpoint. Opening a socket connection to its endpoint will be enough to start the chat with given users.

Summary

This post became longer than I expected. I would write more details about the app, but I don’t wanna give more clues about the app lol I hope you learned a couple of tricks related to reversing iOS applications.

I am available for new work
Interested? Feel free to reach