Chapters

Hide chapters

Advanced Apple Debugging & Reverse Engineering

Third Edition · iOS 12 · Swift 4.2 · Xcode 10

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section III: Low Level

Section 3: 7 chapters
Show chapters Hide chapters

Section IV: Custom LLDB Commands

Section 4: 8 chapters
Show chapters Hide chapters

31. DTrace vs. objc_msgSend
Written by Derek Selander

You’ve seen how powerful DTrace is against Objective-C and Swift code which you have the source for, or code that resides in a Framework like UIKit. You’ve used DTrace to trace this code and make interesting tweaks all while performing zero modifications to already compiled source code.

Unfortunately, when DTrace is put up against a stripped executable, it is unable to create any probes to dynamically inspect those functions.

However, when exploring Apple code, you still have one very powerful ally on your side: objc_msgSend. In this chapter you’ll use DTrace to hook objc_msgSend’s entry probe and pull out the class name along with the Objective-C selector for that class.

By the end of this chapter, you’ll have LLDB generating a DTrace script which only generates tracing info for code implemented within the main executable that calls objc_msgSend.

Building your proof-of-concept

Included in the starter folder is an app called VCTransitions, which is a very basic Objective-C/Swift application that showcases a normal UINavigationController push transition, as well as a custom push transition.

Open up this Xcode project, build and run on the iPhone XS Simulator and take a quick look around.

It’s important to note, there are two schemes inside this application: VCTransitions and Stripped VCTransitions. Make sure to select the VCTransitions scheme when running. We’ll talk more about the Stripped VCTransitions scheme in a second.

Note: Normally I don’t care about the exact version of the software you’re running, so long as it’s iOS 12. This time, however, I insist you run iOS 12.1.x (or earlier) since you’ll be viewing assembly that could change in a future release. You’ll be exploring some assembly in this chapter, and I can’t guarantee it’s unchanged in a new iOS version that I’ve not viewed (at the time of writing).

There are buttons to perform the two navigation pushes, and there’s also a button named Execute Methods that will loop through all known Objective-C methods which are implemented/overriden by a given Class. If the method takes no parameters, it executes it.

For example, the first view controller displayed is ObjCViewController. If you tap Execute Methods, it will call anEmptyMethod as well as all the getters for the overridden properties, since all of those methods don’t require parameters.

Now, onto the fun stuff.

Jump over to OjbCViewController.m and take a look at the IBAction methods implemented by this class. Make a DTrace one-liner in Terminal to ensure that you can see these methods getting hit.

Make sure the Simulator is alive and running the VCTransitions project.

In Terminal:

sudo dtrace -n 'objc$target:ObjCViewController::entry' -p `pgrep VCTransitions`

Press Enter to start this bad boy up. Enter your password when DTrace asks you then head back over to the Simulator and start tapping on buttons.

You’ll see the Terminal DTrace window fill up with the IBAction methods implemented by ObjCViewController.

Now, tap one of the push buttons so you’re on the SwiftViewController view controller.

Although this is a subclass of UIViewController, tapping on the IBActions will not produce any results for the objcPID probe. Even though there are dynamic methods implemented or overridden by SwiftViewController, and being executed through objc_msgSend, the actual code is Swift code (even those @objc bridging methods).

Pop quiz: If SwiftViewController contains the following code:

class SwiftViewController: UIViewController, UIViewControllerTransitioningDelegate {
  @objc var coolViewDTraceTest: UIView? = nil
  @objc var coolBooleanDTraceTest: Bool = false

