Bypassing OpenSSL Certificate Pinning in iOS Apps

When mobile applications communicate with an API or web service, this should generally happen via TLS/SSL (e.g., HTTPS). In order to verify the identity of the server and to prevent man-in-the-middle attacks, TLS relies on certificates which prove the identity of the web server. Browsers and mobile operating systems come preconfigured with a list of trusted Certificate Authorities (CAs). Since any of the CAs may issue a certificate for any hostname/server, security-conscious applications should “pin” the expected server certificate in the application, i.e., not accept any certificate but the one issued by the known-good CA which the application developer uses.

From a penetration testing perspective, this may cause practical problems since it is difficult to intercept the communication of an application that makes use of this technique. Without pinning, interception typically involves adding the TLS certificate of an intercepting proxy (such as Burp) to the certificate store of the target operating system. However, when the app uses certificate pinning, this store is often ignored. On iOS, when the app uses standard iOS APIs, the iOS SSL Kill Switch, developed by Matasano’s sister company iSEC Partners, can be used to bypass pinning and force the application to accept any certificate presented by the server or proxy. The Kill Switch uses the Cydia Substrate which hooks the iOS functions used for certificate validation and modifies them so that they accept any certificate. It becomes more complicated when the app uses the OpenSSL library instead of the native iOS frameworks since they are not affected by the Kill Switch’s hooking.

There is more than one way for bypassing OpenSSL-based certificate pinning and in our newly posted whitepaper we discuss two of the major approaches: binary patching and in-memory hooking (using cycript). In this blog post we focus on the former method as it is better suited for teaching a low-level understanding of how the bypass operates. Binary patching is also the more versatile technique since it is applicable to platforms where tools like cycript and Cydia Substrate are not readily available.

The Scenario: Mock-up App on Github

To make this post easier to follow, we created a mock-up iOS application that uses OpenSSL and pins certificates in a particular way. There are many different ways for implementing pinning and the solutions presented in this post are transferable to most of them.

The mock-up app simply connects to https://www.example.org and performs a GET request for /. The result is then displayed in a simple text view. Some debug information is output via NSLog to make debugging easier. The relevant code is in the ViewController.mm file.

The repository contains two XCode projects, one which builds ARMv7 and one that builds ARMv8 executables. In addition, the binaries folder includes example binaries to illustrate the patching performed in this post.

Note, in order to perform the steps and modifications described in this blog post yourself, you must have a jailbroken iDevice. The main reason is that we will require root-level device access as well as disabled application signature checking.

Before we dive into the pinning bypass, in the next section, we first describe how to position ourselves between the app and www.example.org.

The Easy Part: Redirecting App Traffic to Burp

The challenges when an app uses OpenSSL instead of native iOS functions begin before the pinning gets in the way. In order to intercept application traffic, it is usually sufficient to set the iOS system proxy such that it points at the intercepting proxy. However, if the app uses C(++) code to open a TCP socket via OpenSSL, you guessed it, it couldn’t care less about iOS proxy settings.

One common way around this is to modify the hostname to IP address mapping used by the application. One could stand up a custom DNS server and configure the device to use it—effectively making the app connect to an IP address of our choosing. An easier way to achieve the same result is to modify the device’s /etc/hosts file. Using my tool idb this is can easily be accomplished under the “Tools” tab:

idh host editor

Here we point the server www.example.org to resolve to the loopback address 127.0.0.1:

1
127.0.0.1 www.example.org

We can then use SSH forwarding (e.g., in idb) to forward traffic from port 443 on the iDevice to port 8080 on our workstation, where Burp is listening and transparently proxying requests to example.org.

idb port forwardingburp listening

The application now tries to connect to Burp, but fails due to certificate pinning. Note that it may be tricky to figure out why the connection fails. Check the ‘Alerts’ tab in Burp, or if you are lucky there may be some app logging that discloses a certificate validation failure.burp errorlog error

The Hard Part: Circumventing OpenSSL Certificate Pinning

As mentioned above, we cannot simply use iSEC’s SSL Kill Switch to turn off SSL certificate validation. One alternative approach is to hook and override the OpenSSL certificate validation functions using Cydia Substrate or cycript which we demonstrate in our related whitepaper. In this blog post, we explore how patching the application binary can be used to bypass certificate pinning (the content of this blog post is also part of the whitepaper).

Analyzing the Pinning Mechanism

The main mechanism this app uses for pinning is a restricted CA list. Instead of relying on a third-party collection of certificates, the app dynamically generates an OpenSSL certificate store in-memory (X509_STORE * cert_store = X509_STORE_new();) and provisions it with the single known-good certificate chain using load_cert:

1
2
3
4
5
void load_cert(char pem[], X509_STORE *cert_store) {
    BIO * bio = BIO_new_mem_buf(pem, (int)strlen(pem));
    pinned_cert = PEM_read_bio_X509(bio, NULL, NULL, NULL);
    X509_STORE_add_cert(cert_store, pinned_cert);
}

The trusted CA certificates are hard-coded in the app source code (in PEM format). For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
 * Adding the root CA and the intermediary CA certificates to the trust store
 * example.org has 2 intermediaries in addition to the root CA cert.
 * We need all of them in order to validate the entire chain.
 */
void setup_pinned_ca_certs() {

    char root_ca_cert[] = "-----BEGIN CERTIFICATE-----\n"
    "MIICWjCCAcMCAgGlMA0GCSqGSIb3DQEBBAUAMHUxCzAJBgNVBAYTAlVTMRgwFgYD\n"
    "VQQKEw9HVEUgQ29ycG9yYXRpb24xJzAlBgNVBAsTHkdURSBDeWJlclRydXN0IFNv\n"
    "bHV0aW9ucywgSW5jLjEjMCEGA1UEAxMaR1RFIEN5YmVyVHJ1c3QgR2xvYmFsIFJv\n"
    "b3QwHhcNOTgwODEzMDAyOTAwWhcNMTgwODEzMjM1OTAwWjB1MQswCQYDVQQGEwJV\n"
    "UzEYMBYGA1UEChMPR1RFIENvcnBvcmF0aW9uMScwJQYDVQQLEx5HVEUgQ3liZXJU\n"
    "cnVzdCBTb2x1dGlvbnMsIEluYy4xIzAhBgNVBAMTGkdURSBDeWJlclRydXN0IEds\n"
    "b2JhbCBSb290MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCVD6C28FCc6HrH\n"
    "iM3dFw4usJTQGz0O9pTAipTHBsiQl8i4ZBp6fmw8U+E3KHNgf7KXUwefU/ltWJTS\n"
    "r41tiGeA5u2ylc9yMcqlHHK6XALnZELn+aks1joNrI1CqiQBOeacPwGFVw1Yh0X4\n"
    "04Wqk2kmhXBIgD8SFcd5tB8FLztimQIDAQABMA0GCSqGSIb3DQEBBAUAA4GBAG3r\n"
    "GwnpXtlR22ciYaQqPEh346B8pt5zohQDhT37qw4wxYMWM4ETCJ57NE7fQMh017l9\n"
    "3PR2VX2bY1QY6fDq81yx2YtCHrnAlU66+tXifPVoYb+O7AWXX1uw16OFNMQkpw0P\n"
    "lZPvy5TYnh+dXIVtx6quTx8itc2VrbqnzPmrC3p/\n"
    "-----END CERTIFICATE-----";

    load_cert(root_ca_cert, cert_store);

In general, it is common to store CA certificates in the file system and it would be natural to do the same for pinned certificates in mobile apps. One downside of that approach is that the certificates can easily be swapped out on a jailbroken device. In contrast, certificates that live in the binary are more difficult to exchange, since one has to modify the binary itself to do so. As we will see, neither will stop a dedicated reverse-engineer from bypassing this kind of mechanism.

It is important to note that access to the application source code is not required in order to apply the techniques described in this paper. We just include it here in order to better illustrate the used techniques. Once the relevant functions are known, it is possible to reverse engineer the pinning mechanism from the binary alone.

Bypassing the Pinning

Given this setup, we can either exchange the certificate in the binary or disable the certificate validation another way. Exchanging the certificates is challenging since different certificates have different lengths and the space in the binary where the original certificates are located may not be sufficient. In addition, large edits in binaries can be error-prone. So let us instead look at the code that enables certificate validation in OpenSSL:

1
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);

A quick look at the documentation for SSL_CTX_set_verify tells us that we need to turn SSL_VERIFY_PEER into SSL_VERIFY_NONE to disable certificate validation. Both of these values are constants defined in ssl.h:

1
2
#define SSL_VERIFY_NONE                   0x00
#define SSL_VERIFY_PEER                   0x01

This means we will need to find the call to SSL_CTX_set_verify in the app binary, and change the second argument from 0x01 to 0x00. It is important to note that such modifications will break app signing so you must use a jailbroken device which disables signature verification.

