Before going into details, it might be useful to clarify what iOS checks before loading and executing a binary.
- A digital signature is enclosed in every installation package (IPA). The signature contains a hash for each file in the installation package and a cryptographic signature, provided by Apple (directly or indirectly). Broken IPAs cannot be installed. Jailbreaking means that this restriction is relieved so that any signing authority is accepted - that is, a self-signed certificate is sufficient for signing IPAs.
- Every executable is supplied with an embedded XML, which gives a detailed description about what that particular executable is allowed to do. For instance this is the place to list the allowed keychain access groups, or flags that indicate that the application is allowed to hook into other processes through the kernel (~gdb).
- Memory pages are signed and the kernel checks their signature before executing their contents. Therefore, it is very hard to inject payload (shellcode) directly into a process memory space - however, this restriction does not mean that we are unable to tamper with the process at runtime.
Having said that, consider a Proof-of-Concept example for very basic jailbreak checking. The most naive approach checks for known residual artifacts of the jailbreaking process ('/Applications/Cydia.app', '/var/log/cydia.log' etc.) The process utilises an array of strings and existence of any of the files indicates a jailbroken device.
BOOL isJailbroken(AppDelegate* delegate) {
return [[NSFileManager defaultManager] fileExistsAtPath:@"/Applications/Cydia.app"];
}
- (void)jailbreakDetection
{
if (isJailbroken(self))
{
[self vanIlyen];
}
else
{
[self nincsIlyen];
}
}
- (void)vanIlyen
{
[[[UIAlertView alloc] initWithTitle:@"Jailbroken device!"
message:@"Indeed, it is."
delegate:nil
cancelButtonTitle:@"Got it."
otherButtonTitles: nil] show];
}
- (void)nincsIlyen
{
[[[UIAlertView alloc] initWithTitle:@"Clean device.:)"
message:@"Oh yes."
delegate:nil
cancelButtonTitle:@"Got it."
otherButtonTitles: nil] show];
The algorithm is simple. We check whether the '/Applications/Cydia.app' exists and update the user interface accordingly. From a pentester's point of view, the source is the documentation and it is a rare and happy moment when we have access to it while testing. Therefore, take a look at the disassembly view:
; =============== S U B R O U T I N E =======================================
; AppDelegate - (void)jailbreakDetection
; Attributes: bp-based frame
; void __cdecl __AppDelegate jailbreakDetection_(struct AppDelegate *self, SEL)
__AppDelegate_jailbreakDetection_ ; DATA XREF: __objc_const:00003318 o
PUSH {R4,R7,LR}
MOV R4, R0
MOV R0, (selRef_isJailbroken - 0x2406) ; selRef_isJailbroken
ADD R7, SP, #4
ADD R0, PC ; selRef_isJailbroken
LDR R1, [R0] ; "isJailbroken"
MOV R0, R4
BLX _objc_msgSend
TST.W R0, #0xFF
BEQ loc_241E
MOV R0, (selRef_vanIlyen - 0x241E) ; selRef_vanIlyen
ADD R0, PC ; selRef_vanIlyen
B loc_2428
; ---------------------------------------------------------------------------
loc_241E ; CODE XREF: -[AppDelegate jailbreakDetection]+1C j
MOV R0, (selRef_nincsIlyen - 0x242A) ; selRef_nincsIlyen
ADD R0, PC ; selRef_nincsIlyen
loc_2428 ; CODE XREF: -[AppDelegate jailbreakDetection]+28 j
LDR R1, [R0]
MOV R0, R4
BLX _objc_msgSend
POP {R4,R7,PC}
; End of function -[AppDelegate jailbreakDetection]
; ---------------------------------------------------------------------------
The assembly view of the isJailbroken function looks as follows:
; =============== S U B R O U T I N E =======================================
; AppDelegate - (char)isJailbroken
; char __cdecl __AppDelegate isJailbroken_(struct AppDelegate *self, SEL)
__AppDelegate_isJailbroken_ ; DATA XREF: __objc_const:00003324 o
PUSH {R7,LR}
MOVW R1, #(selRef_defaultManager - 0x244C) ; selRef_defaultManager
MOV R7, SP
MOVT.W R1, #0
MOV R0, (classRef_NSFileManager - 0x244E) ; classRef_NSFileManager
ADD R1, PC ; selRef_defaultManager
ADD R0, PC ; classRef_NSFileManager
LDR R1, [R1] ; "defaultManager"
LDR R0, [R0] ; _OBJC_CLASS_$_NSFileManager
BLX _objc_msgSend
MOV R1, (selRef_fileExistsAtPath_ - 0x2460) ; selRef_fileExistsAtPath_
ADD R1, PC ; selRef_fileExistsAtPath_
LDR R1, [R1] ; "fileExistsAtPath:"
MOV R2, (cfstr_ApplicationsCy - 0x246C) ; "/Applications/Cydia.app"
ADD R2, PC ; "/Applications/Cydia.app"
BLX _objc_msgSend
POP {R7,PC}
; End of function -[AppDelegate isJailbroken]
There are plenty of ways to bypass this check. The easiest and most trivial is to patch the binary so that the file name string is different - indeed, the patch works in this case perfectly.
However, it is much more interesting to do the whole magic in dynamic mode - although string patching is viable and perfectly working option in this case, it is not applicable in most cases (especially when it comes to more complex checks).
We use gdb to perform the bypass. In order to stop execution in the correct moment, we create a breakpoint in the [NSFileManager fileExistsAtPath:] API call.
Attaching to process 1172.
Reading symbols for shared libraries + done
Reading symbols for shared libraries ++ done
Reading symbols for shared libraries + done
0x2feb8470 in __dyld_strcmp ()
(gdb) b fileExistsAtPath:
Breakpoint 1 at 0x37fd9c3a
(gdb) c
The app runs and we hit the breakpoint.
Breakpoint 1, 0x37fd9c3a in -[NSFileManager fileExistsAtPath:] ()
(gdb)
Now take a look at the source in the beginning. It is easy to assemble the calling stack, even manually:
[NSFileManager fileExistsAtPath:]
[AppDelegate isJailbroken]
[AppDelegate jailbreakDetection]
[...]
gdb confirms our theory:
(gdb) bt
#0 0x37fd9c3a in -[NSFileManager fileExistsAtPath:] ()
#1 0x000a146e in ?? ()
#2 0x000a140c in ?? ()
#3 0x000a13ec in ?? ()
#4 0x31515c30 in -[UIApplication _stopDeactivatingForReason:] ()
#5 0x31503914 in -[UIApplication _runWithURL:payload:launchOrientation:statusBarStyle:statusBarHidden:] ()
[...]
We create another breakpoint, in the [AppDelegateisJailbroken] function, right after the return of the [NSFileManager fileExistsAtPath:].
(gdb) b *0x000a146e
Breakpoint 2 at 0xa146e
(gdb) c
Continuing.
We hit the second breakpoint - at this point, in accordance with ARM calling conventions, the returned value from the fileExistsAtPath: is in $r0. All we need to do is to set it to 0.
(gdb) i r $r0
r0 0x1 1
(gdb) set $r0=0
(gdb) c
Continuing.
...and the application pops up an alert with "Clean device. :)" Easy, isn't it?