  // ...

Will an Objective-C DTrace probe pick up coolBooleanDTraceTest or coolViewDTraceTest?

To answer this, first see if these Swift properties are even exposed as Objetive-C probes. They should be, right? They have the @objc attributes.

Type the following in Terminal:

sudo dtrace -ln 'objc$target::*cool*Test*:entry' -p `pgrep VCTransitions`

Dang, only the properties for the Objective-C ObjCViewController are displayed and not SwiftViewControllers! This is because of Swift proposition 160 https://github.com/apple/swift-evolution/blob/master/proposals/0160-objc-inference.md, which includes a proposition that NSObject’s no longer infer @objc. In addition, Swift will not create an Objective-C symbol even for dynamic code.

This means you’ll have to use the non-Objective-C provider to query Swift DTrace probes.

You can confirm this by augmenting your DTrace script to dump any methods that include the word cool followed sometime later by the word Test, like so:

sudo dtrace -n 'pid$target::*cool*Test*:entry' -p `pgrep VCTransitions` 

This is another reason to go after objc_msgSend instead of the objc$target probe, because calls to objc_msgSend will catch dynamically executed Swift code, where objc$target will miss them.

Repeating your steps on a stripped build

Included within the project is a scheme called Stripped VCTransitions.

This runs the exact same target (executable) as the VCTransitions app, except Xcode will generate a stripped build that doesn’t contain any debugging information.

Select the Stripped VCTransitions scheme, make sure it’s on the iPhone XS Simulator (again on iOS 12 or earlier) and build and run.

Once running, pause the application and bring up LLDB. Search for any code that belongs to SwiftViewController using your newly created image lookup alternative, lookup command, you created in Chapter 26, “SB Examples, Improved Lookup” (if you skipped that chapter, default back to using image lookup -rn).

(lldb) lookup SwiftViewController

Hmm… you won’t get any hits. Maybe it’s a Swift bug? Try dumping everything pertaining to ObjCViewController:

(lldb) lookup ObjCViewController

Still nothing. What gives?

This executable has been stripped of it’s information. You can’t use the debugging symbols typically available to you to reference an address in memory.

However, LLDB is smart enough to realize these locations in memory are, in fact, functions. LLDB will generate a unique function name for the methods it doesn’t have information for. The automatically generated function name will take the following form:

___lldb_unnamed_symbol[FUNCTION_ID]$$[MODULE_NAME]

This means you can list all the functions LLDB has generated inside the VCTransitions executable with the following lookup command:

(lldb) lookup VCTransitions

I get 292 hits, with the following truncated output:

...
___lldb_unnamed_symbol289$$VCTransitions

___lldb_unnamed_symbol290$$VCTransitions

___lldb_unnamed_symbol291$$VCTransitions

Dang, LLDB can’t get the names of these functions. Do you think DTrace can read content in a stripped binary?

Type the following in Terminal:

sudo dtrace -ln 'objc$target:ObjCViewController::' -p `pgrep VCTransitions`

This queries the VCTransitions process for the count of probes containing the module ObjCViewController, which is DTrace’s way of referencing an Objective-C class.

I get the following:

   ID   PROVIDER            MODULE                          FUNCTION NAME
dtrace: failed to match objc57009:ObjCViewController:: No probe matches description

I can tell my PID is 57009 and I get 0 hits!

If I wanted to ensure that ObjCViewController was producing valid probes (which you saw earlier), simply rebuild this project using the non-stripped Xcode scheme, then run the above Terminal command again. I’ll leave that exercise to you if you’re interested in proving this works.

How to get around no probes in a stripped binary

So how can you architect a DTrace action and/or probe to get around this hurdle of not being able to inspect a stripped binary?

Since you know Objective-C (and dynamic Swift) methods need to go through objc_msgSend (or similar for super calls), you can use the knowledge you’ve learned about objc_msgSend to figure out how to create a nice DTrace action that prints out the name of the class along with the Objective-C selector.

A quick reminder about how objc_msgSend works. The function signature looks like this:

objc_msgSend(instance_or_class, SEL,  ...);

So, objc_msgSend takes a class or instance as the first parameter, the Objective-C selector as the second, followed by a variable amount of arguments.

With that in mind, if you had the following code:

UIViewController *vc = [UIViewController new];
[vc setTitle:@"yay, DTrace"];

The compiler would translate it into the following pseudocode:

vc = objc_msgSend(UIViewControllerClassRef, "new");
objc_msgSend(vc, "setTitle:", @"yay, DTrace");

From a DTrace standpoint, getting the Objective-C selector is rather easy. Just copyinstr(arg1) and you’re golden. As you’ve learned earlier, this will copy the pointer from arg1, the Objective-C selector (aka a char*), into kernel-land so DTrace can read it.

Now for the hard part: You want the class name of the first parameter passed into objc_msgSend as a char*.

DTrace won’t let you execute arbitrary methods, so you can’t rely on the Objective-C runtime, or any of the methods it implements, to dig the information out for you.

Instead, you get to go spelunking through the memory of the arg0 instance and find the char* yourself, which represents the class name, then automate it into a DTrace script.

Hey, this is the culmination of your DTrace skills coming together! You might as well go all out.

Researching method calls using… DTrace!

Let’s see if there are any documented ways to go after this thing. In the objc/runtime.h header, you have the following declaration:

struct objc_class {
    Class isa  OBJC_ISA_AVAILABILITY;

#if !__OBJC2__
    Class super_class                                        OBJC2_UNAVAILABLE;
    const char *name                                         OBJC2_UNAVAILABLE;
    long version                                             OBJC2_UNAVAILABLE;
    long info                                                OBJC2_UNAVAILABLE;
    long instance_size                                       OBJC2_UNAVAILABLE;
    struct objc_ivar_list *ivars                             OBJC2_UNAVAILABLE;
    struct objc_method_list **methodLists                    OBJC2_UNAVAILABLE;
    struct objc_cache *cache                                 OBJC2_UNAVAILABLE;
    struct objc_protocol_list *protocols                     OBJC2_UNAVAILABLE;
#endif

} OBJC2_UNAVAILABLE;
/* Use `Class` instead of `struct objc_class *` */

Back in the Objective-C 2.0 days with a 64-bit machine, if you had a pointer at X which pointed to a valid class, you could get to that const char *name described in the #if !__OBJC2__:

po *(char *)(X + 0x10)

Unfortunately, this is rather dated. This class structure dates back to before Objective-C 2.0. Structs and pointer locations have long since changed. Apple has opted to make the current layout of the objc_class structs a little less public for your viewing pleasure.

This means you need to hunt for a function that takes an Objective-C class (or instance of the class) and returns a char* for the class so we can figure out what it’s doing.

Note: Although hidden in the headers from developers, you can obtain the Objective-C class layout by navigating to the latest version here https://opensource.apple.com/source/objc4/ and hunting for the objc_class struct. However, for this tutorial, you’ll determine what you need to do by looking at the assembly instead of this C struct.

Fortunately, jumping back to the objc/runtime.h header file, there’s also a function by the name of class_getName. Don’t believe me? Execute open -h runtime.h in Terminal.

Looking at the headerfile, class_getName has the following signature:

/** 
 * Returns the name of a class.
 * 
 * @param cls A class object.
 * 
 * @return The name of the class, or the empty string if \e cls is \c Nil.
 */
OBJC_EXPORT const char *class_getName(Class cls) 
    OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0);

