iOS Reverse Engineering End-to-End - Jailbreak Dumping and Hooking a Chat App Dice Roll

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

Looking at how the writeups break down, Android outnumbers iOS by five or six to one. I’ve spent most of my own time on Android too, so this doubles as an iOS introduction aimed at Android security engineers. It walks through a complete iOS reverse engineering session — jailbreak, dumping, UI analysis, dynamic and static analysis, and finally hooking — using the dice-roll mini-game in a widely-used IM app (WeChat) as the running example.

Jailbreak

The focus here is application security, so I won’t go deep on how jailbreaking itself is achieved. There are plenty of writeups and books on XNU kernel vulnerabilities — SockPuppet, the Project Zero surveys, MacOS and iOS Internals, and so on. Reading public exploit code (like oob_timestamp) helps, but for this article we just stand on those shoulders and use the packaged tools. Jailbreaking differs from Android rooting in that iOS is closed and far less fragmented — privilege escalation is mostly a single path (tfp0), and there’s no reflashing. Since we care about the app layer, use an existing jailbreak tool and move on.

Dumping

Once jailbroken you can ssh in — the iOS equivalent of Android’s adb shell. The next step is getting the app bundle. On Android that’s one adb pull; on iOS it’s more involved.

Most iOS apps come from the App Store, and App Store apps are protected by Apple’s FairPlay DRM: signed and encrypted. To analyze them you first decrypt — colloquially “dump” the binary. The usual tools:

  • stefanesser/dumpdecrypted — injects a dylib into the process to obtain decrypted files (no dynamic libraries).
  • conradev/dumpdecrypted — updated fork, decrypts per-module.
  • Clutch — spawns the process via posix_spawnp and decrypts, supports repackaging as ipa. (Static approaches like this mostly stopped working after iOS 12.)
  • frida-ios-dump — dynamic decryption via frida, supports repackaging.
  • bagbak — same idea, but a Node.js frida binding.

Some of these have rotted. I noticed dumpdecrypted mishandles the file sandbox and fails to save — needs a small patch.

Most of these tools are memory dumps: they exec the target program to decrypt, which lets an app detect the dump via its initializers. If that happens, you can use a mremap_encrypted-based approach that calls the kernel interface directly, decrypting statically without ever launching the target.

Getting a foothold

Jailbroken, dumped — now what? Drop it in IDA? You can, but an iOS app’s main binary is a huge Mach-O. WeChat’s arm64 executable alone is 218 MB after decryption — a wall even in a reversing tool. You need an entry point, a “foothold.”

The most common one is the UI. On Android you’d use uiautomatorviewer and chase UI IDs through decompiled code (I usually just grab the top Activity instead). On iOS, UI analysis is genuinely effective, because iOS apps are MVC: a View’s events are handled by its Controller. Xcode has Debug View Hierarchy (requires the target to allow debugging); more commonly you use view-debugging frameworks like Reveal or the open-source FLEX.

Inject FLEX into the process for UI analysis:

flex.gif

(That injection uses cycript — more on that later.) Or analyze from a desktop with Reveal, which still requires injecting RevealServer.dylib and calling [IBARevealLoader startServer] via the official lldb script:

Reveal

The dice button is a UIImageView — it doesn’t respond to taps itself, so I need to find the owning ViewController or Gesture Recognizer.

Dynamic analysis

From the UI I know the current Controller class is BaseMsgContentViewController, so I script a trace of every method it calls while sending a dice roll:

[-] 13:04:24.188 Hooked: -[BaseMsgContentViewController didRotateFromInterfaceOrientation:]
[-] 13:04:24.188 Hooked: -[BaseMsgContentViewController previewingContext:viewControllerForLocation:]
[-] 13:04:24.188 Hooked: -[BaseMsgContentViewController previewingContext:commitViewController:]
[-] 13:04:24.188 Hooked: -[BaseMsgContentViewController previewActionItems]
[-] 13:04:24.188 Hooked: -[BaseMsgContentViewController m_delegate]
[!] 13:04:24.291 Failed to hook: -[BaseMsgContentViewController willShow]
[-] 13:04:24.291 Hooked: -[BaseMsgContentViewController canPasteImage]
[-] 13:04:24.291 Hooked: -[BaseMsgContentViewController setTableFooterView:]
[-] 13:04:24.291 Hooked: -[BaseMsgContentViewController documentInteractionControllerViewControllerForPreview:]
[-] 13:04:24.291 Hooked: -[BaseMsgContentViewController willAppear]
[-] 13:04:24.291 Hooked: -[BaseMsgContentViewController showLoadingView]
[-] 13:04:24.291 Hooked: -[BaseMsgContentViewController initTableView]
[-] 13:04:24.293 Hooked: 614 methods for class BaseMsgContentViewController

