writeup

No Escape - Mobile Hacking Lab Writeup

This is a challenge about Jailbreak-detect bypass.

#ios

Introduction

This is an iOS challenge focused on jailbreak detection bypass. The app is pretty straightforward: it detects if you're running it on a jailbroken device, shows a lock icon, and immediately closes. The goal? Bypass all these checks and get the flag.

Remember when I said in my Strings writeup that I wasn't crazy enough to open Ghidra? Well, I used Hopper this time. Still not that crazy, but we're getting there.

Reconnaissance

Initial Setup

First things first, I installed the IPA via TrollStore on my jailbroken iPhone. Then I set up my debugging environment:

  1. Started iproxy for two ports:

    • Port 1234 for debugserver
    • Port 2222 for SSH
    • Both accessible via localhost
  2. In one terminal, I SSH'd into the device and started the debugserver:

debugserver '*:1234' --waitfor "No Escape"
  1. Got the app name "No Escape" by running frida-ps -Ua with the app open, which showed me the exact process name to wait for.

  2. Opened the app, which made the debugserver enter waiting mode.

Connecting LLDB

In my main terminal, I fired up LLDB:

lldb -o "process connect connect://localhost:1234"

Side note: my LLDB always shows this annoying warning: "libobjc.A.dylib is being read from process memory. This indicates that LLDB could not find the on-disk shared cache for this device. This will likely reduce debugging performance." I have no idea what this means, I just know it takes forever to start LLDB and it stresses me out.

Reverse Engineering

Finding the Jailbreak Check Function

The simplest approach was using LLDB's image lookup to find jailbreak-related functions:

(lldb) image lookup -r -n "Jailbroken"
1 match found in /private/var/containers/Bundle/Application/5E15B961-AB72-4C27-8F82-6F5B75138083/No Escape.app/No Escape:
        Address: No Escape[0x000000010000a068] (No Escape.__TEXT.__text + 24680)
        Summary: No Escape`No_Escape.isJailbroken() -> Swift.Bool

Bingo! Found the isJailbroken() function. Now let's set a breakpoint on it:

br s -n No_Escape.isJailbroken

Analyzing the Function

After setting the breakpoint, I tried to disassemble but got an error (process wasn't stopped yet). So I continued execution with c to hit my breakpoint, then ran disas again:

(lldb) disas
error: Cannot disassemble around the current function without the process being stopped.
Process 39340 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x000000010031e068 No Escape`isJailbroken()
No Escape`isJailbroken:
->  0x10031e068 <+0>:  sub    sp, sp, #0x20
    0x10031e06c <+4>:  stp    x29, x30, [sp, #0x10]
    0x10031e070 <+8>:  add    x29, sp, #0x10
    0x10031e074 <+12>: bl     0x10031e118    ; No_Escape.checkForJailbreakFiles() -> Swift.Bool

I did a register read and ran disas again, and suddenly got more complete output (not sure why it didn't load properly the first time):

(lldb) disas
No Escape`isJailbroken:
->  0x10031e068 <+0>:   sub    sp, sp, #0x20
    0x10031e06c <+4>:   stp    x29, x30, [sp, #0x10]
    0x10031e070 <+8>:   add    x29, sp, #0x10
    0x10031e074 <+12>:  bl     0x10031e118    ; No_Escape.checkForJailbreakFiles() -> Swift.Bool
    0x10031e078 <+16>:  tbz    w0, #0x0, 0x10031e08c ; <+36>
    0x10031e07c <+20>:  b      0x10031e080    ; <+24>
    0x10031e080 <+24>:  mov    w0, #0x1 ; =1
    0x10031e084 <+28>:  stur   w0, [x29, #-0x4]
    0x10031e088 <+32>:  b      0x10031e098    ; <+48>
    0x10031e08c <+36>:  bl     0x10031e3fc    ; No_Escape.checkForWritableSystemDirectories() -> Swift.Bool
    0x10031e090 <+40>:  stur   w0, [x29, #-0x4]
    0x10031e094 <+44>:  b      0x10031e098    ; <+48>
    0x10031e098 <+48>:  ldur   w8, [x29, #-0x4]
    0x10031e09c <+52>:  tbz    w8, #0x0, 0x10031e0b0 ; <+72>
    0x10031e0a0 <+56>:  b      0x10031e0a4    ; <+60>
    0x10031e0a4 <+60>:  mov    w0, #0x1 ; =1
    0x10031e0a8 <+64>:  str    w0, [sp, #0x8]
    0x10031e0ac <+68>:  b      0x10031e0bc    ; <+84>
    0x10031e0b0 <+72>:  bl     0x10031e6fc    ; No_Escape.canOpenCydia() -> Swift.Bool
    0x10031e0b4 <+76>:  str    w0, [sp, #0x8]
    0x10031e0b8 <+80>:  b      0x10031e0bc    ; <+84>
    0x10031e0bc <+84>:  ldr    w8, [sp, #0x8]
    0x10031e0c0 <+88>:  tbz    w8, #0x0, 0x10031e0d4 ; <+108>
    0x10031e0c4 <+92>:  b      0x10031e0c8    ; <+96>
    0x10031e0c8 <+96>:  mov    w0, #0x1 ; =1
    0x10031e0cc <+100>: str    w0, [sp, #0x4]
    0x10031e0d0 <+104>: b      0x10031e0e0    ; <+120>
    0x10031e0d4 <+108>: bl     0x10031e940    ; No_Escape.checkSandboxViolation() -> Swift.Bool
    0x10031e0d8 <+112>: str    w0, [sp, #0x4]
    0x10031e0dc <+116>: b      0x10031e0e0    ; <+120>
    0x10031e0e0 <+120>: ldr    w8, [sp, #0x4]
    0x10031e0e4 <+124>: tbz    w8, #0x0, 0x10031e0f8 ; <+144>
    0x10031e0e8 <+128>: b      0x10031e0ec    ; <+132>
    0x10031e0ec <+132>: mov    w8, #0x1 ; =1
    0x10031e0f0 <+136>: str    w8, [sp]
    0x10031e0f4 <+140>: b      0x10031e104    ; <+156>
    0x10031e0f8 <+144>: mov    w8, #0x0 ; =0
    0x10031e0fc <+148>: str    w8, [sp]
    0x10031e100 <+152>: b      0x10031e104    ; <+156>
    0x10031e104 <+156>: ldr    w8, [sp]
    0x10031e108 <+160>: and    w0, w8, #0x1
    0x10031e10c <+164>: ldp    x29, x30, [sp, #0x10]
    0x10031e110 <+168>: add    sp, sp, #0x20
    0x10031e114 <+172>: ret

Understanding the Jailbreak Checks

Now we can see the full picture! The function performs multiple checks:

  1. checkForJailbreakFiles() - Looks for common jailbreak files
  2. checkForWritableSystemDirectories() - Tries to write to system directories
  3. canOpenCydia() - Attempts to open the Cydia app
  4. checkSandboxViolation() - Checks for sandbox escapes

Each check stores either 1 (jailbroken/true) or 0 (not jailbroken/false) in registers. The function does multiple tbz (test bit and branch if zero) instructions to chain these checks together.

The key insight: instead of patching all these individual checks, we can just modify the final return value in the x0 register. Since we want the function to return false (no jailbreak detected), we need x0 to be 0.

Exploitation

Approach 1: Direct Function Bypass

The simplest method - find the function and patch its return value.

  1. Set a breakpoint at the return instruction:
br s -a 0x10031e114
  1. Continue execution:
c
  1. Check the register value:
(lldb) register read
General Purpose Registers:
        x0 = 0x0000000000000001

As expected, x0 contains 1 (jailbreak detected).

  1. Change it to 0:
register write x0 0
  1. Continue execution:
c

But wait... the breakpoint hit again! The function was called a second time. No problem, just repeat the process:

Process 39340 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x000000010031e068 No Escape`isJailbroken()

Changed x0 to 0 again, continued, and... boom!

Flag

Approach 2: Calculating ASLR Offset with Hopper

This approach uses a disassembler to find the function offset, then calculates the correct address accounting for ASLR (Address Space Layout Randomization).

Step 1: Find the Function Offset

I opened the binary in Hopper Disassembler (could also use Ghidra) and searched for "jailbroken". Found the isJailbroken function at offset 0x000000010000a068.

hopper-image

Step 2: Calculate ASLR Slide

ASLR randomizes where the binary loads in memory, so we need to calculate the actual runtime address. First, dump the sections:

(lldb) image dump sections "No Escape"
Sections for '/private/var/containers/Bundle/Application/5E15B961-AB72-4C27-8F82-6F5B75138083/No Escape.app/No Escape(0x0000000104478000)' (arm64):
  SectID             Type                   Load Address                             Perm File Off.  File Size  Flags      Section Name
  ------------------ ---------------------- ---------------------------------------  ---- ---------- ---------- ---------- ----------------------------
  0x0000000000000100 container              [0x0000000000000000-0x0000000100000000)* ---  0x00000000 0x00000000 0x00000000 No Escape.__PAGEZERO
  0x0000000000000200 container              [0x0000000104478000-0x00000001045dc000)  r-x  0x00000000 0x00164000 0x00000000 No Escape.__TEXT

The important values here are:

  • __PAGEZERO ends at: 0x0000000100000000 (binary's base address)
  • __TEXT starts at: 0x0000000104478000 (where it actually loaded)

Calculate the ASLR slide:

(lldb) p/x 0x0000000104478000 - 0x0000000100000000
(long) 0x0000000004478000

This slide value (0x0000000004478000) is the offset between where the binary should load and where it actually loaded due to ASLR.

Step 3: Calculate Function Address

Now add the slide to our function offset from Hopper:

(lldb) p/x 0x0000000004478000 + 0x000000010000a068
(long) 0x0000000104482068

Step 4: Set Breakpoint and Patch

br s -a 0x0000000104482068

This creates a breakpoint at the correct runtime address:

Breakpoint 4: where = No Escape`isJailbroken(), address = 0x0000000104482068

From here, the process is identical to Approach 1:

  • Continue execution (c)
  • Hit the breakpoint
  • disas to see where we are
  • Set breakpoint at return (br s -a <return_address>)
  • Patch x0 register to 0
  • Continue twice (function is called twice)
  • Get the flag!

Conclusion

This challenge was a solid exercise in:

  • iOS debugging with LLDB and debugserver (went full manual mode instead of using Frida like they suggested)
  • Understanding ARM64 assembly and calling conventions
  • ASLR and runtime address calculation
  • Dynamic analysis and register manipulation
  • Multiple jailbreak detection techniques:
    • File-based detection
    • System directory write tests
    • URL scheme checks (Cydia)
    • Sandbox violation detection

The key takeaway: Understanding the function's control flow helps you find the most efficient bypass point.

Also, I finally used a disassembler for iOS reversing. Progress!