This function takes a Class and returns a char*. You’ll use DTrace to trace this method and see what methods this class is calling underneath the covers.

Hopefully, your VCTransitions app is still running. If not, re-run the application. Once active, pause the application in LLDB.

Get the reference to the Class representing a UIView:

(lldb) p/x [UIView class]

You’ll get something similar:

(Class) $0 = 0x0000000109d4ce60 UIView

Take this reference to the UIView class and apply it to the class_getName function:

(lldb) po class_getName(0x0000000109d4ce60)

You’ll get a number? Why is that?

0x000000010999319f

Oh yeah, duh… this function returns a C char*. You have to cast those:

(lldb) po (char *)class_getName(0x0000000109d4ce60)

You’ll now use DTrace to trace all the non Objective-C methods class_getName calls behind the scenes.

Jump over to a fresh Terminal session and execute the following DTrace one-liner:

sudo dtrace -n 'pid$target:::entry' -p `pgrep VCTransitions`

All the while, LLDB should still be suspended when setting up your DTrace script.

Jump on back to LLDB and re-execute that class_getName function with the reference to the UIView class. Your pointer to the UIView class will be different:

(lldb) po (char *)class_getName(0x0000000109d4ce60)

After you’ve executed the above command, the DTrace script will spit out the following list of functions that were called for class_getName.

:~ sudo dtrace -n 'pid$target:::entry' -p `pgrep VCTransitions`
Password:
dtrace: description 'pid$target:::entry' matched 901911 probes
CPU     ID                    FUNCTION:NAME
  6 1405417              class_getName:entry 
  6 1405416 objc_class::demangledName(bool):entry 
  6 566986        _NSPrintForDebugger:entry 
  6 1405847               objc_msgSend:entry 

It looks like that objc_class::demangledName(bool): function is a fun place to explore.

Kill the DTrace script. You don’t want it screwing with your LLDB breakpoints, as setting a DTrace probe on a LLDB breakpoint can have unexpected consequences.

Once the DTrace script has terminated, set a breakpoint on objc_class::demangledName(bool) with LLDB, like so:

(lldb) b objc_class::demangledName(bool)

Rerun the expression, but tell LLDB to honor breakpoints:

(lldb) exp -i0 -O -- class_getName([UIView class])