# tap the dice icon to send an emote
[-] 13:04:35.075 ENTER -[BaseMsgContentViewController useTransparentNavibar]
[-] 13:04:35.076 ENTER -[BaseMsgContentViewController useTransparentNavibar]
[-] 13:04:35.078 ENTER -[BaseMsgContentViewController shouldInteractivePop]
[-] 13:04:35.078 ENTER -[BaseMsgContentViewController toolView]
[-] 13:04:35.132 ENTER -[BaseMsgContentViewController SendEmoticonMesssageToolView:]
[-] 13:04:35.148 ENTER -[BaseMsgContentViewController findNodeDataByLocalId:]
[-] 13:04:35.148 ENTER -[BaseMsgContentViewController addMessageNode:layout:addMoreMsg:]
[-] 13:04:35.148 ENTER -[BaseMsgContentViewController findNodeDataByLocalId:]
[-] 13:04:35.148 ENTER -[BaseMsgContentViewController getCurContentSizeHeight]
...

frida

SendEmoticonMesssageToolView looks interesting. Print its stack with frida:

import { kObjC } from "../../agent/objc";
import { kNative } from "../../agent/native";

// kObjC.traceClass("BaseMsgContentViewController");
kObjC.traceMethod("BaseMsgContentViewController", "- SendEmoticonMesssageToolView:", {
  onEnter: function(args) {
    log.i("SendEmoticonMesssageToolView", args[0], args[1]);
    kNative.printStackTrace(this.context);
  }
});

Output:

[-] 13:10:32.079 Hooked: -[BaseMsgContentViewController SendEmoticonMesssageToolView:]
[+] 13:10:37.212 SendEmoticonMesssageToolView 0x11f8a1800 0x10bf6e8ba
[-] 13:10:37.636 backtrace:
0x104e06de4 WeChat!0x2676de4
0x10294cde8 WeChat!0x1bcde8
0x1041bb930 WeChat!0x1a2b930
0x1b8331e34 UIKitCore!-[UICollectionView _selectItemAtIndexPath:animated:scrollPosition:notifyDelegate:deselectPrevious:]
0x1b83464b0 UIKitCore!-[UICollectionView _cellForItemAtIndexPath:includePrefetchedCells:]
0x1b8359e10 UIKitCore!-[UICollectionView touchesEnded:withEvent:]
0x1b46ef730 libsystem_malloc.dylib!nanov2_calloc
0x1b46f4100 libsystem_malloc.dylib!calloc
0x1b48ddae4 CoreFoundation!-[__NSSetM addObject:]
0x1b473926c libobjc.A.dylib!objc_autoreleasePoolPop
0x1b4a14320 CoreFoundation!_CFAutoreleasePoolPop
0x1b48dd594 CoreFoundation!-[__NSSetM enumerateObjectsWithOptions:usingBlock:]
0x1b498bc7c CoreFoundation!__NSSetM_new
0x1bb64e208 QuartzCore!CA::Layer::retain_parent(CA::Transaction*) const
0x1b8b5d9e8 UIKitCore!forwardTouchMethod
0x1b8b5dae4 UIKitCore!-[UIResponder touchesEnded:withEvent:]

The WeChat frames have no symbols — they’ve been stripped.

lldb

The same view in lldb:

(lldbinit) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
  * frame #0: 0x0000000105334f04 WeChat`___lldb_unnamed_symbol201841$$WeChat
    frame #1: 0x0000000104b32f80 WeChat`___lldb_unnamed_symbol170264$$WeChat + 160
    frame #2: 0x0000000104e06de4 WeChat`___lldb_unnamed_symbol182196$$WeChat + 2600
    frame #3: 0x000000010294cde8 WeChat`___lldb_unnamed_symbol8796$$WeChat + 248
    frame #4: 0x00000001041bb930 WeChat`___lldb_unnamed_symbol129751$$WeChat + 624
    frame #5: 0x00000001b8331e34 UIKitCore`-[UICollectionView _selectItemAtIndexPath:animated:scrollPosition:notifyDelegate:deselectPrevious:] + 952
    frame #6: 0x00000001b8359e10 UIKitCore`-[UICollectionView touchesEnded:withEvent:] + 572
    frame #7: 0x00000001b8b5d9e8 UIKitCore`forwardTouchMethod + 332
    frame #8: 0x00000001b8b5dae4 UIKitCore`-[UIResponder touchesEnded:withEvent:] + 64
    frame #9: 0x00000001b8b5d9e8 UIKitCore`forwardTouchMethod + 332
    frame #10: 0x00000001b8b5dae4 UIKitCore`-[UIResponder touchesEnded:withEvent:] + 64
    frame #11: 0x00000001b8b5d9e8 UIKitCore`forwardTouchMethod + 332
    frame #12: 0x00000001b8b5dae4 UIKitCore`-[UIResponder touchesEnded:withEvent:] + 64
    frame #13: 0x00000001b86e6478 UIKitCore`_UIGestureEnvironmentUpdate + 6992
    frame #14: 0x00000001b86e48dc UIKitCore`-[UIGestureEnvironment _deliverEvent:toGestureRecognizers:usingBlock:] + 380
    frame #15: 0x00000001b86e4698 UIKitCore`-[UIGestureEnvironment _updateForEvent:window:] + 248
    frame #16: 0x00000001b8b6d654 UIKitCore`-[UIWindow sendEvent:] + 3512
    frame #17: 0x00000001b8b48840 UIKitCore`-[UIApplication sendEvent:] + 348
    frame #18: 0x0000000103de4920 WeChat`___lldb_unnamed_symbol111457$$WeChat + 404
    frame #19: 0x00000001ebaaa87c UIKit`-[UIApplicationAccessibility sendEvent:] + 100

Symbols are nice but not required — we can still resolve addresses in IDA. With ASLR, confirm the image load address:

(lldbinit) vmmap /WeChat
000102790000 - 00010d6d4000  af44000 R-X /private/var/containers/Bundle/Application/2A5D623F-5F8E-4A99-96C8-8CBD00D8B6BE/WeChat.app/WeChat        0 __TEXT
00010d6d4000 - 00010fc40000  256c000 RW- /private/var/containers/Bundle/Application/2A5D623F-5F8E-4A99-96C8-8CBD00D8B6BE/WeChat.app/WeChat  af44000 __DATA
00010fc40000 - 000110ac4000   e84000 R-- /private/var/containers/Bundle/Application/2A5D623F-5F8E-4A99-96C8-8CBD00D8B6BE/WeChat.app/WeChat  cb68000 __LINKEDIT

vmmap is a lldb command I defined myself, modeled on gef/pwndbg in gdb.

The load address here is 0x000102790000, while IDA’s __TEXT base is 0x100004000. You can rebase, but since there aren’t many functions I resolve them manually. lldb can also map addresses to offsets directly:

(lldbinit) image lookup -a 0x0000000105334f04
      Address: WeChat[0x0000000102ba4f04] (WeChat.__TEXT.__text + 45747972)
      Summary: WeChat`___lldb_unnamed_symbol201841$$WeChat
(lldbinit) image lookup -a 0x0000000104b32f80
      Address: WeChat[0x00000001023a2f80] (WeChat.__TEXT.__text + 37351296)
      Summary: WeChat`___lldb_unnamed_symbol170264$$WeChat + 160
(lldbinit) image lookup -a 0x0000000104e06de4
      Address: WeChat[0x0000000102676de4] (WeChat.__TEXT.__text + 40316388)
      Summary: WeChat`___lldb_unnamed_symbol182196$$WeChat + 2600
(lldbinit) image lookup -a 0x000000010294cde8
      Address: WeChat[0x00000001001bcde8] (WeChat.__TEXT.__text + 1805800)
      Summary: WeChat`___lldb_unnamed_symbol8796$$WeChat + 248
