Walkthrough of an iOS CTF

Capture the Flags (CTFs) and crackmes for iOS applications aren’t as common as they are for Android, so here’s a helpful (and fun and quick) review of the general steps I took to solve one.


A quick walkthrough of the general steps I took to solve the challenge and obtain the flag follows. (The walkthrough assumes the reader is already familiar with using Frida as well as a basic understanding of disassembly.)


After downloading the IPA, resigning and installing it to your device, a single screen will display when running:


Capture Flag 1


The app takes the flag as input and will display a message to let you know if the value was correct after tapping "Submit." Incorrect attempts or running on a jailbroken device result in an error message.



Jailbreak Detection Bypass

This was the first step and was not too difficult. After decompressing the ipa and loading the main binary in Hopper, we can quickly see some references to jailbreak detection routines such as the start of the one shown here:


+[JailbreakDetection isJailbroken] :

0000000100005fdc sub sp, sp, #0x50 ;
Objective C Implementation defined at 0x100009220 (class method), DATA XREF=0x100009220
0000000100005fe0 stp x24, x23, [sp, #0x10]
0000000100005fe4 stp x22, x21, [sp, #0x20]
0000000100005fe8 stp x20, x19, [sp, #0x30]
0000000100005fec stp x29, x30, [sp, #0x40]
0000000100005ff0 add x29, sp, #0x40
0000000100005ff4 adrp x23, #0x100009000
0000000100005ff8 ldr x0, [x23, #0x478] ;
0000000100005ffc nop
0000000100006000 ldr x19, =aDefaultmanager ;
0000000100006004 mov x1, x19
0000000100006008 bl imp___stubs__objc_msgSend ;
000000010000600c mov x29, x29
0000000100006010 bl imp___stubs__objc_retainAutoreleasedReturnValue ;
0000000100006014 mov x21, x0
0000000100006018 nop
000000010000601c ldr x20, =aFileexistsatpa ;
0000000100006020 adr x2, #0x100008238 ;


However, the app also performs a couple of other checks that I needed to work around as well; notably, the app makes a call to the [CriticalLogic bat] method. I did not fully understand the purpose of this but did manage to hook the return value.


There are many tutorials on using tools such as Frida to bypass jailbreak and root detection and I will not go into too much detail on the methodology here. The required hooked methods are shown in the Frida script provided at the end of this article and include comments.



Reversing Engineering the Flag

When I use the Frida script to start the app, it no longer complains after I tap "Submit" so we can now see how the submitted data is handled. A little bit of reverse engineering and we can see that the fun starts at a branch call to the function at 0x100005e34 from within the [CriticalLogic b:] method:


0000000100005ad4 mov x22, x0
0000000100005ad8 bl sub_100005e34 ; sub_100005e34


After examining the function at 1005e34(), we can see that the user-supplied guess is passed to it, but also the value after the first six chars is used later on for hashing (more on this later). If we look at the control flow graph for 10005e34() it gets a bit complex as shown in the following image (don't worry about the exact instructions for now, just note the overall structure of the graph):


Capture Flag 2


I have done a few CTFs previously and have come to recognize this "waterfall" shape as being characteristic of a function that makes a series of checks and modifications to a given input – usually involving custom XOR or mathematical routines. Should any of the values fail a check, the function leaves the "waterfall" and exits. While it is possible to manually try and reverse-engineer what input value is required to arrive at a given address in the function, it is also a good candidate for concolic analysis using something like angr, which is much faster.


After using lipo to extract the 64 bit macho from the main executable, we can use angr to solve for the required input to the function at 0x10005e34 using the following angr Python script:


import angr

function_start = 0x100005e60
function_target = 0x100005fac
function_avoid = [0x100005fb0]

proj = angr.Project('iOweA**.arm64')

state = proj.factory.blank_state(addr=function_start)

arg = state.solver.BVS("input_bytes", 8 * 6)
x0 = state.solver.BVS('x0', 64)
x8 = state.solver.BVS('x8', 64)
state.regs.x0 = x0
state.regs.x8 = x8
# pick an arb memory location
bind_addr = 0x1000
state.memory.store(bind_addr, arg)
state.add_constraints(state.regs.x0 == bind_addr)
for byte in arg.chop(8):
state.add_constraints(byte >= '\x20') # ' '
state.add_constraints(byte <= '\x7e') # '~'

sm = proj.factory.simulation_manager(state)
sm.explore(find=function_target, avoid=function_avoid)

found_state = sm.found[0]

print('First 6 chars: {}'.format(found_state.solver.eval(arg, cast_to=bytes)))
print('Min len (x8): {}'.format(found_state.solver.eval(x8)))


which prints:


First 6 chars: b'winni '
Min len (x8): 14


So now we know the first six characters of the flag as well as the total flag length. Note that angr is telling us the input length including the null char so we have determined:


Flag[0..5] = 'winni '
Flag[6..12] = unknown (7 chars)


At this point, because of the donkey displayed by the app and the reference to winni at the start of the flag, I was fairly certain we would be dealing with a reference to Milne's Winnie-the-Pooh. Also note that angr is telling us just one possible solution using the providing constraints (which included a space character) and I suspected that 'winnie' could also be a valid start to the flag.


Before trying my guess for the remaining seven characters, I first wanted to see what the app does with them. During my testing I hooked the calls to CC_SHA256 and CCCrypt but hooking the ObjC [CriticalLogic AES128Operation:data:key:iv:] method works as well. Observing the values passed to and returned by these functions revealed that the user input after the first six characters is first sha256 hashed and then compared with the (AES) decrypted string that is hardcoded in the app.


Hopper pseudo code of the decompiled [CriticalLogic b:] method shows this process nicely. When called, arg2 points to the sha256 hash of the user provided input (less the first six chars) and is assigned to x19. Near the end of the function we see the hash of the user's input being compared to the decrypted hardcoded string:


/* @class CriticalLogic */
-(bool)b:(void *)arg2 {
r31 = r31 - 0x70;
var_50 = r28;
stack[-88] = r27;
var_40 = r26;
stack[-72] = r25;
var_30 = r24;
stack[-56] = r23;
var_20 = r22;
stack[-40] = r21;
var_10 = r20;
stack[-24] = r19;
saved_fp = r29;
stack[-8] = r30;
r29 = &saved_fp;
r20 = self;
r19 = [arg2 retain];
if (sub_100005c5c() != 0x0) {
r20 = 0x0;
else {
r21 = [[NSMutableData alloc] init];
(objc_msgSend(@"7c537a22bc6e1f979ac26341125c30d2eba190d2b003aff7a89454525932d4d8617c7797a1cfe20810cc860cff996373", @selector(length)) >= 0x2) {
r27 = 0x0;
r25 = 0x1;
do {
r24 = @selector(length);

[@"7c537a22bc6e1f979ac26341125c30d2eba190d2b003aff7a89454525932d4d8617c7797a1cfe20810cc860cff996373" characterAtIndex:r25 - 0x1];

[@"7c537a22bc6e1f979ac26341125c30d2eba190d2b003aff7a89454525932d4d8617c7797a1cfe20810cc860cff996373" characterAtIndex:r25];
strtol(&var_54, 0x0, 0x10);
[r21 appendBytes:&var_51 length:0x1];
r27 = r27 + 0x1;
r0 =
objc_msgSend(@"7c537a22bc6e1f979ac26341125c30d2eba190d2b003aff7a89454525932d4d8617c7797a1cfe20810cc860cff996373", r24);
r25 = r25 + 0x2;
} while (r0 >> 0x1 > r27);
r22 = [[r20 AES128Operation:0x1 data:r21 key:@"Bmrb5WBcWgLXRyjJ" iv:0x0] retain];
r20 = [r19 isEqualToData:r22];
[r22 release];
[r21 release];
[r19 release];
r0 = r20;
return r0;


Hooking either the [CriticalLogic AES128Operation:data:key:iv:] method or CCCrypt allows us to obtain the value of the decrypted string: be1f35b7a9353a2aa509eb719fd8ecd054c0a90e891253b0f4fc661699c68911.


So the sha256() hash of the user's input after the first six characters needs to match this value.


It was on my first try in Python I confirmed my suspicions with a hash match:


In [5]: hashlib.sha256(b"thepooh").hexdigest()
Out[5]: 'be1f35b7a9353a2aa509eb719fd8ecd054c0a90e891253b0f4fc661699c68911'


I then started the app using the Frida script and provided a flag value of "winniethepooh." After I tap "Submit" the Frida script generates the following output showing the jailbreak check bypasses and the two hash values being used for comparison:


[-] isJailbroken called
[-] isJailbroken returning: 0x0
[-] coreLogic retval: 0x0
[-] Current bat NSArray retval: naah
[+] changing to "yeah"
[-] 5e34() called with: winniethepooh
[+] 5e34() returning 0x1: 0x1
message: {'type': 'send', 'payload': '[-] b: (sha256 hash) input bytes:'} data: b'\xbe\x1f5\xb7\xa95:*\xa5\t\xebq\x9f\xd8\xec\xd0T\xc0\xa9\x0e\x89\x12S\xb0\xf4\xfcf\x16\x99\xc6\x89\x11'
[+] 5c5c() returning 0x0: 0x0
[-] AES128Operation called
[-] AES128Operation returned
message: {'type': 'send', 'payload': 'decrypted data bytes:'} data: b'\xbe\x1f5\xb7\xa95:*\xa5\t\xebq\x9f\xd8\xec\xd0T\xc0\xa9\x0e\x89\x12S\xb0\xf4\xfcf\x16\x99\xc6\x89\x11'
[-] b: retval: 0x1
[-] a: retval: 0x1


And from the message in the UI we can see that the flag is correct ("winni thepooh" also worked):


Capture Flag 3


Solving CTFs and crackmes is a great way to learn new techniques and tools to assist with reverse engineering. Knowing how to perform fundamental binary analysis can be especially helpful during security assessments when evaluating custom security controls or suspected malware.


In addition to the angr script included above, the Frida script for bypassing the jailbreak checks is included below:


var baseAddress = Process.enumerateModules()[0].base
var JailbreakDetection = ObjC.classes.JailbreakDetection;
var CriticalLogic = ObjC.classes.CriticalLogic;
var NSArray = ObjC.classes.NSArray
var NSString = ObjC.classes.NSString

Interceptor.attach(JailbreakDetection['isJailbroken'].implementation, {
onEnter: function (args) {
console.log('[-] isJailbroken called');
onLeave: function (retval) {
console.log('[-] isJailbroken returning: ' + retval);

Interceptor.attach(baseAddress.add(0x50d8), {
// This is not required if entering the correct length pass. Helpful during reversing
onEnter: function (args) {
this.context.x0 = 0xd;
console.log('[-] Changed x0 to: ' + this.context.x0 + ' for length check');

// I don't think this is doing anything in our case, some ipad check?
Interceptor.attach(CriticalLogic["- coreLogic"].implementation, {
onEnter: function (args) {
onLeave: function (retval) {
console.log('[-] coreLogic retval: ' + retval);

Interceptor.attach(CriticalLogic["- bat"].implementation, {
onEnter: function (args) {
onLeave: function (retval) {
var yeah = NSString["stringWithString:"]("yeah")
var yeahArray = NSArray["arrayWithObject:"](yeah)
var array = new ObjC.Object(retval);
var count = array.count().valueOf();
var current = '';
for (var i = 0; i !== count; i++) {
current = current + array.objectAtIndex_(i);
console.log('[-] Current bat NSArray retval: ' + current);
// I have no idea what the yeah vs naah was, I guess a certain battery level?
console.log('[+] changing to "yeah"')

Interceptor.attach(baseAddress.add(0x5c5c), {
onEnter: function (args) {
// this is called from inside CriticalLogic b: just before AES128Operation
onLeave: function (retval) {
// This needs to be 0 or the self reference gets cleared
console.log('[+] 5c5c() returning 0x0: ' + retval);

Interceptor.attach(baseAddress.add(0x5e34), {
onEnter: function (args) {
console.log('[-] 5e34() called with: ' + Memory.readUtf8String(args[0]));
onLeave: function (retval) {
// we need to return a 1 here
console.log('[+] 5e34() returning 0x1: ' + retval);

// showing the ObjC hooks for decrypt as well as CCCrypt below (commented out)
Interceptor.attach(CriticalLogic["- AES128Operation:data:key:iv:"].implementation, {
onEnter: function (args) {
console.log('[-] AES128Operation called')
onLeave: function (retval) {
console.log('[-] AES128Operation returned');
//console.log('Type of retval -> ' + new ObjC.Object(retval).$className)
var data = new ObjC.Object(retval);
send('decrypted data bytes:', data.bytes().readByteArray(data.length()));

Interceptor.attach(CriticalLogic["- a:"].implementation, {
onEnter: function (args) {
onLeave: function (retval) {
// this should return a 1
console.log('[-] a: retval: ' + retval);

Interceptor.attach(CriticalLogic["- b:"].implementation, {
// should be the hash bytes
onEnter: function (args) {
//console.log('Type of args[2] -> ' + new ObjC.Object(args[2]).$className)
var data = new ObjC.Object(args[2]);
send('[-] b: (sha256 hash) input bytes:', data.bytes().readByteArray(data.length()));
onLeave: function (retval) {
// this should return a 1
console.log('[-] b: retval: ' + retval);

// CCCrypt
const cccrypt = Module.findExportByName(null, 'CCCrypt');
const ccsha256 = Module.findExportByName(null, 'CC_SHA256');
var algs = {
0: 'AES',
1: 'DES',
2: '3DES',
3: 'CAST',
4: 'RC4',
5: 'RC2'
function pad(num, size) {
var s = num + "";
while (s.length size) s = "0" + s;
return s;

Interceptor.attach(cccrypt, {
onEnter: function (args) {
console.log('[*] CCCrypt called:');
args[0] == 0 ? console.log(" [+] Mode: Encrypt") : console.log(" [+] Mode: Decrypt");
this.alg = parseInt(args[1]);
var keyLength = parseInt(args[4]);
var dataInLength = parseInt(args[7]);
this.dataMovedLength = parseInt(args[10]);
this.keyBytes = Memory.readByteArray(args[3], keyLength);
this.ivBytes = Memory.readByteArray(args[5], 16);
this.dataInBytes = Memory.readByteArray(args[6], dataInLength);
this.dataOut = args[8];
this.dataOutAvail = parseInt(args[9]);
this.dataOutMoved = args[10];
onLeave: function (retval) {
var b = new Uint8Array(this.keyBytes);
var keyData = "";
for (var i = 0; i b.length; i++) {
keyData += pad(b[i].toString(16), 2);
var b = new Uint8Array(this.ivBytes);
var ivData = "";
for (var i = 0; i b.length; i++) {
ivData += pad(b[i].toString(16), 2);
var b = new Uint8Array(this.dataInBytes);
var dataInData = "";
for (var i = 0; i b.length; i++) {
dataInData += pad(b[i].toString(16), 2);
var dataOutBytes = Memory.readByteArray(this.dataOut, this.dataOutMoved.readUInt());
var b = new Uint8Array(dataOutBytes);
var dataOutData = "";
for (var i = 0; i b.length; i++) {
dataOutData += pad(b[i].toString(16), 2);
console.log(' [+] Alg: ' + algs[this.alg] +
'\n [+] Key: ' + keyData +
'\n [+] IV: ' + ivData +
'\n [+] DataIn: ' + dataInData +
'\n [+] DataOut: ' + dataOutData);

Interceptor.attach(ccsha256, {
onEnter: function (args) {
console.log('[+] CC_SHA256 called');
var length = parseInt(args[1]);
this.input = Memory.readByteArray(args[0], length);
this.md = args[2];
onLeave: function (retval) {
send('cc_sha256 input', this.input);
var hash = Memory.readByteArray(this.md, 32);
send('cc_sha256 hash', hash);