As soon as you press enter, LLDB will stop on this objc_class::demangledName(bool) function.Take a good look at the assembly.

Scary assembly, part I

As always, this stuff looks scary at first. But when you systematically go through it, it’s not that bad. You’ll actually break the assembly function into chunks to explore. The first chunk will be between offset 0-55.

Inspect the registers so you know what you’re dealing with:

(lldb) po $rdi

You’ll get UIView output which is the description method for the UIView class. But why is that the first parameter? The function signature seems to indicate it should be a bool.

Well, this is a C++ function, and C++ is like Objective-C in the way you call functions on an object. There’s an implicit first parameter which is the object the function is being called on. As mentioned throughout this book, the instance passed in as the first register is not always the case with Swift.

Move onto the second param:

(lldb) po $rsi

You’ll get nil. This is the bool parameter. And nil is 0, so this is false.

Time to break this thing down. The offsets referred to here are the values within the angle brackets. So offset 13 is <+13>.

  • Offset 13: After this line, the function prologue is over. Time for the actual meat of this function.
  • Offset 17: This assigns esi to r12d. This is the Boolean that is passed in. We explored rsi earlier and saw it was 0, so r12d will be 0 as well.
  • Offset 20: rdi contains the UIView class reference and is assigning this value to r15.
  • Offset 33: This offsets r15 by a value of 0x20 and dereferences it. i.e. rax = (*([UIView class] + 0x20)).
  • Offset 37: The value stored in rax is AND’d with 0x7ffffffffff8 and stored into rax.
  • Offset 48: The value at rax is offset by 0x38 and then dereferenced and stored into rbx i.e rbx = *(rax + 0x38).
  • Offset 52-55: rbx is checked for zero. If it returns a non-zero number, then finish up this function, which jumps to <+310>, which is right before the function epilogue.

If this check at offset 55 fails (i.e. if rbx is 0), execution will continue on to the next assembly instruction, <+61>.

The logic between offsets 0-55 is responsible for returning an Objective-C’s class back to you as a char* if (and only if) that class has been properly loaded. This typically happens when at least one method from that class (i.e., that method must be implemented or overriden in that class) is executed.

For example, if a brand new class is called that hasn’t created any initializations during the lifetime of your process, the logic between offsets 0-55 will return nil. You’ll build a command regex to confirm this in a second…

Looking at the assembly, you can deduce the following.

If you have an already-initialized class at instance X, and if you offset X by 0x20 and dereference this, the output would look like:

*(uint64_t *)(X + 0x20)

You then bitwise AND this value with 0x7ffffffffff8:

*(uint64_t *)(X + 0x20) & 0x7ffffffffff8

Next, take this value, offset it by 0x38 and dereference that:

*(uint64_t *)((*(uint64_t *)(X + 0x20) & 0x7ffffffffff8) + 0x38)

This is the final address, so you just need to cast it into the correct type, a char *:

(char *)*(uint64_t *)((*(uint64_t *)(X + 0x20) & 0x7ffffffffff8) + 0x38)

Now, if you have a reference to an NSObject, you know from Chapter 25, “Script Bridging with SBValue & Language Contexts” that the memory address right at the start of the object will point to the class itself (the isa pointer). If you don’t understand that, go back and reread Chapter 25 — or else the remainder of this chapter will get pretty intense. :]

Putting it all together, to get an instance’s class name as a char*, behold this monstrosity:

(char *)*(uint64_t *)((*(uint64_t *)((*(uint64_t *)Instance_of_X) + 0x20) & 0x7ffffffffff8) + 0x38)

Yep, you get to manually replicate this in LLDB to make sure this works!

Note: I’ll repeat this once more: this will NOT work for Objective-C classes that haven’t been initialized yet. There’s a reason why you’re using a UIView, because if you can see the UI on your screen, then the UIView class has definitely been initialized and at least one UIView has been created.

In LLDB, go after the UIView class:

(lldb) p/x [UIView class]
(Class) $1 = 0x000000010c09ce60 UIView

You’ll get a different address. Copy that to your clipboard.

Take that address and offset it by 0x20 and view the memory at that location:

(lldb) x/gx '0x000000010c09ce60 + 0x20'

You’ll get some value:

0x10c09ce80: 0x0000608000064b80

AND that value with 0x7ffffffffff8 (that’s 10 f’s):

(lldb) p/x 0x7ffffffffff8 & 0x0000608000064b80

You’ll get another number:

0x0000608000064b80

Take that value, offset it by 0x38 and dereference it.