(lldbinit) image lookup -a 0x00000001041bb930
      Address: WeChat[0x0000000101a2b930] (WeChat.__TEXT.__text + 27425072)
      Summary: WeChat`___lldb_unnamed_symbol129751$$WeChat + 624

Symbol recovery

Mapping each lldb address to its __TEXT offset and jumping there in IDA gives the calling chain:

  • -[BaseMsgContentViewController SendEmoticonMesssageToolView:] (45747972)
  • -[MMInputToolView didSelectorSelfDefinedEmotcion:] (37351296)
  • -[EmoticonBoardView onTapEmoticonWrap:atIndex:maxCountPerLine:fromSection:] (40316388)
  • -[EmoticonBoardCrossCollectionController onEmoticonPageCellTapEmoticonWrap:atIndex:pid:maxCountPerLine:] (1805800)
  • -[EmoticonBoardCrossCollectionEmoticonPageCell collectionView:didSelectItemAtIndexPath:] (27425072)

Since IDA can recover these ObjC selectors, the symbols are in principle restorable. If you do this often, automate it — see iOS symbol table recovery and restore-symbol.

Static analysis

Dynamic analysis found the entry point and the backtrace, but no obvious setDiceValue. To change the roll I have to read the functions themselves. Reversing is unavoidable now — I prefer not to reverse when I can avoid it, but here it’s the only way.

While the 200 MB Mach-O sat in IDA (four hours in, still not done — I watched a couple of episodes of anime in the meantime), I’ll cover some Objective-C fundamentals.

Objective-C 101

ObjC’s object model is message passing. Before reversing, let’s build a small program to see exactly how a call works:

#import <Foundation/Foundation.h>

@interface MyClass: NSObject
 - (void)insMethod:(const char *)a1 arg2:(int)a2;
 + (void)clsMethod:(const char *)a1 arg2:(int)a2;
@end

@implementation MyClass
 - (void)insMethod:(const char *)a1 arg2:(int)a2 {
     NSLog(@"insMethod called, self=%p, a1=%s", self, a1);
 }
 + (void)clsMethod:(const char *)a1 arg2:(int)a2 {
     NSLog(@"clsMethod called, self=%p, a1=%s", self, a1);
 }
@end

int main() {
    MyClass *c = [[MyClass alloc] init];
    [MyClass clsMethod:"hello" arg2:1];
    [c insMethod:"world" arg2:2];
    return 0;
}

Disassembly of main:

(lldbinit) disassemble
main @ test_hello:
    0x100003e90: push   rbp
    0x100003e91: mov    rbp, rsp
    0x100003e94: sub    rsp, 0x10
    0x100003e98: mov    dword ptr [rbp - 0x4], 0x0
->  0x100003e9f: mov    rax, qword ptr [rip + 0x424a] ; (void *)0x0000000100008120: MyClass
    0x100003ea6: mov    rdi, rax
    0x100003ea9: call   0x100003f04               ; symbol stub for: objc_alloc_init
    0x100003eae: mov    qword ptr [rbp - 0x10], rax
    0x100003eb2: mov    rax, qword ptr [rip + 0x4237] ; (void *)0x0000000100008120: MyClass
    0x100003eb9: mov    rsi, qword ptr [rip + 0x4220] ; "clsMethod:arg2:"
    0x100003ec0: mov    rdi, rax
    0x100003ec3: lea    rdx, [rip + 0xa8]         ; "hello"
    0x100003eca: mov    ecx, 0x1
    0x100003ecf: call   qword ptr [rip + 0x12b]   ; (void *)0x00007fff20439d00: objc_msgSend
    0x100003ed5: mov    rax, qword ptr [rbp - 0x10]
    0x100003ed9: mov    rsi, qword ptr [rip + 0x4208] ; "insMethod:arg2:"
    0x100003ee0: mov    rdi, rax
    0x100003ee3: lea    rdx, [rip + 0x8e]         ; "world"
    0x100003eea: mov    ecx, 0x2
    0x100003eef: call   qword ptr [rip + 0x10b]   ; (void *)0x00007fff20439d00: objc_msgSend
    0x100003ef5: xor    eax, eax
    0x100003ef7: add    rsp, 0x10
    0x100003efb: pop    rbp
    0x100003efc: ret

Both calls funnel into objc_msgSend:

id objc_msgSend(self, op, ...);
  1. self — the receiver: a class pointer for class methods, an object pointer for instance methods.
  2. op — the message identifier, aka the selector, a string naming the method.
  3. ... — the remaining arguments.

Checking the registers for the two calls:

(lldbinit) p/x $rdi
(unsigned long) $29 = 0x0000000100008120
(lldbinit) x/1s $rsi
0x100003f86: "clsMethod:arg2:"
(lldbinit) x/1s $rdx
0x100003f72: "hello"
(lldbinit) p/x $rcx
(unsigned long) $32 = 0x0000000000000001
(lldbinit) p/x $rdi
(unsigned long) $54 = 0x0000000100208340
(lldbinit) x/1s $rsi
0x100003f96: "insMethod:arg2:"
(lldbinit) x/1s $rdx
0x100003f78: "world"
(lldbinit) p/x $rcx
(unsigned long) $57 = 0x0000000000000002

Output:

2021-04-10 22:54:19.487148+0800 test_hello[99332:5120374] clsMethod called, self=0x100008120, a1=hello
2021-04-10 22:54:54.565229+0800 test_hello[99332:5120374] insMethod called, self=0x100208340, a1=world

So Objective-C is, mechanically, pretty close to C — which makes reversing it friendlier.

Reading the chain

By now IDA has mostly finished. Walk the backtrace up, function by function. First didSelectorSelfDefinedEmotcion — it just sends a selected custom emote, not what I want.

Next -[EmoticonBoardView onTapEmoticonWrap:atIndex:maxCountPerLine:fromSection:] — it checks whether the tapped emote is a selfie or custom emote and, if so, uploads asynchronously. Its didSelectorSelfDefinedEmotcion argument is a3, an object pointer. I don’t yet know which class, but it has these members:

  • m_emojiInfo
  • m_isAsyncUpload
  • attachObject:forKey:

Searching the class-dump headers, that’s CEmoticonWrap:

#import <objc/NSObject.h>

#import "PBCoding-Protocol.h"

@class EmojiInfoObj, NSData, NSString;

@interface CEmoticonWrap : NSObject <PBCoding>
{
    _Bool m_bCanDelete;
    _Bool m_isAsyncUpload;
    _Bool m_isRemoteRecommed;
    _Bool m_isLastSended;
    unsigned int m_uiType;
    unsigned int m_uiGameType;
    unsigned int m_lastUsedTime;
    unsigned int m_extFlag;
    NSString *m_nsAppID;
    NSString *m_nsThumbImgPath;
    NSString *m_query;
    EmojiInfoObj *m_emojiInfo;
    NSData *_m_imageData;
}

This function just uploads and records metadata, so keep climbing. The next frame is the same selector on EmoticonBoardView, but here it’s acting as a delegate and just forwards — no real logic. From the name I can guess the custom emote bar is laid out in rows, each emote a Cell.

The whole backtrace has nothing dice-related. Whatever the case, the dice value must be fixed before the dice image is set. From the reversing above, the custom emote’s image lives in [CEmoticonWrap m_emojiInfo]:

@interface EmojiInfoObj : MMObject <PBCoding, NSCopying>
{
    _Bool _disableExtern;
    NSString *md5;
    NSString *url;
    NSString *thumbUrl;
    NSString *designerId;
    NSString *encryptUrl;
    NSString *aesKey;
    NSString *productId;
    NSString *externUrl;
    NSString *attachedText;
    NSString *externMd5;
    NSString *activityId;
    NSString *attachedTextColor;
    NSString *lensId;
    NSString *linkId;
    NSString *_tpUrlString;
    NSString *_authkey;
}

Now I need dynamic analysis again, to pin down when the dice image loads.

Static + dynamic together

Back in SendEmoticonMesssageToolView, the parameter is a CEmoticonWrap. Extend the trace callback to dump its fields:

[+] 21:50:18.958 Called SendEmoticonMesssageToolView:
[+] 21:50:18.958 emotion: <CEmoticonWrap: self.m_uiType=1, self.m_bCanDelete=1, self.m_uiGameType=2, self.m_nsAppID=(null), self.m_extFlag=0, self.m_nsThumbImgPath=(null), self.m_lastUsedTime=0, self.m_isAsyncUpload=0, self.m_isRemoteRecommed=0, self.m_isLastSended=0, self.m_query=(null), self.m_emojiInfo=<EmojiInfoObj: 0x2813c77b0> {
    activityId = <nil>;
    aesKey = <nil>;
    attachedText = <nil>;
    attachedTextColor = <nil>;
    authkey = <nil>;
    designerId = <nil>;
    disableExtern = 0;
    encryptUrl = <nil>;
    externMd5 = <nil>;
    externUrl = <nil>;
    lensId = <nil>;
    linkId = <nil>;
    md5 = "dice_emoticon_md5";
    productId = "custom_emoticon_pid";
    thumbUrl = <nil>;
    tpUrlString = <nil>;
    url = <nil>
}>

So at function entry these are empty; m_emojiInfo’s md5 is "dice_emoticon_md5", and the dice image isn’t generated yet. The real work happens later. The backtrace wasn’t wasted, though — at least we learned the argument layout and call flow.

Back to the function:

void __cdecl -[BaseMsgContentViewController SendEmoticonMesssageToolView:](BaseMsgContentViewController *self, SEL a2, id emotion)
{
  id v4; // x19
  id v5; // x20
  id *v6; // x21
  id v7; // x24
  unsigned __int8 v8; // w23
  id *v9; // x21
  id v10; // x0
  id *v11; // x21
  void *v12; // x20
  id v13; // x24
  unsigned int v14; // w22
  id *v15; // x21

  v4 = objc_retain(emotion);
  v5 = objc_loadWeakRetained((id *)&self->m_delegate);
  if ( ((unsigned int)objc_msgSend(v5, "respondsToSelector:", "CanSendEmoticonMessage") & 1) == 0 )
    goto LABEL_6;
  v7 = objc_loadWeakRetained(v6);
  v8 = (unsigned __int8)objc_msgSend(v7, "CanSendEmoticonMessage");
  objc_release(v7);
  objc_release(v5);
  if ( (v8 & 1) != 0 )
  {
    v10 = objc_loadWeakRetained(v9);
    if ( v10 )
    {
      v12 = v10;
      v13 = objc_loadWeakRetained(v11);
      v14 = (unsigned int)objc_msgSend(v13, "respondsToSelector:", "SendEmoticonMessage:");
      objc_release(v13);
      objc_release(v12);
      if ( v14 )
      {
        v5 = objc_loadWeakRetained(v15);
        objc_msgSend(v5, "SendEmoticonMessage:", v4);
LABEL_6:
        objc_release(v5);
        goto LABEL_7;
      }
    }
  }
LABEL_7:
  objc_release(v4);
}

Another delegate pattern. Print the delegate instance:

kObjC.traceMethod("BaseMsgContentViewController", "- SendEmoticonMesssageToolView:", {
  onEnter(args) {
    log.i("Called", args[1].readCString());
    const vc = new ObjC.Object(args[0]);
    const emotion = new ObjC.Object(args[2]);
    log.i("vc:", vc);
    log.i("m_delegate:", vc.valueForKey_("m_delegate"));
  }
});

Result:

[+] 19:15:24.203 Called SendEmoticonMesssageToolView:
[+] 19:15:24.204 vc: <BaseMsgContentViewController: 0x1200b8e00>
[+] 19:15:24.206 m_delegate: <WeixinContentLogicController: 0x11f340a90>

A WeixinContentLogicController — so look for its SendEmoticonMessage:, or in its parent or a category. Searching by name in IDA on this giant binary is laggy, so a shortcut: jump by function name directly. The actual implementation is inherited, -[BaseMsgContentLogicController SendEmoticonMessage:].

ida.png

In the highlighted line, IDA’s decompiler mislabeled v7 as uninitialized. When that happens, read the assembly — it doesn’t lie:

asm

The first argument is actually a PluginUtil class address, calling the class method +[PluginUtil isPluginUserName:]. m_uiGameType was already dumped as 2, so the else branch is taken. Continuing this static+dynamic dance, the forward call chain resolves to:

  • -[BaseMsgContentLogicController SendEmoticonMessage:]
  • +[GameController GameEmoticonMsgForEmoticonWrap:]
  • +[GameController SetGameContentForMsgWrap:] (parameter type CMessageWrap)

The content is set here:

random

The logic is now obvious. If game type is 1:

content = 1 + rand - rand / 3 * 3 = 1 + rand % 3

If game type is 2:

content = 4 + rand - rand / 6 * 6 = 4 + rand % 6

We picked dice, so game type is 2, and content ranges 4 ~ 9 — exactly six values, one per die face. The three-value case above is plausibly rock-paper-scissors.

Two things worth noting. First, random is called twice but the second result is unused — likely to reduce predictability of the PRNG sequence. Second, the random value is then MD5’d, so if you change the value you may also need to fix the hash to pass validation — or simpler, just change random’s return value directly:

kNative.trace(null, "random", {
  onLeave(retval) {
    log.i("random:", retval.toInt32());
    retval.replace(ptr(5));
  }
});

The effect:

dice

That’s the core of forcing the dice roll done.

On hooking, again

I’ve heard “hook” enough to be tired of it. PLT hook or inline hook — the essence is the same: modify code at runtime to redirect control flow. For raw binary patching the options are similar across platforms, but iOS also offers others. Let’s go through them.

Inline hook

The most general approach: prepend a jump at the target address, save the context, jump to your code, restore, and return. Inline hooking enables instruction-level tracing and underpins most dynamic tracing tools. Here’s roughly how frida-gum implements it:

frida

The principle is simple; engineering it is not. Code sections are usually non-writable, so you mmap a copy into your own space and patch that. Saving/restoring context is mostly assembly, and cross-platform means per-ISA work. All that heavy lifting is already done for us — we just call Interceptor.attach.

Method swizzling

Since Objective-C’s object model is message passing, you can dynamically swap the implementation behind a selector — method swizzling. Apple’s API:

void method_exchangeImplementations(Method m1, Method m2);

This is the atomic equivalent of:

IMP imp1 = method_getImplementation(m1);
IMP imp2 = method_getImplementation(m2);
method_setImplementation(m1, imp2);
method_setImplementation(m2, imp1);

Again: simple in principle, fiddly in practice. Swapping two implementations directly is rarely done without checks:

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class aClass = [self class];

        SEL originalSelector = @selector(method_original:);
        SEL swizzledSelector = @selector(method_swizzle:);

        Method originalMethod = class_getInstanceMethod(aClass, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(aClass, swizzledSelector);
        BOOL didAddMethod =
        class_addMethod(aClass,
                        originalSelector,
                        method_getImplementation(swizzledMethod),
                        method_getTypeEncoding(swizzledMethod));

        if (didAddMethod) {
            class_replaceMethod(aClass,
                                               swizzledSelector,
                                               method_getImplementation(originalMethod),
                                               method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

Direct swapping is dangerous: if the class doesn’t implement the method, class_getInstanceMethod returns a parent’s Method and you end up swapping the parent’s, surprising every subclass. Hence the existence check above.

There are also selector-name collisions, repeated-swizzling, and _cmd mutation to worry about — which is why mature implementations like RSSwizzle and jrswizzle exist. For developers this matters, because it affects app stability.

frida also exposes an ObjC swizzle-based hook:

const myMethod_ = ObjC.classes.MyClass.myMethod_;
myMethod_.implementation = ObjC.implement(myMethod_, function (handle, selector, arg0) {
  log.i(`self=${handle}, selector=${selector}, arg0=${arg0}`);
});

NSProxy

In Java, java.lang.reflect.Proxy gives reflective dynamic proxying, widely used for AOP. Objective-C has the analogue: NSProxy.

/*	NSProxy.h
	Copyright (c) 1994-2019, Apple Inc. All rights reserved.
*/

#import <Foundation/NSObject.h>

@class NSMethodSignature, NSInvocation;

NS_ASSUME_NONNULL_BEGIN

NS_ROOT_CLASS
@interface NSProxy <NSObject> {
    __ptrauth_objc_isa_pointer Class	isa;
}

+ (id)alloc;
+ (id)allocWithZone:(nullable NSZone *)zone NS_AUTOMATED_REFCOUNT_UNAVAILABLE;
+ (Class)class;

- (void)forwardInvocation:(NSInvocation *)invocation;
- (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel NS_SWIFT_UNAVAILABLE("NSInvocation and related APIs not available");
- (void)dealloc;
- (void)finalize;
@property (readonly, copy) NSString *description;
@property (readonly, copy) NSString *debugDescription;
+ (BOOL)respondsToSelector:(SEL)aSelector;

- (BOOL)allowsWeakReference API_UNAVAILABLE(macos, ios, watchos, tvos);
- (BOOL)retainWeakReference API_UNAVAILABLE(macos, ios, watchos, tvos);

// - (id)forwardingTargetForSelector:(SEL)aSelector;

@end

NS_ASSUME_NONNULL_END

It conforms to the NSObject protocol and its first ivar is an isa pointer, so it can be used as an NSObject — but it’s not an NSObject subclass. You only need to implement two methods:

- (void)forwardInvocation:(NSInvocation *)invocation;
- (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel;

NSInvocation encapsulates an entire method call — arguments and return value. An example:

#import <Foundation/Foundation.h>

@interface MyProxy : NSProxy {
    id victim_;
}
+ (id)hookObject:(id)obj;
@end

@implementation MyProxy
+ (id)hookObject:(id)obj {
    MyProxy *p = [MyProxy alloc];
    p->victim_ = obj;
    return p;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [victim_ methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
    if ([victim_ respondsToSelector:invocation.selector]) {
        NSString *selectorName = NSStringFromSelector(invocation.selector);
        NSLog(@"ENTER %@", selectorName);
        [invocation invokeWithTarget:victim_];
        NSLog(@"LEAVE %@", selectorName);
    }
}
@end

int main(int argc, char *argv[]) {
    dispatch_semaphore_t sem = dispatch_semaphore_create(0);

    NSURL *url = [MyProxy hookObject:[NSURL URLWithString:@"https://evilpan.com"]];
    NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        dispatch_semaphore_signal(sem);
    }];
    [task resume];
    dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
    return 0;
}

Output:

$ ./test_proxy
2021-04-11 11:18:55.193 test_proxy[15011:5637371] ENTER absoluteURL
2021-04-11 11:18:55.194 test_proxy[15011:5637371] LEAVE absoluteURL

We replace the original NSURL with our MyProxy, so every method call on it goes through the proxy — running our code before and after the original. That’s a hook.

Extras

A few things that didn’t fit above.

Thinning

Mach-O is multi-architecture — the same program can hold armv7 and arm64. Multiarch apps are bigger than a single-arch build but smaller than two separate ones (they share resources). Developers ship both for compatibility, but for reversing you only need one. Check whether it’s multiarch:

# via file
$ file pp2048/Payload/2048.app/2048
pp2048/Payload/2048.app/2048: Mach-O universal binary with 2 architectures: [arm_v7:Mach-O executable arm_v7] [arm64]
pp2048/Payload/2048.app/2048 (for architecture armv7):	Mach-O executable arm_v7
pp2048/Payload/2048.app/2048 (for architecture arm64):	Mach-O 64-bit executable arm64

# or via lipo
$ lipo -detailed_info pp2048/Payload/2048.app/2048
Fat header in: pp2048/Payload/2048.app/2048
fat_magic 0xcafebabe
nfat_arch 2
architecture armv7
    cputype CPU_TYPE_ARM
    cpusubtype CPU_SUBTYPE_ARM_V7
    offset 16384
    size 8838944
    align 2^14 (16384)
architecture arm64
    cputype CPU_TYPE_ARM64
    cpusubtype CPU_SUBTYPE_ARM64_ALL
    offset 8863744
    size 10265808
    align 2^14 (16384)

Keep only arm64 with lipo or ditto, both built into Apple’s toolchain:

# lipo
$ lipo -thin arm64 pp2048/Payload/2048.app/2048 -output thin_2048

# or ditto
$ ditto --arch arm64 pp2048/Payload/2048.app/2048 thin_2048

Both produce the same file.

Cycript

Earlier I used cycript to inject a dylib for UI analysis. cycript by saurik is a JavaScript REPL with mixed Objective-C syntax, which makes it very direct:

cy# [[UIApplication sharedApplication] keyWindow]
#"<iConsoleWindow: 0x11581f120; baseClass = UIWindow; frame = (0 0; 1194 834); gestureRecognizers = <NSArray: 0x2800d0570>; layer = <UIWindowLayer: 0x280eb1800>>"

It can call C functions directly and use enum constants:

cy# dlopen
(extern "C" void *dlopen(char const*, int))
cy# RTLD_NOW
2
cy# dlopen("/usr/lib/libFLEX.dylib", RTLD_NOW);
(typedef void*)(0x11a23e180)

The FLEX injection above (and Reveal injection) is exactly this dlopen-into-the-process trick. Cycript is powerful but thinly documented; the manual is the main reference, plus:

Tweak

Jailbroken iOS has Cydia Substrate, the equivalent of Android’s Xposed. It provides a C API for jailbreak apps to patch methods at runtime, a plist-based manifest to select which app to hook, and it handles a method being hooked multiple times. A dylib built on Substrate is a “Tweak,” injected at app launch per the plist.

An example hook:

NSString *(*oldDescription)(id self, SEL _cmd);

// implicit self and _cmd are explicit with IMP ABI
NSString *newDescription(id self, SEL _cmd) {
    NSString *description = (*oldDescription)(self, _cmd);
    description = [description stringByAppendingString:@"!"];
    return description;
}

MSHookMessageEx(
    [NSObject class], @selector(description),
    &newDescription, &oldDescription
);

If that’s too verbose, use Logos — a preprocessor macro set (the %-prefixed directives) that compacts hook code:

%hook SBApplicationController
-(void)uninstallApplication:(SBApplication *)application {
	NSLog(@"Hey, we're hooking uninstallApplication:!");
	%orig; // Call the original implementation of this method
	return;
}
%end

Logos is now part of Theos; the NIC template system scaffolds a Makefile-based Tweak project quickly.

LLDB

LLDB is a powerful debugger — better than GDB in some scenarios. A few everyday examples:

Set breakpoints on every method of an ObjC class (regex):

(lldb) breakpoint set -r '\[ClassName .*\]$'

Print an ObjC object:

(lldb) po addr

List module mappings:

(lldb) image list -h -f

Set the zero flag (common for flipping jump instructions):

(lldb) register write rflags `$rflags|0x40`

Deeper dives and lldb scripting:

Afterword

This article walked through a complete iOS reverse engineering session — jailbreak, dumping, UI analysis, dynamic analysis, static analysis, and the common iOS hooking approaches — using the dice-roll mini-game as the running example. I’d previously reversed the same dice roll on Android, where most of the code is obfuscated; the iOS side is far more readable by comparison. Many apps ship on both platforms, and when one platform resists, the other may yield — for Unity3D C# reversing, Android is actually easier than iOS (thanks to IL2CPP). Staying flexible and open-minded matters for any serious reverse engineer.

References

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch