I’ve always been curious to learn more about app reverse engineering but never had a good case to try it on. I finally found one after upgrading to macOS Catalina where my long-time favorite internet radio app, Radium, broke down.

The app has sadly become an abandonware 1 and after all the years of using it, none of the alternative apps like Triode or OneRadio really worked for me due to some missing features. But I did not want to give up so easily, decided to investigate the crash myself and attempt to patch it.

Radium macOS app Radium macOS app

In this article I document the process of investigating and patching the issue, that should also be applicable to other Mac apps as a way to alter their behavior. The accompanying Xcode project is available on my GitHub. If you just want to fix the Radium app, download the launcher here.

The crash investigation

When you open Radium on Catalina seemingly nothing happens and the app doesn’t start. With Catalina dropping the 32-bit app support I initially suspected this to be the reason, but the knowledge base revealed that Catalina would show a dialog about that.

My next steps went to Console.app where I discovered a crashlog. It is usually not very useful without access to the app codebase and the symbols, but in this case the exception reason was easily interpretable:

Application Specific Information:
terminating with uncaught exception of type NSException
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSDictionaryI name]: unrecognized selector sent to instance 0x600002c00480'

Unrecognized selector sent to instance is a common Objective-C exception typically thrown when a method invocation fails because the method cannot be found on certain instance.

In this case the selector name was not found on __NSDictionaryI class. If only I could provide this missing implementation so the app wouldn’t crash on it, I was thinking. The dynamic nature of the Objective-C selector resolution should make this relatively easy to do.

Code injection

On macOS environment there is a powerful yet straightforward way of injecting code into executables using DYLD_INSERT_LIBRARIES environment variable. It allows to load a dynamic library before application loads, practically allowing the library to modify the app behavior in runtime. This way I wouldn’t even have to modify the original binary which is also a legal win.

As I found out, an Xcode project can be conveniently configured to build the dynamic library and launch the Radium app with DYLD_INSERT_LIBRARIES environment variable in (at least) two ways:

  • Using a Scheme with Radium.app as executable and DYLD_INSERT_LIBRARIES as the Scheme Environment Variable. With this setup Radium even gets launched with LLDB debugger attached to it.

Radium macOS app

  • Using a custom app as a launcher. With this the debugger won’t be attached, but the launcher app with injected library can be exported and used as a standalone app. The launcher can be as simple as the code below. I just had to switch off the sandboxing first.
NSString *radiumPath = @"/Applications/Radium.app/Contents/MacOS/Radium";
NSString *dyldLibrary = [[NSBundle bundleForClass:[self class]] pathForResource:@"libRadiumFix" ofType:@"dylib"];
NSString *launcherString = [NSString stringWithFormat:@"DYLD_INSERT_LIBRARIES=\"%@\" \"%@\" &", dyldLibrary, radiumPath];
system([launcherString UTF8String]);

Fixing the problem

With the code injection environment running, it was time to implement the missing method. From the crashlog I knew that it is -[__NSDictionaryI name]. The return type is not known from the selector, but based on its name I took a wild guess that it would return NSString. __NSDictionaryI is a private class that is exposed to the developer as NSDictionary class cluster – a standard way to hide a private implementation behind a public interface. Thus, I implemented the method as a NSDictionary category.

@implementation NSDictionary (RadiumFix)

- (NSString *)name {
    return @"foo";
}

@end

After compiling and running this code in the injected library, a different exception showed up – a good indication that the injection worked. Two more unrecognized selectors – -[__NSDictionaryI isDefaultPreset] and -[__NSDictionaryI uid] – appeared, that I implemented in the similar fashion as the first one.

Those three selectors were all that was missing on Catalina, after that I got the app to run! 🎉

Fixing the problem properly

But wait, providing dummy implementations for those methods doesn’t seem right, they surely have their importance. That was the right time for some disassembling using Hopper to look for the original implementation in the Radium.app binary. By searching for the unrecognized selectors from the crashlog I quickly found the implementations.

Radium macOS app

Those methods turned out to be a convenience dictionary accessors returning a dictionary value for uid and name keys (with a slight twist on isDefaultPreset). The proper fix for name was a one-liner and the other two were implemented similarly.

@implementation NSDictionary (RadiumFix)

- (NSString *)name {
    return [self valueForKey:@"name"];
}