(lldb) x/gx '0x0000608000064b80 + 0x38'

You’ll get something like:

0x608000064bb8: 0x000000010bce319f

See if the value at 0x000000010bce319f (or at least for me) contains the char* pointer.

(lldb) po (char *)0x000000010bce319f

If everything went well, you’ll get your char* representation for UIView.

Yay! Pointers!

Create a new regex command to verify everything I’ve told you is true.

Just enter this into the console; no need to put this in your ~/.lldbinit file:

command regex getcls 's/(.+)/expression -lobjc -O -- (char *)*(uint64_t *)((*(uint64_t *)((*(uint64_t *)%1) + 0x20) & 0x7ffffffffff8) + 0x38)/'

This grabs the char* class name from any instance whose class has already been loaded into your process.

Once you’ve entered this into your LLDB console, give it a go on the known-to-work UIView:

(lldb) getcls [UIView new]

Now go after something that hasn’t been initialized or has had any methods executed for that class, like UIAlertController:

(lldb) getcls [UIAlertController new]

You’ll get nil, since this class hasn’t executed any code yet that’s unique for the class.

(lldb) po [UIAlertController class]

Re-execute the getcls command:

(lldb) getcls [UIAlertController new]

You’ll now get a reference to the char* representation of UIAlertController. Remember if any unique method for that class is executed, the Objective-C runtime loads that class in.

Now, the class (i.e. -[NSObject class]) method is not unique for UIAlertController, but guess what is?

You’re po’ing this object and the debugDescription and description methods are unique (overridden) to this class.

Therefore, just by po’ing a UIAlertController class, it’ll load it into the runtime!

Run your custom command, methods, that you created in Chapter 15, “Dynamic Frameworks” on UIAlertController to verify the overriden debugDescription method if you have any doubts.

Scary assembly, part II

It’s time to revisit the second part of interest in the objc_class::demangledName(bool) C++ function. This assembly chunk will focus on what the logic does if the initial location for that char* is not in the initial location of interest — that is, if the class isn’t loaded yet.

You need to create a breakpoint on assembly instruction offset 61, the instruction immediately following the instruction on offset 55.

You could blindly call classes to see what classes aren’t loaded in the runtime, but I haven’t a clue what’s in your process, and you have no clue what’s in mine!

Instead, create a symbolic breakpoint that stops on offset 61 of objc_class::demangledName(bool).

Create a symbolic breakpoint in Xcode using the following details:

  • Use dlopen for the symbol.

  • In action 1: remove this breakpoint using br dis 1.

  • In action 2: set a breakpoint on offset 61 of objc_class::demangledName(bool) with this command:

    br set -M objc_class::demangledName(bool) -R 61
    
  • Select “Automatically continue after evaluating actions”.

Rebuild and run the VCTransitions application.

You won’t get very far into your program before this breakpoint is hit; you can see dyld is still busy setting stuff up.

Round two; here we go:

  • Offset 61: Provided the initial location in memory was nil, control continues to 61 where rax + 0x8 is dereferenced and stored into rax again.

  • Offset 65: The value 0x18 is added to rax and stored back into rax. rax could be a struct that is holding a value of interest, which could explain offsetting this address.

  • Offset 69: The value at rax is dereferenced and stored into rbx, which will get passed into rdi 2 instructions later. After that, a call instruction occurs, which by the disassembly commentary, looks to expect a char const * as the first parameter.

This is the “interesting” part of this function to you. After that, this function calls the copySwiftV1DemangledName function and sets up the logic to load the class into the Objective-C runtime.

But for you, this is as far as you need to explore this function.

Feel free to ensure that rdi will always produce a valid char* at offset 77, but again, that will be something you can do on your own time. You’ve still got a DTrace script to write.

Converting research into code

You’ve done the necessary research to figure out how to traverse memory to get the character array representation of a class. Time to implement this thing.

Included in the starter script is a skeleton DTrace script named msgsendsnoop.d.

You’ll start with this DTrace script and build out the code for it. Once working and tested, you’ll transfer that code into a LLDB Python script, which will dynamically generate the code you want.

In Terminal cd into the starter directory. You can drag and drop the directory into Terminal to autocomplete.

cat the contents of this script:

cat ./msgsendsnoop.d

Here’s the output from cat:

#!/usr/sbin/dtrace -s
#pragma D option quiet  

dtrace:::BEGIN
{
    printf("Starting... Hit Ctrl-C to end.\n");
}

