Reverse Engineering Snapchat (Part I): Obfuscation Techniques
When you have 200+ million daily users, you’ll definitely want to keep your API private from spammers and co., so you’ll have to keep a secret in the binary that authorizes it to make API calls to your server.
Snapchat (as of version 10.81.6.81) does this by including an
X-Snapchat-Client-Auth-Token header in each request, a typical one would look like:
Control flow obufscations:
Forget about doing static analysis on this binary. Here’s what they do on a high level: the CFG (control flow graph) is destroyed (not flattened), dead code, library calls are mostly dynamic, and all symbols for the token generation function (let’s call it
gen_token) and its callees are stripped. They’re implemented in C and not Objective-C for this reason, because you can always use the ObjC runtime against itself1 (Trivia: they only started using Swift recently, but for other tasks.)
Indirect branches and opaque predicates
Let’s take a look at the very first block in
gen_token. The block loads some values from different sections of the binary and then:
See the first two instructions. Why would they compare
0xb right after storing
0x3 in it? Opaque predicates1. The
csel condition will always be false, but that doesn’t matter, because as far as the disassembler is concerned, this is a condition, and conditions need to be evaluated at runtime. Replace every single jump (including legit conditions) with a similar block, and you’ve completely destroyed the CFG for any modern disassembler. Now Ghidra/IDA would be happy to display what it thinks is a small function with a tail call, which is in fact a huge function. I’ll give Ghidra that it’s able to calculate the address in
br x8 but only for the first block (because that’s where it thinks the function ends). Now that’s a plugin idea: use emulation to calculate all the addresses in indirect branches with opaque predicates. I actually worked for a bit on implementing this but then that’s not even half the battle for this binary.
Bogus instructions AKA dead code
Every few blocks you’ll find a block that loads a global constant, does some complex-looking operations on it, then just discards it and branches to somewhere else. Those are just there to confuse you and are easily detectible once you see them for a couple of times, so it’s not much of a hindrance.
Dynamic library calls
To make the code as bland as possible, and to prevent you from making educated guesses when you see a call to
SecItemCopyMatching for example, most library calls are dynamic.
So for example instead of a simple
bl SecItemCopyMatching, they would do:
Then, in another block they would:
The disassembler doesn’t know the value of
x23 here because, as stated above, it treats the block as if it doesn’t belong to the current function.
When you have a loop that comes with a pre-determined/fixed counter, you can get rid of the counter, and hardcode the loop iterations. This comes at a cost of the binary size, and it’s slightly faster than using a counter. Snap uses this technique in an encryption function. This block moves a huge array of bytes to another, notice how the offsets increment, replacing the counter:
Suppose you have a function that fills some structure with the right data and another that converts bytes to ASCII:
With a little effort you could intercept calls to both and understand what they do just by watching their behavior. Snapchat has quite a clever way of thwarting this. Instead of the two above, there would be:
argv would include all the arguments needed. Now strip all symbols, add the above obfuscations and you’ve got an unintelligble mammoth of a function. You would think that you could still trace all calls to the joint function and treat the
function_id as an identifier to the function you’re interested in. But breakpoints won’t act as you’d expect them to. See next.
The solution: not breakpoints (AKA anti-debugging measures)
Now most control flow obfuscation is against static analysis, using a debugger to get past the above would do it. Not so fast. Most functions call an anti-debugging function, which I named appropriately and whose signature is:
There’s at least 9 such functions, all the same behavior. I haven’t taken the time to reverse them but their behavior is clear.
Software breakpoints work by patching the instruction at the designated address in memory. The patch is an instruction that triggers an interrupt that’s handled by the parent process, the debugger3. That makes them easily detectable; if you have a checksum of what a certain area in memory looks like, a breakpoint in that area will invalidate the checksum. Or, you could look for the interrupt instruction’s (
brk) bytes in the binary.
After doing its check,
fuckup_debugging will return a
uint64_t, its value which depends on whether there was a breakpoint detected. So actually there’s only two possible values. Isn’t that called a
bool? Yes. But a boolean would be trivial to patch. But with an int you can’t guess the “right” value. The
fuckup_debugging caller uses the return value (I’ll call it the
path_key) to load an address from a jump table, if there was a breakpoint, the fetched address would lead to an infinite loop, leading the app to just keep loading with no feedback, which is the right way to do it.
Data flow obufscations
Data obfuscation is one of the harder things to work with in this binary. Here we have lots of MBA (mixed-boolean arithmetic) and scratch arguments passed to functions just to distract you.
One of the less resarched areas in obfuscation techniques is MBA (shoutout to the awesome Quarkslab for their research on this and many other things). Those are typically used in cryptography but can be utilized for obufscation. Basically they’re expressions that mix logical operations with pure arithmetic. For example,
x = (a ^ b) + (a & b).
The interesting thing about those here is identities, for example
x + y can be re-written as
(x ^ y) + 2 * (x & y)5. Now imagine how huge the simple
x + y expression could get if you substituted each term recursively with its MBA equivalent, crazy stuff.
An example in assembly. All what that block does is
timestamp * 1000:
This one isn’t very prevalent in the binary but it’s still interesting to mention. I’ve seen it used in a function that reads the first 8 bytes at a pointer. It has the signature:
2 are overwritten without being used at all, again, there to slow you down a bit.
Clever shit/time buyers
To make your life even more miserable, Snap ocassionally deprives you of recognizing some basic standard lib functions, namely
memmove, by implementing their own, or maybe just copying the source. You won’t be very happy after spending a day or two reversing a function to find it’s
memmove in the end.
Loading by overflowing
Another honorary mention. This one has a base address and an index and it loads bytes from an array using a loop. Instead of simply adding the base address to the counter to get the byte, they do a calculation that yields two big 64-bit integers that will overflow but whose sum will be equivalent to the simple calculation. So instead of:
In Mach-O binaries, functions whose pointers are in the
__mod_init_func section run before
otool to see how many of those are in Snap, we find a staggering 816 functions:
Hmm, seems too much to count manually. Let’s
And since it’s two function pointers per line, their actual number is 816 (after discarding the first two lines). But wait, all of those point to the same function? They’re probably using duplicates as a distraction and to make your job harder, let’s see how many are there. After doing some regex to get the functions pointers, I found there’s 769 unique functions, still a huge number.
Some of those are dummy functions that do nothing useful. For example the very first one loads a constant, stores it on the stack, then discards it and returns:
Among those 769 functions some will definitely be doing some real initializations, and some could be there as another stealthy jailbreak/debugger detection. Filtering out the dummies should be easy, but we’re still talking about 700+ function, so to find the ones you’re interested in you’ll have to have some idea about how Snap is doing it, so you can get there without having to sift throught all those functions.
I’ll probably do a part II on how to bypass all of this.