Skiptracing Part 2: iOS
This is a sequel to my previous post about tracking skips in the Spotify MacOS desktop client: https://medium.com/@lerner98/skiptracing-reversing-spotify-app-3a6df367287d. In this post, I will do the same for the Spotify iOS application using some new techniques. Let’s get to it.
Step Zero: Jailbreak
Basically any iOS reverse engineering requires a jailbreak. This is because iOS requires each executable as well as virtual memory page to be codesigned by Apple or an Apple developer. If we want to run anything like an SSH server on our iPhone, we need a jailbreak to patch the iOS kernel to bypass codesigning enforcement. I have an iPhone 5 running iOS 10.3.3 so I used the Helix jailbreak. If you have a newer iPhone running a newer version of iOS (which you likely do) you’ll need to use a different jailbreak and much of the following will need to be updated for arm64 (my phone runs armv7).
Step One: Get the Binary
When not running, iOS applications are encrypted. This means that to disassemble the binary, we must first decrypt it. Thankfully, I am far from the first person to want this capability so there are some nice libraries to decrypt iOS applications.
I used Clutch: https://github.com/KJCracks/Clutch for decryption. Instead of breaking the encryption scheme (which would be intractable), Clutch loads the application like it is being executed and then proceeds to dump the decrypted binary from memory.
We can then scp the dumped IPA files back to our computer (assuming you’ve set up OpenSSH on your iPhone). I would highly recommend using SSH over USB: https://iphonedevwiki.net/index.php/SSH_Over_USB since it is far faster than using WiFi.
Step Two: Dumping the Classes
The first thing I wanted to do was take a look at the classes to see if anything stood out. Now usually this would be done with class-dump or class-dump-z but I kept running into segmentation faults or that the architecture of the binary (armv7) was unsupported. Then I saw this line in the class-dump readme:
“This is the same information provided by using ‘otool -ov’, but presented as normal Objective-C declarations.”
If we runotool -ov
on our decrypted binary, we’ll get a complete description of the classes, including their methods and instance variables. This will be super helpful coming up.
The only problem is that the output of this command is pretty massive (hence the -v
flag). We’ll need to come up with a way to hone in on the important classes.
Finding the Right Class
Our final goal is to find the class/methods that deal with playback control. A reasonable way to go about this is to find the previous/next buttons within the view hierarchy and print their targets. After all, the targets of these buttons have to eventually trigger our desried methods, right?
To print the view hierarchy, we’ll use Frida: https://github.com/frida/frida. Once you have Frida installed and have your iPhone connect via USB to your computer, open up Spotify and click on the bottom bar to show the view with the playback control buttons.
Now, we can attach Frida to Spotify by running frida -U -n Spotify
. After maybe a minute, you should have a Javascript console open.
Frida provides a nice Javascript API for us to interact with the Objective-C class information of the attached app. Using this API, we can print the current view hierarchy like so:
console.log(ObjC.classes.UIWindow.keyWindow().recursiveDescription().toString());
There are a lot of levels to the view hierarchy, so it may be helpful to expand your terminal window. If you search through the view hierarchy, you should see a few interesting views:
It looks like we’ve found them. We can now get the targets to the buttons (I’ll do it for the next button but it doesn’t matter which you choose).
First, use the pointer to the button instance to create an Objective-C object:
Then we can print the targets of that object:
We can now see that the next button’s target is a class with a promising name: SPTNowPlayingPlaybackActionsHandlerImplementation
. Let’s look at our Objective-C class dump and see what sorts of interesting methods this class implements.
Scrolling through its method, we come across two methods that should pique your interest:
Let’s look at the disassembly for this method (skipToNext) to see what else it might call (I’m using the demo version of Binary Ninja because IDA free doesn’t support armv7 — make fun of me all you want):
We can see that this method is accessing an instance variable of another class called playbackController
. Now, we could probably be smart and trace through to assembly to see which class this is a method of, but why don’t we just search for the method in our Objective-C class dump.
If you do this, you’ll find that many classes have an instance variable called playbackController
. I’ll just choose the SPTAppBackgroundProtocolController
class but it shouldn’t matter if you choose a different one.
Going back to Frida, we can obtain an instance of SPTAppBackgroundProtocolController
assuming one exists on the heap:
We can see that the playbackController
is of type SPTExternalIntegrationPlaybackControllerImplementation
(verbose!).
Looking through our class dump, we see that this class has an instance variable called player
:
In Frida, we can print out the type of this variable:
which is SPTPlayerImpl
. Just by the name of the class, it feels like we may found the right class. Let’s look at some of its methods to confirm our suspicion:
Oh yeah. skipToNextTrackWithOptions:
and skipToPreviousTrackWithOptions:
seem like good candidates for methods to hook.
To confirm this let’s write some javascript to hook the functions using Frida:
const playerClass = ObjC.classes.SPTPlayerImpl;
const nextMeth = playerClass["- skipToNextTrackWithOptions:"];
const prevMeth = playerClass["- skipToPreviousTrackWithOptions:"];Interceptor.attach(nextMeth.implementation, {
onEnter: function(options) {
console.log('next');
}
});Interceptor.attach(prevMeth.implementation, {
onEnter: function(options) {
console.log('prev');
}
});
Run Frida again, this time with passing -l <script name>.js
and press the next or previous button in Spotify. Sure enough, you’ll see our log messages being printed in the terminal! You can even use the buttons on the lock screen or control center and the hooks should trigger. It looks like we’ve found our target functions!
Step Three: Code Injection
That’s great but it’s not super convenient to have to attach Frida to Spotify every time we want to track some skips. What we really want is a way to package our code into the Spotify IPA and have it run automatically every time we open Spotify.
I thought for a long time about this problem. I was expecting to have to do some Mach-O magic, increasing the size of the __text section and inserting some compiled Objective-C, and manually changing the imp pointers of our target methods. However, just as I was about to start this daunting task, I came across Kenneth Poon’s article on iOS code injection: https://medium.com/@kennethpoon/how-to-perform-ios-code-injection-on-ipa-files-1ba91d9438db.
Kenneth creates a dynamic library (dylib) and links it with the target application. That’s so much simpler than what I was planning on doing! Let’s follow this approach and write a dylib to perform our hooking for us on startup.
Dylib Writing
First, let’s create a new framework in Xcode. Xcode will automatically create an Objective-C file for you. In this file, let’s set up our function to run when our library is first linked:
static void __attribute__((constructor)) initialize(void)
{
}
The constructor
attribute tells the loader to run this function right after it has mapped us into virtual memory. It’s kind of like a main function for our library.
Within this function, we’ll want to replace the SPTPlayerImpl
's implementations for skipToPrev..
and skipToNext...
with our own implementations:
static void __attribute__((constructor)) initialize(void)
{
Class playerImplClass = NSClassFromString(@"SPTPlayerImpl"); // Get the imp pointer to the prev method SEL prevSel = NSSelectorFromString(@"skipToPreviousTrackWithOptions"); Method prevMeth = class_getInstanceMethod(playerImplClass, prevSel); origPrevImp = method_getImplementation(prevMeth); // Replace it with our own
class_replaceMethod(prevMeth, prevSel, (IMP)prev, method_getTypeEncoding(prevMeth));
... Do the same for next
}
We get the implementation for the previous method, and replace it with our own prev
function which is defined like so:
id prev(id self, SEL _cmd, id options) {}
because every Objective-C method has two “hidden” parameters: a pointer to the calling instance and its selector. The constructor function also relies on a global IMP
variable origPrevImp
which we will call in our hook.
Our prev and next hooks will look pretty much like those in part one; we will only register the event if we are past halfway in the track. Also, when skipping backwards, only register if the new track ID is different from the old one.
The current track ID, player position, and track duration (which if you recall from part one are needed for our hooks) are obtained through the state
instance variable of self
in our hooks which is of type SPTPlayerState
. For example, to get the track ID, the code looks like this:
id state = [self valueForKey:@"state"];
NSURL *uri = [[state valueForKey:@"track"] valueForKey:@"URI"];
NSString *tid = [[uri absoluteString] substringFromIndex:14];
We use the valueForKey
method to retrieve instance variables since the compiler won’t recognize an object of type id
responding to the selector state
.
Lastly, we have our SkipManager
class that deals with writing skips to disk. This class is almost identical to the skip manager in part one but I updated it to be an Objective-C class.
Here is a link to the full code if you want to take a closer look: https://github.com/SamL98/libskip.
Data Exfiltration
Now that we can track our skips, we need a way to get the data from our iPhone to the computer. It’s not very convenient to have to constantly scp the file over and if we ever decide to un-jailbreak our phone, we won’t even have access to scp.
My solution to this was to set up a simple web server (running free on Heroku of course) that has two endpoints: upload and download. In the dylib constructor, the skip file is uploaded to the upload endpoint. We can then download the skip file from the download endpoint. Here is the Python code for the server using Flask:
from flask import Flask, request, send_from_directory
from os.path import join as pjoinapp = Flask(__name__)
app.config['UPLOAD_FOLDER'] = '.'@app.route('/upload', methods=['POST'])
def upload():
if not 'file' in request.files:
return 'No uploaded file', 400 f = request.files['file']
f.save(pjoin(app.config['UPLOAD_FOLDER'], 'skipped.csv')) return 'All good', 200@app.route('/download', methods=['GET'])
def download():
return send_from_directory(app.config['UPLOAD_FOLDER'], 'skipped.csv')if __name__ == '__main__':
app.run()
We can then upload from Objective-C with an NSURLRequest
to <server url>/upload
(Uploader.m in the Github repo) and download it on our computer with curl <server url>/download
. We can even make the file download a cron job. Everything is fully automated!
Mach-O Manipulation
Before we can wrap things up, we need to do one last very important step: tell the linker to load our dylib! To do that, we will edit the header of the Spotify Mach-O binary and add a dylib load command (credit to optool: https://github.com/alexzielenski/optool for showing how to do this). Before reading this section, I recommend taking a look at the OS X ABI documentation: https://github.com/aidansteele/osx-abi-macho-file-format-reference.
The Mach-O file format consists of among other things, a header followed by a sequence of load commands. The header specifies metadata about the binary such as the target architecture and how many load commands are present. The exact format of the 32-bit header can be seen below:
Each load command tells the loader something about the process image such as where each segment is located in the file and where it should be mapped to in virtual memory.
The command we are interested in is theLC_LOAD_DYLIB
command which tells the dynamic linker to load a library the binary has been linked with. To have our library loaded into the process image, we must 1) Increment the ncmds
field in the header, 2) increase the sizeofcmds
field to accomodate for our new load command, and 3) Insert our LC_LOAD_DYLIB
command at the end of the existing load commands.
We can edit the header with the following code:
DYLIB_LC = 12
DYLIB_LC_SIZE = 24
DYLIB_PATH = "@rpath/libskip.dylib"def read_int(f, nbytes):
val = 0
for i in range(nbytes):
byte = ord(f.read(1))
val |= byte << (i * 8)
return valdef write_int(f, val, nbytes):
f.write(bytes([(val >> i*8) & 0xff for i in range(nbytes)]))
# Calculate the total size of the new LC, make sure to pad
# to 4 bytes otherwise the loader will be very unhappy
lc_size = DYLIB_LC_SIZE + len(DYLIB_PATH) + 1 # null terminator
if lc_size % 4 != 0:
lc_size += 4 - (lc_size % 4)
with open('<path to spotify binary>', 'r+b') as f:
f.seek(16) # seek past the first four fields of the header
ncmd = read_int(f, 4)
sizeofcmds = read_int(f, 4) f.seek(16) # re-seek to the offset of ncmds
write_int(f, ncmd + 1, 4)
write_int(f, sizeofcmds + lc_size, 4)
We first calculate the size of our new load command by adding its base size, 24 bytes (we will see why soon), to the length of the null-terminated path to the library, which we will put in the binary’s @rpath
. In this case, @rpath
is equal to Spotify.app/Frameworks
. Then we edit the cmds
and sizeofcmds
fields so that they are in harmony with the new load command we will add.
Then we have to insert our load command. Thankfully, there is enough padding after the end of the existing load commands to insert our command and not have to shift things around.
Before we add the actual load command, lets take a look at its structure:
The struct is pretty simple, all we need is the command (12), its size (24), and a dylib struct which is represented as:
This struct provides metadata about our library. We don’t really care about these fields, so we’ll set them to zero (except for the name of course). As a side note, it seems that the timestamp field is usually set to 2. I have no clue why but I didn’t mess with it.
The final field is the name field, which for our purposes is just an offset in the file to where the name string is located:
Now that we know what we’re writing, we can insert it into the binary:
f.seek(sizeofcmds + 4, 1) # 1 seeks from the current offset.
# Since we just wrote sizeofcmds, the
# curr + sizeofcmds + 4 seek will be
# to the end of the current LC's. The 4
# is for the flags field of the header write_int(f, DYLIB_LC, 4)
write_int(f, lc_size, 4)
write_int(f, DYLIB_LC_SIZE, 4)
write_int(f, 2, 4)
write_int(f, 0, 4)
write_int(f, 0, 4)
f.write(bytes(DYLIB_PATH.encode('ascii')))
The only tricky part of this is writing the offset
of the name
lc_str
in the dylib struct. This is offset to the name from the beginning of the load command. Since the size of the load command is 24 bytes and we are writing the string directly after the load command, that will be our offset.
Step Four: Brewing the IPA
Now that our dylib is written, our webserver up and running, our cron job set, and our dylib loaded, all that’s left to do is move our dylib to the Frameworks directory within Spotify.app and repackage the IPA like so:
cp ~/Library/Developer/Xcode/DerivedData/<libskipdir>/Build/Producted/Debug-iphoneos/SkipTracer.framework/SkipTracer Payload/Spotify.app/Frameworks/libskip.dylibzip -r Spotify-resigned.ipa Payload
There’s only one problem, if we want our new IPA to run on a non-jailbroken iPhone, we will need to code sign it. Thankfully, this process is explained in https://stackoverflow.com/questions/6896029/re-sign-ipa-iphone.
After signing the app and frameworks, just re-zip and use a tool like Cydia Impactor to install the IPA on your device. You should now be up and running!
Conclusion
To be honest I was a little disappointed in how easy it was to inject our own code; I was expecting to have to get down and dirty with some assembly. Nonetheless, this was a neat project and I learned a heck of a lot about the Objective-C runtime and the Mach-O file format. As always, let me know what you think and thanks for reading!