pid$target::objc_msgSend:entry 
{
  this->selector = copyinstr(arg1);
  printf("0x%016p, +|-[%s %s]\n", arg0, "__TODO__",
                                         this->selector);
}

Let’s break this down. This script will stop on the objc_msgSend entry probe with the appropriate PID passed in (thanks to the pid$target provider). Once hit, the selector’s char* is copied into the kernel and printed.

As an example of what will happen, let’s say a -[UIView initWithFrame:] is about to be called. The following will print out:

0x00000000deadbeef, +|-[__TODO__ initWithFrame:]

Verify this is true by tracing all the objc_msgSend calls in the VCTransitions application:

sudo ./msgsendsnoop.d -p `pgrep VCTransitions`

Tap around on some classes. Hopefully this gives you an idea of how frequently this method gets called.

It’s time to fix that annoying __TODO__ and replace it with the actual name of the class.

Open up msgsendsnoop.d and replace the existing pid$target::objc_msgSend:entry code with the following:

pid$target::objc_msgSend:entry 
{
  /* 1 */
  this->selector = copyinstr(arg1); 
  /* 2 */
  size = sizeof(uintptr_t);  
  /* 3 */
  this->isa = *((uintptr_t *)copyin(arg0, size));

  /* 4 */
  this->rax = *((uintptr_t *)copyin((this->isa + 0x20), size)); 
  this->rax =  (this->rax & 0x7ffffffffff8); 

  /* 5 */
  this->rbx = *((uintptr_t *)copyin((this->rax + 0x38), size)); 
  
  this->rax = *((uintptr_t *)copyin((this->rax + 0x8),  size));  

  /* 6 */
  this->rax = *((uintptr_t *)copyin((this->rax + 0x18), size));  
  
  /* 7 */
  this->classname = copyinstr(this->rbx != 0 ? 
                               this->rbx  : this->rax);   
  printf("0x%016p +|-[%s %s]\n", arg0, this->classname, 
                                       this->selector);
}

Note: I would recommend you type in each line and make sure it runs, instead of typing in everything at once. Some DTrace script errors can be tricky to hunt down.

Deep breath. Here’s what each line does:

  1. this->selector does a copyinstr, because you know the second parameter (aka arg1) is an Objective-C selector (aka a C string). Since C char*s end with a null character, DTrace can automatically determine how much data to read.
  2. In a moment, you’re going to copyin some data. However, copyin expects a size, because unlike a string, DTrace doesn’t know when the arbitrary data ends. You declare a variable named size, which equals the length of a pointer. In x64, this will be 8 bytes.
  3. This is getting the reference to the class of the instance. Remember, the dereferenced pointer at the start address of a Objective-C or Swift instance will point to the class.
  4. Now for the fun part you learned about from the assembly in objc_class::demangledName(bool). You’ll replicate the logic found in the registers, as well as even use the same names for the registers! You’re using rax to mimic the logic that this function performs.
  5. This is the logic where (rax + 0x38) gets set to this->rbx, just like in the actual assembly.
  6. This is the final line if the value this->rbx is 0 (aka the class has not been loaded yet).
  7. You are using a ternary operator to figure out which clause local variable to use. If this->rbx is non-null, use it. Otherwise, reference this->rax.

Save your work. Jump over to Terminal and relaunch this DTrace script:

sudo ./msgsendsnoop.d -p `pgrep VCTransitions`

Woooooooooooooooooot! That crazy hack actually worked!

Scanning the content in your script, it looks like the script is throwing errors occasionally when objc_msgSend is calling a nil object (i.e. RDI, aka arg0, is 0x0).

You can view only the errors with the following command:

sudo ./msgsendsnoop.d -p `pgrep VCTransitions` | grep invalid

Let’s fix that now with a simple predicate. Immediately following pid$target::objc_msgSend:entry, add the following predicate so it looks like this:

pid$target::objc_msgSend:entry / arg0 > 0x100000000 /

This says, “Don’t run this DTrace action if the first param is nil or a section of memory that is not utilized.”

Typically, in a macOS userland process, this section of memory is off-limits for reading, writing, and executing. If anything is below the number 0x100000000, DTrace ain’t gonna like it, along with anything else reading memory there.

Therefore, if it’s below that number, just have DTrace skip it. You can of course, confirm this using LLDB with the following command:

(lldb) image dump sections VCTransitions

But that’s for you to verify when you’re bored. You still gotta finish this script.

