Using Hopper to investigate an iOS bug
In this article, I share how we investigated a bug in UIKit, which Apple wasn’t able to solve since the introduction of a new major iOS version until the recent release of iOS 13.4.1.
The problem was first noticed in August 2019 with the first beta versions of iOS13. In Badoo and Bumble applications, we are constantly working to improve interfaces. For example, we are trying to achieve maximum optimisation of a registration process that we know users find annoying and off-putting. One way of streamlining the process is by using systemic predictive suggestions that appear just above the keyboard. These are an excellent way of keeping precious clicks to a minimum as users enter their data. In the new iOS, we were surprised to discover that predictive suggestions for the user’s telephone number had disappeared.
When GM came out we saw that the problem had not been solved. It seemed to us that such an obvious omission was bound to be picked up by subsequent regressive tests (and Apple must have these), and thought everything would then be resolved in the first major update pack. That’s what other developers were hoping for as well. However, when 13.1 came out the changes had not materialised, so there was nothing left for us to do but to open up radar — which is what we did at the start of October. Time passed: 13.2 came out, then 13.3… and the bug remained unresolved.
In February our registration team found they had some extra time on their hands, and having no work backlog decided to investigate this problem in greater depth.
To start ‘digging’, of course, you need to know where to ‘dig’. Since the predictive suggestions were still working for some types of keyboard, when we started looking for where to be ‘digging’, our first idea was to explore the hierarchy of its views in various versions of iOS.
iOS12 vs iOS13
This is where it became clear that in iOS13 Apple had carried out a refactoring of the keyboard implementation and had assigned predictive suggestions to a separate controller (UIPredictionViewController). Clearly, the trend for modularisation and decomposition had now reached Apple. It is likely that the functional regress occurred as part of this process. Our search was starting to narrow.
Most iOS developers will know that interfaces for private system classes are readily available and easy to find — they are just one search engine query away. Study the class interface and one of its methods immediately catches your attention:
This leads to a hypothesis which would be quite easy to check out using that trusty old tool, swizzling where a suspect function always returns a true value.
Rerunning the test project, I discovered that telephone number predictive suggestions were back in iOS13, and were working without any noticeable problems whatsoever. At this point, we could have terminated the investigation and possibly have, very carefully, used this risky solution (forbidden by the Apple guidelines) in a release build with scope for remotely switching it on or off for some users. But all the while I couldn’t shake off my curiosity: what is the logic that leads this function to return ‘false’ when using a telephone-type keyboard?
To get to the truth, you need the function’s source code. Obviously, Apple doesn’t disclose code for iOS components left, right and centre; it’s not something you can just google. There is only one way to go: reverse engineering to decompile binary code into source code. Prior to this I had heard more than once about a product called Hopper and had read several articles about how to use it for poking around in the system libraries, but personally never had once used it. It was a pleasant surprise to find that you don’t even have to purchase the full version in order to play around with it and study the tools it uses. The demo version includes endless 30-minutes work sessions which prevent you from saving the index or making changes to the binary files. This all makes it an excellent environment for doing experiments.
From the same published private interface you discover that UIPredictionViewController is part of the framework’s UIKitCore. All that’s left to do is find it and load it into Hopper. Binary files for frameworks are buried deep in the Xcode. Here, for example, is the full pathway to the UIKitCore we need:
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore
We drag&drop the file into Hopper, confirm the action in the system dialog and wait for indexation to complete (for a large framework like UIKit, this can take 4–6 minutes). The program’s interface is pretty simple; here are the key elements which were required for my investigation. In the left-hand panel of the interface is a search string which allows you to navigate the code. If you do a search based on the name of the class you are interested in, it will quickly return the function under investigation and will open its assembler code in the main window.
In the upper task panel is a button for switching modes for displaying code. From left to right:
● ASM mode is an assembler code.
● CFG mode is an assembler code in the form of a flow chart (tree), where chunks of the code are combined into boxes and the transitions are shown in the form of branching.
● Pseudo-code mode is a generated pseudo-code (see below for more details).
● Hex mode is a hexadecimal representation of a binary file, otherwise known as abracadabra, of little use for the purposes of our investigation.
Now all that remains to be done is explain what actually goes on inside the function. Its body is quite long so, looking at the assembler code, it would take a real asm guru to get to the bottom of the logic — and that is not something I can claim to be. This is where pseudo-code mode comes in useful. Here, Hopper simplifies assembler operations as much as possible, substituting, where possible, the real names of functions and using the names of registers as variables. This is what it looks like:
Now we just need to follow the logic of the function and see which of the branches we end up in. For this I found Symbolic Breakpoints to be extremely helpful. It can also be set up for system calls, printing in parallel to the Xcode console all the necessary variables and results of function calls. Using this far-from-cunning approach, I discovered that the function is interrupted with a premature exit due to the fact that, in the block of code given as an example, one of the conditions does not work. Let’s proceed step-by-step to work out what is going on here.
Here is some context: in the rbx register, slightly higher up in the code (which I moved down for the sake of simplicity), there is a link to an object which implements the UITextInputTraits_Private protocol. This is an extended version of the UITextInputTraits public protocol.
Okay, so one of the conditions is to verify that the suggestions are not obscured by the entry field configuration and in the debug you can see that the condition is met: the hidePrediction property returns ‘false’. The second condition verifies that the keyboard is not in ‘split’ mode (if you are not aware of this, on your iPad swipe the lower right button upwards — only 2–3% of users know about it). In our case, this condition is met.
Let’s move on. At the next stage things start to happen with the keyboardType, which shows us that we are getting closer. First, it verifies that the current keyboardType is less than or equal to 0xb (or 11 in the decimal system). If in XCode you open up the UIKeyboardType declaration, you will see that there are 13 keyboard types in total, one of which (UIKeyboardTypeAlphabet) is obsolete and given as an alias to another keyboard type. This means that in total there are 12 types in enum: if we start with 0, then the final one will have a value equal to 11. In other words, validation of the value is performed in the code in the form of verification for overfill and, again, this is performed successfully.
Next, we find a very strange condition, if (!COND), and for a long time I could not understand what it tests, since the variable, COND, was to be found nowhere in the code above. What’s more, my debug breakpoints were showing that it is precisely non-fulfilment of this condition which leads to a premature exit from the function. At this point, there is nothing left to do but to return to the ASM mode and study the section of the code in question in assembler view.
In order to find this condition in the ASM listing, you can use the “No code duplication” option. This shows the pseudo-code, not in the form of source code with if-else conditions, but in the form of unique blocks of code and transitions in goto form. In this case, we see the starting position for these blocks in the binary file and use this pointer for searching in ASM mode. This is how we discover that the block we are interested in is to be found at fe79f4.
Having switched to ASM mode, it is easy for us to find this block:
We are now approaching the most complicated part, namely where we are going to look at three strings of assembler code.
I recognised the first string from memory, thanks to those assembler lessons at the institution where I studied. Everything is quite simple here: the ecx register contains the constant, 0x930 (100100110000 in binary form).
In the second string we see the instruction, bt, to be performed on the registers, exc and eax. The value of one of these is already known. The value of the second can be seen in the previous screenshot: rax = [rbx keyboardType]. This is where the current keyboard type is to be found. (rax is the entire 64-bit register; eax is its 32-bit part).
Having worked out the data, now we need to understand the command logic. Google provides us with the following description:
Selects the bit in a bit string (specified with the first operand, called the bit base) at the bit-position designated by the bit offset operand (second operand) and stores the value of the bit in the CF flag.
The instruction extracts a bit from the first operand (the constant 0x930) at the position determined by the second operand (keyboard type), and places it in the CF (carry flag). That is to say, as a result, the value in the CF flag will be 0 or 1 depending on the keyboard type.
Let’s move to the final operation, jb, which has the following description: Jump short if below (CF=1). It isn’t difficult to guess that this is where the juncture in the performance of the function occurs (premature exit), if the value in the CF flag is 1.
And this is where the puzzle all starts to come together to produce a coherent picture. We have the following bit mask: 100100110000 (this has 12 bits, one for each keyboard type available), and it is precisely this which determines the premature exit condition. Now, when we check for suggestions for all keyboard types, in the rawValue ascending order, everything is as it should be.
This logic is not found in iOS 12: in that operating system predictive suggestions work for any keyboard type. I suspect that in iOS 13 they decided to switch off predictive suggestions for digital keyboards, which basically makes sense. I can’t think of any scenarios where the system would need to suggest numbers. It looks as if UIKeyboardTypePhonePad ended up as collateral damage by mistake, since it looks very much like an ordinary digital keyboard; while UIKeyboardTypeNamePhonePad, which, for the purposes of searching for phone contacts, combines a qwerty keyboard and the same kind of digital keyboard switched off, continued to offer predictive suggestions.
I was surprised to find that working with Hopper turned out to be so pleasant and appealing; it had been ages since I had so much fun. I shared my findings with Apple engineers in my bug report and in due course its status changed to “Potential fix identified — For a future OS update”. I hope that this fix will soon find its way to users in future updates. Having said that, Hopper can be used not only to identify the causes of the bugs you or Apple have, but also to find Apple fixes for third-party developers’ programs.