How do I protect an iOS app from reverse engineering?

In this post, you will find proven ways to prevent your application from reverse engineering.

Wojciech Reguła 2021.11.17   –   6 MIN read

TL;DR

  • Use Swift instead of Objective-C
  • Inlining functions makes them harder to manipulate
  • For high risk applications consider code obfuscation
  • Implement dynamic RE protections
  • Consider additional parameters encryption under the SSL
  • Make sure to be up to date with iOS security best practices  -> see our Guidelines on mobile application security – iOS edition.

Context

This article explains in general how reverse engineering is performed on iOS applications. It shows what the analyst may retrieve from the application package and how to make their job harder. In the beginning of this article I want to once again mention that implementing reverse engineering protections does not relieve you of caring for secure code. Making the potential’s attacker life harder reduces the risk they will select your application to attack. The approach is – if you would rob a house, which would you pick? Barely protected house or the one with cameras, fence, dog and guard? The main reason for implementing RE protections is to scare off.

1. Use Swift instead of Objective-C

Objective-C is a programming language created in 1983 while Swift was created in 2014. Those 30 years made a big difference not only in usability but also in security. Objective-C under the hood was only a kind of C wrapper, now it’s a bit more complicated. However, keep in mind that your Objective-C apps still use a lot of C functions. That’s why the vulnerabilities typical to C exist also in Objective-C.

In this section we will not focus on the vast comparison of the features, but I will show you how these 2 languages look from the reverse engineering perspective.

Consider the following Objective-C code:

- (void)viewDidLoad {
   [super viewDidLoad];
   NSString *first = @"Mobile ";
   NSString *second = @"Guide";
   NSString *concat = [first stringByAppendingString:second];
   NSLog(@"Hello from %@!", concat);
}

After decompilation using Hopper the code was almost recreated:

-(void)viewDidLoad {
   [[&var_20 super] viewDidLoad];
   stack[-80] = [[[@"Mobile " retain] stringByAppendingString:[@"Guide" retain]] retain];
   NSLog(@"Hello from %@!", @selector(stringByAppendingString:));
   objc_storeStrong(&var_38, 0x0);
   objc_storeStrong(&var_30, 0x0);
   objc_storeStrong(&var_28, 0x0);
   return;
}

Now, let’s take a look on the Swift code:

override func viewDidLoad() {
   super.viewDidLoad()
   let first = "Mobile "
   let second = "Guide"
   let concat = first + second
   print("Hello from \(concat)")
}

After decompilation looks horrible:

int _$s16MobileGuideSwift14ViewControllerC11viewDidLoadyyF() {
   type metadata accessor for MobileGuideSwift.ViewController();
   [[&var_30 super] viewDidLoad];
   ___swift_instantiateConcreteTypeFromMangledName(0x10000db38);
   r0 = swift_allocObject();
   *(int128_t *)(r0 + 0x10) = *0x100006190;
   Swift.String.write<A where A: Swift.TextOutputStream>();
   Swift.String.write<A where A: Swift.TextOutputStream>();
   Swift.String.write<A where A: Swift.TextOutputStream>();
   *(r19 + 0x38) = *type metadata for Swift.String;
   *(int128_t *)(r19 + 0x20) = 0x0;
   *(int128_t *)(r19 + 0x28) = 0xe000000000000000;
   Swift.print();
   r0 = swift_release(r19);
   return r0;
}

As you can see the decompiled code is much different. Objective-C was almost recreated and looks almost identical while Swift is a total opposite. Creating applications with pure Swift is the first step to implement the reverse engineering protections.

2. Consider obfuscation for high-risk applications

As Wikipedia says:

> In software development, obfuscation is the deliberate act of creating source or machine code that is difficult for humans to understand. Like obfuscation in natural language, it may use needlessly roundabout expressions to compose statements. Programmers may deliberately obfuscate code to conceal its purpose (security through obscurity) or its logic or implicit values embedded in it, primarily, in order to prevent tampering, deter reverse engineering, or even to create a puzzle or recreational challenge for someone reading the source code.

Obusfaction will make the source code unreadable. All the methods, variables will be changed to meaningless values. As Objective-C code is easy to read after decompilation, obfuscation helps prevent the analyst from quickly understanding what the code does. On the other hand, not everything can be obfuscated. External functions, system calls cannot be obfuscated as we do not control code on the second side. There would be a call to an unnamed function / unnamed system call.

As you probably expect, obfuscating Swift code makes the code even harder to decompile. When even the methods and class names are changed, the reverse engineering can be really painful. Consider the example presented on SwiftShield’s Github page:

struct fjiovh4894bvic: XbuinvcxoDHFh3fjid {
 let VNfhnfn3219d: Vnahfi5n34djga
 func cxncjnx8fh83FDJSDd() -> Lghbna2gf0gmh3d {
   return vPAOSNdcbif372hFKF(VNfhnfn3219d.Gjanbfpgi3jfg())
 }
}

Obfuscation has some drawbacks. I’ve heard about applications that couldn’t pass the Apple’s review because of the obfuscation. Apple was unable to verify if the application doesn’t contain malicious code. Obfuscated applications are harder to analyze not only for attackers but also for developers. If your application reports a bug (stacktrace) to your analytics you have to somehow decode the obfuscated names.

3. Implement dynamic RE protections

Process of analyzing and reverse engineering applications is often supported by tools inspecting the runtime. In terms of iOS app security the 2 most common tools are Cycript and Frida. It’s a good practice to detect these and react accordingly. Such tools load dynamic libraries as they want to be in the same memory space as the reversed process. The Apple’s API allows enumerating currently loaded dylibs and their names. So, the first technique is to verify if the process is not contaminated with foreign code:

private static func checkDYLD() -> Bool {
   let suspiciousLibraries = [
       "FridaGadget",
       "frida",
       "cynject",
       "libcycript"
   ]
   for libraryIndex in 0..<_dyld_image_count() {
 
       guard let loadedLibrary = String(validatingUTF8: _dyld_get_image_name(libraryIndex)) else { continue }
       for suspiciousLibrary in suspiciousLibraries {
           if loadedLibrary.lowercased().contains(suspiciousLibrary.lowercased()) {
               return true
           }
       }
   }
   return false
}

We call the _dyld_image_count() to get the number of loaded dylibs. Then, for each index we call _dyld_get_image_name() to retrieve the name that will be verified. Of course, it’s again a cat and mouse game as the attacker may change the dylib names. For example the HideJB tweak changes all of the commonly detected names to others, not denylisted.

The second technique I’d like to present is detecting the Frida server. Frida usually binds to the port 27042, so we can verify if the port is open. More complicated mechanisms will also check if this is indeed Frida, not another process listening on that port. However, we will focus on the simple implementation:

private static func isFridaRunning() -> Bool {
   func swapBytesIfNeeded(port: in_port_t) -> in_port_t {
       let littleEndian = Int(OSHostByteOrder()) == OSLittleEndian
       return littleEndian ? _OSSwapInt16(port) : port
   }
 
   var serverAddress = sockaddr_in()
   serverAddress.sin_family = sa_family_t(AF_INET)
   serverAddress.sin_addr.s_addr = inet_addr("127.0.0.1")
   serverAddress.sin_port = swapBytesIfNeeded(port: in_port_t(27042))
   let sock = socket(AF_INET, SOCK_STREAM, 0)
 
   let result = withUnsafePointer(to: &serverAddress) {
       $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
           connect(sock, $0, socklen_t(MemoryLayout<sockaddr_in>.stride))
       }
   }
   if result != -1 {
       return true
   }
   return false
}

Presented dynamic RE protections are only examples. There are many more techniques. You can learn more about them by inspecting our ISS’ code.

4. Take a look at the additional under-SSL encryption

High risk applications that I pentest sometimes implement additional networking hardening. The most common is additional encryption of sent HTTP requests body. All the parameters and data sent within the request are encrypted and thus unreadable to the attacker. The analyst has to decrypt the data in order to read them. So, an example encrypted request looks as follows:

POST /api/data HTTP/1.1
Host: securing.biz
Connection: close
Authorization: [...]
Content-Length: x
 
encrypted binary data .......

The attacker may have a need to modify the sent data to attack the API. When the data is encrypted using asymmetric cryptography (e.g. RSA) a sophisticated proxy has to be used. It will require to change an encryption key on the device, decrypt it on the beginning of the proxy, modify the data and then again encrypt using the server’s encryption key. Such effort required to break the encryption may scare off potential attackers.

Summary

This article showed how to make your application harder to reverse engineering. As I showed in the Swift vs Objective-C section the hardening starts from selecting a programming language. Swift is much more difficult to reverse engineer. Sometimes, the obfuscation comes into play, but you have to keep in mind that it may cause really painful and inconvenient debugging. Implementing dynamic RE protections may discourage inexperienced attackers from analyzing your application, so the additional under-SSL encryption. 

While hardening applications is a good practice, never forget about coding your application securely. It’s only an additional layer of holistic defense. To make it easier, we prepared a handy guide:

If you have any questions about this article feel free to contact us via contact form. 

Wojciech Reguła
Wojciech Reguła Principal IT Security Consultant
Head of Mobile Security