Removing noise

To be honest, I couldn’t care less about tracing memory-management code the compiler has generated. This means anything with retain or release needs to get outta here.

Make a new clause with the same DTrace probe above your current probe:

pid$target::objc_msgSend:entry 
{
  this->selector = copyinstr(arg1);
}

/* old code below */
pid$target::objc_msgSend:entry / arg0 > 0x100000000 /

You’re now declaring the selector in a new clause before the main clause with all the memory jumping logic. This will let you filter Objective-C methods inside the predicate section of the main clause.

Speaking of which, augment the predicate in the main clause now:

pid$target::objc_msgSend:entry / arg0 > 0x100000000 / && 
                    this->selector != "retain" && 
                    this->selector != "release" /                              

This will now ignore any Objective-C selectors that equal retain or release.

While you’re at it, there’s no need to reassign the this->selector in the main clause now you’re doing it in the other one. Although it isn’t harmful, it’s superfluous logic. Remove it, or don’t… whatever makes you happy.

Your two clauses should now (hopefully somewhat?) look like this:

pid$target::objc_msgSend:entry 
{
  this->selector = copyinstr(arg1); 
}

pid$target::objc_msgSend:entry / arg0 > 0x100000000 && 
                    this->selector != "retain" && 
                    this->selector != "release" /                              
{
  size = sizeof(uintptr_t);  
  this->isa = *((uintptr_t *)copyin(arg0, size));

  this->rax = *((uintptr_t *)copyin((this->isa + 0x20), size)); 
  this->rax =  (this->rax & 0x7ffffffffff8); 
  this->rbx = *((uintptr_t *)copyin((this->rax + 0x38), size)); 
  
  this->rax = *((uintptr_t *)copyin((this->rax + 0x8),  size));  
  this->rax = *((uintptr_t *)copyin((this->rax + 0x18), size));  
  
  this->classname = copyinstr(this->rbx != 0 ? 
                               this->rbx  : this->rax);   
  printf("0x%016p +|-[%s %s]\n", arg0, this->classname, 
                                       this->selector);
}

Relaunch the script:

sudo ./msgsendsnoop.d -p `pgrep VCTransitions`

Oh man, that’s sooo much better.

But still, that’s too much noise. Time to take this script and combine it with LLDB to only produce output that pertains to code in the main executable.

Limiting scope with LLDB

Included within the starter folder is a LLDB Python script that creates a DTrace script and runs it with the exact logic you’ve just implemented.

Womp womp… spoiler alert. You could have just used that script in the first place. But that wouldn’t have been as much fun.

This file is named snoopie.py. Take this file and copy it into your ~/lldb directory. If you’ve followed along with Chapter 26, “SB Examples, Improved Lookup”, you have a script in there named lldbinit.py that automatically loads all the scripts in the same directory for you.

If you were too cool for school and didn’t do that chapter, you’ll need to add the following line of code into your ~/.lldbinit file:

command script import ~/lldb/snoopie.py

You’ll use a creative solution to filter out the code in this DTrace script to only trace Objective-C/dynamic Swift code belonging to the VCTransitions executable. Normally, when snooping code in a framework, I’ll often grab the __TEXT segment of a module and compare the instruction pointer to the upper and lower bounds of the __TEXT segment that’s loaded in memory (the area in memory containing executable code). If the instruction pointer is between the upper and lower bounds, then you can assume you want to use DTrace to trace the code.

Unfortunately, you’re going after objc_msgSend, the chokepoint used for Objective-C code in all modules. This means that you can’t rely on the instruction pointer to tell you which module you’re in.

Instead, you need to go about this by isolating the addresses of a class to only be contained within the __DATA segment of the main executable.

Head on back to your Xcode project, VCTransitions.

Build, run, stop execution and bring up LLDB. Then type the following:

(lldb) p/x (void *)NSClassFromString(@"ObjCViewController")

You’ll get the address to the ObjCViewController class:

(void *) $0 = 0x000000010db34080

Take this address and determine what section of memory this thing is located in.

(lldb) image lookup -a 0x000000010db34080

You’ll get something similar to the following:

Address: VCTransitions[0x0000000100012080] (VCTransitions.__DATA.__objc_data + 40)
Summary: (void *)0x000000010db34058

Therefore, you can deduce this class is within the VCTransitions __DATA segment inside the __objc_data section. You’ll use the LLDB Python module to find the upper and lower bounds of this __DATA segment.

