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.