Download as pdf or txt
Download as pdf or txt
You are on page 1of 11

Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

Home (/) > Explore Optiv Insights (/insights) > Source Zero (/insights/source-zero)
> Walkthrough of an iOS CTF

August 25, 2020

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:

1 of 11 7/9/21, 8:15 PM
Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

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 (https://www.hopperapp.com/), 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] ;
objc_cls_ref_NSFileManager,_OBJC_CLASS_$_NSFileManager
0000000100005ffc nop
0000000100006000 ldr x19, =aDefaultmanager ;
"defaultManager",@selector(defaultManager)
0000000100006004 mov x1, x19
0000000100006008 bl imp___stubs__objc_msgSend ;
objc_msgSend
000000010000600c mov x29, x29
0000000100006010 bl imp___stubs__objc_retainAutoreleasedReturnValue ;
objc_retainAutoreleasedReturnValue
0000000100006014 mov x21, x0
0000000100006018 nop
000000010000601c ldr x20, =aFileexistsatpa ;
"fileExistsAtPath:",@selector(fileExistsAtPath:)
0000000100006020 adr x2, #0x100008238 ;
@"/Applications/Cydia.app"

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

2 of 11 7/9/21, 8:15 PM
Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

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):

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 (https://angr.io/), 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]

3 of 11 7/9/21, 8:15 PM
Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

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 (https://en.wikipedia.org/wiki/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;

4 of 11 7/9/21, 8:15 PM
Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

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];
if
(objc_msgSend(@"7c537a22bc6e1f979ac26341125c30d2eba190d2b003aff7a89454525932d4d8617c7797a1cfe20810cc860cff
@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(@"7c537a22bc6e1f979ac26341125c30d2eba190d2b003aff7a89454525932d4d8617c7797a1cfe20810cc860cff9
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:

5 of 11 7/9/21, 8:15 PM
Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

[-] 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):

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

6 of 11 7/9/21, 8:15 PM
Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

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) {
retval.replace(0x0);
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"')
retval.replace(yeahArray);
}
})

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
retval.replace(0)
console.log('[+] 5c5c() returning 0x0: ' + retval);
}
})

Interceptor.attach(baseAddress.add(0x5e34), {

7 of 11 7/9/21, 8:15 PM
Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

onEnter: function (args) {


console.log('[-] 5e34() called with: ' + Memory.readUtf8String(args[0]));
},
onLeave: function (retval) {
retval.replace(0x1);
// 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 return s;
}

Interceptor.attach(cccrypt, {

8 of 11 7/9/21, 8:15 PM
Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

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
keyData += pad(b[i].toString(16), 2);
}
var b = new Uint8Array(this.ivBytes);
var ivData = "";
for (var i = 0; i
ivData += pad(b[i].toString(16), 2);
}
var b = new Uint8Array(this.dataInBytes);
var dataInData = "";
for (var i = 0; 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
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);
}
})
*/

By: Optiv AppSec Team (/blog/author/optiv-appsec-team)

9 of 11 7/9/21, 8:15 PM
Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

Share:    

ANGR FRIDA
(/TAXONOMY/TAGS/ANGR) (/TAXONOMY/TAGS/FRIDA)

RED TEAM SOURCE ZERO


(/TAXONOMY/TAGS/RED-TEAM) (/TAXONOMY/TAGS/SOURCE-ZERO)

HOPPER
(/TAXONOMY/TAGS/HOPPER)

Copyright © 2021 Optiv Security Inc. All rights reserved.


No license, express or implied, to any intellectual property or other content is granted or intended
hereby.
This blog is provided to you for information purposes only. While the information contained in this
site has been obtained from sources believed to be reliable, Optiv disclaims all warranties as to the
accuracy, completeness or adequacy of such information.
Links to third party sites are provided for your convenience and do not constitute an endorsement by
Optiv. These sites may not have the same privacy, security or accessibility standards.
Complaints / questions should be directed to Legal@optiv.com (mailto:Legal@optiv.com)

RELATED INSIGHTS

BLOG
March 30, 2018

Mobile App Testing With Automation Trickery in


Frida (/explore-optiv-insights/blog/mobile-app-
testing-automation-trickery-frida)
When you spend a lot of time doing security testing on mobile apps like I do,
you begin to worry that a large part of your life will be spent rebootin...

(/explore-optiv-insights/blog/mobile-app-testing-automation- See Details (/explore-optiv-insights/blog/mobile-app-testing-


trickery-frida) automation-trickery-frida)

BLOG
July 10, 2020

Optiv’s REST API “Goat” (/insights/source-


zero/blog/optivs-rest-api-goat)
Optiv is releasing REST API Goat, a vulnerable API, to help boost AppSec
skills.

See Details (/insights/source-zero/blog/optivs-rest-api-goat)


(/insights/source-zero/blog/optivs-rest-api-goat)

10 of 11 7/9/21, 8:15 PM
Walkthrough: iOS Capture the Flag | Optiv https://www.optiv.com/insights/source-zero/blog/walkthrough...

BLOG
July 22, 2020

Anatomy of a Kubernetes Attack - How


Untrusted Docker Images Fail Us (/insights
/source-zero/blog/anatomy-kubernetes-attack-
how-untrusted-docker-images-fail-us)
An attacker could use a poisoned docker image to break out of a container.

(/insights/source-zero/blog/anatomy-kubernetes-attack-how-
See Details (/insights/source-zero/blog/anatomy-kubernetes-
untrusted-docker-images-fail-us)
attack-how-untrusted-docker-images-fail-us)

How Can We Help?


Let us know what you need, and we will have an Optiv professional contact you
shortly.

SECURITY SOLUTIONS (/SECURITY- PARTNER DIRECTORY (/PARTNERS) EXPLORE OPTIV INSIGHTS


SOLUTIONS) (/INSIGHTS)

OUR STORY (/COMPANY/ABOUT-US) JOIN OPTIV TEAM (/COMPANY CLIENT PORTAL


/CAREERS) (HTTP://CLIENT.OPTIV.COM)

 (https://www.linkedin.com/company/optiv-inc/)  (https://twitter.com/Optiv)  (https://www.facebook.com/OptivInc/)  (https://www.youtube.com

/channel/UC5dqDQ0tLgaohPd9meSCB6g)

Home (/) | Contact (/contact-us) | Cookie Policy (/optiv-cookie-policy) | Privacy Policy (/privacy-policy) | Terms of Use (/terms-of-use) | Sitemap (/sitemap)

The content provided is for informational purposes only. Links to third party sites are provided for your convenience and do not constitute an endorsement. These sites may not
have the same privacy, security or accessibility standards.

© 2020 – 2021. Optiv Security Inc. All Rights Reserved.

11 of 11 7/9/21, 8:15 PM

You might also like