Now you’re going to use the good old script command to find how you can create this code through the LLDB module. Back in LLDB, type the following:

(lldb) script path = lldb.target.executable.fullpath

This will give you the SBFileSpec representing the executable, VCTransitions, and assign it to the variable path. Print out the path to make sure it’s valid:

(lldb) script path

You’ll get the full path to the location of this executable. You can use this path variable to get the correct SBModule from the SBTarget. Type the following into LLDB:

(lldb) script print lldb.target.module[path]

You’ll get the SBModule representing the main executable.

Within a SBModule, there’s SBSections. You can get all sections within an SBModule using the sections property, or you can get a specific section using section[index]. Yep, that property conforms to Python’s __getitem__. Type the following into LLDB:

(lldb) script print lldb.target.module[path].section[0]

You’ll get something like:

[0x0000000000000000-0x0000000100000000) VCTransitions.__PAGEZERO

The implementation of __getitem__ can also let SBSection act as a dictionary. So you can also access the __PAGEZERO section like so:

(lldb) script print lldb.target.module[path].section['__PAGEZERO']

This means you can easily access the __DATA SBSection by using the following:

(lldb) script print lldb.target.module[path].section['__DATA']

Cool, that works. Assign this SBSection to a variable named section, like so:

(lldb) script section = lldb.target.module[path].section['__DATA']

You now have a reference to the correct segment. There are segments in the __DATA section you can dissect, but you might as well grab the whole section, since it’s one contiguous region in memory.

Get the load address for the section, like so:

(lldb) script section.GetLoadAddress(lldb.target)

This will print the start location. Grab the size as well, while you’re at it:

(lldb) script section.size

So what does this give you? You can make a DTrace predicate that checks if the class is in between these values in memory. If they are, execute the DTrace action. If they’re not, ignore. Let’s implement this!

Fixing up the snoopie script

As indicated, this snoopie.py script works as-is, so you’re just going to add some small logic to the predicate to filter only instances.

Open up ~/lldb/snoopie.py and navigate to the generateDTraceScript function. Remove the dataSectionFilter = ... line.

Then add the following code in its place:

target = debugger.GetSelectedTarget()
path = target.executable.fullpath
section = target.module[path].section['__DATA']
start_address = section.GetLoadAddress(target)
end_address = start_address + section.size

dataSectionFilter = '''{} <= *((uintptr_t *)copyin(arg0, 
    sizeof(uintptr_t))) && 
   *((uintptr_t *)copyin(arg0, sizeof(uintptr_t))) <= {}
'''.format(start_address, end_address)

The interesting point here is you’re taking the arg0 and dereferencing it if (and only if) arg0 is greater than 0x100000000, which indicates a valid instance in memory.

That’s it! No more code! You’re all done!

Save your work, jump over to the LLDB console, reload the contents in LLDB either through your custom reload_script command or manually by command script import ~/.lldbinit.

Once reloaded, try this thing out. In LLDB:

(lldb) snoopie

Paste the contents to a Terminal window and have fun.

DTrace now only profiles code that’s in your main (stripped) executable.

Have fun with this script on some other apps on your computer!

Where to go from here?

You’ve got some homework to do on your end. This script will not play nicely with Objective-C categories. For example, there could be a class that’s implemented within a different module, which has an Objective-C category implemented within the main executable. You’ll need to figure out some creative way to check if the Objective-C selector in objc_msgSend was implemented within the main executable or not.

In addition, the printf in your current code doesn’t indicate whether arg0 is a class method or not. You’ll need to figure out how to determine if the arg0 parameter is a class or an instance solely by jumping through memory.

How can you go about finding this?

  • If arg0 is an instance of a class, the isa param will point to a non-meta class.
  • If arg0 is the class, then the isa param will point to the meta class.
  • Explore the assembly of class_isMetaClass to determine what values inside a Class indicate if it’s a meta class or not.

Once you’ve found how to jump through memory to determine if a class is a meta class or not, replicate the logic found in class_isMetaClass in your DTrace script.

Since this is either an instance of a class or the Class object itself, you can use a ternary operator inside your DTrace script with something similar to the following:

this->isMeta = ... // logic here
this->isMetaChar = this->isMeta ? '+' : '-'

printf("0x%016p %c[%s %s]\n", arg0, this->isMetaChar, 
                                    this->classname, 
                                    this->selector);

Heh… isMetaChar. That will totally be a Pokémon name some day.

Good luck!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.