@end

From the disassembly it was also apparent why the app did not crash on earlier operating systems. Those methods were originally declared on NSMutableDictionary, while (as we know from the crashlog) Catalina calls them on NSDictionary. It’s clear that something in the system framework had to change to provide immutable NSDictionary in Catalina where it supplied mutable NSMutableDictionary before.

So what went wrong?

To see what has actually changed in Catalina I had to do a bit more of reverse engineering. Using the code injection approach with a Scheme running the Radium executable, I got LLDB attached to Radium directly in Xcode. That allowed me to investigate the stack frames in the original exception backtrace more deeply.

I focused on the stack frames originating in Radium. Not being very good at reading the assembly myself I was going back and forth to Hopper to check for disassembly for different addresses.

Radium macOS app

Eventually, after some playing around I found the source of the __NSDictionaryI that I could see in the original crashlog. As seen in the console output above, the dictionary is wrapped in a __NSArrayI class. And the array originates from address 0x10002cc90 (highlighted in the screenshot). Checking for that address in Hopper made it finally clear where does the data come from.

Radium macOS app

The source is NSUserDefaults, a standard system key-value storage. NSUserDefaults can also store certain objects (in our case a dictionary) and the internals of the object deserialization was what had to change in Catalina.

Minimum reproducible example

As the last step of my investigation I tried to find a minimum reproducible example, which I suspected could be storing a dictionary into NSUserDefaults and retrieving it.

[[NSUserDefaults standardUserDefaults] setObject:@{@"obj1": @"val1", @"obj2":@"val2"} forKey:@"key"];
NSMutableDictionary *val = [[NSUserDefaults standardUserDefaults] objectForKey:@"key"];

NSLog(@"%@", [val class]);

In Catalina the NSLog printed __NSDictionaryI as expected. Then I run the same code on Mojave, and voilà, it printed __NSCFDictionary instead 2 3. So what makes those two private dictionary classes different? Let’s call class dump headers for help.

As already discussed, __NSDictionaryI is accessed by an app developer through the NSDictionary class cluster 4. In contrast to this, __NSCFDictionary is represented as NSMutableDictionary class cluster. That explains why the latter one can access method on mutable dictionary and the former one can’t.

Does this actually mean that Apple broke the API contract in Catalina? Well, not really, the documentation of the [NSUserDefaults objectForKey:] explicitly mentions that the returned object is always immutable. So it was just a coincidence that the code worked before even though it shouldn’t.

The returned dictionary and its contents are immutable, even if the values you originally set were mutable.

– Reference for NSUserDefaults objectForKey:

The catch is that even though __NSCFDictionary is publicly represented as NSMutableDictionary, it’s not necessarily always mutable. Weird, huh? Turns out the same private class was used to implement both mutable and immutable dictionaries, and mutability is just an internal state of it. And it can’t really be told from the class. So our dictionary was indeed the immutable one even prior to Catalina, which I quickly verified by calling setObject:forKey on it. Yes, it crashed.

As I learned later, Apple has been migrating private dictionary classes from __NSCFDictionary to __NSDictionaryM and __NSDictionaryI over time, perhaps also because of the increased safety those new implementations offer. And in Catalina Apple happened to migrate the deserialization logic the NSUserDefaults uses.

Conclusion

This investigation nicely illustrates some of the good and the bad parts of the Objective-C dynamism. The good one as it’s so easy to inject the code and play around with it even without access to the code. The bad one as there is a chance that in a less dynamic language, such as Swift, the problem of the suddenly missing method wouldn’t have even happened.

This case is also a good example of the Hyrum’s Law. Radium developers relied on observable behavior of the API that was not in the contract (in fact, in contradiction with it). In this sense the use of an immutable NSDictionary class cluster when the dictionary is meant to be immutable is clear improvement. Of course, unless a poor app like Radium doesn’t expect it.


  1. As seen on numerous tweets to the developer that remain unanswered 

  2. The same difference can be observed between iOS 13 and iOS 12. 

  3. This behavior actually originates in CFPreferences that NSUserDefaults internally uses, I verified the same behavior when using CFPreferencesCopyAppValue, but I didn’t go any deeper in my analysis. 

  4. There’s also a private class __NSDictionaryM publicly usable as NSMutableDictionary class cluster.