Pull the app binary from the device (or the binaries folder on Github) and disassemble it with otool which comes pre-installed on OS X and can be downloaded and compiled for Linux. (Note that you may need to decrypt the app binary if it comes from an app store. idb is able to do this by internally using dumpdecrypted.

1
 otool -Vjt openssl-pinning

Note that if you happen to have Hopper or IDA this may be much easier, but let us continue down the manual path. All modern iOS devices (Apple A7 or better) use the ARMv8-A architecture which has full 64-bit support so we will see a 64-bit address space in our binary. For the sake of educational simplicity let us start by looking at a binary compiled for ARMv7 first and at the 64-bit version afterwards.

ARMv7 Binary

Searching for SSL_CTX_set_verify in the disassembly gives us:

1
2
3
4
5
6
7
8
9
00009bf4            2101        movs    r1, #0x1   <- set first parameter to 1 (=SSL_VERIFY_PEER)
00009bf6        f2c00100        movt    r1, #0x0
00009bfa            2200        movs    r2, #0x0
00009bfc        f2c00200        movt    r2, #0x0
00009c00        f24c60e8        movw    r0, #0xc6e8
00009c04        f2c0000b        movt    r0, #0xb
00009c08            4478        add     r0, pc
00009c0a            6800        ldr     r0, [r0]
00009c0c        f073fab4        bl      _SSL_CTX_set_verify

The important thing to note here are the lines before the branch and link (bl _SSL_CTX_set_verify) in which the arguments are set up. ARM assembly normally passes function arguments in registers. SSL_CTX_set_verify takes three arguments which are passed via r0r1, and r2. These registers are set here using 16-bit instructions (ARM thumb mode). Because of that, the registers are set using two instructions movs and movt where movswrites the lower half-word and movt the upper half-word of the register. Since we are only concerned with modifying the lowest bit, we can ignore the movt instruction.

Register r0 will point to the OpenSSL ctx and r3 should be NULL which matches the assignments in the disassembly. Then r1 holds the second argument to SSL_CTX_set_verify which is #0x1 (=SSL_VERIFY_PEER). Recall that SSL_VERIFY_NONE = 0 so changing the instruction at 00009bf4 from 2101 to 2100 gives us movs r1, #0x0 and should disable cert validation (see MOVS documentation).

Now finding that instruction in the binary is a bit painful since the addresses used by otool are memory locations and not the offset in the file on disk. I found it easiest to search for a sequence of opcodes/instructions in a hex editor (I used Synalyze It! in the past). Another aside: byte order can be annoying since in little endian e.g., f073fab4 will be 73f0b4fa in the binary. Searching for 73f0b4fa:

hex search

Moving backwards in the binary it is easy to spot the bytes to be modified:hex value

After changing the byte you can use otool again to ensure the patching worked as expected:

1
00009bf4            2100        movs    r1, #0x0

After re-uploading the binary to the app folder on the device, it will fail again with a different certificate validation error. Turns out this particular app is rather thorough and validates several different aspects of the received cert manually (beyond relying on the OpenSSL feature patched above). So back to patching the relevant code. In bool verify_certificate() there is sequence of 4 if constructs all looking similar to this:

1
2
3
4
5
6
// First let us verify this is a cert in our trust store.
if(SSL_get_verify_result(ssl)!=X509_V_OK)
{
    NSLog(@"Certificate doesn't verify. Error: %s", X509_verify_cert_error_string(SSL_get_verify_result(ssl)));
    return false;
}

This code explicitly verifies the certificate against the trust store. This is one of the checks that OpenSSL already performs internally when peer verification is enabled, making this a redundant check. But the developer of this app decided to verify it one more time explicitly. Searching for SSL_get_verify_result in the assembly gives us something like

1
2
3
4
5
6
7
8
00009e2a        f073ffdf        bl      _SSL_get_verify_result
[ Omitting a bunch of instructions for the NSLog line ]
00009eb0        f0a5eff2        blx     0xafe98 @ symbol stub for: _NSLog
00009eb4            2000        movs    r0, #0x0
00009eb6        f2c00000        movt    r0, #0x0
00009eba        f0000001        and     r0, r0, #0x1
00009ebe        f88d0068        strb.w  r0, [sp, #104]
00009ec2            e139        b       0xa138

Another thing to know about ARM is that there is no RET instruction. The return value is typically passed in r0 and then a branching (b) instructions jumps to either the return address (from a register or stack) or in this case a hard-coded address 0xa138. So r0 is set to #0x0 (false) here. In order to return true instead and trick the logic into accepting the certificate, we just change r0 to #0x1 which corresponds to an instruction of 2001. We are again in thumb mode so we leave the movt instruction intact. (Hint: search for 8d f8 68 00 39 e1):

hex second value

1
00009eb4            2001        movs    r0, #0x1    <- patched version

This allows us to return early, skip all the other annoying validation functions, and our app is talking to Burp!

app log success

Now that we have the basics down using ARMv7, let us look how the same process looks like for ARMv8 (ARM64).

ARMv8 (ARM64) binary

Interestingly, the ARMv8 assembly looks very different, which makes it slightly more challenging to work with in our case. Lets look at the disassembly and search for SSL_CTX_set_verify as before:

1
2
3
4
5
6
0000000100004f84        320003e1                orr     w1, wzr, #0x1
0000000100004f88        d2800002                movz    x2, #0
0000000100004f8c        d00007a8                adrp    x8, 246 ; 0x100005000
0000000100004f90        912b4100                add     x0, x8, #2768
0000000100004f94        f9400000                ldr     x0, [x0]
0000000100004f98        9402620b                bl      _SSL_CTX_set_verify

Again, the important part is the setup of the registers. The confusing part here is that registers are not named in a straightforward manner: x registers are the default general purpose registers and w registers are 32-bit sub registers (For those interested, Apple does not seem to fully follow the ARM specification for function calls, see Apple’s ARM64 function call conventions).

Here x0 is the first argument, w1 the second, and x2 the third. Meaning x0 is the pointer to the OpenSSL ctxw1 is our target value for the certificate validation flag, and x3 should just point to NULL.

Lets focus on w1: Register wzr always returns #0x0. Then orr performs a bitwise OR of #0x0 with the constant #0x1 and the result is stored in w1. That means w1 will be set to #0x1 (=SSL_VERIFY_PEER).

Looking at the machine code 320003e1 for this line it is not entirely clear to me how to modify this intoorr w1, wzr, #0x0 (in fact Section 5.3.2 in the ARM reference manual suggests it is not possible. So let us modify the instruction altogether. XORing any register with itself will yield #0x0 so eor w1, w1, w1 should store #0x0 intow1. But we must determine which opcodes this corresponds to. Thankfully XCode on OS X ships with llvm-gcc (and similar tools are available for Linux) which can assemble the instruction

1
EOR w1, w1, w1

by running: llvm-gcc -arch arm64 -c eor.s -o eor.out. The result gives us the op codes for our desired instruction:

1
2
3
eor.out:
(__TEXT,__text) section
0000000000000000  4a010021     eor  w1, w1, w1

We can now use this and replace the instruction at 0000000100004f84 (320003e1) with 4a010021 just as we did for ARMv7 binaries. When searching for the location in the binary, it is important to remember that the instruction word size on this architecture is different. That means that the required little endian encoding of 9402620b (the call to _SSL_CTX_set_verify) is 0b620294. So after searching for that in the binary, we find the location that needs patching:

arm 64 value

The otool output after performing this modification will look like this:

1
0000000100004f84        4a010021                eor     w1, w1, w1

By running the resulting binary, we will see again that the second certificate verification fails. So let us patch that one as well. The relevant section calling _SSL_get_verify_result looks like this:

1
2
3
4
5
6
7
00000001000051d0        9402658a                bl      _SSL_get_verify_result
[ Omitting a bunch of instructions for the NSLog line ]
0000000100005204        94028107                bl      0x1000a5620 ; symbol stub for: _NSLog
0000000100005208        52800009                movz    w9, #0
000000010000520c        12000129                and     w9, w9, #0x1
0000000100005210        39029fe9                strb    w9, [sp, #167]
0000000100005214        140000af                b       0x1000054d0

This piece of disassembly should look familiar from above. Presumably changing movz w9, #0 to movz w9, #1 like we did before should be sufficient. Lets use again our assembler to see how the machine code looks for that:

1
2
(__TEXT,__text) section
0000000000000000  52800029        movz w9, #1

By editing the binary and modifying the instruction we get the desired result:

1
0000000100005208        52800029                movz    w9, #1

Now all is left to replace the original binary on the device with the patched version and we bypassed the pinning once again!

Conclusion

Certificate pinning is a helpful technique to protect against rogue CAs or to ensure that corporate proxies which intercept TLS traffic are unable to access sensitive application traffic (note that your app will break, but data is protected). From a penetration testing perspective, they can pose challenges but it is certainly always possible to bypass it! Be it via the iOS SSL Kill Switch, manual cycript hooking, or binary patching as described above. Therefore, relying on certificate pinning to prevent an attacker from learning more about the underlying communication protocol used by the application is merely security by obscurity. This is neither the intended purpose of pinning nor is it effective.

As mentioned above, our new whitepaper also demonstrates the cycript-based method, so go and check it out!

I hope you found this post informative, and please don’t hesitate to get in touch for questions or point out mistakes/errors (Twitter: @DanlAMayer).

Published date:  06 January 2015

Written by:  Daniel A. Mayer